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
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import type { Settings, TaskDetail, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
|
|
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled } from "@fusion/core";
|
|
|
|
import { createDefaultNodeHandlers, createNoopLegacySeams, type WorkflowLegacySeams } from "./workflow-node-handlers.js";
|
|
|
|
export type WorkflowNodeOutcome = "success" | "failure";
|
|
|
|
export interface WorkflowNodeResult {
|
|
outcome: WorkflowNodeOutcome;
|
|
value?: string;
|
|
contextPatch?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface WorkflowNodeExecutionContext {
|
|
task: TaskDetail;
|
|
settings: Pick<Settings, "experimentalFeatures"> | undefined;
|
|
context: Record<string, unknown>;
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
const TERMINAL_FAILURE: WorkflowGraphExecutorResult = {
|
|
executed: false,
|
|
outcome: "failure",
|
|
context: {},
|
|
visitedNodeIds: [],
|
|
};
|
|
|
|
export class WorkflowGraphExecutor {
|
|
private readonly maxRetriesPerNode: number;
|
|
|
|
private readonly handlers: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>>;
|
|
|
|
public constructor(private readonly deps: WorkflowGraphExecutorDeps) {
|
|
this.maxRetriesPerNode = Math.max(1, Math.floor(deps.maxRetriesPerNode ?? 2));
|
|
this.handlers = {
|
|
...createDefaultNodeHandlers(deps.seams ?? createNoopLegacySeams()),
|
|
...(deps.handlers ?? {}),
|
|
};
|
|
}
|
|
|
|
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),
|
|
},
|
|
};
|
|
}
|
|
}
|