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:
7
.changeset/remove-legacy-step-engine.md
Normal file
7
.changeset/remove-legacy-step-engine.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8439,7 +8439,6 @@
|
||||
"gateBlocks": "게이트(차단)",
|
||||
"gateMode": "게이트 모드",
|
||||
"insertTemplate": "{{name}} 템플릿 삽입",
|
||||
"interpreterOnly": "이 워크플로는 분기되므로 그래프 인터프리터에서 실행됩니다 — 선형 단계 엔진으로 컴파일할 수는 없지만 그래도 실행됩니다.",
|
||||
"joinAll": "모든 분기",
|
||||
"joinAny": "임의 분기",
|
||||
"joinMode": "조인 모드",
|
||||
|
||||
@@ -8439,7 +8439,6 @@
|
||||
"gateBlocks": "关卡(阻断)",
|
||||
"gateMode": "关卡模式",
|
||||
"insertTemplate": "插入模板 {{name}}",
|
||||
"interpreterOnly": "此工作流包含分支,将在图解释器上运行——无法编译为线性步骤引擎,但仍会正常执行。",
|
||||
"joinAll": "所有分支",
|
||||
"joinAny": "任意分支",
|
||||
"joinMode": "合并模式",
|
||||
|
||||
@@ -8439,7 +8439,6 @@
|
||||
"gateBlocks": "關卡(阻擋)",
|
||||
"gateMode": "關卡模式",
|
||||
"insertTemplate": "插入範本 {{name}}",
|
||||
"interpreterOnly": "這個工作流程有分支,因此會在圖形解譯器上執行 — 它無法編譯成線性步驟引擎,但仍可執行。",
|
||||
"joinAll": "所有分支",
|
||||
"joinAny": "任一分支",
|
||||
"joinMode": "合併模式",
|
||||
|
||||
1
packages/i18n/src/resources.d.ts
vendored
1
packages/i18n/src/resources.d.ts
vendored
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user