feat(core): WorkflowIr v2 — workflow-defined columns, hold/split/join nodes, v1 upgrade (U1)

This commit is contained in:
gsxdsm
2026-06-04 00:13:11 -07:00
parent 44cb67e5db
commit 964744cd41
7 changed files with 730 additions and 13 deletions

View File

@@ -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");
});
});

View File

@@ -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();

View 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/);
});
});

View File

@@ -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" },

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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;
}