feat(core): WorkflowIr v2 — workflow-defined columns, hold/split/join nodes, v1 upgrade (U1)
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr, serializeWorkflowIr } from "../index.js";
|
||||
import {
|
||||
BUILTIN_CODING_WORKFLOW_IR,
|
||||
DEFAULT_WORKFLOW_COLUMN_IDS,
|
||||
parseWorkflowIr,
|
||||
serializeWorkflowIr,
|
||||
} from "../index.js";
|
||||
|
||||
describe("builtin coding workflow ir", () => {
|
||||
it("parses and round-trips", () => {
|
||||
const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR);
|
||||
const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed));
|
||||
expect(reparsed).toEqual(parsed);
|
||||
expect(parsed.version).toBe("v1");
|
||||
// The built-in default workflow is now a v2 graph (columns + placement).
|
||||
expect(parsed.version).toBe("v2");
|
||||
});
|
||||
|
||||
it("contains exactly one start and one end node", () => {
|
||||
@@ -22,4 +28,34 @@ describe("builtin coding workflow ir", () => {
|
||||
expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"]));
|
||||
expect(seams).not.toContain("triage");
|
||||
});
|
||||
|
||||
it("defines the six legacy columns in legacy order (KTD-1)", () => {
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
|
||||
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
|
||||
const ids = BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id);
|
||||
expect(ids).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]);
|
||||
expect(ids).toEqual(["triage", "todo", "in-progress", "in-review", "done", "archived"]);
|
||||
});
|
||||
|
||||
it("maps default-workflow traits to columns verbatim (R12)", () => {
|
||||
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => [c.id, c]));
|
||||
const traitsFor = (id: string) => byId.get(id)!.traits.map((t) => t.trait);
|
||||
expect(traitsFor("triage")).toEqual(["intake"]);
|
||||
expect(traitsFor("todo")).toEqual(["hold", "reset-on-entry"]);
|
||||
expect(traitsFor("in-progress")).toEqual(["wip", "abort-on-exit", "timing"]);
|
||||
expect(traitsFor("in-review")).toEqual(["merge-blocker", "stall-detection", "merge"]);
|
||||
expect(traitsFor("done")).toEqual(["complete"]);
|
||||
expect(traitsFor("archived")).toEqual(["archived"]);
|
||||
// todo's hold is capacity-released (legacy "pull from todo when a slot frees").
|
||||
const hold = byId.get("todo")!.traits.find((t) => t.trait === "hold");
|
||||
expect(hold?.config?.release).toBe("capacity");
|
||||
});
|
||||
|
||||
it("places seam nodes in their columns", () => {
|
||||
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
expect(byId.get("merge")?.column).toBe("in-review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "../builtin-workflows.js";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||
import { compileWorkflowToSteps } from "../workflow-compiler.js";
|
||||
import { parseWorkflowIr } from "../workflow-ir.js";
|
||||
import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr } from "../workflow-ir.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("built-in workflows", () => {
|
||||
@@ -15,6 +16,14 @@ describe("built-in workflows", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => {
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2");
|
||||
if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2");
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id)).toEqual([
|
||||
...DEFAULT_WORKFLOW_COLUMN_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes a coding and a compound-engineering workflow", () => {
|
||||
expect(getBuiltinWorkflow("builtin:coding")).toBeDefined();
|
||||
expect(getBuiltinWorkflow("builtin:compound-engineering")).toBeDefined();
|
||||
|
||||
344
packages/core/src/__tests__/workflow-ir.test.ts
Normal file
344
packages/core/src/__tests__/workflow-ir.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseWorkflowIr,
|
||||
serializeWorkflowIr,
|
||||
WorkflowIrError,
|
||||
DEFAULT_WORKFLOW_COLUMN_IDS,
|
||||
} from "../workflow-ir.js";
|
||||
import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrV1,
|
||||
WorkflowIrV2,
|
||||
WorkflowIrNode,
|
||||
WorkflowIrEdge,
|
||||
} from "../workflow-ir-types.js";
|
||||
|
||||
function v2(
|
||||
columns: WorkflowIrV2["columns"],
|
||||
nodes: WorkflowIrNode[],
|
||||
edges: WorkflowIrEdge[],
|
||||
): WorkflowIrV2 {
|
||||
return { version: "v2", name: "test", columns, nodes, edges };
|
||||
}
|
||||
|
||||
const startEnd: WorkflowIrNode[] = [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
];
|
||||
|
||||
describe("parseWorkflowIr — v2 columns & placement", () => {
|
||||
it("parses a v2 graph with columns, placement and a hold node", () => {
|
||||
const ir = v2(
|
||||
[
|
||||
{ id: "intake", name: "Intake", traits: [{ trait: "intake" }] },
|
||||
{ id: "work", name: "Work", traits: [] },
|
||||
],
|
||||
[
|
||||
{ id: "start", kind: "start", column: "intake" },
|
||||
{ id: "wait", kind: "hold", column: "intake", config: { release: "manual" } },
|
||||
{ id: "end", kind: "end", column: "work" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "wait" },
|
||||
{ from: "wait", to: "end" },
|
||||
],
|
||||
);
|
||||
const parsed = parseWorkflowIr(ir);
|
||||
expect(parsed.version).toBe("v2");
|
||||
expect(parsed).toEqual(ir);
|
||||
});
|
||||
|
||||
it("rejects a node referencing an undefined column id", () => {
|
||||
const ir = v2(
|
||||
[{ id: "only", name: "Only", traits: [] }],
|
||||
[
|
||||
{ id: "start", kind: "start", column: "only" },
|
||||
{ id: "end", kind: "end", column: "ghost" },
|
||||
],
|
||||
[{ from: "start", to: "end" }],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/undefined column 'ghost'/);
|
||||
});
|
||||
|
||||
it("rejects duplicate column ids within a workflow", () => {
|
||||
const ir = v2(
|
||||
[
|
||||
{ id: "dup", name: "A", traits: [] },
|
||||
{ id: "dup", name: "B", traits: [] },
|
||||
],
|
||||
startEnd,
|
||||
[{ from: "start", to: "end" }],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWorkflowIr — v1 upgrade", () => {
|
||||
const v1: WorkflowIrV1 = {
|
||||
version: "v1",
|
||||
name: "legacy",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
|
||||
{ id: "review", kind: "prompt", config: { seam: "review" } },
|
||||
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
|
||||
{ id: "custom", kind: "prompt", config: { name: "Plan" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute" },
|
||||
{ from: "execute", to: "review", condition: "success" },
|
||||
{ from: "review", to: "merge", condition: "success" },
|
||||
{ from: "merge", to: "custom", condition: "success" },
|
||||
{ from: "custom", to: "end" },
|
||||
],
|
||||
};
|
||||
|
||||
it("upgrades a v1 graph to v2 with synthesized default columns", () => {
|
||||
const parsed = parseWorkflowIr(v1);
|
||||
expect(parsed.version).toBe("v2");
|
||||
if (parsed.version !== "v2") throw new Error("expected v2");
|
||||
expect(parsed.columns.map((c) => c.id)).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]);
|
||||
});
|
||||
|
||||
it("places nodes by seam (execute→in-progress, review/merge→in-review, others→todo)", () => {
|
||||
const parsed = parseWorkflowIr(v1);
|
||||
if (parsed.version !== "v2") throw new Error("expected v2");
|
||||
const byId = new Map(parsed.nodes.map((n) => [n.id, n]));
|
||||
expect(byId.get("execute")?.column).toBe("in-progress");
|
||||
expect(byId.get("review")?.column).toBe("in-review");
|
||||
expect(byId.get("merge")?.column).toBe("in-review");
|
||||
expect(byId.get("custom")?.column).toBe("todo");
|
||||
expect(byId.get("start")?.column).toBe("todo");
|
||||
});
|
||||
|
||||
it("upgrade is idempotent (round-trips through serialize unchanged)", () => {
|
||||
const once = parseWorkflowIr(v1);
|
||||
const twice = parseWorkflowIr(serializeWorkflowIr(once));
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it("v1 fixtures still parse (back-compat)", () => {
|
||||
const minimal: WorkflowIr = {
|
||||
version: "v1",
|
||||
name: "min",
|
||||
nodes: startEnd,
|
||||
edges: [{ from: "start", to: "end" }],
|
||||
};
|
||||
expect(() => parseWorkflowIr(minimal)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWorkflowIr — hold release kinds", () => {
|
||||
const holdCols = [{ id: "c", name: "C", traits: [] }];
|
||||
function holdIr(release: unknown): WorkflowIrV2 {
|
||||
return v2(
|
||||
holdCols,
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "h", kind: "hold", column: "c", config: { release } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "h" },
|
||||
{ from: "h", to: "end" },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
it.each(["manual", "timer", "capacity", "dependency", "external-event"])(
|
||||
"accepts hold release '%s'",
|
||||
(release) => {
|
||||
expect(() => parseWorkflowIr(holdIr(release))).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unknown hold release kind", () => {
|
||||
expect(() => parseWorkflowIr(holdIr("teleport"))).toThrow(/unknown release kind 'teleport'/);
|
||||
});
|
||||
|
||||
it("rejects a hold node missing its release config", () => {
|
||||
expect(() => parseWorkflowIr(holdIr(undefined))).toThrow(/unknown release kind/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWorkflowIr — split/join parallelism (KTD-11)", () => {
|
||||
const cols = [{ id: "c", name: "C", traits: [] }];
|
||||
|
||||
function p(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[]): WorkflowIrV2 {
|
||||
return v2(cols, nodes, edges);
|
||||
}
|
||||
|
||||
it("parses a balanced split → two branches → join", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "split", kind: "split", column: "c" },
|
||||
{ id: "a", kind: "prompt", column: "c" },
|
||||
{ id: "b", kind: "prompt", column: "c" },
|
||||
{ id: "join", kind: "join", column: "c", config: { mode: "all" } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "split" },
|
||||
{ from: "split", to: "a" },
|
||||
{ from: "split", to: "b" },
|
||||
{ from: "a", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
{ from: "join", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).not.toThrow();
|
||||
});
|
||||
|
||||
it("parses one nested level of split/join", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "s1", kind: "split", column: "c" },
|
||||
{ id: "a", kind: "prompt", column: "c" },
|
||||
{ id: "s2", kind: "split", column: "c" },
|
||||
{ id: "n1", kind: "prompt", column: "c" },
|
||||
{ id: "n2", kind: "prompt", column: "c" },
|
||||
{ id: "j2", kind: "join", column: "c", config: { mode: "all" } },
|
||||
{ id: "j1", kind: "join", column: "c", config: { mode: "all" } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "s1" },
|
||||
{ from: "s1", to: "a" },
|
||||
{ from: "s1", to: "s2" },
|
||||
{ from: "a", to: "j1" },
|
||||
{ from: "s2", to: "n1" },
|
||||
{ from: "s2", to: "n2" },
|
||||
{ from: "n1", to: "j2" },
|
||||
{ from: "n2", to: "j2" },
|
||||
{ from: "j2", to: "j1" },
|
||||
{ from: "j1", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a split without a reachable matching join", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "split", kind: "split", column: "c" },
|
||||
{ id: "a", kind: "prompt", column: "c" },
|
||||
{ id: "b", kind: "prompt", column: "c" },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "split" },
|
||||
{ from: "split", to: "a" },
|
||||
{ from: "split", to: "b" },
|
||||
{ from: "a", to: "end" },
|
||||
{ from: "b", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/no reachable matching join/);
|
||||
});
|
||||
|
||||
it("rejects an execute seam node inside a branch (seam-in-branch)", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "split", kind: "split", column: "c" },
|
||||
{ id: "exec", kind: "prompt", column: "c", config: { seam: "execute" } },
|
||||
{ id: "b", kind: "prompt", column: "c" },
|
||||
{ id: "join", kind: "join", column: "c", config: { mode: "all" } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "split" },
|
||||
{ from: "split", to: "exec" },
|
||||
{ from: "split", to: "b" },
|
||||
{ from: "exec", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
{ from: "join", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/seam 'execute'.*forbidden inside a parallel branch/);
|
||||
});
|
||||
|
||||
it("rejects a merge seam node inside a branch (seam-in-branch)", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "split", kind: "split", column: "c" },
|
||||
{ id: "mg", kind: "prompt", column: "c", config: { seam: "merge" } },
|
||||
{ id: "b", kind: "prompt", column: "c" },
|
||||
{ id: "join", kind: "join", column: "c", config: { mode: "all" } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "split" },
|
||||
{ from: "split", to: "mg" },
|
||||
{ from: "split", to: "b" },
|
||||
{ from: "mg", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
{ from: "join", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/seam 'merge'.*forbidden inside a parallel branch/);
|
||||
});
|
||||
|
||||
it("rejects quorum(n) with n exceeding the branch count", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "split", kind: "split", column: "c" },
|
||||
{ id: "a", kind: "prompt", column: "c" },
|
||||
{ id: "b", kind: "prompt", column: "c" },
|
||||
{ id: "join", kind: "join", column: "c", config: { mode: { quorum: 3 } } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "split" },
|
||||
{ from: "split", to: "a" },
|
||||
{ from: "split", to: "b" },
|
||||
{ from: "a", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
{ from: "join", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/quorum\(3\) exceeds the split's 2 branches/);
|
||||
});
|
||||
|
||||
it("accepts quorum(n) with n within the branch count", () => {
|
||||
const ir = p(
|
||||
[
|
||||
{ id: "start", kind: "start", column: "c" },
|
||||
{ id: "split", kind: "split", column: "c" },
|
||||
{ id: "a", kind: "prompt", column: "c" },
|
||||
{ id: "b", kind: "prompt", column: "c" },
|
||||
{ id: "join", kind: "join", column: "c", config: { mode: { quorum: 2 } } },
|
||||
{ id: "end", kind: "end", column: "c" },
|
||||
],
|
||||
[
|
||||
{ from: "start", to: "split" },
|
||||
{ from: "split", to: "a" },
|
||||
{ from: "split", to: "b" },
|
||||
{ from: "a", to: "join" },
|
||||
{ from: "b", to: "join" },
|
||||
{ from: "join", to: "end" },
|
||||
],
|
||||
);
|
||||
expect(() => parseWorkflowIr(ir)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWorkflowIr — version & shape guards", () => {
|
||||
it("rejects an unknown version", () => {
|
||||
expect(() => parseWorkflowIr({ version: "v3", name: "x", nodes: startEnd, edges: [] } as unknown as WorkflowIr)).toThrow(
|
||||
/version must be v1 or v2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing start/end nodes", () => {
|
||||
const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []);
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/exactly one start and one end/);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,54 @@
|
||||
import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import { parseWorkflowIr } from "./workflow-ir.js";
|
||||
|
||||
/**
|
||||
* The built-in default workflow as a v2 IR. Its six columns have ids that are
|
||||
* EXACTLY the legacy enum values in legacy order (KTD-1), so a task with no
|
||||
* workflow selection resolves here and its stored `column` value is already a
|
||||
* valid column id — migration rewrites zero task rows.
|
||||
*
|
||||
* Trait ids are plain strings (the trait registry ships in U2); the mapping
|
||||
* reproduces legacy behavior verbatim (R12):
|
||||
* triage = intake
|
||||
* todo = hold(capacity) + reset-on-entry
|
||||
* in-progress = wip + abort-on-exit + timing
|
||||
* in-review = merge-blocker + stall-detection + merge
|
||||
* done = complete
|
||||
* archived = archived
|
||||
*
|
||||
* The seam nodes (execute/review/merge) are placed in their columns; the graph
|
||||
* walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph
|
||||
* executor continues to drive execute → review → merge unchanged.
|
||||
*/
|
||||
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
|
||||
version: "v1",
|
||||
version: "v2",
|
||||
name: "builtin-coding-workflow",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||
{
|
||||
id: "todo",
|
||||
name: "Todo",
|
||||
traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }],
|
||||
},
|
||||
{
|
||||
id: "in-progress",
|
||||
name: "In progress",
|
||||
traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }],
|
||||
},
|
||||
{
|
||||
id: "in-review",
|
||||
name: "In review",
|
||||
traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }],
|
||||
},
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
{ id: "archived", name: "Archived", traits: [{ trait: "archived" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "execute", kind: "prompt", config: { seam: "execute" } },
|
||||
{ id: "review", kind: "prompt", config: { seam: "review" } },
|
||||
{ id: "merge", kind: "prompt", config: { seam: "merge" } },
|
||||
{ id: "end", kind: "end" },
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } },
|
||||
{ id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } },
|
||||
{ id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute" },
|
||||
|
||||
@@ -49,12 +49,20 @@ export {
|
||||
parseWorkflowIr,
|
||||
serializeWorkflowIr,
|
||||
WorkflowIrError,
|
||||
DEFAULT_WORKFLOW_COLUMN_IDS,
|
||||
} from "./workflow-ir.js";
|
||||
export type {
|
||||
WorkflowIr,
|
||||
WorkflowIrV1,
|
||||
WorkflowIrV2,
|
||||
WorkflowIrNode,
|
||||
WorkflowIrEdge,
|
||||
WorkflowIrNodeKind,
|
||||
WorkflowIrColumn,
|
||||
WorkflowIrColumnTrait,
|
||||
WorkflowHoldRelease,
|
||||
WorkflowJoinMode,
|
||||
WorkflowJoinBranchFailure,
|
||||
} from "./workflow-ir-types.js";
|
||||
export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
|
||||
export type {
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
export type WorkflowIrNodeKind = "start" | "prompt" | "script" | "gate" | "end";
|
||||
/** Node kinds. v1 kinds (start/prompt/script/gate/end) plus the v2 additions:
|
||||
* `hold` (passive dwell column states), and `split`/`join` (parallel fan-out). */
|
||||
export type WorkflowIrNodeKind =
|
||||
| "start"
|
||||
| "prompt"
|
||||
| "script"
|
||||
| "gate"
|
||||
| "end"
|
||||
| "hold"
|
||||
| "split"
|
||||
| "join";
|
||||
|
||||
export interface WorkflowIrNode {
|
||||
id: string;
|
||||
kind: WorkflowIrNodeKind;
|
||||
/** v2: the column this node is placed in. Must reference a defined column id. */
|
||||
column?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -12,9 +24,51 @@ export interface WorkflowIrEdge {
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowIr {
|
||||
/** A single trait configuration applied to a column. The `trait` is an opaque
|
||||
* registry id (resolved by the trait registry shipped in U2); `config` carries
|
||||
* trait-specific options validated by that trait's schema. */
|
||||
export interface WorkflowIrColumnTrait {
|
||||
trait: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A workflow-defined board column. */
|
||||
export interface WorkflowIrColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
traits: WorkflowIrColumnTrait[];
|
||||
}
|
||||
|
||||
/** Release conditions for a `hold` node (KTD-2, R3). */
|
||||
export type WorkflowHoldRelease =
|
||||
| "manual"
|
||||
| "timer"
|
||||
| "capacity"
|
||||
| "dependency"
|
||||
| "external-event";
|
||||
|
||||
/** Join synchronization mode (KTD-11). `quorum` requires `quorum.n` completed branches. */
|
||||
export type WorkflowJoinMode = "all" | "any" | { quorum: number };
|
||||
|
||||
/** What happens to sibling branches when one branch fails before the join (KTD-11). */
|
||||
export type WorkflowJoinBranchFailure = "fail-fast" | "collect";
|
||||
|
||||
/** A v1 workflow IR graph. Frozen by FN-5769; retained for back-compat. */
|
||||
export interface WorkflowIrV1 {
|
||||
version: "v1";
|
||||
name: string;
|
||||
nodes: WorkflowIrNode[];
|
||||
edges: WorkflowIrEdge[];
|
||||
}
|
||||
|
||||
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. */
|
||||
export interface WorkflowIrV2 {
|
||||
version: "v2";
|
||||
name: string;
|
||||
columns: WorkflowIrColumn[];
|
||||
nodes: WorkflowIrNode[];
|
||||
edges: WorkflowIrEdge[];
|
||||
}
|
||||
|
||||
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */
|
||||
export type WorkflowIr = WorkflowIrV1 | WorkflowIrV2;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { WorkflowIr } from "./workflow-ir-types.js";
|
||||
import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrColumn,
|
||||
WorkflowIrEdge,
|
||||
WorkflowIrNode,
|
||||
WorkflowIrV1,
|
||||
WorkflowIrV2,
|
||||
WorkflowHoldRelease,
|
||||
} from "./workflow-ir-types.js";
|
||||
|
||||
export class WorkflowIrError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -7,13 +15,224 @@ export class WorkflowIrError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const HOLD_RELEASE_KINDS: ReadonlySet<WorkflowHoldRelease> = new Set([
|
||||
"manual",
|
||||
"timer",
|
||||
"capacity",
|
||||
"dependency",
|
||||
"external-event",
|
||||
]);
|
||||
|
||||
/** Seam config values that may not appear inside a parallel branch (KTD-11):
|
||||
* one worktree/session per task and exclusive merge are physical constraints. */
|
||||
const SEAM_FORBIDDEN_IN_BRANCH: ReadonlySet<string> = new Set(["execute", "merge"]);
|
||||
|
||||
/** Default-workflow column ids in legacy enum order (KTD-1). */
|
||||
export const DEFAULT_WORKFLOW_COLUMN_IDS = [
|
||||
"triage",
|
||||
"todo",
|
||||
"in-progress",
|
||||
"in-review",
|
||||
"done",
|
||||
"archived",
|
||||
] as const;
|
||||
|
||||
/** Place a v1 node into a synthesized default-workflow column by its seam. */
|
||||
function defaultColumnForNode(node: WorkflowIrNode): string {
|
||||
const seam = node.config?.seam;
|
||||
if (seam === "execute") return "in-progress";
|
||||
if (seam === "review") return "in-review";
|
||||
if (seam === "merge") return "in-review";
|
||||
return "todo";
|
||||
}
|
||||
|
||||
/** The synthesized default-workflow columns used when upgrading a v1 graph. The
|
||||
* trait set here is intentionally minimal (placement only); the full default
|
||||
* workflow with traits is BUILTIN_CODING_WORKFLOW_IR. */
|
||||
function synthesizeDefaultColumns(): WorkflowIrColumn[] {
|
||||
return DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
|
||||
}
|
||||
|
||||
/** Upgrade a v1 graph to v2 by synthesizing default columns and placing nodes
|
||||
* by their seam (execute→in-progress, review/merge→in-review, others→todo). */
|
||||
function upgradeV1ToV2(ir: WorkflowIrV1): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: ir.name,
|
||||
columns: synthesizeDefaultColumns(),
|
||||
nodes: ir.nodes.map((node) =>
|
||||
node.column ? node : { ...node, column: defaultColumnForNode(node) },
|
||||
),
|
||||
edges: ir.edges,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOutgoing(edges: WorkflowIrEdge[]): Map<string, WorkflowIrEdge[]> {
|
||||
const outgoing = new Map<string, WorkflowIrEdge[]>();
|
||||
for (const edge of edges) {
|
||||
const list = outgoing.get(edge.from);
|
||||
if (list) list.push(edge);
|
||||
else outgoing.set(edge.from, [edge]);
|
||||
}
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
function seamOf(node: WorkflowIrNode): string | undefined {
|
||||
const seam = node.config?.seam;
|
||||
return typeof seam === "string" ? seam : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `split`/`join` parallelism (KTD-11):
|
||||
* - every split has a reachable matching join (recursively for nested splits);
|
||||
* - execute/merge seam nodes inside a branch reject (seam-in-branch);
|
||||
* - join `quorum(n)` with n exceeding the split's branch count rejects.
|
||||
*/
|
||||
function validateParallelism(
|
||||
nodes: WorkflowIrNode[],
|
||||
outgoing: Map<string, WorkflowIrEdge[]>,
|
||||
nodesById: Map<string, WorkflowIrNode>,
|
||||
): void {
|
||||
const splits = nodes.filter((n) => n.kind === "split");
|
||||
|
||||
for (const split of splits) {
|
||||
const branchEdges = outgoing.get(split.id) ?? [];
|
||||
if (branchEdges.length < 2) {
|
||||
throw new WorkflowIrError(`split '${split.id}' must fan out into at least two branches`);
|
||||
}
|
||||
|
||||
// Walk each branch forward until the matching join is reached. Track join
|
||||
// hit-counts and ensure every branch reaches the SAME join (nested splits
|
||||
// resolve to their own join first, so balanced nesting still terminates).
|
||||
const joinsReached = new Set<string>();
|
||||
for (const edge of branchEdges) {
|
||||
const join = walkBranchToJoin(edge.to, split.id, outgoing, nodesById);
|
||||
if (!join) {
|
||||
throw new WorkflowIrError(`split '${split.id}' has a branch with no reachable matching join`);
|
||||
}
|
||||
joinsReached.add(join);
|
||||
}
|
||||
if (joinsReached.size !== 1) {
|
||||
throw new WorkflowIrError(`split '${split.id}' branches converge on more than one join`);
|
||||
}
|
||||
const joinId = [...joinsReached][0];
|
||||
const join = nodesById.get(joinId)!;
|
||||
|
||||
const mode = join.config?.mode;
|
||||
if (mode && typeof mode === "object" && "quorum" in mode) {
|
||||
const n = (mode as { quorum: unknown }).quorum;
|
||||
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
|
||||
throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`);
|
||||
}
|
||||
if (n > branchEdges.length) {
|
||||
throw new WorkflowIrError(
|
||||
`join '${join.id}' quorum(${n}) exceeds the split's ${branchEdges.length} branches`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a single branch from `startNodeId` until a `join` node is reached.
|
||||
* Rejects execute/merge seam nodes encountered inside the branch. Handles one
|
||||
* level of nesting by recursing through inner splits to their inner join. */
|
||||
function walkBranchToJoin(
|
||||
startNodeId: string,
|
||||
ownerSplitId: string,
|
||||
outgoing: Map<string, WorkflowIrEdge[]>,
|
||||
nodesById: Map<string, WorkflowIrNode>,
|
||||
): string | undefined {
|
||||
const visited = new Set<string>();
|
||||
let cursor: string | undefined = startNodeId;
|
||||
while (cursor && !visited.has(cursor)) {
|
||||
visited.add(cursor);
|
||||
const node = nodesById.get(cursor);
|
||||
if (!node) return undefined;
|
||||
|
||||
if (node.kind === "join") return node.id;
|
||||
|
||||
if (node.kind === "split") {
|
||||
// Nested split: resolve to its inner join, then continue from there.
|
||||
const inner = (outgoing.get(node.id) ?? [])
|
||||
.map((e) => walkBranchToJoin(e.to, node.id, outgoing, nodesById))
|
||||
.find(Boolean);
|
||||
if (!inner) return undefined;
|
||||
cursor = innerJoinNext(inner, outgoing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seam = seamOf(node);
|
||||
if (seam && SEAM_FORBIDDEN_IN_BRANCH.has(seam)) {
|
||||
throw new WorkflowIrError(
|
||||
`seam '${seam}' node '${node.id}' is forbidden inside a parallel branch of split '${ownerSplitId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const next = (outgoing.get(cursor) ?? []).find((e) => e.condition !== "failure");
|
||||
cursor = next?.to;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The node following a join along its (non-failure) outgoing edge. */
|
||||
function innerJoinNext(joinId: string, outgoing: Map<string, WorkflowIrEdge[]>): string | undefined {
|
||||
return (outgoing.get(joinId) ?? []).find((e) => e.condition !== "failure")?.to;
|
||||
}
|
||||
|
||||
function validateColumns(ir: WorkflowIrV2): void {
|
||||
if (!Array.isArray(ir.columns)) {
|
||||
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const column of ir.columns) {
|
||||
if (!column || typeof column.id !== "string" || !column.id) {
|
||||
throw new WorkflowIrError("Workflow IR column must have a non-empty id");
|
||||
}
|
||||
if (seen.has(column.id)) {
|
||||
throw new WorkflowIrError(`Workflow IR has duplicate column id '${column.id}'`);
|
||||
}
|
||||
seen.add(column.id);
|
||||
if (!Array.isArray(column.traits)) {
|
||||
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateV2(ir: WorkflowIrV2): void {
|
||||
validateColumns(ir);
|
||||
|
||||
const columnIds = new Set(ir.columns.map((c) => c.id));
|
||||
const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
|
||||
|
||||
for (const node of ir.nodes) {
|
||||
if (node.column !== undefined && !columnIds.has(node.column)) {
|
||||
throw new WorkflowIrError(
|
||||
`Workflow node '${node.id}' references undefined column '${node.column}'`,
|
||||
);
|
||||
}
|
||||
if (node.kind === "hold") {
|
||||
const release = node.config?.release;
|
||||
if (!HOLD_RELEASE_KINDS.has(release as WorkflowHoldRelease)) {
|
||||
throw new WorkflowIrError(
|
||||
`hold node '${node.id}' has unknown release kind '${String(release)}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outgoing = buildOutgoing(ir.edges);
|
||||
validateParallelism(ir.nodes, outgoing, nodesById);
|
||||
}
|
||||
|
||||
export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
|
||||
const value: unknown = typeof input === "string" ? JSON.parse(input) : input;
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new WorkflowIrError("Workflow IR must be an object");
|
||||
}
|
||||
const ir = value as WorkflowIr;
|
||||
if (ir.version !== "v1") throw new WorkflowIrError("Workflow IR version must be v1");
|
||||
if (ir.version !== "v1" && ir.version !== "v2") {
|
||||
throw new WorkflowIrError("Workflow IR version must be v1 or v2");
|
||||
}
|
||||
if (!Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) {
|
||||
throw new WorkflowIrError("Workflow IR nodes/edges must be arrays");
|
||||
}
|
||||
@@ -22,6 +241,14 @@ export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
|
||||
if (startCount !== 1 || endCount !== 1) {
|
||||
throw new WorkflowIrError("Workflow IR must contain exactly one start and one end node");
|
||||
}
|
||||
|
||||
if (ir.version === "v1") {
|
||||
// Read-path upgrade: v1 graphs become v2 with synthesized default columns
|
||||
// and seam-based node placement. v1 fixtures keep parsing (FN-5769 contract).
|
||||
return upgradeV1ToV2(ir);
|
||||
}
|
||||
|
||||
validateV2(ir);
|
||||
return ir;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user