feat(FN-2869): merge fusion/fn-2869
- docs(FN-2869): pluggable notification providers reference + architecture - chore: changeset for pluggable notification providers - ui(merge): clarify mergeConflictStrategy option labels — both "smart" and "prefer-main" run the full smart cascade; differ only in the final fallback (-X theirs vs -X ours) - TaskDetailModal: keep Node Routing summary block removed Conflicts in executor.ts, core/index.ts, and RoutingTab.* resolved by keeping HEAD — fn-2869 branched from a stale main and was dragging in older versions of files that have since been updated by sibling tasks (including the FN-2887 rebase fix on executor.ts itself).
This commit is contained in:
5
.changeset/pluggable-notification-providers.md
Normal file
5
.changeset/pluggable-notification-providers.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add pluggable notification provider system with built-in ntfy and webhook support.
|
||||||
@@ -338,7 +338,10 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
|||||||
### Observability and reflection
|
### Observability and reflection
|
||||||
- `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging
|
- `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging
|
||||||
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
|
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
|
||||||
- `Notifier` (`notifier.ts`) — notification delivery (`NtfyNotifier`)
|
- `Notifier` (`notifier.ts`) — legacy ntfy compatibility shim (`NtfyNotifier`) plus shared ntfy helpers
|
||||||
|
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
|
||||||
|
- `NotificationProvider` interface (`@fusion/core` `notification/provider.ts`) — pluggable provider contract
|
||||||
|
- Built-in providers: `NtfyNotificationProvider` (`notification/ntfy-provider.ts`), `WebhookNotificationProvider` (`notification/webhook-provider.ts`)
|
||||||
- `AgentReflection` (`agent-reflection.ts`) — reflection extraction and persistence
|
- `AgentReflection` (`agent-reflection.ts`) — reflection extraction and persistence
|
||||||
|
|
||||||
### Heartbeat execution
|
### Heartbeat execution
|
||||||
@@ -750,7 +753,7 @@ Settings are split by scope.
|
|||||||
### Global scope
|
### Global scope
|
||||||
- File: `~/.fusion/settings.json`
|
- File: `~/.fusion/settings.json`
|
||||||
- Managed by `GlobalSettingsStore` (`packages/core/src/global-settings.ts`)
|
- Managed by `GlobalSettingsStore` (`packages/core/src/global-settings.ts`)
|
||||||
- Examples: `themeMode`, `colorTheme`, default model/provider, notification preferences
|
- Examples: `themeMode`, `colorTheme`, default model/provider, notification preferences (`ntfy*` legacy fields and `notificationProviders`)
|
||||||
|
|
||||||
### Project scope
|
### Project scope
|
||||||
- Stored in per-project config (`config` table + compatibility file `.fusion/config.json`)
|
- Stored in per-project config (`config` table + compatibility file `.fusion/config.json`)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |
|
||||||
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked. |
|
| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked. |
|
||||||
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
| `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. |
|
||||||
|
| `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). |
|
||||||
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
|
| `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. |
|
||||||
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |
|
| `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. |
|
||||||
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |
|
| `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. |
|
||||||
@@ -68,6 +69,39 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
|||||||
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
|
| `dashboardCurrentNodeId` | `string` | `undefined` | Currently selected dashboard node ID. Restores the last-viewed node on fresh browser/PWA sessions. `undefined` means viewing the local node. |
|
||||||
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
|
| `dashboardCurrentProjectIdByNode` | `Record<string, string>` | `undefined` | Map of node ID to last-selected project ID. Use key `"local"` for the local node. Persists project context across browser restarts and PWA sessions. |
|
||||||
|
|
||||||
|
### Notification providers (pluggable)
|
||||||
|
|
||||||
|
Fusion now supports a provider-list notification model via `notificationProviders` while keeping legacy flat ntfy/webhook settings intact.
|
||||||
|
|
||||||
|
- **Recommended for new setups:** configure providers in `notificationProviders`.
|
||||||
|
- **Backward compatible:** existing `ntfyEnabled`, `ntfyTopic`, `ntfyBaseUrl`, `ntfyEvents`, and `ntfyDashboardHost` still work unchanged.
|
||||||
|
- This is additive/non-breaking; no migration is required for existing ntfy users.
|
||||||
|
|
||||||
|
`notificationProviders` entry shape (`NotificationProviderConfig`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in provider IDs:
|
||||||
|
- `ntfy`
|
||||||
|
- `webhook`
|
||||||
|
|
||||||
|
#### Webhook provider config
|
||||||
|
|
||||||
|
When `id` is `"webhook"`, the provider `config` supports:
|
||||||
|
|
||||||
|
| Field | Type | Default | Notes |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `webhookUrl` | `string` | _required_ | Must be a valid `http://` or `https://` URL. |
|
||||||
|
| `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Invalid/omitted values fall back to `"generic"`. |
|
||||||
|
| `events` | `string[]` | `[]` | Event filter list. Empty/omitted means all events are sent. |
|
||||||
|
|
||||||
Disable daily update checks globally:
|
Disable daily update checks globally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2998,13 +2998,21 @@ export function SettingsModal({
|
|||||||
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart" | "ai-only" | "prefer-main" | "abort" }))
|
setForm((f) => ({ ...f, mergeConflictStrategy: e.target.value as "smart" | "ai-only" | "prefer-main" | "abort" }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="smart">Smart — AI, then auto-resolve, then prefer task branch (default)</option>
|
<option value="smart">Smart, prefer task on fallback — AI → auto-resolve → -X theirs (default, original behavior)</option>
|
||||||
<option value="ai-only">AI only — never silently pick a side</option>
|
<option value="prefer-main">Smart, prefer main on fallback — AI → auto-resolve → -X ours (protects just-merged sibling work)</option>
|
||||||
<option value="prefer-main">Prefer main — fall back to keeping main's version</option>
|
<option value="ai-only">AI only — AI → auto-resolve → AI retry; never silently pick a side</option>
|
||||||
<option value="abort">Abort — fail merge if AI can't resolve (manual review)</option>
|
<option value="abort">Abort — one AI attempt; require manual resolution if it fails</option>
|
||||||
</select>
|
</select>
|
||||||
<small>
|
<small>
|
||||||
What to do on the final merge attempt when AI and auto-resolve haven't fully resolved the conflict. <strong>Smart</strong> matches the historical behavior — the task branch wins on hard conflicts, which is fast but can resurrect code an earlier sibling task deleted. <strong>Prefer main</strong> sides with main instead, protecting just-merged sibling work. <strong>AI only</strong> retries the AI agent rather than silently choosing a side. <strong>Abort</strong> requires manual resolution for any conflict the AI can't handle.
|
Every option starts with the same first two attempts: an AI agent resolves the conflict, then auto-resolve handles lock/generated/trivial files. They differ only in the <em>final fallback</em>:
|
||||||
|
{" "}
|
||||||
|
<strong>Smart, prefer task</strong> uses <code>-X theirs</code> so the task branch wins — fast, but can resurrect code an earlier sibling task deleted (the FN-2887 class of regression).
|
||||||
|
{" "}
|
||||||
|
<strong>Smart, prefer main</strong> uses <code>-X ours</code> so main wins instead — same speed, protects just-merged sibling work.
|
||||||
|
{" "}
|
||||||
|
<strong>AI only</strong> retries the AI agent rather than auto-picking a side.
|
||||||
|
{" "}
|
||||||
|
<strong>Abort</strong> stops after the first AI attempt and waits for a human.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -1702,6 +1702,31 @@ export function TaskDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MergeDetails task={task} />
|
<MergeDetails task={task} />
|
||||||
|
<div className="detail-section">
|
||||||
|
<h4>Node Routing</h4>
|
||||||
|
<dl className="detail-source-grid">
|
||||||
|
<div>
|
||||||
|
<dt>Task Override</dt>
|
||||||
|
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Effective Node</dt>
|
||||||
|
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Routing Source</dt>
|
||||||
|
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Unavailable Node Policy</dt>
|
||||||
|
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Blocking Reason</dt>
|
||||||
|
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
{task.sourceIssue && (
|
{task.sourceIssue && (
|
||||||
<div className="detail-section detail-source-section">
|
<div className="detail-section detail-source-section">
|
||||||
<h4>Source Issue</h4>
|
<h4>Source Issue</h4>
|
||||||
|
|||||||
Reference in New Issue
Block a user