Files
fusion/packages/engine/src/workflow-node-runners/notify-runner.ts

107 lines
4.0 KiB
TypeScript

import type { NotificationEvent, NotificationPayload } from "@fusion/core";
import { schedulerLog } from "../logger.js";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "../workflow-graph-executor.js";
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
const WORKFLOW_ID_CONTEXT_KEY = "workflow:id";
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}}}`;
});
}
/*
FNXC:WorkflowNodeRunners 2026-07-01-00:00:
Notify nodes are a dedicated runner because their side effect is optional operator notification, not graph traversal. Missing dispatch stays a successful skipped outcome so workflows do not fail when notification infrastructure is unwired.
*/
export class NotifyNodeRunner implements WorkflowNodeRunner {
public readonly kind = "notify" as const;
public constructor(private readonly notifyDispatch?: WorkflowNotifyDispatch) {}
public async run(node: Parameters<WorkflowNodeHandler>[0], ctx: WorkflowNodeRunnerContext): Promise<WorkflowNodeResult> {
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 (!this.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 this.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 function createNotifyHandler(notifyDispatch?: WorkflowNotifyDispatch): WorkflowNodeHandler {
const runner = new NotifyNodeRunner(notifyDispatch);
return (node, context) => runner.run(node, context);
}