FN-6031: add workflow notification nodes

Add workflow notify nodes with templated notification dispatch support.

- add notify node support to workflow IR, engine handlers, and executor wiring
- expose notify node configuration and summaries in the dashboard editor and node metadata
- add regression tests and a published changeset, plus workflow/settings documentation updates

Files changed:
 .changeset/fn-6031-notification-node.md            |   5 +
 docs/settings-reference.md                         |   6 +-
 docs/workflow-steps.md                             |  10 +-
 packages/core/src/__tests__/workflow-ir.test.ts    |  59 ++++++++++
 packages/core/src/types.ts                         |   4 +-
 packages/core/src/workflow-ir-types.ts             |   4 +-
 packages/core/src/workflow-ir.ts                   |  19 +++
 packages/dashboard/app/components/WorkflowNodeEditor.tsx          |  76 +++++++++++-
 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx          |  61 ++++++++++
 packages/dashboard/app/components/__tests__/node-summary.test.ts  |  29 +++++
 packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts        |  37 ++++++
 packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx     |   7 +-
 packages/dashboard/app/components/nodes/node-summary.ts |   5 +
 packages/engine/src/__tests__/workflow-node-handlers-notify.test.ts          | 131 +++++++++++++++++++++
 packages/engine/src/executor.ts                    |   2 +
 packages/engine/src/notification/ntfy-provider.ts  |  11 ++
 packages/engine/src/notification/webhook-provider.ts    |   6 +-
 packages/engine/src/workflow-graph-executor.ts     |   4 +
 packages/engine/src/workflow-graph-task-runner.ts  |   4 +
 packages/engine/src/workflow-node-handlers.ts      |  93 ++++++++++++++-
 20 files changed, 560 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-6031

Fusion-Task-Lineage: ff5c91e2-3872-4264-9c18-5d9e11628f13
This commit is contained in:
gsxdsm
2026-06-09 01:57:16 -07:00
parent 59c613cf38
commit 13c6d96fe1
20 changed files with 560 additions and 13 deletions

View File

@@ -232,6 +232,65 @@ describe("downgradeIrToV1IfPure — rollback compat (#1405)", () => {
});
});
describe("parseWorkflowIr — notify nodes", () => {
const cols = [{ id: "c", name: "C", traits: [] }];
function notifyIr(config: Record<string, unknown> | 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 {

View File

@@ -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. */

View File

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

View File

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

View File

@@ -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:<verdict>` 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<string, unknown> }> = [
{ 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<WorkflowEditorNodeKind> = new Set<WorkflowEdi
"loop",
"step-review",
"parse-steps",
"notify",
"merge",
]);
@@ -3601,6 +3612,69 @@ function InnerEditor({
</>
) : 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 (
<>
<label className="wf-field">
<span>{t("workflowNodes.notifyEvent", "Event type")}</span>
<select
value={isCustom ? NOTIFY_CUSTOM_EVENT_VALUE : eventValue}
onChange={(e) => {
const value = e.target.value;
updateSelectedData({
config: {
event: value === NOTIFY_CUSTOM_EVENT_VALUE ? "custom-event" : value,
},
});
}}
>
{NOTIFY_EVENT_OPTIONS.map((event) => (
<option key={event} value={event}>{event}</option>
))}
<option value={NOTIFY_CUSTOM_EVENT_VALUE}>{t("workflowNodes.notifyCustom", "Custom")}</option>
</select>
</label>
{isCustom ? (
<label className="wf-field">
<span>{t("workflowNodes.notifyCustomEvent", "Custom event")}</span>
<input
value={eventValue}
placeholder="custom-event"
onChange={(e) => updateSelectedData({ config: { event: e.target.value } })}
/>
</label>
) : null}
<label className="wf-field">
<span>{t("workflowNodes.notifyTitle", "Title (optional)")}</span>
<input
value={String(selectedNode.data.config?.title ?? "{{taskTitle}}")}
placeholder="{{taskTitle}}"
onChange={(e) => updateSelectedData({ config: { title: e.target.value } })}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.notifyMessage", "Message (optional)")}</span>
<textarea
rows={4}
value={String(selectedNode.data.config?.message ?? "")}
placeholder="Task {{taskId}} reached {{workflowName}}"
onChange={(e) => updateSelectedData({ config: { message: e.target.value } })}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.notifyNote",
"Templates may use {{taskTitle}}, {{taskId}}, {{workflowName}}, and {{context:key}}.",
)}
</p>
</>
);
})()
) : null}
{selectedNode.data.kind === "prompt" ||
selectedNode.data.kind === "gate" ||
selectedNode.data.kind === "script" ? (

View File

@@ -752,6 +752,7 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
expect(screen.getByText("Step review")).toBeInTheDocument();
expect(screen.getByText("Parse steps")).toBeInTheDocument();
expect(screen.getByText("Code")).toBeInTheDocument();
expect(screen.getByText("Notify")).toBeInTheDocument();
});
it("auto-populates a step-execute child when a foreach is added from the palette", async () => {
@@ -935,6 +936,64 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
fireEvent.change(timeout, { target: { value: "12000" } });
expect(timeout.value).toBe("12000");
});
it("edits notify event, title, and message fields", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
fireEvent.click(screen.getByText("Notify").closest("button")!);
await waitFor(() => expect(screen.getByTestId("wf-node-notify")).toBeInTheDocument());
const eventSelect = (await screen.findByText("Event type")).parentElement!.querySelector("select")! as HTMLSelectElement;
expect(eventSelect.value).toBe("in-review");
fireEvent.change(eventSelect, { target: { value: "workflow-notify" } });
expect(eventSelect.value).toBe("workflow-notify");
const title = screen.getByText("Title (optional)").parentElement!.querySelector("input")! as HTMLInputElement;
fireEvent.change(title, { target: { value: "{{taskTitle}} done" } });
expect(title.value).toBe("{{taskTitle}} done");
const message = screen.getByText("Message (optional)").parentElement!.querySelector("textarea")! as HTMLTextAreaElement;
fireEvent.change(message, { target: { value: "Task {{taskId}} reached {{workflowName}}" } });
expect(message.value).toContain("{{workflowName}}");
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
const notify = ir.nodes.find((n) => n.kind === "notify");
expect(notify?.config).toMatchObject({
event: "workflow-notify",
title: "{{taskTitle}} done",
message: "Task {{taskId}} reached {{workflowName}}",
});
});
it("toggles notify custom event input and preserves it across reselect", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
fireEvent.click(screen.getByText("Notify").closest("button")!);
await waitFor(() => expect(screen.getByTestId("wf-node-notify")).toBeInTheDocument());
const eventSelect = (await screen.findByText("Event type")).parentElement!.querySelector("select")! as HTMLSelectElement;
fireEvent.change(eventSelect, { target: { value: "__custom" } });
const custom = (await screen.findByText("Custom event")).parentElement!.querySelector("input")! as HTMLInputElement;
expect(custom.value).toBe("custom-event");
fireEvent.change(custom, { target: { value: "deploy-finished" } });
expect(custom.value).toBe("deploy-finished");
fireEvent.click(screen.getByTestId("wf-node-start"));
await waitFor(() => expect(screen.queryByText("Custom event")).not.toBeInTheDocument());
fireEvent.click(screen.getByTestId("wf-node-notify"));
const preserved = (await screen.findByText("Custom event")).parentElement!.querySelector("input")! as HTMLInputElement;
expect(preserved.value).toBe("deploy-finished");
});
});
// ── Regression: selecting the real stepwise built-in renders the foreach group
@@ -1371,6 +1430,8 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
it("renames the workflow inline: click → input prefilled → Enter commits", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
const nameBtn = await screen.findByTestId("wf-workflow-name");
expect(nameBtn).toHaveTextContent("Custom");
fireEvent.click(nameBtn);

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { nodeConfigSummary } from "../nodes/node-summary";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
describe("nodeConfigSummary", () => {
it("summarizes notify nodes by event", () => {
const data: WorkflowFlowNodeData = {
kind: "notify",
label: "Notify",
config: { event: "custom-event" },
};
expect(nodeConfigSummary(data)).toBe("custom-event");
});
it("includes a truncated notify message preview", () => {
const data: WorkflowFlowNodeData = {
kind: "notify",
label: "Notify",
config: {
event: "workflow-notify",
message: "This message is intentionally long enough that the summary should truncate it cleanly.",
},
};
expect(nodeConfigSummary(data)).toBe("workflow-notify · This message is intentionally long enou…");
});
});

View File

@@ -243,6 +243,43 @@ describe("workflow-flow-mapping v2 round-trip", () => {
const { ir: out } = flowToIr("wf", nodes, edges, columnsOf(def));
expect(out.version).toBe("v1");
});
it("round-trips notify nodes as first-class editor nodes", () => {
const notifyIr: WorkflowDefinition["ir"] = {
version: "v2",
name: "notify-wf",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{
id: "notify",
kind: "notify",
column: "todo",
config: { event: "workflow-notify", title: "{{taskTitle}}", message: "Task {{taskId}}" },
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "notify", condition: "success" },
{ from: "notify", to: "end", condition: "success" },
],
};
const { nodes, edges } = irToFlow(v2Def(notifyIr));
const notifyNode = nodes.find((node) => node.id === "notify");
expect(notifyNode?.type).toBe("notify");
expect(notifyNode?.data.kind).toBe("notify");
const { ir: out } = flowToIr("notify-wf", nodes, edges, columnsOf(v2Def(notifyIr)));
expect(out.version).toBe("v2");
if (out.version !== "v2") return;
const roundTripped = out.nodes.find((node) => node.id === "notify");
expect(roundTripped).toMatchObject({
kind: "notify",
column: "todo",
config: { event: "workflow-notify", title: "{{taskTitle}}", message: "Task {{taskId}}" },
});
});
});
describe("workflow-flow-mapping validation helpers", () => {

View File

@@ -1,5 +1,5 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { nodeConfigSummary } from "./node-summary";
import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
@@ -25,7 +25,8 @@ export type WorkflowEditorNodeKind =
| "loop"
| "step-review"
| "parse-steps"
| "code";
| "code"
| "notify";
export interface WorkflowFlowNodeData {
kind: WorkflowEditorNodeKind;
@@ -62,6 +63,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
"step-review": ClipboardCheck,
"parse-steps": ListChecks,
code: Code2,
notify: Bell,
};
/** Shared error-state component (U10): one component renders both the
@@ -205,4 +207,5 @@ export const workflowNodeTypes = {
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,
notify: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="notify" />,
};

View File

@@ -197,6 +197,11 @@ export function nodeConfigSummary(
const source = firstLine(str(config.source));
return source ? truncate(source, COMMAND_TRUNCATE) : t("workflowNodes.summaryCodeDefault", "TypeScript");
}
case "notify": {
const event = str(config.event) || "workflow-notify";
const message = truncate(firstLine(str(config.message)), COMMAND_TRUNCATE);
return message ? `${event} · ${message}` : event;
}
// No meaningful summary: structural/control nodes.
case "start":
case "end":

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import type { NotificationPayload, TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import { createDefaultNodeHandlers, createNoopLegacySeams } from "../workflow-node-handlers.js";
import type { WorkflowNodeExecutionContext } from "../workflow-graph-executor.js";
const notifyNode: WorkflowIrNode = {
id: "notify",
kind: "notify",
column: "todo",
config: {
event: "workflow-notify",
title: "{{taskTitle}} in {{workflowName}}",
message: "Task {{taskId}} hit {{context:stage}} with {{context:object}}",
},
};
function ctx(overrides: Partial<WorkflowNodeExecutionContext> = {}): WorkflowNodeExecutionContext {
return {
task: {
id: "FN-6031",
title: "Notify task",
description: "Task body",
} as TaskDetail,
settings: undefined,
context: {
"workflow:id": "Custom Workflow",
stage: "review",
object: { ok: true },
},
...overrides,
};
}
describe("workflow notify node handler", () => {
it("dispatches an interpolated notification payload", async () => {
const dispatch = vi.fn(async () => undefined);
const handlers = createDefaultNodeHandlers(createNoopLegacySeams(), undefined, {
notifyDispatch: dispatch,
});
const result = await handlers.notify(notifyNode, ctx());
expect(result).toEqual({ outcome: "success" });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(
"workflow-notify",
expect.objectContaining({
taskId: "FN-6031",
taskTitle: "Notify task",
taskDescription: "Task body",
event: "workflow-notify",
metadata: expect.objectContaining({
nodeId: "notify",
workflowName: "Custom Workflow",
title: "Notify task in Custom Workflow",
message: 'Task FN-6031 hit review with {"ok":true}',
}),
} satisfies Partial<NotificationPayload>),
);
});
it("skips successfully when dispatch is unwired", async () => {
const handlers = createDefaultNodeHandlers(createNoopLegacySeams(), undefined, {});
await expect(handlers.notify(notifyNode, ctx())).resolves.toEqual({
outcome: "success",
value: "notify-skipped",
});
});
it("handles missing config gracefully", async () => {
const dispatch = vi.fn(async () => undefined);
const handlers = createDefaultNodeHandlers(createNoopLegacySeams(), undefined, {
notifyDispatch: dispatch,
});
await expect(handlers.notify({ id: "notify", kind: "notify" } as WorkflowIrNode, ctx())).resolves.toEqual({
outcome: "success",
value: "notify-skipped",
});
expect(dispatch).not.toHaveBeenCalled();
});
it("does not fail the node when dispatch throws", async () => {
const dispatch = vi.fn(async () => {
throw new Error("provider down");
});
const handlers = createDefaultNodeHandlers(createNoopLegacySeams(), undefined, {
notifyDispatch: dispatch,
});
await expect(handlers.notify(notifyNode, ctx())).resolves.toEqual({ outcome: "success" });
});
it("is wired into the graph executor default handlers", async () => {
const dispatch = vi.fn(async () => undefined);
const ir: WorkflowIr = {
version: "v2",
name: "Notify Workflow",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "notify", kind: "notify", column: "todo", config: { event: "custom-event", message: "{{workflowName}}" } },
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "notify" },
{ from: "notify", to: "end" },
],
};
const executor = new WorkflowGraphExecutor({ notifyDispatch: dispatch });
const result = await executor.run(
{ id: "FN-6031", description: "body" } as TaskDetail,
{ experimentalFeatures: { workflowGraphExecutor: true } },
ir,
);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toEqual(["start", "notify"]);
expect(dispatch).toHaveBeenCalledWith(
"custom-event",
expect.objectContaining({
taskTitle: "FN-6031",
metadata: expect.objectContaining({ message: "Notify Workflow" }),
}),
);
});
});

View File

@@ -20,6 +20,7 @@ import {
} from "@fusion/core";
import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js";
import { createCodeNodeRunner } from "./code-node-runner.js";
import { getActiveNotificationService } from "./notifier.js";
import type { ParseStepsHandlerDeps, CodeNodeRunner } from "./workflow-node-handlers.js";
import type { WorkflowBranchPersistence, WorkflowBranchRunState } from "./workflow-graph-branches.js";
import type {
@@ -3786,6 +3787,7 @@ export class TaskExecutor {
// Step-inversion (KTD-15, U14): code node runner — esbuild compile +
// child-process execution with the harness contract.
runCode: this.buildCodeNodeRunner(),
notifyDispatch: (event, payload) => getActiveNotificationService()?.dispatch(event, payload),
// PR-entity nodes (U3): pr-create/pr-respond/pr-merge handler deps —
// engine-owned store + CLI-injected GitHub callbacks. Absent → fail closed.
prNodes: this.options.prNodes,

View File

@@ -39,6 +39,7 @@ type SupportedNtfyEvent =
| "planning-awaiting-input"
| "fallback-used"
| "task-created"
| "workflow-notify"
| "message:agent-to-user"
| "message:agent-to-agent"
| "message:room"
@@ -53,6 +54,7 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
"planning-awaiting-input",
"fallback-used",
"task-created",
"workflow-notify",
"message:agent-to-user",
"message:agent-to-agent",
"message:room",
@@ -235,6 +237,15 @@ export class NtfyNotificationProvider implements NotificationProvider {
message: `${typeof payload.metadata?.agentName === "string" && payload.metadata.agentName.trim().length > 0 ? payload.metadata.agentName.trim() : "An agent"} created "${identifier}"`,
priority: "default",
},
"workflow-notify": {
title: typeof payload.metadata?.title === "string" && payload.metadata.title.trim().length > 0
? payload.metadata.title.trim()
: `Workflow notification for ${taskId}`,
message: typeof payload.metadata?.message === "string" && payload.metadata.message.trim().length > 0
? payload.metadata.message.trim()
: `Workflow notification for task "${identifier}"`,
priority: "default",
},
"message:agent-to-user": {
title: `New message from ${senderLabel}`,
message: `${senderLabel} → you: ${preview}`,

View File

@@ -196,8 +196,10 @@ export class WebhookNotificationProvider implements NotificationProvider {
const providerName = typeof payload.metadata?.providerName === "string" ? payload.metadata.providerName : providerId;
return `Your ${providerName} OAuth token has expired — please re-authenticate`;
}
default:
return `Event "${event}" for task ${identifier}`;
default: {
const message = typeof payload.metadata?.message === "string" ? payload.metadata.message.trim() : "";
return message || `Event "${event}" for task ${identifier}`;
}
}
}

View File

@@ -10,6 +10,7 @@ import {
type CodeNodeRunner,
type ForeachActiveContext,
type ParseStepsHandlerDeps,
type WorkflowNotifyDispatch,
type WorkflowCustomNodeRunner,
type WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
@@ -74,6 +75,8 @@ export interface WorkflowGraphExecutorDeps {
/** Step-inversion (U14, KTD-15): runner for the `code` node (esbuild compile +
* child-process execution). Absent → a code node fails cleanly. */
runCode?: CodeNodeRunner;
/** notify node dispatch callback. Absent → notify nodes succeed with notify-skipped. */
notifyDispatch?: WorkflowNotifyDispatch;
/** PR-entity nodes (U3): deps for `pr-create`/`pr-respond`/`pr-merge` (injected
* GitHub callbacks + store accessor). Absent → the pr-* kinds fail cleanly. */
prNodes?: PrNodeDeps;
@@ -233,6 +236,7 @@ export class WorkflowGraphExecutor {
primitives: deps.primitives,
parseSteps: deps.parseStepsDeps,
runCode: deps.runCode,
notifyDispatch: deps.notifyDispatch,
prNodes: deps.prNodes,
}),
...(deps.handlers ?? {}),

View File

@@ -6,6 +6,7 @@ import type {
CodeNodeRunner,
ForeachActiveContext,
ParseStepsHandlerDeps,
WorkflowNotifyDispatch,
WorkflowCustomNodeRunner,
WorkflowLegacySeams,
} from "./workflow-node-handlers.js";
@@ -72,6 +73,8 @@ export interface WorkflowGraphTaskRunnerDeps {
/** Step-inversion (U14, KTD-15): `code` node runner. Additive; a workflow with
* no code node never invokes it. */
runCode?: CodeNodeRunner;
/** notify node dispatch callback. Additive; absent → notify nodes are skipped. */
notifyDispatch?: WorkflowNotifyDispatch;
/** PR-entity nodes (U3): deps for `pr-create`/`pr-respond`/`pr-merge`. Additive;
* a workflow with no pr-* node never invokes them; absent → they fail closed. */
prNodes?: PrNodeDeps;
@@ -224,6 +227,7 @@ export class WorkflowGraphTaskRunner {
onReworkReset: this.deps.onReworkReset,
parseStepsDeps: this.deps.parseStepsDeps,
runCode: this.deps.runCode,
notifyDispatch: this.deps.notifyDispatch,
prNodes: this.deps.prNodes,
// Step-inversion (KTD-11, U10): worktree isolation + parallel scheduling.
allocateInstanceWorktree: this.deps.allocateInstanceWorktree,

View File

@@ -1,8 +1,9 @@
import { WorkflowIrError, getStepParser, instanceNodeId } from "@fusion/core";
import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { NotificationEvent, NotificationPayload, TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js";
import { schedulerLog } from "./logger.js";
import {
primitiveNodeContext,
type WorkflowPrimitiveContext,
@@ -763,6 +764,92 @@ export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHan
};
}
export type WorkflowNotifyDispatch = (
event: NotificationEvent,
payload: NotificationPayload,
) => Promise<void> | void;
function stringifyTemplateValue(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function interpolateNotifyTemplate(
template: string,
vars: {
taskId: string;
taskTitle: string;
workflowName: string;
context: Record<string, unknown>;
},
): string {
return template.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_match, rawName: string) => {
const name = rawName.trim();
if (name === "taskId") return vars.taskId;
if (name === "taskTitle") return vars.taskTitle;
if (name === "workflowName") return vars.workflowName;
if (name.startsWith("context:")) {
return stringifyTemplateValue(vars.context[name.slice("context:".length)]);
}
return `{{${rawName}}}`;
});
}
export function createNotifyHandler(notifyDispatch?: WorkflowNotifyDispatch): WorkflowNodeHandler {
return async (node, ctx) => {
const cfg = (node.config ?? {}) as { event?: unknown; message?: unknown; title?: unknown };
const event = typeof cfg.event === "string" ? cfg.event.trim() : "";
if (!event) {
schedulerLog.log(`Workflow notify node '${node.id}' skipped because it has no event`);
return { outcome: "success", value: "notify-skipped" };
}
if (!notifyDispatch) {
schedulerLog.log(`Workflow notify node '${node.id}' skipped because notification dispatch is unwired`);
return { outcome: "success", value: "notify-skipped" };
}
const taskTitle = typeof ctx.task.title === "string" && ctx.task.title.trim() !== ""
? ctx.task.title
: ctx.task.id;
const workflowName = typeof ctx.context[WORKFLOW_ID_CONTEXT_KEY] === "string"
? ctx.context[WORKFLOW_ID_CONTEXT_KEY]
: "unknown";
const vars = { taskId: ctx.task.id, taskTitle, workflowName, context: ctx.context };
const title = typeof cfg.title === "string" ? interpolateNotifyTemplate(cfg.title, vars) : taskTitle;
const message = typeof cfg.message === "string" ? interpolateNotifyTemplate(cfg.message, vars) : "";
const payload: NotificationPayload = {
taskId: ctx.task.id,
taskTitle,
taskDescription: ctx.task.description,
event,
timestamp: new Date().toISOString(),
metadata: {
nodeId: node.id,
workflowName,
title,
message,
},
};
try {
await notifyDispatch(event, payload);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
schedulerLog.log(`Workflow notify node '${node.id}' dispatch failed for event=${event}: ${detail}`);
}
return { outcome: "success" };
};
}
export interface DefaultNodeHandlerDeps {
/** Workflow-native runtime primitives. When present they replace legacy seams. */
primitives?: WorkflowRuntimePrimitives;
@@ -770,6 +857,8 @@ export interface DefaultNodeHandlerDeps {
parseSteps?: ParseStepsHandlerDeps;
/** code node runner (U14). When absent, a code node fails cleanly. */
runCode?: CodeNodeRunner;
/** notify node dispatch callback. When absent, notify nodes succeed with notify-skipped. */
notifyDispatch?: WorkflowNotifyDispatch;
/** PR node deps (U3). When absent, the three pr-* kinds fail cleanly. */
prNodes?: PrNodeDeps;
}
@@ -785,6 +874,7 @@ export function createDefaultNodeHandlers(
| "step-review"
| "parse-steps"
| "code"
| "notify"
| "pr-create"
| "pr-respond"
| "pr-merge",
@@ -827,6 +917,7 @@ export function createDefaultNodeHandlers(
: createStepReviewHandler(seams),
"parse-steps": parseSteps,
code: createCodeNodeHandler(deps?.runCode),
notify: createNotifyHandler(deps?.notifyDispatch),
...prNodes,
};
}