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: 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. 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 ## 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. - **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`. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. 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 ### Overriding built-in workflow prompts
<!-- <!--

View File

@@ -29,7 +29,22 @@ describe("workflow IR extension metadata", () => {
__resetWorkflowExtensionRegistryForTests(); __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({ const parsed = parseWorkflowIr(ir({
columns: [ columns: [
{ {
@@ -60,6 +75,22 @@ describe("workflow IR extension metadata", () => {
expect(parsed.nodes[0].extensions?.["plugin:workflow-pack:node-handler"]).toEqual({ handler: "plan" }); 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", () => { it("rejects extension metadata keys outside the plugin namespace", () => {
expect(() => expect(() =>
parseWorkflowIr(ir({ parseWorkflowIr(ir({
@@ -76,6 +107,14 @@ describe("workflow IR extension metadata", () => {
}); });
it("rejects non-object extension metadata values", () => { 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(() => expect(() =>
parseWorkflowIr(ir({ parseWorkflowIr(ir({
nodes: [ nodes: [
@@ -140,6 +179,14 @@ describe("workflow IR extension metadata", () => {
}); });
it("keeps v2 when otherwise-pure workflows carry 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({ const parsed = parseWorkflowIr(ir({
columns: [ columns: [
{ {

View File

@@ -13,6 +13,7 @@ import type {
WorkflowIrNode, WorkflowIrNode,
WorkflowIrEdge, WorkflowIrEdge,
} from "../workflow-ir-types.js"; } from "../workflow-ir-types.js";
import { BUILTIN_WORKFLOWS } from "../builtin-workflows.js";
function v2( function v2(
columns: WorkflowIrV2["columns"], columns: WorkflowIrV2["columns"],
@@ -153,6 +154,25 @@ describe("parseWorkflowIr — v2 columns & placement", () => {
); );
expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/); 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: // FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
@@ -659,8 +679,92 @@ describe("parseWorkflowIr — version & shape guards", () => {
); );
}); });
it("rejects missing start/end nodes", () => { it("rejects missing start nodes", () => {
const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []); 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/); 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; 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): * Validate a foreach `template` subgraph recursively (KTD-3):
* - non-empty; * - non-empty;
@@ -1234,7 +1264,14 @@ function validateRegisteredExtensionMetadata(
value: Record<string, unknown>, value: Record<string, unknown>,
): void { ): void {
const definition = getWorkflowExtensionRegistry().get(key); 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; if (!fields || fields.length === 0) return;
for (const field of fields) { for (const field of fields) {
if (field.required && !(field.key in value)) { if (field.required && !(field.key in value)) {
@@ -1314,6 +1351,17 @@ function validateV2(ir: WorkflowIrV2): void {
validateColumns(ir); validateColumns(ir);
const columnIds = new Set(ir.columns.map((c) => c.id)); 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])); const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
for (const node of ir.nodes) { for (const node of ir.nodes) {
@@ -1394,6 +1442,7 @@ function validateV2(ir: WorkflowIrV2): void {
} }
validateNoIllegalCycles(ir.nodes, outgoing); validateNoIllegalCycles(ir.nodes, outgoing);
validateRequiredTopLevelReachability(ir.nodes, outgoing);
validateForeachDominance(ir.nodes, ir.edges, outgoing); validateForeachDominance(ir.nodes, ir.edges, outgoing);
} }

View File

@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events"; 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 type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr } from "@fusion/core";
import { TaskStore } from "@fusion/core";
import { NotificationService } from "../notification/notification-service.js"; import { NotificationService } from "../notification/notification-service.js";
import { WorkflowGraphTaskRunner, type WorkflowGraphRunnerStore } from "../workflow-graph-task-runner.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/); 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 () => { it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
const calls: string[] = []; const calls: string[] = [];
const getWorkflowDefinition = vi.fn(async () => undefined); const getWorkflowDefinition = vi.fn(async () => undefined);
@@ -252,61 +306,92 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
expect(getWorkflowDefinition).not.toHaveBeenCalled(); expect(getWorkflowDefinition).not.toHaveBeenCalled();
}); });
it("falls back (never strands the task) when the interpreter throws", async () => { it("fails closed with invalid-ir before any side-effect seam when resolved IR is malformed", async () => {
// Malformed graph: edge references unknown node → WorkflowIrError inside run().
const badIr: WorkflowIr = { const badIr: WorkflowIr = {
version: "v1", version: "v2",
name: "bad", name: "bad",
columns: [{ id: "c", name: "C", traits: [] }],
nodes: [ nodes: [
{ id: "start", kind: "start" }, { id: "start", kind: "start", column: "c" },
{ id: "end", kind: "end" }, { 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 events: string[] = [];
const runner = new WorkflowGraphTaskRunner({ const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(badIr)), store: storeWith(definition(badIr)),
seams: recordingSeams([]), seams: recordingSeams(calls),
runCustomNode: async () => ({ outcome: "success" }), runCustomNode: async (node) => {
onEvent: (e) => events.push(e.type), calls.push(`custom:${node.id}`);
return { outcome: "success" };
},
onEvent: (e) => events.push(`${e.type}:${e.detail}`),
}); });
const result = await runner.run(task, flagOn); const result = await runner.run(task, flagOn);
expect(result.disposition).toBe("fell-back");
expect(result.reason).toMatch(/interpreter-error/); expect(result.disposition).toBe("failed");
expect(events).toContain("fallback"); 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 () => { it("fails closed with invalid-ir before any custom node when resolved IR has a dangling edge", async () => {
// Cycle reached only after custom nodes execute: re-running legacy would const badIr: WorkflowIr = {
// repeat the implementation, so the runner must not signal fallback. version: "v2",
const cyclicIr: WorkflowIr = { name: "bad-edge",
version: "v1", columns: [{ id: "c", name: "C", traits: [] }],
name: "cyclic",
nodes: [ nodes: [
{ id: "start", kind: "start" }, { id: "start", kind: "start", column: "c" },
{ id: "a", kind: "prompt", config: { prompt: "a" } }, { id: "a", kind: "prompt", column: "c" },
{ id: "b", kind: "prompt", config: { prompt: "b" } }, { id: "end", kind: "end", column: "c" },
{ id: "end", kind: "end" },
], ],
edges: [ edges: [
{ from: "start", to: "a", condition: "success" }, { from: "start", to: "a" },
{ from: "a", to: "b", condition: "success" }, { from: "a", to: "ghost" },
{ from: "b", to: "a", condition: "success" },
], ],
}; };
const calls: string[] = []; const calls: string[] = [];
const runner = new WorkflowGraphTaskRunner({ const runner = new WorkflowGraphTaskRunner({
store: storeWith(definition(cyclicIr)), store: storeWith(definition(badIr)),
seams: recordingSeams(calls), seams: recordingSeams(calls),
runCustomNode: async (node) => { runCustomNode: async (node) => {
calls.push(`custom:${node.id}`); calls.push(`custom:${node.id}`);
return { outcome: "success" }; return { outcome: "success" };
}, },
}); });
const result = await runner.run(task, flagOn); const result = await runner.run(task, flagOn);
expect(calls.length).toBeGreaterThan(0);
expect(result.disposition).toBe("failed"); 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 () => { 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 type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr, WorkflowStepResult } from "@fusion/core";
import { getBuiltinWorkflow, isBuiltinWorkflowId } 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 { WorkflowGraphExecutor, type WorkflowGraphExecutorDeps, type WorkflowNodeOutcome, type WorkflowTaskProjection } from "./workflow-graph-executor.js";
import type { import type {
@@ -29,11 +35,15 @@ import type { WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "./runt
*/ */
export type WorkflowGraphRunDisposition = "completed" | "failed" | "fell-back"; 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 { export interface WorkflowGraphTaskRunResult {
disposition: WorkflowGraphRunDisposition; disposition: WorkflowGraphRunDisposition;
outcome?: WorkflowNodeOutcome; outcome?: WorkflowNodeOutcome;
visitedNodeIds: string[]; 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; reason?: string;
/** Shared graph context after the run (node outcomes/values). */ /** Shared graph context after the run (node outcomes/values). */
context?: Record<string, unknown>; context?: Record<string, unknown>;
@@ -141,6 +151,11 @@ export class WorkflowGraphTaskRunner {
return { disposition: "fell-back", reason, visitedNodeIds: [] }; 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( public async run(
task: TaskDetail, task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined, settings: Pick<Settings, "experimentalFeatures"> | undefined,
@@ -167,13 +182,28 @@ export class WorkflowGraphTaskRunner {
return this.fallBack(task.id, `workflow-missing: ${selection.workflowId}`); 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.emit("start", task.id, definition.id);
this.branchProgress.clear(); this.branchProgress.clear();
// Track whether any node side effects ran. A pre-run interpreter error // Track whether any node side effects ran. Invalid IR is rejected above as
// (bad IR structure, wiring) can safely fall back to the legacy pipeline; // failed/terminal before this point; once execution starts, no error can
// a mid-run error cannot — re-running legacy would repeat the implementation // safely fall back to legacy because that could repeat a partial workflow run.
// session — so it terminates as "failed" for the caller to park instead.
let sideEffectsRan = false; let sideEffectsRan = false;
const invoked: string[] = []; const invoked: string[] = [];
const seams = this.deps.seams; 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) { if (!result.executed) {
return this.fallBack(task.id, "not-executed"); return this.fallBack(task.id, "not-executed");
} }