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:
gsxdsm
2026-04-28 15:13:46 -07:00
parent ebeedb194f
commit c9241d8a9c
5 changed files with 82 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add pluggable notification provider system with built-in ntfy and webhook support.

View File

@@ -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`)

View File

@@ -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

View File

@@ -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">

View File

@@ -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>