FN-5766: add flagged-off workflow graph executor scaffold

Add the Phase 2 workflow-graph interpreter scaffold and builtin coding IR wiring while keeping execution behavior unchanged by default.

- add BUILTIN_CODING_WORKFLOW_IR and builder in @fusion/core with coverage tests
- export new coding workflow IR APIs from core index
- add WorkflowGraphExecutor scaffold in @fusion/engine plus parity-focused test coverage
- document the flagged-off interpreter scaffold, parity gate, and v1 IR gap reconciliation in workflow docs
- include a changeset for published @runfusion/fusion

Files changed:
 .../fn-5766-workflow-graph-executor-scaffold.md    | 11 +++
 docs/workflow-steps.md                             | 27 +++++++
 .../__tests__/builtin-coding-workflow-ir.test.ts   | 34 +++++++++
 packages/core/src/builtin-coding-workflow-ir.ts    | 60 ++++++++++++++++
 packages/core/src/index.ts                         |  4 ++
 .../workflow-graph-executor-parity.test.ts         | 32 +++++++++
 packages/engine/src/index.ts                       |  7 ++
 packages/engine/src/workflow-graph-executor.ts     | 82 ++++++++++++++++++++++
 8 files changed, 257 insertions(+)

Fusion-Task-Id: FN-5766
Fusion-Task-Lineage: 4bbef713-a415-4d42-a20b-9ae4f3235419
This commit is contained in:
gsxdsm
2026-05-31 04:47:42 -07:00
parent 3d22a98cf2
commit 1edbb54fce
8 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
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";
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 });
const absent = await executor.run({ workflow: BUILTIN_CODING_WORKFLOW_IR, settings: undefined });
expect(absent).toEqual({ executed: false, visitedNodeIds: [], reason: "flag-disabled" });
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();
});
it("loads builtin coding workflow IR", () => {
const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const stages = parsed.nodes.map((node) => String(node.config?.stage ?? ""));
expect(stages).toEqual(expect.arrayContaining(["triage", "execute", "review", "merge"]));
});
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");
});

View File

@@ -18,6 +18,13 @@ 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,
} from "./workflow-graph-executor.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

@@ -0,0 +1,82 @@
import { isExperimentalFeatureEnabled, type Settings, type Task, type WorkflowIr, type WorkflowIrEdge, type WorkflowIrNode } from "@fusion/core";
export const WORKFLOW_GRAPH_EXECUTOR_FLAG = "workflowGraphExecutor" as const;
export interface WorkflowGraphExecutorDependencies {
onNode?: (node: WorkflowIrNode) => Promise<void> | void;
}
export interface WorkflowGraphExecutorRunInput {
workflow: WorkflowIr;
settings?: Pick<Settings, "experimentalFeatures">;
task?: Pick<Task, "id">;
}
export interface WorkflowGraphExecutorRunResult {
executed: boolean;
visitedNodeIds: string[];
reason?: "flag-disabled";
}
export class WorkflowGraphExecutor {
constructor(private readonly deps: WorkflowGraphExecutorDependencies = {}) {}
async run(input: WorkflowGraphExecutorRunInput): Promise<WorkflowGraphExecutorRunResult> {
if (!isExperimentalFeatureEnabled(input.settings, WORKFLOW_GRAPH_EXECUTOR_FLAG)) {
return { executed: false, visitedNodeIds: [], reason: "flag-disabled" };
}
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 };
}
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)}`);
}
}
}
}