From 964744cd41d6075e391f2d40c5ff75adf11c3339 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 00:13:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20WorkflowIr=20v2=20=E2=80=94=20wor?= =?UTF-8?q?kflow-defined=20columns,=20hold/split/join=20nodes,=20v1=20upgr?= =?UTF-8?q?ade=20(U1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../builtin-coding-workflow-ir.test.ts | 40 +- .../src/__tests__/builtin-workflows.test.ts | 11 +- .../core/src/__tests__/workflow-ir.test.ts | 344 ++++++++++++++++++ .../core/src/builtin-coding-workflow-ir.ts | 51 ++- packages/core/src/index.ts | 8 + packages/core/src/workflow-ir-types.ts | 58 ++- packages/core/src/workflow-ir.ts | 231 +++++++++++- 7 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/__tests__/workflow-ir.test.ts diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 110f4905c8..3edf7b5f8d 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -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"); + }); }); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index b6e54b247e..e605ee50f0 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -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(); diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts new file mode 100644 index 0000000000..6bc7878e55 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -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/); + }); +}); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 663dc85a4b..381d78156e 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -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" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc731acb8f..d76c50978c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index c5e2e537e2..d636618f8d 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -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; } @@ -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; +} + +/** 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; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 1e86e1bd4d..5127a00e70 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -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 = 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 = 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 { + const outgoing = new Map(); + 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, + nodesById: Map, +): 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(); + 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, + nodesById: Map, +): string | undefined { + const visited = new Set(); + 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 | 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(); + 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; }