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

@@ -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,
};
}