diff --git a/.changeset/fn-6031-notification-node.md b/.changeset/fn-6031-notification-node.md new file mode 100644 index 0000000000..0fd111bea8 --- /dev/null +++ b/.changeset/fn-6031-notification-node.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add workflow `notify` nodes so custom workflows can dispatch templated notifications through configured providers. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 3ebd4e11f1..aac27b972e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -46,7 +46,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `ntfyTopic` | `string` | `undefined` | ntfy topic name. | | `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. | | `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer ` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. | -| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | 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; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | +| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | 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; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | | `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. | | `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. | | `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. | @@ -151,7 +151,7 @@ When `id` is `"webhook"`, the provider `config` supports: |---|---|---:|---| | `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. Includes `memory-dreams-processed` for DREAMS.md updates from manual dream processing. | +| `events` | `string[]` | `[]` | Event filter list. Empty/omitted means all events are sent. Includes `memory-dreams-processed` for DREAMS.md updates from manual dream processing and `workflow-notify` for workflow `notify` nodes when explicitly filtered. | #### ntfy provider config @@ -162,7 +162,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports | `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). | | `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. | | `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer ` on ntfy publishes. | -| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. | +| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. | | `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. | Disable daily update checks globally: diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 6637a2e998..4174de1772 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -91,7 +91,7 @@ A v2 column can optionally name a **permanent agent** from the agent registry, s **Write-time validation.** Saving a workflow validates agent references: an unknown `agentId` is rejected with a typed 4xx naming the column. Binding an agent whose permission policy is broader than the project default requires an explicit policy-escalation confirmation (`confirmPolicyEscalation`) at save time, so override cannot silently re-key action gates to a more-privileged agent. -### Workflow IR v2 — step inversion (foreach, loop, step-review, parse-steps, code) +### Workflow IR v2 — step inversion (foreach, loop, step-review, parse-steps, code, notify) The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**. @@ -155,6 +155,12 @@ Parallelism is opt-in *per step by the planner*, not asserted by the workflow au `code` (`{ source, timeoutMs? }`, default 30s, cap 300s) runs inline TypeScript (compiled with esbuild, executed in a timeout-bounded child process with cwd = the task worktree) for logic no built-in node covers. The script default-exports `async (ctx) => result` where `ctx = { task, steps, customFields, context, artifacts: { read(key) }, instance? }` (`instance` present inside a foreach template). The returned `{ outcome?, value?, contextPatch?, customFields? }` routes `outcome:` edges, merges `contextPatch` into walk context, and writes `customFields` through the validated field authority. It gets **no store handle**, cannot write the step list, and a throw/timeout/non-zero exit becomes an audited `failure`. Source compile errors are rejected at save time (a dashboard 400 listing the failing node ids). It runs at the same trust tier as existing project-local script steps. +#### `notify` node — workflow-authored notifications + +`notify` (`{ event, title?, message? }`) dispatches a notification through Fusion's active notification service and then always continues on the normal success path. `event` may be one of the standard notification events (for example `in-review`, `merged`, or `failed`), the built-in workflow-authored event `workflow-notify`, or a provider-specific custom event string. `title` and `message` are optional templates; the engine interpolates `{{taskTitle}}`, `{{taskId}}`, `{{workflowName}}`, and `{{context:key}}` from the workflow walk context. + +Notification delivery is intentionally best-effort: a missing/unconfigured notification service, an empty `event`, or a provider delivery failure is logged/audited but does not fail the workflow node. Providers receive the rendered title/message in notification metadata so ntfy and webhook notifications can show workflow-specific copy. `workflow-notify` is **not** part of the default ntfy event allowlist; add it to `ntfyEvents` or the provider `events` filter when you want workflow-authored notifications delivered. + #### Workflow-defined custom task fields Workflows declare typed task fields via IR `fields: [{ id, name, type, required?, default?, options?, render? }]` (`type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options` for enum kinds; `render.placement ∈ card | detail | detail-section`, `render.widget`, `render.badge`). Values live in `tasks.customFields` and are validated through a single store authority (`updateTaskCustomFields`) with typed rejections (offending `fieldId` + `code`). Editing or switching a workflow **orphans** (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via `fn_task_update`'s `custom_fields` patch; authors set them via `fn_workflow_create/update`. Field values are surfaced in task/session context. @@ -489,7 +495,7 @@ Workflow graph execution is the task lifecycle runtime. `TaskExecutor` pins `wor Default node dispatch: - `prompt` / `script` nodes with `config.seam` dispatch through workflow runtime primitives (`planning`, `execute`, `workflow-step`, `review`, `merge`, `schedule`, `step-execute`) -- `step-review`, `parse-steps`, `code`, and PR nodes use their dedicated primitive/dependency adapters +- `step-review`, `parse-steps`, `code`, `notify`, and PR nodes use their dedicated primitive/dependency adapters - `gate` nodes evaluate context-key expectations or run configured executable checks Traversal semantics: diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 62409d4b7a..599b06c111 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -232,6 +232,65 @@ describe("downgradeIrToV1IfPure — rollback compat (#1405)", () => { }); }); +describe("parseWorkflowIr — notify nodes", () => { + const cols = [{ id: "c", name: "C", traits: [] }]; + + function notifyIr(config: Record | undefined): WorkflowIrV2 { + return v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "notify", kind: "notify", column: "c", config }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "notify" }, + { from: "notify", to: "end" }, + ], + ); + } + + it("accepts a notify node with an event and optional templates", () => { + expect(() => + parseWorkflowIr( + notifyIr({ + event: "workflow-notify", + title: "{{taskTitle}}", + message: "Task {{taskId}} reached {{workflowName}}", + }), + ), + ).not.toThrow(); + }); + + it("accepts omitted message and title", () => { + expect(() => parseWorkflowIr(notifyIr({ event: "custom-event" }))).not.toThrow(); + }); + + it("rejects a notify node missing its event", () => { + expect(() => parseWorkflowIr(notifyIr(undefined))).toThrow( + /notify node 'notify' must declare a non-empty event/, + ); + }); + + it("rejects an empty notify event", () => { + expect(() => parseWorkflowIr(notifyIr({ event: " " }))).toThrow(/non-empty event/); + }); + + it("rejects non-string optional templates", () => { + expect(() => parseWorkflowIr(notifyIr({ event: "workflow-notify", message: 42 }))).toThrow( + /message must be a string/, + ); + expect(() => parseWorkflowIr(notifyIr({ event: "workflow-notify", title: false }))).toThrow( + /title must be a string/, + ); + }); + + it("keeps v2 when a notify node is present", () => { + const parsed = parseWorkflowIr(notifyIr({ event: "workflow-notify" })); + expect(downgradeIrToV1IfPure(parsed).version).toBe("v2"); + }); +}); + describe("parseWorkflowIr — hold release kinds", () => { const holdCols = [{ id: "c", name: "C", traits: [] }]; function holdIr(release: unknown): WorkflowIrV2 { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b2814f3c26..9ee6a8c411 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -571,7 +571,8 @@ export type NtfyNotificationEvent = | "message:agent-to-agent" | "message:room" | "oauth-token-expired" - | "task-created"; + | "task-created" + | "workflow-notify"; /** Known notification event types. Providers may support additional custom events. */ export const NOTIFICATION_EVENTS = [ @@ -592,6 +593,7 @@ export const NOTIFICATION_EVENTS = [ "message:room", "oauth-token-expired", "task-created", + "workflow-notify", ] as const; /** Notification event type. Known events plus provider-specific custom events. */ diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 222aad0278..6256537432 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -3,7 +3,8 @@ * step-inversion additions (FN step-inversion, KTD-3/4/12/15): `foreach` * (runtime-expanding per-step template region), `step-review` (per-step review * verdicts as outcome edges), `parse-steps` (graph-native step-list parsing), - * `code` (sandboxed TypeScript), and `loop` (bounded repeat-until region); + * `code` (sandboxed TypeScript), `notify` (workflow-authored notifications), + * and `loop` (bounded repeat-until region); * and the unified PR-entity additions (U3): * `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */ @@ -21,6 +22,7 @@ export type WorkflowIrNodeKind = | "step-review" | "parse-steps" | "code" + | "notify" | "pr-create" | "pr-respond" | "pr-merge"; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 14b2bef99b..a0cbdf400d 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -849,6 +849,24 @@ function validateCodeNodes(nodes: WorkflowIrNode[]): void { } } +/** Validate workflow-authored notification node config. */ +function validateNotifyNodes(nodes: WorkflowIrNode[]): void { + for (const node of nodes) { + if (node.kind !== "notify") continue; + const cfg = node.config as { event?: unknown; message?: unknown; title?: unknown } | undefined; + const event = cfg?.event; + if (typeof event !== "string" || event.trim() === "") { + throw new WorkflowIrError(`notify node '${node.id}' must declare a non-empty event`); + } + if (cfg?.message !== undefined && typeof cfg.message !== "string") { + throw new WorkflowIrError(`notify node '${node.id}' message must be a string`); + } + if (cfg?.title !== undefined && typeof cfg.title !== "string") { + throw new WorkflowIrError(`notify node '${node.id}' title must be a string`); + } + } +} + /** Validate `fields` declarations (KTD-13). */ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { if (fields === undefined) return; @@ -1210,6 +1228,7 @@ function validateV2(ir: WorkflowIrV2): void { validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); validateParseStepsNodes(ir); validateCodeNodes(ir.nodes); + validateNotifyNodes(ir.nodes); validateFields(ir.fields); validateSettings(ir.settings); diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index e7339baf6e..33921e967f 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -15,7 +15,7 @@ import { type Edge as FlowEdge, } from "@xyflow/react"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles } from "lucide-react"; import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -189,6 +189,15 @@ const BUILTIN_STEP_PARSERS = ["step-headings", "json-steps"] as const; /** Step-review verdict outcomes (KTD-4), authored as `outcome:` edge * conditions and displayed as short labels. */ const STEP_REVIEW_VERDICTS = ["approve", "revise", "rethink", "unavailable"] as const; +const NOTIFY_EVENT_OPTIONS = [ + "in-review", + "merged", + "failed", + "awaiting-approval", + "task-created", + "workflow-notify", +] as const; +const NOTIFY_CUSTOM_EVENT_VALUE = "__custom"; const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record }> = [ { kind: "prompt", label: "Prompt", icon: MessageSquare }, @@ -205,6 +214,7 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } }, { kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } }, { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, + { kind: "notify", label: "Notify", icon: Bell, presetConfig: { event: "in-review", title: "{{taskTitle}}", message: "" } }, ]; /** Map a step template to a single pre-configured editor node (kind + config), @@ -254,6 +264,7 @@ const USER_NODE_KINDS: ReadonlySet = new Set ) : null} + {selectedNode.data.kind === "notify" ? ( + (() => { + const eventValue = String(selectedNode.data.config?.event ?? "workflow-notify"); + const isCustom = !NOTIFY_EVENT_OPTIONS.includes(eventValue as typeof NOTIFY_EVENT_OPTIONS[number]); + return ( + <> + + {isCustom ? ( + + ) : null} + +