feat(FN-7360): remove legacy workflow step engine

The step execution engine was already gone (runWorkflowSteps deleted,
workflow_steps table dropped in migration 132). This removes what remained:
the linear step compiler (compileWorkflowToSteps/validateLinearity/
WorkflowCompileError), which survived only as a validator + step-preview
generator.

parseWorkflowIr/validateV2 (which accepts branching graphs) is now the sole
workflow validity gate at save/select/refine and in the graph task runner.
Custom branching workflows are now selectable and run on the graph
interpreter instead of being rejected as non-linear.

- core: delete workflow-compiler.ts; rework store.validateWorkflowCompilable
  onto parseWorkflowIr; move MERGE_REGION_NODE_KINDS into
  workflow-lifecycle-validation; retag workflow-steps-to-ir as legacy lowering
- engine: drop the compiler double-validation in workflow-graph-task-runner
- dashboard: remove POST /api/workflows/:id/compile + client wrapper; drop the
  interpreterOnly response field and editor banner; no post-save compile check
- i18n: remove the orphaned workflowNodes.interpreterOnly key across locales
- tests: reframe two workflow-selection tests whose premise inverted; fix a
  pre-existing red in builtin-lead-generation (completion-summary node)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 11:28:03 -07:00
parent 4694b4a8c9
commit 14bb7a3707
25 changed files with 157 additions and 1007 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Branching workflows now run on the graph interpreter; the legacy step-compiler and its interpreter-only banner are gone.
category: feature
dev: Removed the linear WorkflowStep compiler (compileWorkflowToSteps/validateLinearity/WorkflowCompileError) from @fusion/core; parseWorkflowIr is now the sole workflow validity gate at save/select/refine and in the graph task runner. Deleted the POST /api/workflows/:id/compile preview route and its client wrapper, and dropped the interpreterOnly response field and editor banner. MERGE_REGION_NODE_KINDS moved into workflow-lifecycle-validation.

View File

@@ -6,7 +6,6 @@ import {
defaultEnabledBuiltinWorkflowIds,
getBuiltinWorkflow,
} from "../builtin-workflows.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js";
describe("built-in lead-generation workflow IR", () => {
@@ -46,7 +45,7 @@ describe("built-in lead-generation workflow IR", () => {
expect(ir.columns.filter((column) => column.traits.some((trait) => trait.trait === "archived"))).toHaveLength(1);
});
it("places every node in a defined column and compiles the linear prompt spine", () => {
it("places every node in a defined column and orders the prompt spine", () => {
const workflow = getBuiltinWorkflow("builtin:lead-generation")!;
const ir = parseWorkflowIr(workflow.ir);
if (ir.version !== "v2") throw new Error("expected v2");
@@ -65,7 +64,13 @@ describe("built-in lead-generation workflow IR", () => {
expect((ir.nodes.find((node) => node.id === "qualification-gate")?.config as { gateMode?: string })?.gateMode).toBe(
"advisory",
);
for (const node of ir.nodes.filter((candidate) => candidate.kind === "prompt" || candidate.kind === "gate")) {
// FNXC:WorkflowCompletion 2026-07-01-00:00: the generic `completion-summary`
// node (config.summaryTarget === "task") is a workflow-agnostic summary tail,
// not a lead-domain prompt — exclude it from the domain-prompt assertions.
for (const node of ir.nodes.filter(
(candidate) =>
(candidate.kind === "prompt" || candidate.kind === "gate") && candidate.config?.summaryTarget !== "task",
)) {
const config = node.config as { prompt?: string; seam?: string } | undefined;
expect(config?.seam, node.id).toBeUndefined();
expect(config?.prompt, node.id).toEqual(expect.stringMatching(/lead|prospect|outreach|customer|company/i));
@@ -73,7 +78,18 @@ describe("built-in lead-generation workflow IR", () => {
expect(ir.nodes.find((node) => node.id === "enrich-lead")?.config?.prompt).toContain("fn_task_document_write");
expect(ir.nodes.find((node) => node.id === "draft-outreach")?.config?.prompt).toContain("fn_task_document_write");
expect(compileWorkflowToSteps(ir).map((step) => step.name)).toEqual([
// FNXC:WorkflowStepCRUD 2026-07-01-00:00: the linear compiler was removed;
// assert the prompt-spine ORDER directly from the IR node sequence (non-seam
// prompt/gate nodes) instead of from compiled step names.
const spineNames = ir.nodes
.filter(
(node) =>
(node.kind === "prompt" || node.kind === "gate") &&
!node.config?.seam &&
node.config?.summaryTarget !== "task",
)
.map((node) => (node.config as { name?: string } | undefined)?.name);
expect(spineNames).toEqual([
"Source prospects",
"Qualify lead",
"Qualification go / no-go",

View File

@@ -16,7 +16,6 @@ import { PLAN_REVIEW_GROUP_ID, PLAN_REVIEW_STEP_NODE_ID } from "../builtin-plan-
import { builtinPromptConfig, BUILTIN_SEAM_PROMPTS } from "../builtin-workflow-prompts.js";
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js";
import { resolveColumnFlags } from "../trait-registry.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js";
import { createSharedTaskStoreTestHarness } from "./store-test-helpers.js";
import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "../builtin-stepwise-final-review-coding-workflow-ir.js";
@@ -49,28 +48,14 @@ function columnTraitMatrix(ir: { columns: Array<{ id: string; traits: Array<{ tr
}
describe("built-in workflows", () => {
// Non-compiler built-ins model graph-only node kinds or reusable fragments the
// linear compiler cannot lower to a step list. They still must parse as valid IR.
const NON_COMPILABLE_BUILTIN_IDS = new Set([
"builtin:coding",
"builtin:legacy-coding",
"builtin:quick-fix",
"builtin:review-heavy",
"builtin:design",
"builtin:marketing",
"builtin:compound-engineering",
"builtin:stepwise-coding",
"builtin:pr-workflow",
]);
it("every built-in has a valid IR; linear built-ins compile without error", () => {
// FNXC:WorkflowStepCRUD 2026-07-01-00:00: the linear WorkflowStep compiler was
// removed; the graph interpreter runs every built-in (including branching ones)
// directly. `parseWorkflowIr` is now the sole validity gate for all built-ins.
it("every built-in has a valid IR", () => {
expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4);
for (const wf of BUILTIN_WORKFLOWS) {
expect(isBuiltinWorkflowId(wf.id)).toBe(true);
expect(() => parseWorkflowIr(wf.ir)).not.toThrow();
if (!NON_COMPILABLE_BUILTIN_IDS.has(wf.id)) {
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
}
}
});

View File

@@ -1,267 +0,0 @@
import { describe, it, expect } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js";
import {
compileWorkflowToSteps,
validateLinearity,
WorkflowCompileError,
WORKFLOW_INTERPRETER_DEFERRED_SUFFIX,
} from "../workflow-compiler.js";
import { serializeWorkflowIr, parseWorkflowIr } from "../workflow-ir.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
/** Linear graph: start → (user nodes) → execute → review → merge → (user nodes) → end. */
function graph(
preMerge: WorkflowIr["nodes"],
postMerge: WorkflowIr["nodes"] = [],
{ withSeams = true }: { withSeams?: boolean } = {},
): WorkflowIr {
const seamNodes: WorkflowIr["nodes"] = withSeams
? [
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
]
: [];
const ordered = [
{ id: "start", kind: "start" as const },
...preMerge,
...seamNodes,
...postMerge,
{ id: "end", kind: "end" as const },
];
const edges: WorkflowIr["edges"] = [];
for (let i = 0; i < ordered.length - 1; i += 1) {
edges.push({ from: ordered[i].id, to: ordered[i + 1].id, condition: "success" });
}
// Canonical seam failure edges to end.
if (withSeams) {
for (const seam of ["execute", "review", "merge"]) {
edges.push({ from: seam, to: "end", condition: "failure" });
}
}
return { version: "v1", name: "test", nodes: ordered, edges };
}
describe("compileWorkflowToSteps (U2)", () => {
it("compiles a linear pre-merge gate + prompt in authored order", () => {
const ir = graph([
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
{ id: "spec", kind: "prompt", config: { name: "Spec check", prompt: "Check the spec" } },
]);
const steps = compileWorkflowToSteps(ir);
expect(steps).toHaveLength(2);
expect(steps[0].name).toBe("Lint");
expect(steps[0].phase).toBe("pre-merge");
expect(steps[0].mode).toBe("script");
expect(steps[0].gateMode).toBe("gate");
expect(steps[1].name).toBe("Spec check");
expect(steps[1].mode).toBe("prompt");
expect(steps[1].gateMode).toBe("advisory");
});
it("partitions nodes after the merge seam into post-merge", () => {
const ir = graph(
[{ id: "pre", kind: "prompt", config: { prompt: "before" } }],
[{ id: "post", kind: "script", config: { scriptName: "notify" } }],
);
const steps = compileWorkflowToSteps(ir);
expect(steps.map((s) => s.phase)).toEqual(["pre-merge", "post-merge"]);
expect(steps[1].mode).toBe("script");
expect(steps[1].scriptName).toBe("notify");
});
it("does not emit the execute/review/merge seams as steps", () => {
const ir = graph([{ id: "only", kind: "prompt", config: { prompt: "x" } }]);
const steps = compileWorkflowToSteps(ir);
expect(steps).toHaveLength(1);
expect(steps.every((s) => s.name !== "execute" && s.name !== "review" && s.name !== "merge")).toBe(true);
});
it("treats a gate node as gateMode=gate regardless of mode", () => {
const ir = graph([{ id: "g", kind: "gate", config: { prompt: "block?" } }]);
const steps = compileWorkflowToSteps(ir);
expect(steps[0].gateMode).toBe("gate");
expect(steps[0].mode).toBe("prompt");
});
it("carries prompt-node model overrides into the step", () => {
const ir = graph([
{
id: "p",
kind: "prompt",
config: { prompt: "x", modelProvider: "anthropic", modelId: "claude-sonnet-4-5" },
},
]);
const [step] = compileWorkflowToSteps(ir);
expect(step.modelProvider).toBe("anthropic");
expect(step.modelId).toBe("claude-sonnet-4-5");
});
it("rejects a graph with branching (fan-out beyond success/failure)", () => {
const ir: WorkflowIr = {
version: "v1",
name: "branchy",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt", config: { prompt: "a" } },
{ id: "b", kind: "prompt", config: { prompt: "b" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a", condition: "success" },
{ from: "a", to: "b", condition: "success" },
{ from: "a", to: "end", condition: "success" }, // illegal second success branch
{ from: "b", to: "end", condition: "success" },
],
};
const err = validateLinearity(ir);
expect(err).toBeInstanceOf(WorkflowCompileError);
expect(err?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
expect(() => compileWorkflowToSteps(ir)).toThrow(WorkflowCompileError);
expect(() => compileWorkflowToSteps(ir)).toThrow(/interpreter \(deferred\)/i);
});
it("defers both builtin coding and stepwise to the interpreter (U6: coding now carries an optional-group)", () => {
// U6: builtin:coding gained the `browser-verification` optional-group on its
// pre-merge path — a branching, single-pass container the linear WorkflowStep
// runner cannot lower. Like stepwise, coding is now interpreter-deferred.
const codingErr = validateLinearity(BUILTIN_CODING_WORKFLOW_IR);
expect(codingErr).toBeInstanceOf(WorkflowCompileError);
expect(codingErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
const stepwiseErr = validateLinearity(BUILTIN_STEPWISE_CODING_WORKFLOW_IR);
expect(stepwiseErr).toBeInstanceOf(WorkflowCompileError);
expect(stepwiseErr?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
});
it("defers compiling the builtin coding workflow to the interpreter (U6)", () => {
// The browser-verification optional-group makes the graph non-linear, so
// compileWorkflowToSteps throws the interpreter-deferred error rather than
// producing a (previously empty) linear pre-merge step list.
expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(WorkflowCompileError);
expect(() => compileWorkflowToSteps(BUILTIN_CODING_WORKFLOW_IR)).toThrow(/interpreter \(deferred\)/i);
});
it("compiles a workflow whose post-review merge region branches into primitives (FN-6035)", () => {
// Mirrors the builtin:coding shape: review → merge-gate fans out into the
// engine-owned merge/branch-group/retry subgraph. These primitive kinds are a
// terminal boundary, so the graph still compiles to its pre-merge step list
// instead of failing as interpreter-only.
const ir: WorkflowIr = {
version: "v1",
name: "merge-region",
nodes: [
{ id: "start", kind: "start" },
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "spec" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "merge-gate", kind: "merge-gate", config: { gate: "auto-merge" } },
{ id: "merge-attempt", kind: "merge-attempt" },
{ id: "merge-hold", kind: "manual-merge-hold" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "spec", condition: "success" },
{ from: "spec", to: "review", condition: "success" },
{ from: "review", to: "merge-gate", condition: "success" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge-gate", to: "merge-attempt", condition: "outcome:auto-on" },
{ from: "merge-gate", to: "merge-hold", condition: "outcome:auto-off" },
{ from: "merge-attempt", to: "end", condition: "success" },
{ from: "merge-hold", to: "merge-attempt", condition: "success" },
],
};
expect(validateLinearity(parseWorkflowIr(ir))).toBeNull();
const steps = compileWorkflowToSteps(ir);
// Only the pre-merge user node lowers; the merge primitives emit no steps.
expect(steps.map((s) => s.name)).toEqual(["Spec"]);
});
it("rejects a graph missing the start/end nodes via parse", () => {
const ir = { version: "v1", name: "x", nodes: [{ id: "p", kind: "prompt" }], edges: [] } as WorkflowIr;
expect(() => compileWorkflowToSteps(ir)).toThrow();
});
it("rejects a disconnected node not on the main path", () => {
const ir: WorkflowIr = {
version: "v1",
name: "orphan",
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt", config: { prompt: "a" } },
{ id: "orphan", kind: "prompt", config: { prompt: "o" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a", condition: "success" },
{ from: "a", to: "end", condition: "success" },
{ from: "orphan", to: "end", condition: "success" },
],
};
const err = validateLinearity(ir);
expect(err).toBeInstanceOf(WorkflowCompileError);
expect(err?.message).toContain(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
expect(err?.message).toMatch(/disconnected nodes/);
});
it("rejects seams that are out of the planning -> execute -> review -> merge order", () => {
const ir: WorkflowIr = {
version: "v1",
name: "misordered-seams",
nodes: [
{ id: "start", kind: "start" },
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
{ id: "review", kind: "prompt", config: { seam: "review" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "merge", condition: "success" },
{ from: "merge", to: "review", condition: "success" },
{ from: "review", to: "end", condition: "success" },
],
};
const err = validateLinearity(ir);
expect(err).toBeInstanceOf(WorkflowCompileError);
expect(err?.message).toMatch(/planning -> execute -> review -> merge order/);
});
it("rejects a graph with a duplicated seam role", () => {
const ir: WorkflowIr = {
version: "v1",
name: "dup-merge",
nodes: [
{ id: "start", kind: "start" },
{ id: "merge1", kind: "prompt", config: { seam: "merge" } },
{ id: "merge2", kind: "prompt", config: { seam: "merge" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "merge1", condition: "success" },
{ from: "merge1", to: "merge2", condition: "success" },
{ from: "merge2", to: "end", condition: "success" },
],
};
const err = validateLinearity(ir);
expect(err).toBeInstanceOf(WorkflowCompileError);
expect(err?.message).toMatch(/appears more than once/);
});
it("returns an empty step set for a graph with only start/seams/end", () => {
const ir = graph([]);
expect(compileWorkflowToSteps(ir)).toEqual([]);
});
it("is deterministic across a serialize/parse round-trip", () => {
const ir = graph([
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "x" } },
]);
const first = compileWorkflowToSteps(ir);
const second = compileWorkflowToSteps(parseWorkflowIr(serializeWorkflowIr(ir)));
expect(second).toEqual(first);
});
});

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { getBuiltinWorkflow } from "../builtin-workflows.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import {
applyPromptOverridesToIr,
enumeratePromptBearingWorkflowNodes,
@@ -49,10 +48,9 @@ describe("workflow prompt override overlay", () => {
);
});
it("bakes non-seam prompt overrides before compilation", () => {
it("bakes non-seam prompt overrides into the IR node", () => {
const ce = getBuiltinWorkflow("builtin:compound-engineering")!.ir;
const overlaid = applyPromptOverridesToIr(ce, { plan: "Plan override" });
const steps = compileWorkflowToSteps(overlaid);
expect(steps.find((step) => step.name === "Plan")?.prompt).toBe("Plan override");
expect(overlaid.nodes.find((node) => node.id === "plan")?.config?.prompt).toBe("Plan override");
});
});

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { WorkflowCompileError } from "../workflow-compiler.js";
import type { WorkflowIr } from "../workflow-ir-types.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
@@ -191,12 +190,17 @@ describe("TaskStore workflow selection (U3)", () => {
expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined();
});
it("rejects selecting a non-linear workflow without writing partial state", async () => {
// FNXC:WorkflowSelection 2026-07-01-00:00: the linear WorkflowStep compiler was
// removed; the graph interpreter runs branching graphs directly. A branching
// workflow is now a valid, selectable workflow (previously rejected as
// non-linear). `parseWorkflowIr` is the sole validity gate.
it("selects a branching workflow (runs on the graph interpreter)", async () => {
const wf = await store.createWorkflowDefinition({ name: "Branchy", ir: branchingIr() });
const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] });
await expect(store.selectTaskWorkflow(task.id, wf.id)).rejects.toBeInstanceOf(WorkflowCompileError);
expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined();
await expect(store.selectTaskWorkflow(task.id, wf.id)).resolves.toBeDefined();
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id);
// branchingIr has no optional-group nodes, so the selection seeds an empty set.
const detail = await store.getTask(task.id);
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
});
@@ -458,18 +462,24 @@ describe("TaskStore workflow selection (U3)", () => {
expect(detail.column).toBe("todo");
});
// FNXC:WorkflowSelection 2026-07-01-00:00: branching workflows are now valid
// (the linear compiler was removed), so the "cannot materialize" path is
// exercised by a source selection pointing at a FRAGMENT — fragments are not
// selectable, so materialization throws a non-"not found" error that
// refineTask rethrows (a stale "not found" would instead fall back to the
// default). refineTask must still fail BEFORE creating the refinement row.
it("fails before creating a refinement when the explicit source workflow cannot materialize", async () => {
const invalidWorkflow = await store.createWorkflowDefinition({ name: "Branchy", ir: branchingIr() });
const fragment = await store.createWorkflowDefinition({ name: "Frag", kind: "fragment", ir: fragmentIr() });
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
store.getDatabase().prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, stepIds = excluded.stepIds, updatedAt = excluded.updatedAt`,
).run(source.id, invalidWorkflow.id, JSON.stringify([]), new Date().toISOString());
).run(source.id, fragment.id, JSON.stringify([]), new Date().toISOString());
await moveToDone(source.id);
const before = (await store.listTasks({ includeArchived: true })).length;
await expect(store.refineTask(source.id, "follow up")).rejects.toBeInstanceOf(WorkflowCompileError);
await expect(store.refineTask(source.id, "follow up")).rejects.toThrow(/fragment/i);
expect((await store.listTasks({ includeArchived: true })).length).toBe(before);
});

View File

@@ -1,9 +1,18 @@
import { describe, it, expect } from "vitest";
import { stepsToWorkflowIr, stepToFragmentIr, layoutForIr } from "../workflow-steps-to-ir.js";
import { compileWorkflowToSteps } from "../workflow-compiler.js";
import { parseWorkflowIr } from "../workflow-ir.js";
import type { WorkflowStep, WorkflowStepInput } from "../types.js";
import type { WorkflowStep } from "../types.js";
/*
FNXC:WorkflowStepCRUD 2026-07-01-00:00:
The linear WorkflowStep compiler (compileWorkflowToSteps) was removed — the graph
interpreter is the sole executor. `stepsToWorkflowIr` / `stepToFragmentIr` survive
only as legacy migration + fragment-layout helpers (they lower old persisted
WorkflowStep rows into IR). These tests now assert the produced IR STRUCTURE
(parseable, seam encoding, node ordering, layout) rather than the former
IR→steps→IR round-trip parity, which no longer has an inverse.
*/
/** Build a fully-specified WorkflowStep fixture. */
function step(overrides: Partial<WorkflowStep>): WorkflowStep {
@@ -28,40 +37,8 @@ function step(overrides: Partial<WorkflowStep>): WorkflowStep {
};
}
/** Project a compiled step input down to exactly the compiler-visible fields the
* round-trip contract pins (KTD-2). Normalizes optional fields for comparison. */
function visible(input: WorkflowStepInput) {
return {
name: input.name,
mode: input.mode,
phase: input.phase,
gateMode: input.gateMode,
prompt: input.mode === "script" ? undefined : (input.prompt ?? ""),
scriptName: input.scriptName,
toolMode: input.mode === "script" ? undefined : input.toolMode,
skillName: input.mode === "script" ? undefined : input.skillName,
modelProvider: input.modelProvider,
modelId: input.modelId,
};
}
function visibleStep(s: WorkflowStep) {
return {
name: s.name,
mode: s.mode,
phase: s.phase ?? "pre-merge",
gateMode: s.gateMode,
prompt: s.mode === "script" ? undefined : (s.prompt ?? ""),
scriptName: s.mode === "script" ? s.scriptName : undefined,
toolMode: s.mode === "script" ? undefined : (s.toolMode ?? "readonly"),
skillName: s.mode === "script" ? undefined : s.skillName,
modelProvider: s.mode === "prompt" ? s.modelProvider : undefined,
modelId: s.mode === "prompt" ? s.modelId : undefined,
};
}
describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
it("reproduces every compiler-visible field for a mixed step set", () => {
describe("stepsToWorkflowIr — produces valid IR structure", () => {
it("lowers a mixed step set into a parseable IR carrying each step's config", () => {
const steps: WorkflowStep[] = [
step({
id: "WS-1",
@@ -73,37 +50,7 @@ describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
toolMode: "coding",
phase: "pre-merge",
}),
step({
id: "WS-2",
name: "Lint",
mode: "script",
gateMode: "gate",
scriptName: "lint",
phase: "pre-merge",
}),
step({
id: "WS-3",
name: "Security gate",
mode: "prompt",
gateMode: "gate",
prompt: "Block on exploitable findings",
toolMode: "readonly",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
phase: "pre-merge",
}),
// U1 / INVERSION CONTRACT: a skill-executor step (pre-merge, grouped with
// the other pre-merge steps so declaration order matches compiled order)
// must round-trip its skillName through stepInputToNode → nodeToStepInput.
step({
id: "WS-6",
name: "CE skill step",
mode: "prompt",
gateMode: "advisory",
prompt: "Invoke the skill",
skillName: "compound-engineering:ce-work",
phase: "pre-merge",
}),
step({ id: "WS-2", name: "Lint", mode: "script", gateMode: "gate", scriptName: "lint", phase: "pre-merge" }),
step({
id: "WS-4",
name: "Document",
@@ -112,40 +59,21 @@ describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
prompt: "Write docs",
phase: "post-merge",
}),
step({
id: "WS-5",
name: "Deploy script",
mode: "script",
gateMode: "advisory",
scriptName: "deploy",
phase: "post-merge",
}),
];
const ir = stepsToWorkflowIr(steps, "Migrated");
const compiled = compileWorkflowToSteps(ir);
expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
});
it("undefined phase maps to pre-merge and round-trips", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }),
step({ id: "WS-2", name: "B", mode: "prompt", gateMode: "advisory", prompt: "b" }),
];
const ir = stepsToWorkflowIr(steps, "AllUndefined");
// parseable
expect(() => parseWorkflowIr(ir)).not.toThrow();
const compiled = compileWorkflowToSteps(ir);
expect(compiled.map((c) => c.phase)).toEqual(["pre-merge", "pre-merge"]);
expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
// Pre-merge steps precede the merge seam; post-merge steps follow it.
const ids = ir.nodes.map((n) => n.id);
expect(ids.indexOf("step-1")).toBeLessThan(ids.indexOf("merge"));
expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("step-3"));
// The prompt node carries the source prompt through the lowering.
expect(ir.nodes.find((n) => n.id === "step-1")?.config?.prompt).toBe("Implement the change");
});
it("empty step list yields a minimal valid IR that compiles to []", () => {
it("empty step list yields a minimal valid IR (start + seams + end)", () => {
const ir = stepsToWorkflowIr([], "Empty");
expect(() => parseWorkflowIr(ir)).not.toThrow();
expect(compileWorkflowToSteps(ir)).toEqual([]);
// start + 3 seams + end.
expect(ir.nodes.map((n) => n.id)).toEqual(["start", "execute", "review", "merge", "end"]);
});
@@ -156,12 +84,9 @@ describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
const ir = stepsToWorkflowIr(steps, "PostOnly");
const ids = ir.nodes.map((n) => n.id);
expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("step-1"));
const compiled = compileWorkflowToSteps(ir);
expect(compiled).toHaveLength(1);
expect(compiled[0].phase).toBe("post-merge");
});
it("produced IR passes parseWorkflowIr and encodes seams exactly per linear()", () => {
it("produced IR passes parseWorkflowIr and encodes seams exactly", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "A", mode: "prompt", gateMode: "advisory", prompt: "a" }),
];
@@ -181,18 +106,6 @@ describe("stepsToWorkflowIr — round-trip parity (R4/KTD-2)", () => {
const failureEdges = ir.edges.filter((e) => e.condition === "failure");
expect(failureEdges).toHaveLength(3);
});
it("gate vs advisory both round-trip for prompt and script modes", () => {
const steps: WorkflowStep[] = [
step({ id: "WS-1", name: "PG", mode: "prompt", gateMode: "gate", prompt: "p" }),
step({ id: "WS-2", name: "PA", mode: "prompt", gateMode: "advisory", prompt: "p" }),
step({ id: "WS-3", name: "SG", mode: "script", gateMode: "gate", scriptName: "s" }),
step({ id: "WS-4", name: "SA", mode: "script", gateMode: "advisory", scriptName: "s" }),
];
const compiled = compileWorkflowToSteps(stepsToWorkflowIr(steps, "Gates"));
expect(compiled.map((c) => c.gateMode)).toEqual(["gate", "advisory", "gate", "advisory"]);
expect(compiled.map(visible)).toEqual(steps.map(visibleStep));
});
});
describe("stepToFragmentIr (R6/KTD-1)", () => {
@@ -210,19 +123,14 @@ describe("stepToFragmentIr (R6/KTD-1)", () => {
expect(() => parseWorkflowIr(ir)).not.toThrow();
expect(ir.nodes.map((n) => n.id)).toEqual(["start", "step-1", "end"]);
expect(ir.nodes.map((n) => n.kind)).toEqual(["start", "prompt", "end"]);
// The single node compiles back to a step mirroring the source.
const compiled = compileWorkflowToSteps(ir);
expect(compiled).toHaveLength(1);
expect(visible(compiled[0])).toEqual(visibleStep(s));
expect(ir.nodes.find((n) => n.id === "step-1")?.config?.prompt).toBe("Document the change");
});
it("fragment IR is pure v1 (no v2-only features)", () => {
it("script fragment carries scriptName through the lowering", () => {
const ir = stepToFragmentIr(step({ id: "WS-1", name: "S", mode: "script", gateMode: "gate", scriptName: "lint" }));
// parseWorkflowIr upgrades to v2 in-memory; the SOURCE we built is v1-shaped.
const compiled = compileWorkflowToSteps(ir);
expect(compiled[0].mode).toBe("script");
expect(compiled[0].scriptName).toBe("lint");
const node = ir.nodes.find((n) => n.id === "step-1");
expect(node?.kind).toBe("script");
expect(node?.config?.scriptName).toBe("lint");
});
});

View File

@@ -383,11 +383,6 @@ export {
MAX_WORKFLOW_ICON_LENGTH,
normalizeWorkflowIcon,
} from "./workflow-definition-types.js";
export {
compileWorkflowToSteps,
validateLinearity,
WorkflowCompileError,
} from "./workflow-compiler.js";
export {
stepsToWorkflowIr,
stepToFragmentIr,

View File

@@ -115,7 +115,6 @@ import type {
WorkflowNodeLayout,
} from "./workflow-definition-types.js";
import { normalizeWorkflowIcon } from "./workflow-definition-types.js";
import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js";
import { analyzeWorkflowLifecycle } from "./workflow-lifecycle-validation.js";
import { resolveDefaultOnOptionalGroupIds } from "./workflow-optional-steps.js";
import {
@@ -16095,16 +16094,16 @@ ${stepsSection}`;
this.db.prepare("DELETE FROM task_workflow_selection WHERE taskId = ?").run(taskId);
}
/** Validate a workflow's IR by compiling it; throws on genuinely invalid graphs.
* Interpreter-deferred built-ins (optional-group bearing) are valid and tolerated.
* No `workflow_steps` rows are written (U7c). */
private validateWorkflowCompilable(workflowId: string, def: { ir: WorkflowIr }): void {
try {
compileWorkflowToSteps(def.ir);
} catch (err) {
if (isBuiltinWorkflowId(workflowId) && isInterpreterDeferredWorkflowCompileError(err)) return;
throw err;
}
/**
* FNXC:WorkflowSelection 2026-07-01-00:00:
* Validate a workflow's IR up front; throws (WorkflowIrError) on genuinely
* invalid graphs. The linear WorkflowStep compiler was removed — the graph
* interpreter is the sole executor, so `parseWorkflowIr`/`validateV2` (which
* accepts branching graphs) is the validity gate. Branching is no longer a
* rejected shape, so no built-in tolerance is needed.
*/
private validateWorkflowCompilable(_workflowId: string, def: { ir: WorkflowIr }): void {
parseWorkflowIr(def.ir);
}
/** Resolve the project-default workflow into the selection seed (workflow id +

View File

@@ -1,325 +0,0 @@
import type { WorkflowIr, WorkflowIrNode, WorkflowIrEdge } from "./workflow-ir-types.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import type { WorkflowStepInput, WorkflowStepGateMode } from "./types.js";
/**
* Raised when a WorkflowIr graph cannot be compiled onto the executable
* WorkflowStep engine — typically because it branches beyond the canonical
* seam success/failure chain and therefore requires the (deferred) graph
* interpreter rather than the linear pre/post-merge step runner.
*/
export class WorkflowCompileError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkflowCompileError";
}
}
export const WORKFLOW_INTERPRETER_DEFERRED_SUFFIX = "require the workflow interpreter (deferred)";
export function isInterpreterDeferredWorkflowCompileError(error: unknown): boolean {
return error instanceof WorkflowCompileError && error.message.includes(WORKFLOW_INTERPRETER_DEFERRED_SUFFIX);
}
/** Workflow-owned merge/retry/recovery policy primitives. The WorkflowStep
* compiler treats this region as a terminal engine-owned boundary: these nodes
* may branch internally, are not emitted as steps, and are not walked by the
* linear step compiler. */
export const MERGE_REGION_NODE_KINDS: ReadonlySet<WorkflowIrNode["kind"]> = new Set([
"merge-gate",
"merge-attempt",
"manual-merge-hold",
"retry-backoff",
"recovery-router",
"branch-group-member-integration",
"branch-group-promotion",
]);
function isMergeRegionKind(node: WorkflowIrNode): boolean {
return MERGE_REGION_NODE_KINDS.has(node.kind);
}
/** Seam anchor kinds, encoded on IR nodes as `config.seam`. These map to the
* fixed planning → execute → review → merge pipeline and are
* not emitted as steps. */
const SEAM_NAMES = new Set(["planning", "execute", "review", "merge"]);
const ENGINE_PRIMITIVE_NODE_KINDS = new Set<WorkflowIrNode["kind"]>([
"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 isEnginePrimitive(node: WorkflowIrNode): boolean {
return ENGINE_PRIMITIVE_NODE_KINDS.has(node.kind);
}
function seamOf(node: WorkflowIrNode): string | undefined {
const seam = node.config?.seam;
return typeof seam === "string" && SEAM_NAMES.has(seam) ? seam : undefined;
}
function configString(node: WorkflowIrNode, key: string): string | undefined {
const value = node.config?.[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function buildOutgoing(ir: WorkflowIr): Map<string, WorkflowIrEdge[]> {
const outgoing = new Map<string, WorkflowIrEdge[]>();
for (const edge of ir.edges) {
const list = outgoing.get(edge.from);
if (list) list.push(edge);
else outgoing.set(edge.from, [edge]);
}
return outgoing;
}
function mainEdge(edges: WorkflowIrEdge[]): WorkflowIrEdge | undefined {
return edges.find((edge) => edge.condition !== "failure");
}
/**
* Validate that a workflow graph reduces to a linear pre-merge → seams →
* post-merge chain the WorkflowStep engine can run. Returns a
* WorkflowCompileError describing the first problem, or null when compilable.
*
* Allowed shape: a single path from start to end or to the engine-owned merge
* policy region. Seam nodes may carry an extra `failure` edge to the end node;
* merge-policy primitives are terminal and may fan out internally; every other
* non-terminal node has exactly one outgoing edge. Anything else (true
* branching) requires the deferred interpreter.
*/
export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
const nodesById = new Map(ir.nodes.map((node) => [node.id, node]));
for (const edge of ir.edges) {
if (!nodesById.has(edge.from)) return new WorkflowCompileError(`edge references unknown node '${edge.from}'`);
if (!nodesById.has(edge.to)) return new WorkflowCompileError(`edge references unknown node '${edge.to}'`);
}
const endNode = ir.nodes.find((node) => node.kind === "end");
const startNode = ir.nodes.find((node) => node.kind === "start");
if (!startNode || !endNode) {
return new WorkflowCompileError("workflow must contain exactly one start and one end node");
}
const outgoing = buildOutgoing(ir);
for (const node of ir.nodes) {
const outs = outgoing.get(node.id) ?? [];
if (node.kind === "end") {
if (outs.length > 0) return new WorkflowCompileError("end node must have no outgoing edges");
continue;
}
if (isEnginePrimitive(node)) {
continue;
}
if (isMergeRegionKind(node)) {
continue;
}
const seam = seamOf(node);
if (seam) {
const failureEdges = outs.filter((edge) => edge.condition === "failure");
const mainEdges = outs.filter((edge) => !edge.condition || edge.condition === "success");
const outcomeEdges = outs.filter((edge) => edge.condition?.startsWith("outcome:"));
if (mainEdges.length !== 1) {
return new WorkflowCompileError(`seam '${node.id}' must have exactly one success path`);
}
if (failureEdges.length > 1) {
return new WorkflowCompileError(`seam '${node.id}' has multiple failure edges`);
}
if (failureEdges[0] && failureEdges[0].to !== endNode.id) {
return new WorkflowCompileError(`seam '${node.id}' failure edge must target the end node`);
}
const nonTerminalOutcomeEdge = outcomeEdges.find((edge) => edge.to !== endNode.id);
if (nonTerminalOutcomeEdge) {
return new WorkflowCompileError(`seam '${node.id}' outcome edge must target the end node`);
}
continue;
}
// start or a user node (prompt/script/gate): exactly one outgoing edge.
if (outs.length === 0) {
return new WorkflowCompileError(`node '${node.id}' has no outgoing edge`);
}
if (outs.length > 1) {
// NOTE: WORKFLOW_INTERPRETER_DEFERRED_SUFFIX is matched by the dashboard
// editor/routes (KTD-4) to render an info-tone "interpreter-only" banner
// instead of an error. Keep interpreter-deferred messages carrying this
// exact suffix in sync.
return new WorkflowCompileError(
`node '${node.id}' branches into ${outs.length} edges — graphs with branches ${WORKFLOW_INTERPRETER_DEFERRED_SUFFIX}`,
);
}
}
// Reachability: the single main path must reach end and cover every node.
// While walking, enforce the canonical seam pipeline: each of planning/
// execute/review/merge may appear at most once and only in that
// order. The compiler treats seams as a fixed lifecycle boundary (merge flips
// pre- to post-merge), so out-of-order or duplicate seams would compile
// inconsistently with the runtime contract.
const expectedSeamOrder = ["planning", "execute", "review", "merge"] as const;
const seenSeams = new Set<string>();
let nextExpectedSeamIndex = 0;
const visited = new Set<string>();
let reachedTerminal = false;
let cursor: string | undefined = startNode.id;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
const node = nodesById.get(cursor);
if (node && isMergeRegionKind(node)) {
reachedTerminal = true;
break;
}
const seam = node ? seamOf(node) : undefined;
if (seam) {
if (seenSeams.has(seam)) {
return new WorkflowCompileError(`seam '${seam}' appears more than once`);
}
while (
nextExpectedSeamIndex < expectedSeamOrder.length &&
expectedSeamOrder[nextExpectedSeamIndex] !== seam
) {
nextExpectedSeamIndex += 1;
}
if (expectedSeamOrder[nextExpectedSeamIndex] !== seam) {
return new WorkflowCompileError(
"seams must follow the planning -> execute -> review -> merge order",
);
}
seenSeams.add(seam);
nextExpectedSeamIndex += 1;
}
if (cursor === endNode.id || (node && isEnginePrimitive(node))) {
reachedTerminal = true;
break;
}
cursor = mainEdge(outgoing.get(cursor) ?? [])?.to;
}
if (!reachedTerminal) {
return new WorkflowCompileError("workflow main path does not reach the end node");
}
const unreached = ir.nodes.filter((node) => !visited.has(node.id) && node.kind !== "end" && !isEnginePrimitive(node));
if (unreached.length > 0) {
return new WorkflowCompileError(
`node '${unreached[0].id}' is not on the main path — disconnected nodes ${WORKFLOW_INTERPRETER_DEFERRED_SUFFIX}`,
);
}
return null;
}
function defaultGateMode(node: WorkflowIrNode, mode: "prompt" | "script"): WorkflowStepGateMode {
if (node.kind === "gate") return "gate";
const explicit = node.config?.gateMode;
if (explicit === "gate" || explicit === "advisory") return explicit;
return mode === "script" ? "gate" : "advisory";
}
/**
* Map a single user IR node onto a WorkflowStepInput. This is the forward half
* of the steps↔IR round-trip contract (workflow-editor-consolidation R4/KTD-2);
* its exact inverse is `stepInputToNode` in `workflow-steps-to-ir.ts`. Parity is
* pinned by `__tests__/workflow-steps-to-ir.test.ts` over exactly the
* compiler-visible fields: name / mode / phase / gateMode / prompt / scriptName /
* toolMode / skillName / modelProvider / modelId. `enabled` / `defaultOn` /
* `templateId` are NOT compiler-visible and are handled by migration policy, not
* the converter.
*
* INVERSION CONTRACT: when you add a field here, extend `stepInputToNode` (and
* the parity test) in `workflow-steps-to-ir.ts` to keep the round-trip exact.
*/
function nodeToStepInput(node: WorkflowIrNode, phase: "pre-merge" | "post-merge"): WorkflowStepInput {
const scriptName = configString(node, "scriptName");
const mode: "prompt" | "script" = node.kind === "script" || (node.kind === "gate" && scriptName) ? "script" : "prompt";
const gateMode = defaultGateMode(node, mode);
const input: WorkflowStepInput = {
name: configString(node, "name") ?? node.id,
description: configString(node, "description") ?? "",
mode,
phase,
gateMode,
};
if (mode === "script") {
input.scriptName = scriptName;
} else {
input.prompt = configString(node, "prompt") ?? "";
input.toolMode = node.config?.toolMode === "coding" ? "coding" : "readonly";
// Carry the node's skill name so the step session can load it (U1). Only
// present on skill-executor nodes; omitted otherwise to keep round-trip exact.
const skillName = configString(node, "skillName");
if (skillName) input.skillName = skillName;
const provider = configString(node, "modelProvider");
const modelId = configString(node, "modelId");
if (provider && modelId) {
input.modelProvider = provider;
input.modelId = modelId;
}
}
return input;
}
/**
* Compile a workflow graph into an ordered list of WorkflowStep inputs ready to
* persist and run on the existing engine. User prompt/script/gate nodes become
* steps; execute/review seams are skipped; the merge seam is the pre-/post-merge
* boundary; merge-policy primitives form a terminal engine-owned region that is
* skipped and never emitted as steps. Throws WorkflowCompileError for non-linear
* graphs.
*
* The returned array order is the execution order (it maps directly onto a
* task's `enabledWorkflowSteps`).
*/
export function compileWorkflowToSteps(ir: WorkflowIr): WorkflowStepInput[] {
const parsed = parseWorkflowIr(ir);
const error = validateLinearity(parsed);
if (error) throw error;
const nodesById = new Map(parsed.nodes.map((node) => [node.id, node]));
const outgoing = buildOutgoing(parsed);
const startNode = parsed.nodes.find((node) => node.kind === "start")!;
const steps: WorkflowStepInput[] = [];
let phase: "pre-merge" | "post-merge" = "pre-merge";
const visited = new Set<string>();
let cursor: string | undefined = startNode.id;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
const node = nodesById.get(cursor);
if (!node) break;
if (isMergeRegionKind(node)) {
break;
}
const seam = seamOf(node);
if (isEnginePrimitive(node)) {
break;
}
if (seam === "merge") {
phase = "post-merge";
} else if (!seam && node.kind !== "start" && node.kind !== "end" && node.kind !== "optional-group") {
// FNXC:WorkflowOptionalGroup 2026-06-27-00:00: Optional-group nodes are graph-native enable containers keyed by node id; compiling them into legacy WorkflowStepInput rows would create an empty duplicate step and bypass the per-task toggle semantics.
steps.push(nodeToStepInput(node, phase));
}
if (node.kind === "end") break;
cursor = mainEdge(outgoing.get(cursor) ?? [])?.to;
}
return steps;
}

View File

@@ -1,6 +1,22 @@
import type { WorkflowDefinitionKind } from "./workflow-definition-types.js";
import type { WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "./workflow-ir-types.js";
import { MERGE_REGION_NODE_KINDS } from "./workflow-compiler.js";
/**
* FNXC:WorkflowLifecycle 2026-07-01-00:00:
* Workflow-owned merge/retry/recovery policy primitives — a terminal,
* engine-owned region that may branch internally. Formerly exported from the
* (now-deleted) linear WorkflowStep compiler; the graph interpreter is the sole
* executor, so this set now lives with its only remaining consumer.
*/
const MERGE_REGION_NODE_KINDS: ReadonlySet<WorkflowIrNode["kind"]> = new Set([
"merge-gate",
"merge-attempt",
"manual-merge-hold",
"retry-backoff",
"recovery-router",
"branch-group-member-integration",
"branch-group-promotion",
]);
export type WorkflowLifecycleWarningCode =
| "missing-completion-summary"

View File

@@ -6,20 +6,20 @@ import { parseWorkflowIr } from "./workflow-ir.js";
/**
* Steps → IR converter (workflow-editor-consolidation U1, R4/KTD-2).
*
* This module is the exact INVERSE of the compiler's `nodeToStepInput`
* (`workflow-compiler.ts`). The round-trip contract is:
* FNXC:WorkflowStepCRUD 2026-07-01-00:00:
* The linear WorkflowStep compiler (`compileWorkflowToSteps` / `nodeToStepInput`)
* was removed — the graph interpreter is the sole executor. This module survives
* as a LEGACY LOWERING: it converts old persisted `WorkflowStep[]` rows (and
* single-step fragments) into valid WorkflowIr for migration and for the palette
* fragment layout. It no longer has a forward inverse, so there is no round-trip
* parity contract; correctness is now "the produced IR is well-formed and carries
* each step's config" (pinned by `__tests__/workflow-steps-to-ir.test.ts`).
*
* compileWorkflowToSteps(stepsToWorkflowIr(steps, name)) ≡ steps
*
* over exactly the compiler-visible fields: name / mode / phase / gateMode /
* prompt / scriptName / toolMode / skillName / modelProvider / modelId.
* `enabled` / `defaultOn` / `templateId` / `migratedFragmentId` are NOT
* compiler-visible and are handled by migration policy (KTD-3), not by this
* converter. Parity is pinned by `__tests__/workflow-steps-to-ir.test.ts`.
*
* INVERSION CONTRACT: when a compiler-visible field is added to `nodeToStepInput`
* (see the contract comment there), extend `stepInputToNode` below and the parity
* test to keep the round-trip exact.
* Each step maps to one IR node: mode "script" → kind "script" with
* `config.scriptName`; mode "prompt" → kind "prompt" with
* `config.prompt`/`toolMode`/`skillName`/model overrides; `config.gateMode` is
* always written. `enabled` / `defaultOn` / `templateId` / `migratedFragmentId`
* are handled by migration policy (KTD-3), not this converter.
*
* Seam encoding mirrors `linear()` in `builtin-workflows.ts` exactly: the fixed
* execute → review → merge pipeline is emitted as prompt-kind nodes carrying
@@ -38,19 +38,12 @@ const LAYOUT_DX = 170;
const LAYOUT_Y = 160;
/**
* Inverse of `nodeToStepInput` (workflow-compiler.ts). Produces a single user IR
* node whose forward compilation reproduces every compiler-visible field of the
* given step.
* Lower a single `WorkflowStep` into one user IR node.
*
* kind ↔ mode/gateMode mapping (the heart of the contract):
* - mode "script" → kind "script", `config.scriptName` set. The compiler reads
* mode from `kind === "script"`, so this round-trips to mode "script".
* - mode "prompt" → kind "prompt", `config.prompt`/`toolMode`/model overrides.
* kind ↔ mode/gateMode mapping:
* - mode "script" → kind "script", `config.scriptName` set.
* - mode "prompt" → kind "prompt", `config.prompt`/`toolMode`/`skillName`/model overrides.
* - gateMode is ALWAYS written to `config.gateMode` (both "gate" and "advisory").
* The compiler's `defaultGateMode` returns an explicit `config.gateMode` for
* non-gate-kind nodes verbatim, so this round-trips for both modes without
* needing the `gate` node kind (which the compiler only emits via scriptName
* heuristics — using explicit `config.gateMode` keeps the inverse total).
*/
function stepInputToNode(step: WorkflowStep, id: string): WorkflowIrNode {
const config: Record<string, unknown> = {
@@ -93,8 +86,7 @@ function seamNode(seam: (typeof SEAM_ORDER)[number]): WorkflowIrNode {
*
* Steps with `phase` undefined map to pre-merge (R4). Seam nodes get an extra
* `failure → end` edge, mirroring `linear()`. The result always passes
* `parseWorkflowIr`. An empty step list yields the minimal seam-only pipeline
* (which compiles back to `[]`).
* `parseWorkflowIr`. An empty step list yields the minimal seam-only pipeline.
*/
export function stepsToWorkflowIr(steps: WorkflowStep[], name: string): WorkflowIr {
const preMerge = steps.filter((s) => (s.phase ?? "pre-merge") === "pre-merge");

View File

@@ -16,7 +16,6 @@ import type {
ActivityLogEntry,
ActivityEventType,
WorkflowStep,
WorkflowStepInput,
WorkflowStepResult,
PluginInstallation,
PluginSetupCheckResult,
@@ -5487,13 +5486,6 @@ export function updateWorkflowPromptOverrides(
);
}
/** Preview the compiled steps for a workflow. Rejects (422) for non-linear graphs. */
export function compileWorkflow(id: string, projectId?: string): Promise<{ steps: WorkflowStepInput[] }> {
return api<{ steps: WorkflowStepInput[] }>(withProjectId(`/workflows/${encodeURIComponent(id)}/compile`, projectId), {
method: "POST",
});
}
/** A workflow export envelope (U5/R9/KTD-5). `schemaVersion` is the SERVER's
* schema version at export time — the import route version-gates against it
* (the app build aliases @fusion/core to types-only, so the value can only come
@@ -5561,13 +5553,12 @@ export function importWorkflow(
// MigrateLegacyStepsResult along with the legacy workflow_steps table and its route.
/** Result of POST /api/workflows/design (U10/R11). The server validates the
* AI-produced IR (parseWorkflowIr), triages compilability (`interpreterOnly`),
* and strips trust-escalating flags (`strippedApprovalFlags`). Persists nothing
* — the client decides what to do with the returned graph. */
* AI-produced IR (parseWorkflowIr) and strips trust-escalating flags
* (`strippedApprovalFlags`). Persists nothing — the client decides what to do
* with the returned graph. */
export interface DesignWorkflowResult {
ir: import("@fusion/core").WorkflowIr;
layout: import("@fusion/core").WorkflowDefinition["layout"];
interpreterOnly: boolean;
strippedApprovalFlags: boolean;
}

View File

@@ -26,7 +26,6 @@ import {
createWorkflow,
updateWorkflow,
deleteWorkflow,
compileWorkflow,
exportWorkflow,
importWorkflow,
designWorkflow,
@@ -777,10 +776,6 @@ function InnerEditor({
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
// Info-tone state (KTD-4): set when a save compiles-rejects solely because the
// graph branches (interpreter-only), distinct from the warning-toned
// validationError used for genuine problems.
const [interpreterOnly, setInterpreterOnly] = useState<boolean>(false);
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode<WorkflowFlowNodeData>>([]);
const [edges, setEdges] = useEdgesState<FlowEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@@ -955,10 +950,6 @@ function InnerEditor({
const [aiEditBusy, setAiEditBusy] = useState(false);
const [aiEditError, setAiEditError] = useState<string | null>(null);
const aiEditAbortRef = useRef<AbortController | null>(null);
// U10/R11: when a create-from-AI result is interpreter-only, the new workflow
// becomes active and its load effect resets the banner — so we stash the flag
// here and the load effect re-raises it once for the workflow it activates.
const pendingInterpreterOnlyRef = useRef(false);
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
@@ -1281,14 +1272,6 @@ function InnerEditor({
setValidationError(null);
// Position viewport at top-left so the laid-out nodes are visible.
setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 0 });
// Honor a pending AI interpreter-only flag exactly once for the workflow it
// just activated; otherwise the banner clears on load (U10/R11).
if (pendingInterpreterOnlyRef.current) {
pendingInterpreterOnlyRef.current = false;
setInterpreterOnly(true);
} else {
setInterpreterOnly(false);
}
}, [activeWorkflow, setNodes, setEdges, setViewport]);
useEffect(() => {
@@ -1683,7 +1666,6 @@ function InnerEditor({
setValidationError(null);
// Leave the loaded snapshot pointing at the (still-persisted) base so the
// replaced graph reads dirty — the user must explicitly Save.
setInterpreterOnly(result.interpreterOnly);
if (result.strippedApprovalFlags) {
addToast(
t("workflows.importStripped", "Auto-approval flags were removed from imported nodes"),
@@ -1936,9 +1918,9 @@ function InnerEditor({
// Calls the server design route (no IR posted), then creates the workflow
// seeded from the returned {ir, layout} via the existing create path. The name
// comes from the dialog's name field if filled, else "AI: <first 30 chars>".
// After activation: interpreterOnly surfaces the existing info banner; a strip
// shows the shared importStripped toast (reused per spec). Throws on failure so
// the dialog renders the server message inline and stays open (nothing created).
// A stripped-approval-flags result shows the shared importStripped toast
// (reused per spec). Throws on failure so the dialog renders the server message
// inline and stays open (nothing created).
const handleDesignNewWorkflow = useCallback(
async (prompt: string, dialogName: string, signal: AbortSignal) => {
const result = await designWorkflow({ prompt }, projectId, signal);
@@ -1953,9 +1935,6 @@ function InnerEditor({
},
projectId,
);
// Stash the interpreter-only flag BEFORE activating so the new workflow's
// load effect re-raises the banner instead of clearing it (U10/R11).
pendingInterpreterOnlyRef.current = result.interpreterOnly;
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
setWorkflowListStageOpen(false);
@@ -2045,7 +2024,6 @@ function InnerEditor({
setSaving(true);
setValidationError(null);
setInterpreterOnly(false);
setServerNodeError(null);
try {
const trimmedName = name.trim() || activeWorkflow.name;
@@ -2080,25 +2058,11 @@ function InnerEditor({
setName(updated.name);
setDescription(updated.description ?? "");
setIcon(updated.icon);
// Validate by compiling — surfaces non-linear graphs as a banner.
try {
await compileWorkflow(updated.id, projectId);
addToast(t("workflows.saved", "Workflow saved"), "success");
} catch (compileErr) {
const compileMsg = getErrorMessage(compileErr) || "";
// KTD-4: branching graphs reject with this shared suffix from
// workflow-compiler.ts (both the fan-out and off-main-path messages).
// Such a graph still runs on the interpreter — present it as info, not a
// warning. NOTE: this string is coupled to the compiler's message; if
// that wording changes, update both sites (see compiler message site).
if (compileMsg.includes("require the workflow interpreter (deferred)")) {
setInterpreterOnly(true);
} else {
setValidationError(
compileMsg || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"),
);
}
}
// FNXC:WorkflowEditor 2026-07-01-00:00: The linear WorkflowStep compiler
// was removed and the graph interpreter runs branching graphs directly, so
// there is no post-save compile check. The server already validated the IR
// (parseWorkflowIr) on the update PATCH; reaching here means it is valid.
addToast(t("workflows.saved", "Workflow saved"), "success");
};
const savePayload = {
ir,
@@ -3548,18 +3512,6 @@ function InnerEditor({
{validationError}
</div>
)}
{interpreterOnly && (
<div
className="wf-editor-banner wf-editor-banner--info"
role="status"
data-testid="wf-interpreter-only-banner"
>
{t(
"workflowNodes.interpreterOnly",
"This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.",
)}
</div>
)}
{unplaced.length > 0 && (
<div className="wf-editor-banner wf-editor-banner--warn" role="alert" data-testid="wf-unplaced-summary">
{t("workflowColumns.unplacedCount", "{{count}} nodes not placed in a column", {

View File

@@ -41,7 +41,6 @@ vi.mock("../../api", () => ({
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
compileWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
importWorkflow: vi.fn(),
designWorkflow: vi.fn(),
@@ -81,7 +80,6 @@ import {
fetchTraits,
fetchStepParsers,
updateWorkflow,
compileWorkflow,
createWorkflow,
deleteWorkflow,
fetchModels,
@@ -996,7 +994,6 @@ describe("WorkflowNodeEditor", () => {
...v2Def(),
...(updates as object),
}));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
@@ -1711,7 +1708,6 @@ describe("WorkflowNodeEditor — U10 columns/traits/holds", () => {
...v2Def(),
...(updates as object),
}));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -1744,7 +1740,6 @@ describe("WorkflowNodeEditor — U10 columns/traits/holds", () => {
...v2Def(),
...(updates as object),
}));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// Wait for the column panel to hydrate before saving — saving earlier
@@ -1990,7 +1985,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
it("auto-populates a step-execute child when a foreach is added from the palette", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -2029,7 +2023,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
it("adds an optional-group from the palette and round-trips its template on save", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -2079,7 +2072,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
it("edits optional-group maxRevisions and unbounded revision mode", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -2102,7 +2094,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
it("persists optional-group unbounded maxRevisions mode", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -2127,7 +2118,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
opt.config = { ...opt.config, maxRevisions: 2 };
vi.mocked(fetchWorkflows).mockResolvedValue([def]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def, ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -2174,7 +2164,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
it("toggles optional-group defaultOn, marks the editor dirty, and persists on save", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([optionalGroupDef()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...optionalGroupDef(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
@@ -2369,7 +2358,6 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
it("edits notify event, title, and message fields", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument();
@@ -2638,58 +2626,10 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
});
});
// ── U2: edge-condition authoring (compile-banner split) ─────────────────────
describe("WorkflowNodeEditor — U2 interpreter-only banner", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
...v2Def(),
...(updates as object),
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
async function saveActive() {
await screen.findByText("Save");
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
}
it("shows an info-tone status banner (not an error) when compile rejects with the interpreter-deferred suffix", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(compileWorkflow).mockRejectedValue(
new Error(
"node 'step' branches into 2 edges — graphs with branches require the workflow interpreter (deferred)",
),
);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await saveActive();
const banner = await screen.findByTestId("wf-interpreter-only-banner");
expect(banner).toHaveAttribute("role", "status");
expect(banner.className).toMatch(/wf-editor-banner--info/);
// No alert-toned error banner.
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps the warning error banner for other (non-interpreter) compile errors", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(compileWorkflow).mockRejectedValue(new Error("node 'step' has no outgoing edge"));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await saveActive();
const banner = await screen.findByRole("alert");
expect(banner).toHaveTextContent(/no outgoing edge/i);
expect(screen.queryByTestId("wf-interpreter-only-banner")).not.toBeInTheDocument();
});
});
// FNXC:WorkflowEditor 2026-07-01-00:00: the U2 interpreter-only banner describe
// block was removed with the linear WorkflowStep compiler. Branching graphs now
// run on the graph interpreter directly; there is no post-save compile check and
// no interpreter-only banner, so there is nothing left to assert here.
// ── U4: dialogs, inline rename/description, dirty guard ─────────────────────
@@ -2999,7 +2939,6 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
it("persists a renamed name through the save PATCH", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
@@ -3584,7 +3523,6 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
it("inserts an add-on as a single node carrying its template config", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: STEP_TEMPLATE_FIXTURES,
});
@@ -3613,7 +3551,6 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
it("inserts an add-on as an optional-group whose template holds the projected node and defaultOn matches", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: STEP_TEMPLATE_FIXTURES,
});
@@ -3645,7 +3582,6 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => {
it("remaps ids when the same add-on subgraph is inserted twice (no collision)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) }));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({
templates: STEP_TEMPLATE_FIXTURES,
});
@@ -3696,7 +3632,6 @@ describe("WorkflowNodeEditor — U10 design-with-AI", () => {
],
},
layout: { start: { x: 0, y: 0 }, "ai-lint": { x: 120, y: 0 }, end: { x: 240, y: 0 } },
interpreterOnly: false,
strippedApprovalFlags: false,
...over,
};
@@ -3794,20 +3729,9 @@ describe("WorkflowNodeEditor — U10 design-with-AI", () => {
expect(rejectFn).toBeDefined();
});
it("interpreterOnly result seeds the info banner after the workflow loads", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(designWorkflow).mockResolvedValue(designedResult({ interpreterOnly: true }));
vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-AI", name: "AI branchy" });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
await screen.findByTestId("wf-create-dialog");
fireEvent.click(screen.getByTestId("wf-ai-toggle"));
fireEvent.change(await screen.findByTestId("wf-ai-prompt"), { target: { value: "branchy" } });
fireEvent.click(screen.getByTestId("wf-ai-submit"));
expect(await screen.findByTestId("wf-interpreter-only-banner")).toBeInTheDocument();
});
// FNXC:WorkflowEditor 2026-07-01-00:00: the "interpreterOnly seeds the info
// banner" test was removed with the linear compiler — branching AI-designed
// workflows are accepted and run on the graph interpreter with no banner.
// ── Toolbar flow ───────────────────────────────────────────────────────────

View File

@@ -98,8 +98,9 @@ function linearIr(overrides?: { nodeConfig?: Record<string, unknown> }): Workflo
} as WorkflowIr;
}
/** A branching IR: one node with two `success` edges → triggers the compiler's
* deferred-interpreter suffix (interpreterOnly). */
/** A branching IR: one node with two `success` edges. The linear compiler was
* removed — the graph interpreter runs branching graphs directly, so this is an
* accepted design result. */
function branchingIr(): WorkflowIr {
return {
version: "v1",
@@ -193,13 +194,13 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => {
return (await store.listWorkflowDefinitions()).filter((w) => !isBuiltinWorkflowId(w.id)).length;
}
it("valid linear IR → 200 {ir, interpreterOnly:false} with layout", async () => {
it("valid IR → 200 {ir} with layout", async () => {
const { factory, captured } = makeFakeAgent(JSON.stringify(linearIr()));
__setCreateFnAgentForDesign(factory);
const res = await postJson("/api/workflows/design", { prompt: "a coding flow" });
expect(res.status).toBe(200);
expect(res.body.interpreterOnly).toBe(false);
expect(res.body.interpreterOnly).toBeUndefined();
expect(res.body.ir.nodes).toHaveLength(3);
expect(res.body.layout).toBeTruthy();
expect(Object.keys(res.body.layout).length).toBeGreaterThan(0);
@@ -232,7 +233,6 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => {
const res = await postJson("/api/workflows/design", { prompt: "a coding flow" });
expect(res.status).toBe(200);
expect(res.body.interpreterOnly).toBe(false);
expect(res.body.ir.nodes).toHaveLength(3);
expect(captured.userPrompt).toContain("Design a workflow");
});
@@ -245,16 +245,15 @@ describe("POST /api/workflows/design (U7/R11/KTD-6)", () => {
const res = await postJson("/api/workflows/design", { prompt: "make a flow" });
expect(res.status).toBe(200);
expect(res.body.ir.nodes).toHaveLength(3);
expect(res.body.interpreterOnly).toBe(false);
});
it("branching IR → 200 {interpreterOnly:true}", async () => {
it("branching IR → 200 (accepted; runs on the graph interpreter)", async () => {
const { factory } = makeFakeAgent(JSON.stringify(branchingIr()));
__setCreateFnAgentForDesign(factory);
const res = await postJson("/api/workflows/design", { prompt: "branch it" });
expect(res.status).toBe(200);
expect(res.body.interpreterOnly).toBe(true);
expect(res.body.interpreterOnly).toBeUndefined();
expect(res.body.ir.nodes).toHaveLength(5);
});

View File

@@ -1,5 +1,5 @@
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, getBuiltinWorkflow, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps, enumeratePromptBearingWorkflowNodes, normalizeWorkflowIcon } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowIrError, ColumnAgentBindingError, WorkflowSettingRejectionError, SCHEMA_VERSION, assertColumnTraitsValid, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, resolveWorkflowIrById, resolveEffectiveSettingValues, findOrphanedSettingValues, isBuiltinWorkflowId, getBuiltinWorkflow, BUILTIN_WORKFLOW_SETTINGS, AgentStore, validateColumnAgentBindings, resolveWorkflowOptionalSteps, enumeratePromptBearingWorkflowNodes, normalizeWorkflowIcon } from "@fusion/core";
import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
@@ -438,27 +438,6 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
});
// POST /api/workflows/:id/compile — preview the compiled WorkflowSteps.
// 200 with the step set, or 422 when the graph requires the deferred interpreter.
router.post("/workflows/:id/compile", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const def = await store.getWorkflowDefinition(req.params.id);
if (!def) throw notFound(`Workflow '${req.params.id}' not found`);
try {
res.json({ steps: compileWorkflowToSteps(def.ir) });
} catch (compileErr: unknown) {
if (compileErr instanceof WorkflowCompileError || compileErr instanceof WorkflowIrError) {
throw new ApiError(422, compileErr.message);
}
throw compileErr;
}
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// GET /api/workflows/:id/setting-values — read the per-`(workflowId, project)`
// setting values for the workflow node editor's Values tab (U6, R5). Returns
// the raw `stored` map, the `effective` map (stored ?? declaration default,
@@ -643,7 +622,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
enabledWorkflowSteps = result.enabledWorkflowSteps;
reconciliation = result.reconciliation;
} catch (selectErr: unknown) {
if (selectErr instanceof WorkflowCompileError || selectErr instanceof WorkflowIrError) {
if (selectErr instanceof WorkflowIrError) {
throw new ApiError(422, selectErr.message);
}
if (selectErr instanceof Error && /not found/i.test(selectErr.message)) {
@@ -958,8 +937,6 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
// rate limit exhausted → 429
// unknown workflowId → 404
// invalid JSON / parseWorkflowIr → 422 (parser message)
// compile deferred-suffix failure → 200 { interpreterOnly: true }
// other compile failure → 422 (graph unsound for both engines)
router.post("/workflows/design", async (req, res) => {
try {
const { store } = await getProjectContext(req);
@@ -1052,22 +1029,10 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
);
}
// Compile triage: parseWorkflowIr is the validity gate. A compile failure
// whose message carries the deferred-interpreter suffix means the graph is
// structurally valid but only runnable on the (deferred) interpreter →
// interpreterOnly:true (NOT an error). Any OTHER compile failure means the
// graph is unsound for BOTH engines → 422.
let interpreterOnly = false;
try {
compileWorkflowToSteps(ir);
} catch (compileErr: unknown) {
const message = compileErr instanceof Error ? compileErr.message : String(compileErr);
if (message.includes("require the workflow interpreter (deferred)")) {
interpreterOnly = true;
} else {
throw new ApiError(422, message);
}
}
// FNXC:WorkflowDesign 2026-07-01-00:00: parseWorkflowIr (above) is the sole
// validity gate. The linear WorkflowStep compiler was removed and the graph
// interpreter runs branching graphs directly, so there is no interpreter-only
// triage — any structurally valid IR is accepted.
// Strip trust-escalating flags (shared helper; R11 trust boundary).
const strippedApprovalFlags = stripApprovalFlags(ir);
@@ -1075,7 +1040,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
// Deterministic layout for the returned IR (server-side value import).
const layout = layoutForIr(ir);
res.json({ ir, layout, interpreterOnly, strippedApprovalFlags });
res.json({ ir, layout, strippedApprovalFlags });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);

View File

@@ -1,10 +1,8 @@
import type { Settings, TaskDetail, TaskStep, WorkflowDefinition, WorkflowIr, WorkflowStepResult } from "@fusion/core";
import {
compileWorkflowToSteps,
getBuiltinWorkflow,
isBuiltinWorkflowId,
parseWorkflowIr,
WorkflowCompileError,
} from "@fusion/core";
import {
@@ -46,10 +44,6 @@ 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;
@@ -211,13 +205,11 @@ export class WorkflowGraphTaskRunner {
/*
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.
FNXC:WorkflowExecution 2026-07-01-00:00:
The linear WorkflowStep compiler was removed; the graph interpreter is the sole executor. `parseWorkflowIr` (which validates branching graphs via validateV2) is now the only IR validity gate here — there is no separate linear-compile pre-check to satisfy.
*/
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)}`);
}

View File

@@ -8449,7 +8449,6 @@
"gateBlocks": "Gate (blocks)",
"gateMode": "Gate mode",
"insertTemplate": "Insert template {{name}}",
"interpreterOnly": "This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.",
"joinAll": "All branches",
"joinAny": "Any branch",
"joinMode": "Join mode",

View File

@@ -8439,7 +8439,6 @@
"gateBlocks": "Compuerta (bloquea)",
"gateMode": "Modo de compuerta",
"insertTemplate": "Insertar plantilla {{name}}",
"interpreterOnly": "Este flujo de trabajo tiene ramas, por lo que se ejecuta en el intérprete de grafo — no puede compilarse al motor de pasos lineal, pero igualmente se ejecutará.",
"joinAll": "Todas las ramas",
"joinAny": "Cualquier rama",
"joinMode": "Modo de unión",

View File

@@ -8439,7 +8439,6 @@
"gateBlocks": "Barrière (bloque)",
"gateMode": "Mode de barrière",
"insertTemplate": "Insérer le modèle {{name}}",
"interpreterOnly": "Ce workflow comporte des branches, il s'exécute donc sur l'interpréteur de graphe — il ne peut pas être compilé vers le moteur d'étapes linéaire, mais il s'exécutera quand même.",
"joinAll": "Toutes les branches",
"joinAny": "N’importe quelle branche",
"joinMode": "Mode de jointure",

View File

@@ -8439,7 +8439,6 @@
"gateBlocks": "게이트(차단)",
"gateMode": "게이트 모드",
"insertTemplate": "{{name}} 템플릿 삽입",
"interpreterOnly": "이 워크플로는 분기되므로 그래프 인터프리터에서 실행됩니다 — 선형 단계 엔진으로 컴파일할 수는 없지만 그래도 실행됩니다.",
"joinAll": "모든 분기",
"joinAny": "임의 분기",
"joinMode": "조인 모드",

View File

@@ -8439,7 +8439,6 @@
"gateBlocks": "关卡(阻断)",
"gateMode": "关卡模式",
"insertTemplate": "插入模板 {{name}}",
"interpreterOnly": "此工作流包含分支,将在图解释器上运行——无法编译为线性步骤引擎,但仍会正常执行。",
"joinAll": "所有分支",
"joinAny": "任意分支",
"joinMode": "合并模式",

View File

@@ -8439,7 +8439,6 @@
"gateBlocks": "關卡(阻擋)",
"gateMode": "關卡模式",
"insertTemplate": "插入範本 {{name}}",
"interpreterOnly": "這個工作流程有分支,因此會在圖形解譯器上執行 — 它無法編譯成線性步驟引擎,但仍可執行。",
"joinAll": "所有分支",
"joinAny": "任一分支",
"joinMode": "合併模式",

View File

@@ -8467,7 +8467,6 @@ export default interface Resources {
"gateBlocks": "Gate (blocks)",
"gateMode": "Gate mode",
"insertTemplate": "Insert template {{name}}",
"interpreterOnly": "This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.",
"joinAll": "All branches",
"joinAny": "Any branch",
"joinMode": "Join mode",