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:
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
82
packages/engine/src/workflow-graph-executor.ts
Normal file
82
packages/engine/src/workflow-graph-executor.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user