From 14bb7a370791475e6ec72accbbf2588a16d2cddc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 11:28:03 -0700 Subject: [PATCH] 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) --- .changeset/remove-legacy-step-engine.md | 7 + ...uiltin-lead-generation-workflow-ir.test.ts | 24 +- .../src/__tests__/builtin-workflows.test.ts | 23 +- .../src/__tests__/workflow-compiler.test.ts | 267 -------------- .../workflow-prompt-overrides.test.ts | 6 +- .../workflow-selection-store.test.ts | 24 +- .../__tests__/workflow-steps-to-ir.test.ts | 146 ++------ packages/core/src/index.ts | 5 - packages/core/src/store.ts | 21 +- packages/core/src/workflow-compiler.ts | 325 ------------------ .../core/src/workflow-lifecycle-validation.ts | 18 +- packages/core/src/workflow-steps-to-ir.ts | 44 +-- packages/dashboard/app/api/legacy.ts | 15 +- .../app/components/WorkflowNodeEditor.tsx | 64 +--- .../__tests__/WorkflowNodeEditor.test.tsx | 90 +---- .../__tests__/workflow-design-route.test.ts | 15 +- .../src/routes/register-workflow-routes.ts | 49 +-- .../engine/src/workflow-graph-task-runner.ts | 14 +- packages/i18n/locales/en/app.json | 1 - packages/i18n/locales/es/app.json | 1 - packages/i18n/locales/fr/app.json | 1 - packages/i18n/locales/ko/app.json | 1 - packages/i18n/locales/zh-CN/app.json | 1 - packages/i18n/locales/zh-TW/app.json | 1 - packages/i18n/src/resources.d.ts | 1 - 25 files changed, 157 insertions(+), 1007 deletions(-) create mode 100644 .changeset/remove-legacy-step-engine.md delete mode 100644 packages/core/src/__tests__/workflow-compiler.test.ts delete mode 100644 packages/core/src/workflow-compiler.ts diff --git a/.changeset/remove-legacy-step-engine.md b/.changeset/remove-legacy-step-engine.md new file mode 100644 index 0000000000..929b2c957a --- /dev/null +++ b/.changeset/remove-legacy-step-engine.md @@ -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. diff --git a/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts index 96074ee502..30d44dee41 100644 --- a/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-lead-generation-workflow-ir.test.ts @@ -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", diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 906c19cd8b..b52d4a5146 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -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(); - } } }); diff --git a/packages/core/src/__tests__/workflow-compiler.test.ts b/packages/core/src/__tests__/workflow-compiler.test.ts deleted file mode 100644 index 53c5a54042..0000000000 --- a/packages/core/src/__tests__/workflow-compiler.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/core/src/__tests__/workflow-prompt-overrides.test.ts b/packages/core/src/__tests__/workflow-prompt-overrides.test.ts index 8f80f6375c..9bfbcbedca 100644 --- a/packages/core/src/__tests__/workflow-prompt-overrides.test.ts +++ b/packages/core/src/__tests__/workflow-prompt-overrides.test.ts @@ -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"); }); }); diff --git a/packages/core/src/__tests__/workflow-selection-store.test.ts b/packages/core/src/__tests__/workflow-selection-store.test.ts index 560364c771..470653e88a 100644 --- a/packages/core/src/__tests__/workflow-selection-store.test.ts +++ b/packages/core/src/__tests__/workflow-selection-store.test.ts @@ -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); }); diff --git a/packages/core/src/__tests__/workflow-steps-to-ir.test.ts b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts index d98f664ee3..981c0cd1c0 100644 --- a/packages/core/src/__tests__/workflow-steps-to-ir.test.ts +++ b/packages/core/src/__tests__/workflow-steps-to-ir.test.ts @@ -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 { @@ -28,40 +37,8 @@ function step(overrides: Partial): 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"); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25f60b3318..a882d2eb8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 83f3804612..9817718bd5 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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 + diff --git a/packages/core/src/workflow-compiler.ts b/packages/core/src/workflow-compiler.ts deleted file mode 100644 index 7e1b991dd3..0000000000 --- a/packages/core/src/workflow-compiler.ts +++ /dev/null @@ -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 = 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([ - "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 { - const outgoing = new Map(); - 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(); - let nextExpectedSeamIndex = 0; - const visited = new Set(); - 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(); - 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; -} diff --git a/packages/core/src/workflow-lifecycle-validation.ts b/packages/core/src/workflow-lifecycle-validation.ts index 5c9f3dcdbd..a51af64ea6 100644 --- a/packages/core/src/workflow-lifecycle-validation.ts +++ b/packages/core/src/workflow-lifecycle-validation.ts @@ -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 = 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" diff --git a/packages/core/src/workflow-steps-to-ir.ts b/packages/core/src/workflow-steps-to-ir.ts index 196a942aa4..fee092090c 100644 --- a/packages/core/src/workflow-steps-to-ir.ts +++ b/packages/core/src/workflow-steps-to-ir.ts @@ -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 = { @@ -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"); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index ce49f781d8..8a2b5496d2 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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; } diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 122dd34cc5..2e53a95ce2 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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(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(false); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -955,10 +950,6 @@ function InnerEditor({ const [aiEditBusy, setAiEditBusy] = useState(false); const [aiEditError, setAiEditError] = useState(null); const aiEditAbortRef = useRef(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: ". - // 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} )} - {interpreterOnly && ( -
- {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.", - )} -
- )} {unplaced.length > 0 && (
{t("workflowColumns.unplacedCount", "{{count}} nodes not placed in a column", { diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index c6d5e5201c..710d23e025 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -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( {}} addToast={() => {}} />); @@ -1711,7 +1708,6 @@ describe("WorkflowNodeEditor — U10 columns/traits/holds", () => { ...v2Def(), ...(updates as object), })); - vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); render( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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 ─────────────────────────────────────────────────────────── diff --git a/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts b/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts index 668e61203c..64a317be49 100644 --- a/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts +++ b/packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts @@ -98,8 +98,9 @@ function linearIr(overrides?: { nodeConfig?: Record }): 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); }); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 4f85d64086..f0d50129fc 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -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); diff --git a/packages/engine/src/workflow-graph-task-runner.ts b/packages/engine/src/workflow-graph-task-runner.ts index 1de8858bd9..30bb8dabf3 100644 --- a/packages/engine/src/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflow-graph-task-runner.ts @@ -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)}`); } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 4a34214520..d25dd8a47c 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 4b35f1d492..1218372ff1 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -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", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 9faf71d659..8dfd800698 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -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", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 7fc821a436..6461bb0805 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -8439,7 +8439,6 @@ "gateBlocks": "게이트(차단)", "gateMode": "게이트 모드", "insertTemplate": "{{name}} 템플릿 삽입", - "interpreterOnly": "이 워크플로는 분기되므로 그래프 인터프리터에서 실행됩니다 — 선형 단계 엔진으로 컴파일할 수는 없지만 그래도 실행됩니다.", "joinAll": "모든 분기", "joinAny": "임의 분기", "joinMode": "조인 모드", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 03793584ee..c07bb41114 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -8439,7 +8439,6 @@ "gateBlocks": "关卡(阻断)", "gateMode": "关卡模式", "insertTemplate": "插入模板 {{name}}", - "interpreterOnly": "此工作流包含分支,将在图解释器上运行——无法编译为线性步骤引擎,但仍会正常执行。", "joinAll": "所有分支", "joinAny": "任意分支", "joinMode": "合并模式", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 9af70316a5..9c9f4d29cb 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -8439,7 +8439,6 @@ "gateBlocks": "關卡(阻擋)", "gateMode": "關卡模式", "insertTemplate": "插入範本 {{name}}", - "interpreterOnly": "這個工作流程有分支,因此會在圖形解譯器上執行 — 它無法編譯成線性步驟引擎,但仍可執行。", "joinAll": "所有分支", "joinAny": "任一分支", "joinMode": "合併模式", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index bb7167a087..d098eedc85 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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",