FN-7113: validate workflow graph integrity before execution

Reject malformed workflow graphs at save and run boundaries.

- Add central IR validation for duplicate top-level node ids, unregistered extension keys, and unreachable required nodes.
- Re-validate resolved workflow IR in the graph runner before side effects and fail closed on invalid graphs.
- Expand graph integrity tests and document save/run validation behavior with a release changeset.

Files changed:
 .changeset/FN-7113-workflow-graph-integrity.md     |   7 +
 docs/custom-workflow-reliability-acceptance-map.md |   7 +-
 docs/workflow-steps.md                             |  20 +++
 .../workflow-ir-extension-metadata.test.ts         |  49 ++++++-
 packages/core/src/__tests__/workflow-ir.test.ts    | 106 +++++++++++++++-
 packages/core/src/workflow-ir.ts                   |  51 +++++++-
 .../__tests__/workflow-graph-task-runner.test.ts   | 141 +++++++++++++++++----
 packages/engine/src/workflow-graph-task-runner.ts  |  46 +++++--
 8 files changed, 386 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-7113

Fusion-Task-Lineage: f41e0d81-7cdf-497a-96ab-fe42504d0be4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 07:44:03 -07:00
parent 9fc68bc350
commit e1dba3fcc9
8 changed files with 387 additions and 42 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Reject malformed workflow graphs before they can be saved or launched.
category: feature
dev: Hardens the central parseWorkflowIr/validateV2 gate (duplicate-node-id and required top-level reachability rejection) and fail-closed re-validation at the WorkflowGraphTaskRunner run boundary before any side effects (FN-7113).

View File

@@ -9,6 +9,9 @@ This artifact distinguishes MVP/blocking requirements from nice-to-have enhancem
FNXC:WorkflowRouting 2026-06-22-12:00:
Workflow selection acceptance must distinguish operator intent and task creator ownership from executor opportunism. Agents can assign workflows when the user asked or when creating the task; executors cannot reroute the task under execution unless instructed.
FNXC:WorkflowValidation 2026-06-27-00:00:
FN-7113 upgrades graph integrity from an authoring-only expectation to a save-and-run acceptance criterion: malformed workflow DAGs must be rejected before persistence and revalidated before graph execution side effects.
-->
## Purpose
@@ -37,7 +40,7 @@ Use this document to write engineering tasks, QA plans, and release checks. It i
- **Actor / need:** A workflow author needs to create or copy a workflow that can be reviewed, saved, and selected without corrupting built-in definitions.
- **Trigger:** Open the [Workflow Editor](./workflow-editor.md) from the dashboard, duplicate a built-in with **Duplicate to customize**, start from Blank, import a JSON envelope, or use workflow tools such as `fn_workflow_create` / `fn_workflow_update`.
- **Expected happy path + lifecycle transitions + feedback:** The editor serializes graph nodes/edges, columns, fields, and setting declarations into Workflow IR, saves the custom definition, and keeps built-ins read-only. The saved workflow appears in the editor picker and `fn_workflow_list`; no task lifecycle transition occurs until a task selects the workflow. The editor reports whether the workflow can run on the linear engine or must run on the graph interpreter.
- **Failure / recovery expectation:** Invalid JSON, dangling edges, illegal cycles, unplaced nodes, blocking column-trait violations, invalid setting/field declarations, and attempts to mutate built-ins are rejected before partial persistence. Import errors and server validation errors render in a persistent inline error region; built-ins show read-only hints and disable mutation controls.
- **Failure / recovery expectation:** Invalid JSON, duplicate top-level node ids, missing/multiple start or end nodes, dangling edges, illegal cycles, invalid step/template references, unknown plugin workflow-extension keys, unplaced nodes, blocking column-trait violations, invalid setting/field declarations, and attempts to mutate built-ins are rejected before partial persistence. Import errors and server validation errors render in a persistent inline error region; built-ins show read-only hints and disable mutation controls.
- **Measurable success signal:** A stable workflow ID is returned/listed by `fn_workflow_list`; `fn_workflow_get` or the editor reload shows the saved IR; invalid saves return a typed validation failure without changing the prior persisted definition.
- **Priority:** MVP/blocking for save/validation/discovery; enhancement for AI-assisted design quality and richer telemetry around definition registration.
@@ -64,7 +67,7 @@ Use this document to write engineering tasks, QA plans, and release checks. It i
- **Actor / need:** The scheduler/executor needs to run the selected workflow deterministically while preserving Fusion's observable task lifecycle.
- **Trigger:** A schedulable task with a selected or default workflow is picked up for execution.
- **Expected happy path + lifecycle transitions + feedback:** `TaskExecutor.execute()` resolves the workflow, pins graph execution for the run, and `WorkflowGraphExecutor` traverses nodes through workflow runtime primitives such as planning, execute, workflow-step, review, merge, schedule, and step-execute. Standard coding work continues to show `todo → in-progress → in-review → done` (or equivalent workflow-defined columns/holds where enabled), workflow checks appear on task cards/list/detail, and task documents/artifacts are persisted as produced.
- **Failure / recovery expectation:** Unsupported edge conditions throw `WorkflowIrError`; explicit custom workflow resolution failures fail closed; interpreter failures park as workflow failures rather than re-running a legacy imperative path. File-scope guards (`FileScopeViolationError`), squash overlap enforcement, `autoMerge:false` terminal-until-human behavior, and `moveTask(in-progress → todo)` hard-cancel semantics remain non-bypassable.
- **Failure / recovery expectation:** Unsupported edge conditions throw `WorkflowIrError`; explicit custom workflow resolution failures fail closed; resolved IR is revalidated before any graph side effects and malformed graphs fail closed with `invalid-ir: <message>` rather than partially running or falling back into the wrong legacy workflow; interpreter failures after side effects park as workflow failures rather than re-running a legacy imperative path. File-scope guards (`FileScopeViolationError`), squash overlap enforcement, `autoMerge:false` terminal-until-human behavior, and `moveTask(in-progress → todo)` hard-cancel semantics remain non-bypassable.
- **Measurable success signal:** Workflow results are visible in task card/list/detail surfaces; node outcomes route according to `success`, `failure`, or `outcome:<value>` edges; relevant run-audit records exist for lifecycle/git/database mutations; parity instrumentation emits `workflow:parity-observed` or `workflow:parity-drift` when dual-observe is enabled.
- **Priority:** MVP/blocking.

View File

@@ -55,6 +55,26 @@ Decision-only or investigation tasks can also declare `noCommitsExpected` / `**N
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
### Workflow graph integrity validation
<!--
FNXC:WorkflowValidation 2026-06-27-00:00:
FN-7113 makes graph integrity a save-and-run invariant. The central `parseWorkflowIr`/`validateV2` gate rejects malformed author/plugin graphs before persistence, and `WorkflowGraphTaskRunner` re-validates the resolved IR before side-effecting seams so stale or plugin-supplied invalid graphs fail closed instead of partially running or falling back into the wrong legacy workflow.
-->
Workflow definitions are validated through the same central IR gate before they can be saved, imported, AI-designed, selected/materialized for a task, or launched by the graph interpreter. Dashboard routes and workflow tools surface `WorkflowIrError` / `WorkflowCompileError` messages as author-facing validation failures instead of persisting partial definitions.
The enforced integrity classes include:
- exactly one `start` node and exactly one `end` node;
- unique top-level node ids and unique column/field/setting ids;
- every top-level edge endpoint references a declared top-level node;
- no illegal non-rework cycles in DAG-required regions;
- required reachability/dominance rules: every required top-level node must be reachable from `start`, and `parse-steps` must dominate `foreach(source:"task-steps")`; interpreter-owned recovery entry primitives remain valid even when they are re-entered from persisted runtime state instead of the author-facing start path;
- valid node-specific references, including `parse-steps` artifacts, `loop.exitWhen.nodeId`, foreach/loop/optional-group template entry/exit references, and registered plugin workflow-extension keys.
At run time, `WorkflowGraphTaskRunner` resolves the selected built-in or custom workflow, re-runs this integrity validation before any seam, primitive, or custom-node side effect, and fails closed with an `invalid-ir: <message>` reason when the resolved IR is malformed. Once a node side effect has run, runtime failures keep the existing failed-run behavior rather than re-running the legacy pipeline.
### Overriding built-in workflow prompts
<!--

View File

@@ -29,7 +29,22 @@ describe("workflow IR extension metadata", () => {
__resetWorkflowExtensionRegistryForTests();
});
it("accepts plugin-namespaced column and node extension metadata", () => {
it("accepts registered plugin-namespaced column and node extension metadata", () => {
getWorkflowExtensionRegistry().register("workflow-pack", {
extensionId: "role",
name: "Role",
kind: "column-metadata",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
});
getWorkflowExtensionRegistry().register("workflow-pack", {
extensionId: "node-handler",
name: "Node handler",
kind: "node-handler",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
});
const parsed = parseWorkflowIr(ir({
columns: [
{
@@ -60,6 +75,22 @@ describe("workflow IR extension metadata", () => {
expect(parsed.nodes[0].extensions?.["plugin:workflow-pack:node-handler"]).toEqual({ handler: "plan" });
});
it("rejects unknown plugin-namespaced extension metadata keys", () => {
expect(() =>
parseWorkflowIr(ir({
nodes: [
{
id: "start",
kind: "start",
column: "todo",
extensions: { "plugin:workflow-pack:missing": {} },
},
{ id: "end", kind: "end", column: "todo" },
],
})),
).toThrow(/Workflow node 'start' extension key 'plugin:workflow-pack:missing' is not registered/);
});
it("rejects extension metadata keys outside the plugin namespace", () => {
expect(() =>
parseWorkflowIr(ir({
@@ -76,6 +107,14 @@ describe("workflow IR extension metadata", () => {
});
it("rejects non-object extension metadata values", () => {
getWorkflowExtensionRegistry().register("workflow-pack", {
extensionId: "node-handler",
name: "Node handler",
kind: "node-handler",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
});
expect(() =>
parseWorkflowIr(ir({
nodes: [
@@ -140,6 +179,14 @@ describe("workflow IR extension metadata", () => {
});
it("keeps v2 when otherwise-pure workflows carry extension metadata", () => {
getWorkflowExtensionRegistry().register("workflow-pack", {
extensionId: "role",
name: "Role",
kind: "column-metadata",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
});
const parsed = parseWorkflowIr(ir({
columns: [
{

View File

@@ -13,6 +13,7 @@ import type {
WorkflowIrNode,
WorkflowIrEdge,
} from "../workflow-ir-types.js";
import { BUILTIN_WORKFLOWS } from "../builtin-workflows.js";
function v2(
columns: WorkflowIrV2["columns"],
@@ -153,6 +154,25 @@ describe("parseWorkflowIr — v2 columns & placement", () => {
);
expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/);
});
it("rejects duplicate top-level node ids before Map de-duplication can mask them", () => {
const ir = v2(
[{ id: "only", name: "Only", traits: [] }],
[
{ id: "start", kind: "start", column: "only" },
{ id: "dup", kind: "prompt", column: "only" },
{ id: "dup", kind: "script", column: "only" },
{ id: "end", kind: "end", column: "only" },
],
[
{ from: "start", to: "dup" },
{ from: "dup", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/Workflow IR has duplicate node id 'dup'/);
});
});
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
@@ -659,8 +679,92 @@ describe("parseWorkflowIr — version & shape guards", () => {
);
});
it("rejects missing start/end nodes", () => {
const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []);
it("rejects missing start nodes", () => {
const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "end", kind: "end", column: "c" }], []);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/exactly one start and one end/);
});
it("rejects missing end nodes", () => {
const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/exactly one start and one end/);
});
it("rejects illegal non-rework cycles", () => {
const ir = v2(
[{ id: "c", name: "C", traits: [] }],
[
{ id: "start", kind: "start", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "b", kind: "prompt", column: "c" },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "a" },
{ from: "a", to: "b" },
{ from: "b", to: "a" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/illegal cycle.*edge 'b' -> 'a'/);
});
it("rejects unreachable required top-level nodes with the offending node id", () => {
const ir = v2(
[{ id: "c", name: "C", traits: [] }],
[
{ id: "start", kind: "start", column: "c" },
{ id: "reachable", kind: "prompt", column: "c" },
{ id: "orphan", kind: "prompt", column: "c" },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "reachable" },
{ from: "reachable", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/Workflow node 'orphan' is not reachable from the start node/);
});
it("rejects invalid parse-steps artifact references with the offending node and artifact", () => {
const ir = v2(
[{ id: "c", name: "C", traits: [] }],
[
{ id: "start", kind: "start", column: "c" },
{ id: "parse", kind: "parse-steps", column: "c", config: { artifact: "missing.md", parser: "step-headings" } },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "parse" },
{ from: "parse", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
expect(() => parseWorkflowIr(ir)).toThrow(/parse-steps node 'parse' references artifact 'missing.md'/);
});
it("parses valid v2 graphs and every built-in workflow without false rejection", () => {
const valid = v2(
[{ id: "c", name: "C", traits: [] }],
[
{ id: "start", kind: "start", column: "c" },
{ id: "custom", kind: "prompt", column: "c" },
{ id: "end", kind: "end", column: "c" },
],
[
{ from: "start", to: "custom" },
{ from: "custom", to: "end" },
],
);
expect(() => parseWorkflowIr(valid)).not.toThrow();
for (const workflow of BUILTIN_WORKFLOWS) {
expect(() => parseWorkflowIr(workflow.ir)).not.toThrow();
}
});
});

View File

@@ -307,6 +307,36 @@ function reachableFrom(
return seen;
}
const INTERPRETER_ENTRY_NODE_KINDS: ReadonlySet<WorkflowIrNodeKind> = new Set([
"merge-gate",
"merge-attempt",
"manual-merge-hold",
"retry-backoff",
"recovery-router",
"branch-group-member-integration",
"branch-group-promotion",
"pr-create",
"pr-respond",
"pr-merge",
]);
function validateRequiredTopLevelReachability(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
): void {
/*
FNXC:WorkflowValidation 2026-06-27-07:40:
FN-7113 requires required top-level workflow nodes to be reachable from start at parse time, including interpreter-deferred branch graphs. Engine-owned recovery entry primitives stay exempt because they can be re-entered by persisted runtime state rather than by the author-facing start path.
*/
const startNode = nodes.find((node) => node.kind === "start");
if (!startNode) return;
const reachable = reachableFrom(startNode.id, outgoing);
for (const node of nodes) {
if (reachable.has(node.id) || INTERPRETER_ENTRY_NODE_KINDS.has(node.kind)) continue;
throw new WorkflowIrError(`Workflow node '${node.id}' is not reachable from the start node`);
}
}
/**
* Validate a foreach `template` subgraph recursively (KTD-3):
* - non-empty;
@@ -1234,7 +1264,14 @@ function validateRegisteredExtensionMetadata(
value: Record<string, unknown>,
): void {
const definition = getWorkflowExtensionRegistry().get(key);
const fields = definition?.extension.configSchema?.fields;
/*
FNXC:WorkflowValidation 2026-06-27-00:00:
FN-7113 requires plugin-referencing workflow graphs to validate against the same central gate as built-in/custom graphs. Reject unknown workflow extension keys by name so authoring surfaces cannot persist a graph whose plugin node/column contract is missing at save or launch time.
*/
if (!definition) {
throw new WorkflowIrError(`${owner} extension key '${key}' is not registered`);
}
const fields = definition.extension.configSchema?.fields;
if (!fields || fields.length === 0) return;
for (const field of fields) {
if (field.required && !(field.key in value)) {
@@ -1314,6 +1351,17 @@ function validateV2(ir: WorkflowIrV2): void {
validateColumns(ir);
const columnIds = new Set(ir.columns.map((c) => c.id));
const nodeIds = new Set<string>();
for (const node of ir.nodes) {
/*
FNXC:WorkflowValidation 2026-06-27-00:00:
FN-7113 requires top-level duplicate node ids to fail before persistence or launch. Keep this check before nodesById is built so Map de-duplication cannot silently mask a malformed author/plugin workflow graph.
*/
if (nodeIds.has(node.id)) {
throw new WorkflowIrError(`Workflow IR has duplicate node id '${node.id}'`);
}
nodeIds.add(node.id);
}
const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
for (const node of ir.nodes) {
@@ -1394,6 +1442,7 @@ function validateV2(ir: WorkflowIrV2): void {
}
validateNoIllegalCycles(ir.nodes, outgoing);
validateRequiredTopLevelReachability(ir.nodes, outgoing);
validateForeachDominance(ir.nodes, ir.edges, outgoing);
}

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr } from "@fusion/core";
import { TaskStore } from "@fusion/core";
import { NotificationService } from "../notification/notification-service.js";
import { WorkflowGraphTaskRunner, type WorkflowGraphRunnerStore } from "../workflow-graph-task-runner.js";
@@ -229,6 +233,56 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(result.reason).toMatch(/workflow-missing/);
});
it("persists a valid workflow through the store and launches it through the graph runner", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-7113-workflow-run-"));
const globalDir = mkdtempSync(join(tmpdir(), "fn-7113-workflow-global-"));
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
try {
const invalidIr: WorkflowIr = {
version: "v2",
name: "invalid-save",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "dup", kind: "prompt", column: "todo" },
{ id: "dup", kind: "script", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "dup" },
{ from: "dup", to: "end" },
],
};
await expect(store.createWorkflowDefinition({ name: "Invalid", ir: invalidIr })).rejects.toThrow(
/Workflow IR has duplicate node id 'dup'/,
);
const workflow = await store.createWorkflowDefinition({ name: "Valid", ir: fullLifecycleIr() });
const persisted = await store.getWorkflowDefinition(workflow.id);
expect(persisted?.id).toBe(workflow.id);
const savedTask = await store.createTask({ description: "save run", enabledWorkflowSteps: [] });
await store.selectTaskWorkflow(savedTask.id, workflow.id);
const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store,
seams: recordingSeams(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
});
const result = await runner.run(savedTask, flagOn);
expect(result.disposition).toBe("completed");
expect(calls).toEqual(["custom:lint", "execute", "review", "merge", "custom:notify"]);
} finally {
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
}
});
it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
const calls: string[] = [];
const getWorkflowDefinition = vi.fn(async () => undefined);
@@ -252,61 +306,92 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(getWorkflowDefinition).not.toHaveBeenCalled();
});
it("falls back (never strands the task) when the interpreter throws", async () => {
// Malformed graph: edge references unknown node → WorkflowIrError inside run().
it("fails closed with invalid-ir before any side-effect seam when resolved IR is malformed", async () => {
const badIr: WorkflowIr = {
version: "v1",
version: "v2",
name: "bad",
columns: [{ id: "c", name: "C", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
{ id: "start", kind: "start", column: "c" },
{ id: "dup", kind: "prompt", column: "c" },
{ id: "dup", kind: "script", column: "c" },
{ id: "end", kind: "end", column: "c" },
],
edges: [
{ from: "start", to: "dup" },
{ from: "dup", to: "end" },
],
edges: [{ from: "start", to: "ghost" }],
};
const calls: string[] = [];
const events: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(badIr)),
seams: recordingSeams([]),
runCustomNode: async () => ({ outcome: "success" }),
onEvent: (e) => events.push(e.type),
seams: recordingSeams(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
onEvent: (e) => events.push(`${e.type}:${e.detail}`),
});
const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("fell-back");
expect(result.reason).toMatch(/interpreter-error/);
expect(events).toContain("fallback");
expect(result.disposition).toBe("failed");
expect(result.outcome).toBe("failure");
expect(result.reason).toMatch(/invalid-ir: Workflow IR has duplicate node id 'dup'/);
expect(result.visitedNodeIds).toEqual([]);
expect(calls).toEqual([]);
expect(events.some((event) => event.includes("terminal:invalid-ir"))).toBe(true);
});
it("an interpreter error AFTER side effects terminates as failed, not fell-back", async () => {
// Cycle reached only after custom nodes execute: re-running legacy would
// repeat the implementation, so the runner must not signal fallback.
const cyclicIr: WorkflowIr = {
version: "v1",
name: "cyclic",
it("fails closed with invalid-ir before any custom node when resolved IR has a dangling edge", async () => {
const badIr: WorkflowIr = {
version: "v2",
name: "bad-edge",
columns: [{ id: "c", name: "C", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt", config: { prompt: "a" } },
{ id: "b", kind: "prompt", config: { prompt: "b" } },
{ id: "end", kind: "end" },
{ id: "start", kind: "start", column: "c" },
{ id: "a", kind: "prompt", column: "c" },
{ id: "end", kind: "end", column: "c" },
],
edges: [
{ from: "start", to: "a", condition: "success" },
{ from: "a", to: "b", condition: "success" },
{ from: "b", to: "a", condition: "success" },
{ from: "start", to: "a" },
{ from: "a", to: "ghost" },
],
};
const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(cyclicIr)),
store: storeWith(definition(badIr)),
seams: recordingSeams(calls),
runCustomNode: async (node) => {
calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
});
const result = await runner.run(task, flagOn);
expect(calls.length).toBeGreaterThan(0);
expect(result.disposition).toBe("failed");
expect(result.reason).toMatch(/interpreter-error/);
expect(result.outcome).toBe("failure");
expect(result.reason).toMatch(/invalid-ir: Workflow edge 'a' -> 'ghost' references undefined node 'ghost'/);
expect(result.visitedNodeIds).toEqual([]);
expect(calls).toEqual([]);
});
it("a custom-node failure AFTER side effects terminates as failed, not fell-back", async () => {
const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(fullLifecycleIr())),
seams: recordingSeams(calls),
runCustomNode: async (node) => {
throw new Error(`custom boom: ${node.id}`);
},
});
const result = await runner.run(task, flagOn);
expect(calls).toEqual([]);
expect(result.visitedNodeIds).toEqual(["start", "lint"]);
expect(result.disposition).toBe("failed");
expect(result.reason).toBeUndefined();
});
it("exposes node outcomes in the shared context for downstream consumers", async () => {

View File

@@ -1,5 +1,11 @@
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowStepResult } from "@fusion/core";
import { getBuiltinWorkflow, isBuiltinWorkflowId } from "@fusion/core";
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr, WorkflowStepResult } from "@fusion/core";
import {
compileWorkflowToSteps,
getBuiltinWorkflow,
isBuiltinWorkflowId,
parseWorkflowIr,
WorkflowCompileError,
} from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowGraphExecutorDeps, type WorkflowNodeOutcome, type WorkflowTaskProjection } from "./workflow-graph-executor.js";
import type {
@@ -29,11 +35,15 @@ import type { WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "./runt
*/
export type WorkflowGraphRunDisposition = "completed" | "failed" | "fell-back";
function isInterpreterDeferredCompileError(error: unknown): boolean {
return error instanceof WorkflowCompileError && error.message.includes("require the workflow interpreter (deferred)");
}
export interface WorkflowGraphTaskRunResult {
disposition: WorkflowGraphRunDisposition;
outcome?: WorkflowNodeOutcome;
visitedNodeIds: string[];
/** Why the runner fell back (flag-off, no-selection, workflow-missing, interpreter-error). */
/** Why the runner fell back or failed before execution (flag-off, no-selection, workflow-missing, invalid-ir, interpreter-error). */
reason?: string;
/** Shared graph context after the run (node outcomes/values). */
context?: Record<string, unknown>;
@@ -141,6 +151,11 @@ export class WorkflowGraphTaskRunner {
return { disposition: "fell-back", reason, visitedNodeIds: [] };
}
private failBeforeSideEffects(taskId: string, reason: string): WorkflowGraphTaskRunResult {
this.emit("terminal", taskId, reason);
return { disposition: "failed", outcome: "failure", reason, visitedNodeIds: [] };
}
public async run(
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
@@ -167,13 +182,28 @@ export class WorkflowGraphTaskRunner {
return this.fallBack(task.id, `workflow-missing: ${selection.workflowId}`);
}
let validatedIr: WorkflowIr;
try {
/*
FNXC:WorkflowExecution 2026-06-27-07:40:
FN-7113 requires the interpreter to re-validate the resolved built-in/custom/plugin workflow IR before any seam, primitive, or custom-node side effects. Invalid persisted or plugin-authored graphs fail closed with an author-facing invalid-ir reason instead of partially running or falling back into the wrong legacy workflow.
*/
validatedIr = parseWorkflowIr(definition.ir);
try {
compileWorkflowToSteps(validatedIr);
} catch (err) {
if (!isInterpreterDeferredCompileError(err)) throw err;
}
} catch (err) {
return this.failBeforeSideEffects(task.id, `invalid-ir: ${err instanceof Error ? err.message : String(err)}`);
}
this.emit("start", task.id, definition.id);
this.branchProgress.clear();
// Track whether any node side effects ran. A pre-run interpreter error
// (bad IR structure, wiring) can safely fall back to the legacy pipeline;
// a mid-run error cannot — re-running legacy would repeat the implementation
// session — so it terminates as "failed" for the caller to park instead.
// Track whether any node side effects ran. Invalid IR is rejected above as
// failed/terminal before this point; once execution starts, no error can
// safely fall back to legacy because that could repeat a partial workflow run.
let sideEffectsRan = false;
const invoked: string[] = [];
const seams = this.deps.seams;
@@ -254,7 +284,7 @@ export class WorkflowGraphTaskRunner {
}
},
});
const result = await executor.run(task, settings, definition.ir);
const result = await executor.run(task, settings, validatedIr);
if (!result.executed) {
return this.fallBack(task.id, "not-executed");
}