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:
11
.changeset/fn-5766-workflow-graph-executor-scaffold.md
Normal file
11
.changeset/fn-5766-workflow-graph-executor-scaffold.md
Normal 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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
60
packages/core/src/builtin-coding-workflow-ir.ts
Normal file
60
packages/core/src/builtin-coding-workflow-ir.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 { 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";
|
||||||
|
|||||||
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