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,11 @@
---
"@runfusion/fusion": minor
---
Add a flagged-off Workflow Graph Executor scaffold and built-in coding lifecycle Workflow IR exports.
- Adds `BUILTIN_CODING_WORKFLOW_IR` and `buildBuiltinCodingWorkflowIr` to `@fusion/core`.
- Adds `WorkflowGraphExecutor` and `WORKFLOW_GRAPH_EXECUTOR_FLAG` to `@fusion/engine`.
- Adds parity-harness skeleton tests and IR documentation updates.
The new executor path is gated by `experimentalFeatures.workflowGraphExecutor` and remains strict no-op while disabled (default).

View File

@@ -28,6 +28,33 @@ Out of scope for v1:
- Execution history/runtime traces - Execution history/runtime traces
- Migration tooling for future schema versions (future versions should use explicit `schemaVersion` migrations) - Migration tooling for future schema versions (future versions should use explicit `schemaVersion` migrations)
### Workflow Graph Executor (interpreter scaffold)
FN-5766 adds a **flagged-off** interpreter scaffold in `@fusion/engine` (`WorkflowGraphExecutor`) plus a built-in coding lifecycle IR in `@fusion/core` (`BUILTIN_CODING_WORKFLOW_IR`).
- Feature flag key: `experimentalFeatures.workflowGraphExecutor`
- Default: **OFF**
- OFF behavior is strict no-op (no task mutations, no session/git side effects), so the legacy imperative pipeline remains authoritative.
Built-in coding IR currently encodes the legacy lifecycle path as graph stages:
- `triage``execute``review``merge``end`
#### Interpreter-parity gating criterion
Interpreter authority is gated on parity: interpreter-driven coding runs must match legacy behavior for observable task transitions and reliability invariants (file-scope guards including `FileScopeViolationError`, squash/merge contract, self-healing expectations, `autoMerge:false` terminal-until-merged, and `moveTask(in-progress→todo)` hard-cancel semantics).
#### IR-gap reconciliation (v1)
The workflow redesign brief references `agent-call` nodes and typed edges (`success|failure|conditional|fan-out-join`), but shipped v1 IR only supports node kinds `start|prompt|script|gate|end` plus optional string edge `condition`.
Current reconciliation in v1:
- `agent-call` semantics are represented using existing `prompt` nodes with `config` fields (for example stage/role metadata).
- Typed-edge semantics are represented using `condition` token conventions.
Potential first-class schema support for `agent-call`/typed edges is deferred to a follow-up IR extension task (v1.1 candidate), not part of v1 contract changes.
## What They Are ## What They Are
A workflow step is a reusable check (AI prompt or script) that can be enabled on tasks. A workflow step is a reusable check (AI prompt or script) that can be enabled on tasks.

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
BUILTIN_CODING_WORKFLOW_IR,
WORKFLOW_IR_SCHEMA_VERSION,
buildBuiltinCodingWorkflowIr,
parseWorkflowIr,
serializeWorkflowIr,
} from "../index.js";
describe("builtin coding workflow ir", () => {
it("parses and round-trips", () => {
const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
expect(reparsed).toEqual(parsed);
expect(parsed.schemaVersion).toBe(WORKFLOW_IR_SCHEMA_VERSION);
});
it("contains exactly one start and one end node", () => {
const nodes = BUILTIN_CODING_WORKFLOW_IR.nodes;
expect(nodes.filter((node) => node.kind === "start")).toHaveLength(1);
expect(nodes.filter((node) => node.kind === "end")).toHaveLength(1);
});
it("exposes coding lifecycle stages", () => {
const stageNodes = BUILTIN_CODING_WORKFLOW_IR.nodes.filter((node) => node.config?.stage);
const stages = stageNodes.map((node) => String(node.config?.stage));
expect(stages).toEqual(expect.arrayContaining(["triage", "execute", "review", "merge"]));
});
it("builder returns parser-validated ir", () => {
const built = buildBuiltinCodingWorkflowIr();
expect(built.metadata.name).toContain("Coding Lifecycle");
});
});

View File

@@ -0,0 +1,60 @@
import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js";
import { WORKFLOW_IR_SCHEMA_VERSION, type WorkflowIr } from "./workflow-ir-types.js";
/**
* Built-in coding lifecycle workflow encoded in v1 Workflow IR.
*
* Mapping notes:
* - Legacy "agent-call" semantics are represented by `prompt` nodes with `config.agentRole`.
* - Typed edge semantics are represented via `edge.condition` tokens.
*/
export const BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
schemaVersion: WORKFLOW_IR_SCHEMA_VERSION,
metadata: {
name: "Built-in Coding Lifecycle Workflow",
description: "Legacy authoritative coding lifecycle encoded as v1 IR scaffold.",
createdAt: "2026-05-31T00:00:00.000Z",
templateId: "builtin-coding-lifecycle-v1",
},
nodes: [
{ id: "node-start", kind: "start", label: "Start" },
{
id: "node-triage",
kind: "prompt",
label: "Triage",
config: { stage: "triage", agentRole: "triage", legacySeam: "triage" },
},
{
id: "node-execute",
kind: "prompt",
label: "Execute",
config: { stage: "execute", agentRole: "executor", legacySeam: "executor" },
},
{
id: "node-review",
kind: "gate",
label: "Review",
config: { stage: "review", gateMode: "approval", legacySeam: "reviewer" },
},
{
id: "node-merge",
kind: "script",
label: "Merge",
config: { stage: "merge", script: "legacy-merger", legacySeam: "merger" },
},
{ id: "node-end", kind: "end", label: "End" },
],
edges: [
{ id: "edge-start-triage", from: "node-start", to: "node-triage" },
{ id: "edge-triage-execute", from: "node-triage", to: "node-execute", condition: "success" },
{ id: "edge-execute-review", from: "node-execute", to: "node-review", condition: "success" },
{ id: "edge-review-merge", from: "node-review", to: "node-merge", condition: "approved" },
{ id: "edge-review-execute", from: "node-review", to: "node-execute", condition: "revise" },
{ id: "edge-merge-end", from: "node-merge", to: "node-end", condition: "success" },
],
};
/** Ensure built-in IR remains parser-valid and serializable. */
export function buildBuiltinCodingWorkflowIr(): WorkflowIr {
return parseWorkflowIr(serializeWorkflowIr(BUILTIN_CODING_WORKFLOW_IR));
}

View File

@@ -16,6 +16,10 @@ export {
WorkflowIrError, WorkflowIrError,
BUILTIN_WORKFLOW_IR_FIXTURE, BUILTIN_WORKFLOW_IR_FIXTURE,
} from "./workflow-ir.js"; } from "./workflow-ir.js";
export {
BUILTIN_CODING_WORKFLOW_IR,
buildBuiltinCodingWorkflowIr,
} from "./builtin-coding-workflow-ir.js";
export { customProviderRegistryKey } from "./custom-provider-key.js"; export { customProviderRegistryKey } from "./custom-provider-key.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";

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 { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
export { collectTaskEvaluationEvidence } from "./evaluator-evidence.js"; export { collectTaskEvaluationEvidence } from "./evaluator-evidence.js";
export { Scheduler, type SchedulerOptions } from "./scheduler.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 { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js"; export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.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)}`);
}
}
}
}