FN-5767: wire workflow graph executor to legacy seams

Complete Phase 3 interpreter parity by routing workflow graph execution through legacy seam handlers.

- Simplify workflow IR schema and built-in coding workflow to seam-based v1 nodes/edges.
- Add workflow node handler layer with default legacy seam adapters and dedicated handler/parity tests.
- Update engine exports/executor behavior for conditional traversal, retries, outcome context, and parity assertions.
- Refresh core workflow IR tests/coverage for the new shape (including seam-stage expectations without triage) and remove obsolete schema fixture test.

Files changed:
 .changeset/fn-5767-interpreter-parity.md           |   5 +
 .github/actions/setup-node-pnpm/action.yml         |  41 +---
 docs/workflow-steps.md                             |  17 ++
 .../__tests__/builtin-coding-workflow-ir.test.ts   |  25 +--
 packages/core/src/__tests__/workflow-ir.test.ts    |  70 ------
 packages/core/src/builtin-coding-workflow-ir.ts    |  71 ++----
 packages/core/src/index.ts                         |  31 +--
 packages/core/src/workflow-ir-types.ts             |  91 +-------
 packages/core/src/workflow-ir.ts                   | 246 ++-------------------
 .../workflow-graph-executor-handlers.test.ts       | 201 +++++++++++++++++
 .../workflow-graph-executor-parity.test.ts         | 126 ++++++++---
 .../src/__tests__/workflow-node-handlers.test.ts   |  50 +++++
 packages/engine/src/index.ts                       |  16 +-
 packages/engine/src/workflow-graph-executor.ts     | 205 ++++++++++++-----
 packages/engine/src/workflow-node-handlers.ts      |  56 +++++
 15 files changed, 661 insertions(+), 590 deletions(-)

Fusion-Task-Id: FN-5767

Fusion-Task-Lineage: 0c68586e-2709-4521-a2ce-938ba1006ae0
This commit is contained in:
gsxdsm
2026-05-31 05:56:20 -07:00
parent 1edbb54fce
commit ba81d1f0ac
15 changed files with 672 additions and 601 deletions

View File

@@ -0,0 +1,201 @@
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
import type { TaskDetail, WorkflowIr } from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
const task = { id: "FN-5767" } as TaskDetail;
function settingsOn() {
return { experimentalFeatures: { workflowGraphExecutor: true } };
}
describe("WorkflowGraphExecutor traversal", () => {
it("walks linear graph", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "linear",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "end", condition: "success" },
],
};
const handler = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
const result = await executor.run(task, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(handler).toHaveBeenCalledTimes(1);
});
it("routes failure edges", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "failure-route",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "b", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "b", condition: "failure" },
{ from: "b", to: "end", condition: "success" },
],
};
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async () => ({ outcome: "failure" }),
script: async () => ({ outcome: "success" }),
},
});
const result = await executor.run(task, settingsOn(), ir);
expect(result.visitedNodeIds).toContain("b");
});
it("supports outcome:value conditions", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "outcome-value",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "left", kind: "script" },
{ id: "right", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "left", condition: "outcome:left" },
{ from: "a", to: "right", condition: "outcome:right" },
{ from: "left", to: "end" },
{ from: "right", to: "end" },
],
};
const script = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async () => ({ outcome: "success", value: "right" }),
script,
},
});
const result = await executor.run(task, settingsOn(), ir);
expect(result.visitedNodeIds).toContain("right");
expect(result.visitedNodeIds).not.toContain("left");
});
it("leaves outcome unchanged when outcome:value does not match any edge", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "outcome-miss",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "left", kind: "script" },
{ id: "right", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "left", condition: "outcome:left" },
{ from: "a", to: "right", condition: "outcome:right" },
],
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt: async () => ({ outcome: "success", value: "miss" }) } });
const result = await executor.run(task, settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).not.toContain("left");
expect(result.visitedNodeIds).not.toContain("right");
});
it("caps retries and converts exceptions to failure", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "retry",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "end", condition: "failure" },
],
};
const handler = vi.fn(async () => {
throw new Error("boom");
});
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler }, maxRetriesPerNode: 3 });
const result = await executor.run(task, settingsOn(), ir);
expect(handler).toHaveBeenCalledTimes(3);
expect(result.outcome).toBe("failure");
});
it("fan-out executes deterministic sorted order", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "fanout",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "b", kind: "script" },
{ id: "c", kind: "script" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "c" },
{ from: "a", to: "b" },
{ from: "b", to: "end" },
{ from: "c", to: "end" },
],
};
const order: string[] = [];
const executor = new WorkflowGraphExecutor({
handlers: {
prompt: async () => ({ outcome: "success" }),
script: async (node) => {
order.push(node.id);
return { outcome: "success" };
},
},
});
await executor.run(task, settingsOn(), ir);
expect(order).toEqual(["b", "c"]);
});
it("builtin coding workflow ir exposes expected lifecycle nodes", () => {
expect(BUILTIN_CODING_WORKFLOW_IR.nodes.map((node) => node.id)).toEqual(
expect.arrayContaining(["start", "execute", "review", "merge", "end"]),
);
});
it("rejects malformed cyclic graphs", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "cycle",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "a" },
],
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt: async () => ({ outcome: "success" }) } });
await expect(executor.run(task, settingsOn(), ir)).rejects.toThrow("Cycle detected");
});
});

View File

@@ -1,32 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr } from "@fusion/core";
import { WorkflowGraphExecutor, WORKFLOW_GRAPH_EXECUTOR_FLAG } from "../workflow-graph-executor.js";
import type { TaskDetail } from "@fusion/core";
describe("workflow graph executor parity scaffold", () => {
it("is strict no-op when flag is absent or false", async () => {
const onNode = vi.fn();
const executor = new WorkflowGraphExecutor({ onNode });
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
import type { WorkflowLegacySeams } from "../workflow-node-handlers.js";
const absent = await executor.run({ workflow: BUILTIN_CODING_WORKFLOW_IR, settings: undefined });
expect(absent).toEqual({ executed: false, visitedNodeIds: [], reason: "flag-disabled" });
const task = { id: "FN-5767" } as TaskDetail;
const disabled = await executor.run({
workflow: BUILTIN_CODING_WORKFLOW_IR,
settings: { experimentalFeatures: { [WORKFLOW_GRAPH_EXECUTOR_FLAG]: false } },
});
expect(disabled).toEqual({ executed: false, visitedNodeIds: [], reason: "flag-disabled" });
expect(onNode).not.toHaveBeenCalled();
function runLegacy(seams: WorkflowLegacySeams) {
return async () => {
const events: string[] = [];
const execute = await seams.execute(task, {});
events.push(`execute:${execute.outcome}`);
if (execute.outcome !== "success") return events;
const review = await seams.review(task, {});
events.push(`review:${review.outcome}`);
if (review.outcome !== "success") return events;
const merge = await seams.merge(task, {});
events.push(`merge:${merge.outcome}`);
return events;
};
}
describe("WorkflowGraphExecutor interpreter-parity", () => {
it("is a strict no-op when workflowGraphExecutor flag is disabled", async () => {
const prompt = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt, script: prompt, gate: prompt } });
const result = await executor.run(task, { experimentalFeatures: {} });
expect(result.executed).toBe(false);
expect(prompt).not.toHaveBeenCalled();
});
it("loads builtin coding workflow IR", () => {
const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const stages = parsed.nodes.map((node) => String(node.config?.stage ?? ""));
it("matches legacy execute-review-merge success path", async () => {
const events: string[] = [];
const seams: WorkflowLegacySeams = {
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
};
const legacyEvents = await runLegacy(seams)();
const executor = new WorkflowGraphExecutor({ seams, handlers: { prompt: async (node, ctx) => {
const seam = String(node.config?.seam);
const result = await seams[seam as keyof WorkflowLegacySeams](ctx.task, ctx.context);
events.push(`${seam}:${result.outcome}`);
return result;
} } });
expect(stages).toEqual(expect.arrayContaining(["triage", "execute", "review", "merge"]));
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("success");
expect(events).toEqual(legacyEvents);
});
it.todo("parity invariant: file-scope violations match legacy FileScopeViolationError behavior");
it.todo("parity invariant: squash/merge contract outcomes match legacy merger");
it.todo("parity invariant: autoMerge=false keeps in-review terminal until human merge");
it.todo("parity invariant: moveTask in-progress->todo hard-cancels active execution");
it("routes file-scope-like merge failure parity", async () => {
const seams: WorkflowLegacySeams = {
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "failure", value: "FileScopeViolationError" }),
schedule: async () => ({ outcome: "success" }),
};
const legacyEvents = await runLegacy(seams)();
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("failure");
expect(legacyEvents).toEqual(["execute:success", "review:success", "merge:failure"]);
});
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
const seams: WorkflowLegacySeams = {
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "failure", value: "manual-merge-required" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
};
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("failure");
expect(result.visitedNodeIds).not.toContain("merge");
});
it("matches self-healing parity by routing deterministic failure outcomes", async () => {
const seams: WorkflowLegacySeams = {
execute: async () => ({ outcome: "failure", value: "recoverable" }),
review: vi.fn(async () => ({ outcome: "success" as const })),
merge: vi.fn(async () => ({ outcome: "success" as const })),
schedule: async () => ({ outcome: "success" }),
};
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("failure");
expect(result.context["node:execute:value"]).toBe("recoverable");
expect(seams.review).not.toHaveBeenCalled();
});
it("matches moveTask hard-cancel behavior by halting downstream seams", async () => {
const seams: WorkflowLegacySeams = {
execute: async () => ({ outcome: "failure", value: "hard-cancel" }),
review: vi.fn(async () => ({ outcome: "success" as const })),
merge: vi.fn(async () => ({ outcome: "success" as const })),
schedule: async () => ({ outcome: "success" }),
};
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
expect(result.outcome).toBe("failure");
expect(seams.review).not.toHaveBeenCalled();
expect(seams.merge).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, WorkflowIrNode } from "@fusion/core";
import { createDefaultNodeHandlers } from "../workflow-node-handlers.js";
const task = { id: "FN-5767" } as TaskDetail;
const node = (kind: WorkflowIrNode["kind"], seam?: string): WorkflowIrNode => ({ id: kind, kind, config: seam ? { seam } : {} });
describe("workflow node handlers", () => {
it("dispatches prompt node to matching seam", async () => {
const seams = {
execute: vi.fn(async () => ({ outcome: "success" as const })),
review: vi.fn(async () => ({ outcome: "success" as const })),
merge: vi.fn(async () => ({ outcome: "success" as const })),
schedule: vi.fn(async () => ({ outcome: "success" as const })),
};
const handlers = createDefaultNodeHandlers(seams);
await handlers.prompt(node("prompt", "review"), { task, settings: undefined, context: {} });
expect(seams.review).toHaveBeenCalledOnce();
expect(seams.execute).not.toHaveBeenCalled();
});
it("dispatches script node to matching seam", async () => {
const seams = {
execute: vi.fn(async () => ({ outcome: "success" as const })),
review: vi.fn(async () => ({ outcome: "success" as const })),
merge: vi.fn(async () => ({ outcome: "success" as const })),
schedule: vi.fn(async () => ({ outcome: "success" as const })),
};
const handlers = createDefaultNodeHandlers(seams);
await handlers.script(node("script", "execute"), { task, settings: undefined, context: {} });
expect(seams.execute).toHaveBeenCalledOnce();
});
it("gate returns failure when expected context value does not match", async () => {
const handlers = createDefaultNodeHandlers({
execute: async () => ({ outcome: "success" }),
review: async () => ({ outcome: "success" }),
merge: async () => ({ outcome: "success" }),
schedule: async () => ({ outcome: "success" }),
});
const result = await handlers.gate(
{ id: "g", kind: "gate", config: { contextKey: "phase", expect: "merge" } },
{ task, settings: undefined, context: { phase: "review" } },
);
expect(result).toEqual({ outcome: "failure", value: "gate-mismatch" });
});
});

View File

@@ -16,15 +16,19 @@ export {
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
export { collectTaskEvaluationEvidence } from "./evaluator-evidence.js";
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
export {
WorkflowGraphExecutor,
WORKFLOW_GRAPH_EXECUTOR_FLAG,
type WorkflowGraphExecutorDependencies,
type WorkflowGraphExecutorRunInput,
type WorkflowGraphExecutorRunResult,
type WorkflowGraphExecutorDeps,
type WorkflowGraphExecutorResult,
} from "./workflow-graph-executor.js";
export {
createDefaultNodeHandlers,
createNoopLegacySeams,
type WorkflowLegacySeams,
type WorkflowSeamName,
} from "./workflow-node-handlers.js";
export { collectTaskEvaluationEvidence } from "./evaluator-evidence.js";
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";

View File

@@ -1,82 +1,183 @@
import { isExperimentalFeatureEnabled, type Settings, type Task, type WorkflowIr, type WorkflowIrEdge, type WorkflowIrNode } from "@fusion/core";
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled } from "@fusion/core";
export const WORKFLOW_GRAPH_EXECUTOR_FLAG = "workflowGraphExecutor" as const;
import { createDefaultNodeHandlers, createNoopLegacySeams, type WorkflowLegacySeams } from "./workflow-node-handlers.js";
export interface WorkflowGraphExecutorDependencies {
onNode?: (node: WorkflowIrNode) => Promise<void> | void;
export type WorkflowNodeOutcome = "success" | "failure";
export interface WorkflowNodeResult {
outcome: WorkflowNodeOutcome;
value?: string;
contextPatch?: Record<string, unknown>;
}
export interface WorkflowGraphExecutorRunInput {
workflow: WorkflowIr;
settings?: Pick<Settings, "experimentalFeatures">;
task?: Pick<Task, "id">;
export interface WorkflowNodeExecutionContext {
task: TaskDetail;
settings: Pick<Settings, "experimentalFeatures"> | undefined;
context: Record<string, unknown>;
}
export interface WorkflowGraphExecutorRunResult {
export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeExecutionContext) => Promise<WorkflowNodeResult>;
export interface WorkflowGraphExecutorDeps {
handlers?: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
seams?: WorkflowLegacySeams;
maxRetriesPerNode?: number;
}
export interface WorkflowGraphExecutorResult {
executed: boolean;
outcome: WorkflowNodeOutcome;
context: Record<string, unknown>;
visitedNodeIds: string[];
reason?: "flag-disabled";
}
const TERMINAL_FAILURE: WorkflowGraphExecutorResult = {
executed: false,
outcome: "failure",
context: {},
visitedNodeIds: [],
};
export class WorkflowGraphExecutor {
constructor(private readonly deps: WorkflowGraphExecutorDependencies = {}) {}
private readonly maxRetriesPerNode: number;
async run(input: WorkflowGraphExecutorRunInput): Promise<WorkflowGraphExecutorRunResult> {
if (!isExperimentalFeatureEnabled(input.settings, WORKFLOW_GRAPH_EXECUTOR_FLAG)) {
return { executed: false, visitedNodeIds: [], reason: "flag-disabled" };
}
private readonly handlers: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
const nodesById = new Map(input.workflow.nodes.map((node) => [node.id, node]));
const outgoingByNode = new Map<string, WorkflowIrEdge[]>();
for (const edge of input.workflow.edges) {
const list = outgoingByNode.get(edge.from) ?? [];
list.push(edge);
outgoingByNode.set(edge.from, list);
}
const startNodes = input.workflow.nodes.filter((node) => node.kind === "start");
if (startNodes.length !== 1) {
throw new Error(`WorkflowGraphExecutor expected exactly one start node, received ${startNodes.length}.`);
}
const visitedNodeIds: string[] = [];
const queue: string[] = [startNodes[0].id];
const seen = new Set<string>();
while (queue.length > 0) {
const nodeId = queue.shift();
if (!nodeId || seen.has(nodeId)) continue;
seen.add(nodeId);
const node = nodesById.get(nodeId);
if (!node) {
throw new Error(`WorkflowGraphExecutor found unknown node id: ${nodeId}`);
}
visitedNodeIds.push(node.id);
await this.dispatchNode(node);
const nextEdges = outgoingByNode.get(node.id) ?? [];
for (const edge of nextEdges) {
queue.push(edge.to);
}
}
return { executed: true, visitedNodeIds };
public constructor(private readonly deps: WorkflowGraphExecutorDeps) {
this.maxRetriesPerNode = Math.max(1, Math.floor(deps.maxRetriesPerNode ?? 2));
this.handlers = {
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams()),
...(deps.handlers ?? {}),
};
}
private async dispatchNode(node: WorkflowIrNode): Promise<void> {
await this.deps.onNode?.(node);
switch (node.kind) {
case "start":
case "prompt":
case "script":
case "gate":
case "end":
return;
default: {
const exhaustive: never = node.kind;
throw new Error(`Unsupported node kind: ${String(exhaustive)}`);
public async run(
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR,
): Promise<WorkflowGraphExecutorResult> {
if (!isExperimentalFeatureEnabled(settings, "workflowGraphExecutor")) {
return TERMINAL_FAILURE;
}
const startNode = ir.nodes.find((node) => node.kind === "start");
if (!startNode) throw new WorkflowIrError("Workflow IR missing start node");
const nodeMap = new Map(ir.nodes.map((node) => [node.id, node]));
const outgoingMap = new Map<string, WorkflowIrEdge[]>();
for (const edge of ir.edges) {
if (!nodeMap.has(edge.from) || !nodeMap.has(edge.to)) {
throw new WorkflowIrError(`Workflow IR edge references unknown node: ${edge.from} -> ${edge.to}`);
}
const list = outgoingMap.get(edge.from) ?? [];
list.push(edge);
outgoingMap.set(edge.from, list);
}
const context: Record<string, unknown> = {};
const visitedNodeIds: string[] = [];
const inStack = new Set<string>();
const walk = async (nodeId: string): Promise<WorkflowNodeResult> => {
const node = nodeMap.get(nodeId);
if (!node) throw new WorkflowIrError(`Unknown workflow node: ${nodeId}`);
if (inStack.has(nodeId)) throw new WorkflowIrError(`Cycle detected at node: ${nodeId}`);
inStack.add(nodeId);
visitedNodeIds.push(nodeId);
try {
if (node.kind === "start") {
return await traverseChildren(node, { outcome: "success" });
}
if (node.kind === "end") {
return { outcome: "success" };
}
const result = await this.executeNodeWithRetries(node, task, settings, context);
if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome;
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
return await traverseChildren(node, result);
} finally {
inStack.delete(nodeId);
}
};
const traverseChildren = async (node: WorkflowIrNode, sourceResult: WorkflowNodeResult): Promise<WorkflowNodeResult> => {
const edges = outgoingMap.get(node.id) ?? [];
if (edges.length === 0) {
return sourceResult;
}
const matching = edges.filter((edge) => this.shouldTraverseEdge(edge, sourceResult));
if (matching.length === 0) {
return sourceResult;
}
let aggregate: WorkflowNodeResult = sourceResult;
for (const edge of matching.sort((a, b) => a.to.localeCompare(b.to))) {
const target = nodeMap.get(edge.to);
if (target?.kind === "end") {
aggregate = sourceResult;
continue;
}
const child = await walk(edge.to);
if (child.outcome === "failure") {
aggregate = child;
break;
}
aggregate = child;
}
return aggregate;
};
const terminal = await walk(startNode.id);
return {
executed: true,
outcome: terminal.outcome,
context,
visitedNodeIds,
};
}
private shouldTraverseEdge(edge: WorkflowIrEdge, sourceResult: WorkflowNodeResult): boolean {
if (!edge.condition) return sourceResult.outcome === "success";
if (edge.condition === "success") return sourceResult.outcome === "success";
if (edge.condition === "failure") return sourceResult.outcome === "failure";
if (edge.condition.startsWith("outcome:")) {
return sourceResult.value === edge.condition.slice("outcome:".length);
}
throw new WorkflowIrError(`Unsupported edge condition: ${edge.condition}`);
}
private async executeNodeWithRetries(
node: WorkflowIrNode,
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
context: Record<string, unknown>,
): Promise<WorkflowNodeResult> {
const handler = this.handlers[node.kind];
if (!handler) {
throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`);
}
let lastError: unknown;
for (let attempt = 0; attempt < this.maxRetriesPerNode; attempt++) {
try {
return await handler(node, { task, settings, context });
} catch (error) {
lastError = error;
}
}
return {
outcome: "failure",
value: "exception",
contextPatch: {
[`node:${node.id}:error`]: lastError instanceof Error ? lastError.message : String(lastError),
},
};
}
}

View File

@@ -0,0 +1,56 @@
import { WorkflowIrError } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
export type WorkflowSeamName = "execute" | "review" | "merge" | "schedule";
export interface WorkflowLegacySeams {
execute: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
review: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
merge: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
schedule: (task: TaskDetail, context: Record<string, unknown>) => Promise<WorkflowNodeResult>;
}
function resolveSeam(node: { config?: Record<string, unknown> }): WorkflowSeamName {
const seam = node.config?.seam;
if (seam === "execute" || seam === "review" || seam === "merge" || seam === "schedule") {
return seam;
}
throw new WorkflowIrError(`Unsupported workflow seam: ${String(seam)}`);
}
export function createPromptLikeHandler(seams: WorkflowLegacySeams): WorkflowNodeHandler {
return async (node, context) => {
const seam = resolveSeam(node);
return seams[seam](context.task, context.context);
};
}
export const gateNodeHandler: WorkflowNodeHandler = async (node, context) => {
const expected = node.config?.expect;
const actual = context.context[String(node.config?.contextKey ?? "outcome")];
if (typeof expected === "string" && actual !== expected) {
return { outcome: "failure", value: "gate-mismatch" };
}
return { outcome: "success" };
};
export function createDefaultNodeHandlers(seams: WorkflowLegacySeams): Record<"prompt" | "script" | "gate", WorkflowNodeHandler> {
const promptLike = createPromptLikeHandler(seams);
return {
prompt: promptLike,
script: promptLike,
gate: gateNodeHandler,
};
}
export function createNoopLegacySeams(): WorkflowLegacySeams {
const success = async (): Promise<WorkflowNodeResult> => ({ outcome: "success" });
return {
execute: success,
review: success,
merge: success,
schedule: success,
};
}