feat(core): U1 — IR foreach/step-review/parse-steps/code kinds, rework edges, dependsOn parsing (FN step-inversion)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 11:33:33 -07:00
parent d0b5dcbfbe
commit 2a3c285bae
6 changed files with 1413 additions and 13 deletions

View File

@@ -6,7 +6,7 @@ import { existsSync } from "node:fs";
import * as projectMemory from "../project-memory.js";
import { AgentStore } from "../agent-store.js";
import { CentralDatabase } from "../central-db.js";
import { InvalidFileScopeError, isValidFileScopeEntry, TaskStore, TaskHasDependentsError } from "../store.js";
import { InvalidFileScopeError, isValidFileScopeEntry, parseStepHeadings, TaskStore, TaskHasDependentsError } from "../store.js";
import { buildResearchDocumentKey, type Task } from "../types.js";
import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js";
@@ -41,6 +41,104 @@ describe("TaskStore", () => {
const steps = await store.parseStepsFromPrompt(task.id);
expect(steps).toEqual([]);
});
it("parses depends annotations from PROMPT.md (1-indexed → 0-indexed)", async () => {
const task = await store.createTask({ description: "Task with depends" });
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Task
## Steps
### Step 1: First
### Step 2 (depends: 1): Second
### Step 3 (depends: 1,2): Third
`,
);
const steps = await store.parseStepsFromPrompt(task.id);
expect(steps).toEqual([
{ name: "First", status: "pending" },
{ name: "Second", status: "pending", dependsOn: [0] },
{ name: "Third", status: "pending", dependsOn: [0, 1] },
]);
});
});
describe("parseStepHeadings (step-inversion U1)", () => {
it("parses unannotated headings byte-identically to the legacy regex", () => {
const content = `## Steps
### Step 0: Preflight
- [ ] x
### Step 1: Implementation
### Step 2: Testing
`;
// The legacy behavior: name = text after the first colon, trimmed; no dependsOn.
expect(parseStepHeadings(content)).toEqual([
{ name: "Preflight", status: "pending" },
{ name: "Implementation", status: "pending" },
{ name: "Testing", status: "pending" },
]);
});
it("matches the legacy regex output exactly for varied unannotated headings", () => {
const content = [
"### Step 0: A",
"### Step 12: Multi word title",
"### Step 3 — dash but no annotation: Real Name",
"### Step 4: trailing spaces here ",
"### Step 5 no colon at all",
"not a step heading: ignored",
].join("\n");
// Reference: the original regex.
const legacy: { name: string; status: "pending" }[] = [];
const re = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
legacy.push({ name: m[1].trim(), status: "pending" });
}
expect(parseStepHeadings(content)).toEqual(legacy);
});
it("parses (depends: 1,2) into 0-indexed dependsOn", () => {
expect(parseStepHeadings("### Step 3 (depends: 1,2): Title")).toEqual([
{ name: "Title", status: "pending", dependsOn: [0, 1] },
]);
});
it("dedupes and sorts depends values", () => {
expect(parseStepHeadings("### Step 5 (depends: 3,1,3,2): T")).toEqual([
{ name: "T", status: "pending", dependsOn: [0, 1, 2] },
]);
});
it("empty depends list yields no dependsOn", () => {
expect(parseStepHeadings("### Step 2 (depends: ): T")).toEqual([
{ name: "T", status: "pending" },
]);
});
it("falls back deterministically on a malformed depends annotation (name after colon following the paren)", () => {
// 'bad' is not a positive integer → fallback: name starts after the colon
// following the closing paren.
expect(parseStepHeadings("### Step 1 (depends: bad): Real Title")).toEqual([
{ name: "Real Title", status: "pending" },
]);
});
it("falls back deterministically when the annotation has no closing paren", () => {
// No closing paren → name starts after the FIRST colon (inside `depends:`),
// per the documented deterministic fallback.
expect(parseStepHeadings("### Step 1 (depends: 1,2 oops: Title")).toEqual([
{ name: "1,2 oops: Title", status: "pending" },
]);
});
});

View File

@@ -0,0 +1,551 @@
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
downgradeIrToV1IfPure,
WorkflowIrError,
} from "../workflow-ir.js";
import type {
WorkflowIrEdge,
WorkflowIrNode,
WorkflowIrV2,
} from "../workflow-ir-types.js";
// Step-inversion (U1) — foreach / step-review / parse-steps / code / rework /
// fields validation.
const defaultColumns: WorkflowIrV2["columns"] = [
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
];
function v2(
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
extra: Partial<WorkflowIrV2> = {},
): WorkflowIrV2 {
return { version: "v2", name: "test", columns: defaultColumns, nodes, edges, ...extra };
}
/** A minimal valid foreach template: step-execute → step-review(approve→exit). */
function stepTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
return {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
],
edges: [
{ from: "se", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
],
};
}
/** A graph: start → parse-steps → foreach → end. */
function graphWithForeach(
foreachConfig: Record<string, unknown>,
extra: Partial<WorkflowIrV2> = {},
): WorkflowIrV2 {
return v2(
[
{ id: "start", kind: "start" },
{ id: "ps", kind: "parse-steps", config: { artifact: "PROMPT.md", parser: "step-headings" } },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate(), ...foreachConfig } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "ps" },
{ from: "ps", to: "fe" },
{ from: "fe", to: "end" },
],
extra,
);
}
describe("foreach validation", () => {
it("parses a valid foreach dominated by parse-steps", () => {
const ir = parseWorkflowIr(graphWithForeach({})) as WorkflowIrV2;
expect(ir.version).toBe("v2");
const fe = ir.nodes.find((n) => n.id === "fe")!;
expect(fe.kind).toBe("foreach");
});
it("rejects foreach with empty template", () => {
const ir = graphWithForeach({ template: { nodes: [], edges: [] } });
expect(() => parseWorkflowIr(ir)).toThrow(/non-empty/);
});
it("rejects template with two entry nodes", () => {
const tmpl = {
nodes: [
{ id: "a", kind: "prompt", config: { seam: "step-execute" } },
{ id: "b", kind: "prompt" },
] as WorkflowIrNode[],
edges: [] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/exactly one entry/,
);
});
it("rejects template with two exit nodes", () => {
const tmpl = {
nodes: [
{ id: "a", kind: "prompt", config: { seam: "step-execute" } },
{ id: "b", kind: "prompt" },
{ id: "c", kind: "prompt" },
] as WorkflowIrNode[],
edges: [
{ from: "a", to: "b" },
{ from: "a", to: "c" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/exactly one (entry|exit)/,
);
});
it("rejects nested foreach in a template", () => {
const tmpl = {
nodes: [
{ id: "inner", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } },
] as WorkflowIrNode[],
edges: [] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/nested foreach/,
);
});
it("rejects step-execute at the top level", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "se" },
{ from: "se", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/only legal inside a foreach template/);
});
it("rejects step-execute inside a split branch (extends SEAM_FORBIDDEN_IN_BRANCH)", () => {
const tmpl = {
nodes: [
{ id: "split", kind: "split" },
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "other", kind: "prompt" },
{ id: "join", kind: "join" },
] as WorkflowIrNode[],
edges: [
{ from: "split", to: "se" },
{ from: "split", to: "other" },
{ from: "se", to: "join" },
{ from: "other", to: "join" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/step-execute.*forbidden inside a parallel branch/,
);
});
it("rejects a rework edge crossing the template boundary", () => {
const tmpl = stepTemplate();
// Point the rework edge at a node outside the template.
tmpl.edges = tmpl.edges.map((e) =>
e.kind === "rework" ? { ...e, to: "end" } : e,
);
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/both endpoints inside the same template/,
);
});
it("rejects a top-level rework edge", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "a" },
{ from: "a", to: "end" },
{ from: "end", to: "a", kind: "rework" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/only legal inside a foreach template/);
});
it("rejects foreach not dominated by a parse-steps node", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/must be dominated by a parse-steps node/);
});
it("rejects foreach when parse-steps is only on one branch (not all paths)", () => {
// start → split into (ps→join) and (direct→join), join → fe.
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "split", kind: "split" },
{ id: "ps", kind: "parse-steps", config: { artifact: "PROMPT.md", parser: "step-headings" } },
{ id: "direct", kind: "prompt" },
{ id: "join", kind: "join" },
{ id: "fe", kind: "foreach", config: { source: "task-steps", template: stepTemplate() } },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "split" },
{ from: "split", to: "ps" },
{ from: "split", to: "direct" },
{ from: "ps", to: "join" },
{ from: "direct", to: "join" },
{ from: "join", to: "fe" },
{ from: "fe", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/must be dominated by a parse-steps node/);
});
});
describe("foreach mode / isolation / concurrency", () => {
it("rejects parallel + shared", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "shared" })),
).toThrow(/cannot combine mode 'parallel' with isolation 'shared'/);
});
it("accepts parallel + worktree", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 4 })),
).not.toThrow();
});
it("rejects concurrency on sequential mode", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "sequential", concurrency: 2 })),
).toThrow(/concurrency is only valid in 'parallel' mode/);
});
it("rejects concurrency out of range", () => {
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 9 })),
).toThrow(/concurrency must be an integer in 1\.\.8/);
expect(() =>
parseWorkflowIr(graphWithForeach({ mode: "parallel", isolation: "worktree", concurrency: 0 })),
).toThrow(/concurrency must be an integer in 1\.\.8/);
});
});
describe("foreach maxReworkCycles clamp", () => {
it("rejects maxReworkCycles < 1", () => {
expect(() => parseWorkflowIr(graphWithForeach({ maxReworkCycles: 0 }))).toThrow(
/maxReworkCycles must be an integer >= 1/,
);
});
it("clamps maxReworkCycles > 10 to 10", () => {
const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 99 })) as WorkflowIrV2;
const fe = ir.nodes.find((n) => n.id === "fe")!;
expect((fe.config as { maxReworkCycles: number }).maxReworkCycles).toBe(10);
});
it("keeps maxReworkCycles <= 10 unchanged", () => {
const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 5 })) as WorkflowIrV2;
const fe = ir.nodes.find((n) => n.id === "fe")!;
expect((fe.config as { maxReworkCycles: number }).maxReworkCycles).toBe(5);
});
});
describe("step-review verdict routing", () => {
function templateWithReview(reviewEdges: WorkflowIrEdge[]): WorkflowIrV2 {
const tmpl = {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "rev", kind: "step-review", config: { type: "plan" } },
{ id: "exit", kind: "prompt" },
] as WorkflowIrNode[],
edges: [{ from: "se", to: "rev" }, ...reviewEdges],
};
return graphWithForeach({ template: tmpl });
}
it("rejects step-review missing approve routing", () => {
expect(() =>
parseWorkflowIr(
templateWithReview([
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
{ from: "rev", to: "exit", condition: "outcome:other" },
]),
),
).toThrow(/must route outcome:approve/);
});
it("rejects step-review missing revise routing", () => {
expect(() =>
parseWorkflowIr(
templateWithReview([{ from: "rev", to: "exit", condition: "outcome:approve" }]),
),
).toThrow(/must route outcome:revise/);
});
it("accepts approve+revise routing (rethink optional)", () => {
expect(() =>
parseWorkflowIr(
templateWithReview([
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
]),
),
).not.toThrow();
});
it("rejects a verdict-authoring step-review inside a split branch (advisory-only)", () => {
const tmpl = {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "split", kind: "split" },
{ id: "advrev", kind: "step-review", config: { type: "code" } },
{ id: "other", kind: "prompt" },
{ id: "join", kind: "join" },
{ id: "exit", kind: "prompt" },
] as WorkflowIrNode[],
edges: [
{ from: "se", to: "split" },
{ from: "split", to: "advrev" },
{ from: "split", to: "other" },
// advisory review illegally carries approve routing
{ from: "advrev", to: "join", condition: "outcome:approve" },
{ from: "other", to: "join" },
{ from: "join", to: "exit" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).toThrow(
/advisory-only/,
);
});
it("accepts an advisory step-review inside a split branch without verdict routing", () => {
const tmpl = {
nodes: [
{ id: "se", kind: "prompt", config: { seam: "step-execute" } },
{ id: "split", kind: "split" },
{ id: "advrev", kind: "step-review", config: { type: "code" } },
{ id: "other", kind: "prompt" },
{ id: "join", kind: "join" },
{ id: "rev", kind: "step-review", config: { type: "code" } },
{ id: "exit", kind: "prompt" },
] as WorkflowIrNode[],
edges: [
{ from: "se", to: "split" },
{ from: "split", to: "advrev" },
{ from: "split", to: "other" },
{ from: "advrev", to: "join" },
{ from: "other", to: "join" },
{ from: "join", to: "rev" },
{ from: "rev", to: "exit", condition: "outcome:approve" },
{ from: "rev", to: "se", condition: "outcome:revise", kind: "rework" },
] as WorkflowIrEdge[],
};
expect(() => parseWorkflowIr(graphWithForeach({ template: tmpl }))).not.toThrow();
});
});
describe("parse-steps validation", () => {
it("rejects parse-steps with empty parser", () => {
const ir = graphWithForeach({});
(ir.nodes.find((n) => n.id === "ps")!.config as Record<string, unknown>).parser = "";
expect(() => parseWorkflowIr(ir)).toThrow(/non-empty parser/);
});
it("rejects parse-steps referencing an undeclared artifact", () => {
const ir = graphWithForeach({}, { artifacts: [{ key: "OTHER.md" }] });
expect(() => parseWorkflowIr(ir)).toThrow(/undeclared artifact 'PROMPT.md'/);
});
it("accepts parse-steps referencing a declared artifact", () => {
const ir = graphWithForeach({}, { artifacts: [{ key: "PROMPT.md", role: "step-source" }] });
expect(() => parseWorkflowIr(ir)).not.toThrow();
});
it("allows only PROMPT.md when no artifacts are declared", () => {
const ir = graphWithForeach({});
(ir.nodes.find((n) => n.id === "ps")!.config as Record<string, unknown>).artifact = "SPEC.md";
expect(() => parseWorkflowIr(ir)).toThrow(/only 'PROMPT.md' is allowed/);
});
});
describe("code node validation", () => {
function graphWithCode(config: Record<string, unknown>): WorkflowIrV2 {
return v2(
[
{ id: "start", kind: "start" },
{ id: "c", kind: "code", config },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "c" },
{ from: "c", to: "end" },
],
);
}
it("rejects empty source", () => {
expect(() => parseWorkflowIr(graphWithCode({ source: "" }))).toThrow(/non-empty source/);
});
it("rejects source over 64KB", () => {
expect(() => parseWorkflowIr(graphWithCode({ source: "x".repeat(65537) }))).toThrow(
/exceeds 65536/,
);
});
it("accepts valid source and timeout", () => {
expect(() =>
parseWorkflowIr(graphWithCode({ source: "export default async () => ({})", timeoutMs: 30000 })),
).not.toThrow();
});
it("rejects timeoutMs out of range", () => {
expect(() => parseWorkflowIr(graphWithCode({ source: "x", timeoutMs: 999 }))).toThrow(
/timeoutMs must be an integer in 1000\.\.300000/,
);
expect(() => parseWorkflowIr(graphWithCode({ source: "x", timeoutMs: 300001 }))).toThrow(
/timeoutMs must be an integer in 1000\.\.300000/,
);
});
});
describe("fields validation", () => {
function graphWithFields(fields: unknown): WorkflowIrV2 {
return v2(
[
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
[{ from: "start", to: "end" }],
{ fields: fields as WorkflowIrV2["fields"] },
);
}
it("accepts well-formed fields", () => {
expect(() =>
parseWorkflowIr(
graphWithFields([
{ id: "sev", name: "Severity", type: "enum", options: [{ value: "lo", label: "Low" }] },
{ id: "note", name: "Note", type: "text", render: { placement: "detail", widget: "textarea" } },
]),
),
).not.toThrow();
});
it("rejects duplicate field ids", () => {
expect(() =>
parseWorkflowIr(
graphWithFields([
{ id: "a", name: "A", type: "string" },
{ id: "a", name: "A2", type: "number" },
]),
),
).toThrow(/duplicate field id 'a'/);
});
it("rejects unknown field type", () => {
expect(() => parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "color" }]))).toThrow(
/unknown type 'color'/,
);
});
it("requires options on enum/multi-enum", () => {
expect(() => parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "enum" }]))).toThrow(
/must declare non-empty options/,
);
expect(() =>
parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "multi-enum", options: [] }])),
).toThrow(/must declare non-empty options/);
});
it("rejects options on non-enum types", () => {
expect(() =>
parseWorkflowIr(
graphWithFields([{ id: "a", name: "A", type: "string", options: [{ value: "x", label: "X" }] }]),
),
).toThrow(/must not declare options/);
});
it("rejects bad render placement / widget", () => {
expect(() =>
parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "string", render: { placement: "footer" } }])),
).toThrow(/render.placement 'footer' is not allowed/);
expect(() =>
parseWorkflowIr(graphWithFields([{ id: "a", name: "A", type: "string", render: { widget: "slider" } }])),
).toThrow(/render.widget 'slider' is not allowed/);
});
});
describe("downgradeIrToV1IfPure refuses step-inversion features", () => {
it("returns v2 unchanged for a graph with a foreach", () => {
const ir = parseWorkflowIr(graphWithForeach({})) as WorkflowIrV2;
expect(downgradeIrToV1IfPure(ir).version).toBe("v2");
});
it("returns v2 unchanged when fields/artifacts are declared even with pure-v1 nodes", () => {
const ir = v2(
[
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
[{ from: "start", to: "end" }],
{ fields: [{ id: "a", name: "A", type: "string" }] },
);
expect(downgradeIrToV1IfPure(ir).version).toBe("v2");
});
});
describe("JSON round-trip stability", () => {
it("re-parses a serialized foreach graph identically", () => {
const ir = parseWorkflowIr(graphWithForeach({ maxReworkCycles: 3 })) as WorkflowIrV2;
const serialized = serializeWorkflowIr(ir);
const reparsed = parseWorkflowIr(serialized) as WorkflowIrV2;
expect(serializeWorkflowIr(reparsed)).toBe(serialized);
});
});
describe("illegal cycle detection (rework exemption)", () => {
it("still rejects a non-rework cycle at the top level", () => {
const ir = v2(
[
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt" },
{ id: "b", kind: "prompt" },
{ id: "end", kind: "end" },
],
[
{ from: "start", to: "a" },
{ from: "a", to: "b" },
{ from: "b", to: "a" },
{ from: "a", to: "end" },
],
);
expect(() => parseWorkflowIr(ir)).toThrow(/illegal cycle/);
});
it("does not complain about the rework cycle inside a foreach template", () => {
// graphWithForeach's template has a rework edge rev → se; should parse fine.
expect(() => parseWorkflowIr(graphWithForeach({}))).not.toThrow();
});
});

View File

@@ -798,6 +798,87 @@ const KNOWN_FILE_SCOPE_ROOT_FILES = new Set([
"agents.md",
]);
/**
* Parse `### Step N:` headings into the task step list (step-inversion U1).
*
* Backward compatibility is exact: an UNannotated heading parses byte-identically
* to the legacy regex `^###\s+Step\s+\d+[^:]*:\s*(.+)$` (name = text after the
* first colon, trimmed).
*
* The annotation `### Step N (depends: 1,2): Title` is parsed explicitly (the
* legacy regex breaks on the colon inside `depends:`): depends values are
* 1-indexed step numbers in the document and are stored as 0-indexed indices on
* `dependsOn` (deduped, sorted, dropping values <= 0).
*
* Malformed `(depends: …)` annotations fall back deterministically: the heading
* is treated as `### Step N:` with the name starting after the FIRST colon
* following the closing paren (if present), else after the first colon — and no
* `dependsOn` is recorded.
*/
export function parseStepHeadings(content: string): import("./types.js").TaskStep[] {
const steps: import("./types.js").TaskStep[] = [];
// Legacy matcher — UNCHANGED from the original implementation, so unannotated
// headings (and every legacy edge case, including `[^:]*` spanning newlines)
// parse byte-identically. The full match (`m[0]`) is re-inspected only to layer
// the `(depends: …)` annotation on top.
const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
// Well-formed annotation form: `### Step N (depends: …): name`.
const annotatedRegex = /^###\s+Step\s+\d+\s*\(depends:\s*([^)]*)\)\s*:\s*([^\n]+)$/;
let match: RegExpExecArray | null;
while ((match = stepRegex.exec(content)) !== null) {
const full = match[0];
// No annotation present → byte-identical legacy behavior.
if (!full.includes("(depends:")) {
steps.push({ name: match[1].trim(), status: "pending" });
continue;
}
// 1) Well-formed depends annotation.
const annotated = annotatedRegex.exec(full);
if (annotated) {
const parsed = parseDependsList(annotated[1]);
const name = annotated[2].trim();
if (parsed !== null) {
if (parsed.length > 0) steps.push({ name, status: "pending", dependsOn: parsed });
else steps.push({ name, status: "pending" });
continue;
}
}
// 2) Annotation present but unparseable (bad values or no closing paren):
// deterministic fallback — name starts after the FIRST colon following the
// closing paren if present, else after the first colon. Operate on the
// first line of the match only (the heading line itself).
const line = full.split("\n")[0];
const parenIdx = line.indexOf(")");
const colonAfterParen = parenIdx >= 0 ? line.indexOf(":", parenIdx) : -1;
const colonIdx = colonAfterParen >= 0 ? colonAfterParen : line.indexOf(":");
if (colonIdx >= 0) {
const fallbackName = line.slice(colonIdx + 1).trim();
if (fallbackName) steps.push({ name: fallbackName, status: "pending" });
}
}
return steps;
}
/** Parse a `depends:` value list (1-indexed step numbers) into 0-indexed,
* deduped, sorted indices. Returns null if any token is not a positive integer. */
function parseDependsList(raw: string): number[] | null {
const trimmed = raw.trim();
if (trimmed === "") return [];
const tokens = trimmed.split(",").map((t) => t.trim());
const out = new Set<number>();
for (const token of tokens) {
if (!/^\d+$/.test(token)) return null;
const n = Number(token);
if (!Number.isInteger(n) || n < 1) return null;
out.add(n - 1);
}
return [...out].sort((a, b) => a - b);
}
export function isValidFileScopeEntry(token: string): boolean {
const trimmed = token.trim();
if (!trimmed) return false;
@@ -8537,13 +8618,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (!existsSync(promptPath)) return [];
const content = await readFile(promptPath, "utf-8");
const steps: import("./types.js").TaskStep[] = [];
const stepRegex = /^###\s+Step\s+\d+[^:]*:\s*(.+)$/gm;
let match;
while ((match = stepRegex.exec(content)) !== null) {
steps.push({ name: match[1].trim(), status: "pending" });
}
return steps;
return parseStepHeadings(content);
}
/**

View File

@@ -1061,6 +1061,11 @@ export type StepStatus = "pending" | "in-progress" | "done" | "skipped";
export interface TaskStep {
name: string;
status: StepStatus;
/** Step-inversion (KTD-11): 0-indexed indices of steps this step depends on,
* parsed from the PROMPT.md `### Step N (depends: 1,2): Title` annotation
* (1-indexed step numbers in the doc → 0-indexed indices here). Absent for
* unannotated steps. */
dependsOn?: number[];
}
/** Correlation metadata linking a task mutation to the agent run that caused it. */

View File

@@ -1,5 +1,9 @@
/** Node kinds. v1 kinds (start/prompt/script/gate/end) plus the v2 additions:
* `hold` (passive dwell column states), and `split`/`join` (parallel fan-out). */
* `hold` (passive dwell column states), `split`/`join` (parallel fan-out), and
* the step-inversion additions (FN step-inversion, KTD-3/4/12/15):
* `foreach` (runtime-expanding per-step template region), `step-review`
* (per-step review verdicts as outcome edges), `parse-steps` (graph-native
* step-list parsing), and `code` (sandboxed TypeScript). */
export type WorkflowIrNodeKind =
| "start"
| "prompt"
@@ -8,7 +12,11 @@ export type WorkflowIrNodeKind =
| "end"
| "hold"
| "split"
| "join";
| "join"
| "foreach"
| "step-review"
| "parse-steps"
| "code";
export interface WorkflowIrNode {
id: string;
@@ -22,6 +30,71 @@ export interface WorkflowIrEdge {
from: string;
to: string;
condition?: string;
/** Step-inversion (KTD-5): `rework` edges are the only legal cycles, scoped to
* one foreach template instance and bounded by the foreach `maxReworkCycles`.
* They are exempt from cycle/parallelism complaints. */
kind?: "rework";
}
/** Step-inversion (KTD-3): config for a `foreach` node — a runtime-expanding
* template region instantiated once per planned step.
* Defaults: `mode` sequential; `isolation` shared for sequential / worktree for
* parallel; `concurrency` parallel-only. */
export interface WorkflowForeachConfig {
source: "task-steps";
maxReworkCycles?: number;
mode?: "sequential" | "parallel";
concurrency?: number;
isolation?: "shared" | "worktree";
template: {
nodes: WorkflowIrNode[];
edges: WorkflowIrEdge[];
};
}
/** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the
* existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */
export interface WorkflowIrArtifact {
key: string;
title?: string;
producedBy?: "planning" | "manual";
role?: "step-source" | "context";
}
/** Step-inversion (KTD-13): the supported custom-field value types. */
export type WorkflowFieldType =
| "string"
| "text"
| "number"
| "boolean"
| "enum"
| "multi-enum"
| "date"
| "url";
/** A single enum/multi-enum option (KTD-13). */
export interface WorkflowFieldOption {
value: string;
label: string;
color?: string;
}
/** Rendering instructions for a custom field (KTD-14). */
export interface WorkflowFieldRender {
placement?: "card" | "detail" | "detail-section";
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
badge?: boolean;
}
/** Step-inversion (KTD-13): a workflow-defined custom task field. */
export interface WorkflowFieldDefinition {
id: string;
name: string;
type: WorkflowFieldType;
required?: boolean;
default?: unknown;
options?: WorkflowFieldOption[];
render?: WorkflowFieldRender;
}
/** A single trait configuration applied to a column. The `trait` is an opaque
@@ -61,13 +134,17 @@ export interface WorkflowIrV1 {
edges: WorkflowIrEdge[];
}
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. */
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement.
* Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13)
* declarations — both additive; absent on legacy graphs. */
export interface WorkflowIrV2 {
version: "v2";
name: string;
columns: WorkflowIrColumn[];
nodes: WorkflowIrNode[];
edges: WorkflowIrEdge[];
artifacts?: WorkflowIrArtifact[];
fields?: WorkflowFieldDefinition[];
}
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */

View File

@@ -7,6 +7,9 @@ import type {
WorkflowIrV1,
WorkflowIrV2,
WorkflowHoldRelease,
WorkflowForeachConfig,
WorkflowFieldDefinition,
WorkflowFieldType,
} from "./workflow-ir-types.js";
export class WorkflowIrError extends Error {
@@ -25,8 +28,56 @@ const HOLD_RELEASE_KINDS: ReadonlySet<WorkflowHoldRelease> = new Set([
]);
/** 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"]);
* one worktree/session per task and exclusive merge are physical constraints.
* Step-inversion (KTD-4) extends this posture: `step-execute` seam prompt nodes
* may never appear in a split branch either. */
const SEAM_FORBIDDEN_IN_BRANCH: ReadonlySet<string> = new Set([
"execute",
"merge",
"step-execute",
]);
/** Step-inversion field-type whitelist (KTD-13). */
const WORKFLOW_FIELD_TYPES: ReadonlySet<WorkflowFieldType> = new Set([
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
"date",
"url",
]);
const FIELD_RENDER_PLACEMENTS: ReadonlySet<string> = new Set([
"card",
"detail",
"detail-section",
]);
const FIELD_RENDER_WIDGETS: ReadonlySet<string> = new Set([
"select",
"radio",
"chips",
"input",
"textarea",
"toggle",
]);
/** Hard cap on a foreach `maxReworkCycles` (KTD-5: default 3, clamp >10 to 10,
* reject <1). */
const MAX_REWORK_CYCLES_CAP = 10;
/** Parallel concurrency bounds (KTD-3): range 1..8. */
const MAX_FOREACH_CONCURRENCY = 8;
/** The implicit step-source artifact allowed when no artifacts are declared. */
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
/** True when a prompt node carries the `step-execute` seam (KTD-2/KTD-4). */
function isStepExecuteNode(node: WorkflowIrNode): boolean {
return node.kind === "prompt" && node.config?.seam === "step-execute";
}
/** Default-workflow column ids in legacy enum order (KTD-1). */
export const DEFAULT_WORKFLOW_COLUMN_IDS = [
@@ -180,6 +231,501 @@ function innerJoinNext(joinId: string, outgoing: Map<string, WorkflowIrEdge[]>):
return (outgoing.get(joinId) ?? []).find((e) => e.condition !== "failure")?.to;
}
// ---------------------------------------------------------------------------
// Step-inversion validation (FN step-inversion, U1)
// ---------------------------------------------------------------------------
/** True for a `rework`-kind edge (KTD-5). */
function isReworkEdge(edge: WorkflowIrEdge): boolean {
return edge.kind === "rework";
}
/** Collect the set of node ids reachable from `start` following non-rework edges
* (rework edges are intra-template back-edges; the top-level reachability /
* dominance analysis ignores them). */
function reachableFrom(
start: string,
outgoing: Map<string, WorkflowIrEdge[]>,
): Set<string> {
const seen = new Set<string>();
const queue = [start];
while (queue.length) {
const id = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
for (const edge of outgoing.get(id) ?? []) {
if (isReworkEdge(edge)) continue;
if (!seen.has(edge.to)) queue.push(edge.to);
}
}
return seen;
}
/**
* Validate a foreach `template` subgraph recursively (KTD-3):
* - non-empty;
* - exactly one entry (no incoming template edges) and one exit (no outgoing);
* - NO nested foreach;
* - `step-execute` seam nodes are legal here but never inside a split branch
* (SEAM_FORBIDDEN_IN_BRANCH already enforces this via validateParallelism);
* - rework edges legal only when both endpoints are inside this template;
* - step-review verdict routing rules (KTD-4).
*/
function validateForeach(node: WorkflowIrNode, topLevelNodeIds: Set<string>): void {
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if (!cfg || cfg.source !== "task-steps") {
throw new WorkflowIrError(
`foreach node '${node.id}' must declare source 'task-steps'`,
);
}
const template = cfg.template;
if (
!template ||
!Array.isArray(template.nodes) ||
!Array.isArray(template.edges)
) {
throw new WorkflowIrError(
`foreach node '${node.id}' must declare a template with nodes and edges arrays`,
);
}
if (template.nodes.length === 0) {
throw new WorkflowIrError(`foreach node '${node.id}' template must be non-empty`);
}
// mode / isolation / concurrency (KTD-3).
const mode = cfg.mode ?? "sequential";
if (mode !== "sequential" && mode !== "parallel") {
throw new WorkflowIrError(
`foreach node '${node.id}' mode must be 'sequential' or 'parallel'`,
);
}
const isolation = cfg.isolation ?? (mode === "parallel" ? "worktree" : "shared");
if (isolation !== "shared" && isolation !== "worktree") {
throw new WorkflowIrError(
`foreach node '${node.id}' isolation must be 'shared' or 'worktree'`,
);
}
if (mode === "parallel" && isolation === "shared") {
throw new WorkflowIrError(
`foreach node '${node.id}' cannot combine mode 'parallel' with isolation 'shared' (concurrent writes in one worktree are unguardable races)`,
);
}
if (cfg.concurrency !== undefined) {
if (mode !== "parallel") {
throw new WorkflowIrError(
`foreach node '${node.id}' concurrency is only valid in 'parallel' mode`,
);
}
const c = cfg.concurrency;
if (typeof c !== "number" || !Number.isInteger(c) || c < 1 || c > MAX_FOREACH_CONCURRENCY) {
throw new WorkflowIrError(
`foreach node '${node.id}' concurrency must be an integer in 1..${MAX_FOREACH_CONCURRENCY}`,
);
}
}
if (cfg.maxReworkCycles !== undefined) {
const m = cfg.maxReworkCycles;
if (typeof m !== "number" || !Number.isInteger(m) || m < 1) {
throw new WorkflowIrError(
`foreach node '${node.id}' maxReworkCycles must be an integer >= 1`,
);
}
// >10 is clamped at parse time (clampForeachConfig); validation only rejects <1.
}
const templateNodes = template.nodes;
const templateIds = new Set(templateNodes.map((n) => n.id));
if (templateIds.size !== templateNodes.length) {
throw new WorkflowIrError(
`foreach node '${node.id}' template has duplicate node ids`,
);
}
// No nested foreach.
for (const inner of templateNodes) {
if (inner.kind === "foreach") {
throw new WorkflowIrError(
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
);
}
}
// Edge endpoints must reference template nodes; rework edges must stay intra-template.
for (const edge of template.edges) {
const fromInside = templateIds.has(edge.from);
const toInside = templateIds.has(edge.to);
if (!fromInside || !toInside) {
if (isReworkEdge(edge)) {
throw new WorkflowIrError(
`rework edge '${edge.from}' -> '${edge.to}' in foreach '${node.id}' must have both endpoints inside the same template`,
);
}
throw new WorkflowIrError(
`foreach node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
);
}
}
// Single entry / single exit (ignoring rework back-edges, which intentionally
// create incoming edges to earlier template nodes).
const incoming = new Map<string, number>();
const outgoingCount = new Map<string, number>();
for (const edge of template.edges) {
if (isReworkEdge(edge)) continue;
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
outgoingCount.set(edge.from, (outgoingCount.get(edge.from) ?? 0) + 1);
}
const entries = templateNodes.filter((n) => (incoming.get(n.id) ?? 0) === 0);
const exits = templateNodes.filter((n) => (outgoingCount.get(n.id) ?? 0) === 0);
if (entries.length !== 1) {
throw new WorkflowIrError(
`foreach node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
);
}
if (exits.length !== 1) {
throw new WorkflowIrError(
`foreach node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
);
}
// Recurse: validate the template as its own region for parallelism + verdict
// routing. step-execute nodes legal here (they are not validated as forbidden
// at top level — that check lives in validateStepExecutePlacement).
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
const templateOutgoing = buildOutgoing(template.edges);
validateParallelism(templateNodes, templateOutgoing, templateById);
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, true);
// Defensive: top-level node ids and template node ids should not collide
// (instance identity is `<foreachId>#<i>:<templateNodeId>`, but a raw collision
// is still confusing).
for (const id of templateIds) {
if (topLevelNodeIds.has(id)) {
throw new WorkflowIrError(
`foreach node '${node.id}' template node id '${id}' collides with a top-level node id`,
);
}
}
}
/** step-execute seam nodes are legal ONLY inside a foreach template (KTD-4):
* reject any at the top level. (Inside-split-branch rejection is handled by
* SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */
function validateStepExecutePlacement(topLevelNodes: WorkflowIrNode[]): void {
for (const node of topLevelNodes) {
if (isStepExecuteNode(node)) {
throw new WorkflowIrError(
`step-execute seam node '${node.id}' is only legal inside a foreach template`,
);
}
}
}
/**
* step-review verdict routing (KTD-4). For each step-review node:
* - it must have outgoing edges covering `outcome:approve` and `outcome:revise`;
* - `outcome:rethink` optional (defaults to the revise target with reset semantics);
* - `outcome:unavailable` optional;
* - a step-review node inside a split branch is advisory-only: it must NOT carry
* rework or `outcome:approve` routing.
*/
function validateStepReviewRouting(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
nodesById: Map<string, WorkflowIrNode>,
insideForeachTemplate: boolean,
): void {
// Determine which nodes sit inside a split branch (advisory-only zone).
const inBranch = nodesInSplitBranches(nodes, outgoing, nodesById);
for (const node of nodes) {
if (node.kind !== "step-review") continue;
if (node.config?.type !== "plan" && node.config?.type !== "code") {
throw new WorkflowIrError(
`step-review node '${node.id}' must declare type 'plan' or 'code'`,
);
}
if (node.config.model !== undefined && typeof node.config.model !== "string") {
throw new WorkflowIrError(
`step-review node '${node.id}' model must be a string when present`,
);
}
const edges = outgoing.get(node.id) ?? [];
const conditions = new Set(edges.map((e) => e.condition));
const hasRework = edges.some(isReworkEdge);
if (inBranch.has(node.id)) {
// Advisory-only inside a split branch: no rework, no approve routing.
if (hasRework) {
throw new WorkflowIrError(
`step-review node '${node.id}' inside a split branch is advisory-only and may not have rework edges`,
);
}
if (conditions.has("outcome:approve")) {
throw new WorkflowIrError(
`step-review node '${node.id}' inside a split branch is advisory-only and may not carry outcome:approve routing`,
);
}
continue;
}
// Main-path step-review: must route approve and revise.
if (!conditions.has("outcome:approve")) {
throw new WorkflowIrError(
`step-review node '${node.id}' must route outcome:approve`,
);
}
if (!conditions.has("outcome:revise")) {
throw new WorkflowIrError(
`step-review node '${node.id}' must route outcome:revise`,
);
}
void insideForeachTemplate;
}
}
/** Compute the set of node ids that lie strictly inside some split..join branch
* region. Walks each split's branches forward to the join. Lightweight; used
* for the step-review advisory-only rule. */
function nodesInSplitBranches(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
nodesById: Map<string, WorkflowIrNode>,
): Set<string> {
const inBranch = new Set<string>();
const splits = nodes.filter((n) => n.kind === "split");
for (const split of splits) {
for (const edge of outgoing.get(split.id) ?? []) {
let cursor: string | undefined = edge.to;
const visited = new Set<string>();
while (cursor && !visited.has(cursor)) {
const id: string = cursor;
visited.add(id);
const n = nodesById.get(id);
if (!n || n.kind === "join") break;
inBranch.add(id);
const next: WorkflowIrEdge | undefined = (outgoing.get(id) ?? []).find(
(e) => !isReworkEdge(e) && e.condition !== "failure",
);
cursor = next?.to;
}
}
}
return inBranch;
}
/**
* Cycle detection across the top-level graph that EXEMPTS rework edges (KTD-5).
* Any non-rework cycle is rejected; rework edges (intra-template back-edges) are
* skipped. Run over the top-level graph; template internals are validated
* separately.
*/
function validateNoIllegalCycles(
nodes: WorkflowIrNode[],
outgoing: Map<string, WorkflowIrEdge[]>,
): void {
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const n of nodes) color.set(n.id, WHITE);
const visit = (id: string): void => {
color.set(id, GRAY);
for (const edge of outgoing.get(id) ?? []) {
if (isReworkEdge(edge)) continue;
const c = color.get(edge.to);
if (c === GRAY) {
throw new WorkflowIrError(
`Workflow IR has an illegal cycle (edge '${edge.from}' -> '${edge.to}'); only rework edges may form cycles`,
);
}
if (c === WHITE) visit(edge.to);
}
color.set(id, BLACK);
};
for (const n of nodes) {
if (color.get(n.id) === WHITE) visit(n.id);
}
}
/**
* Dominance check (KTD-3): every `foreach(source:"task-steps")` must be dominated
* by a `parse-steps` node — a parse-steps node lies on EVERY path from start to
* the foreach. Implemented via the classic "removal disconnects start from
* target" definition, which is correct for DAGs: for each parse-steps node,
* check whether the foreach is still reachable from start with that node removed.
* The foreach is dominated iff some parse-steps node's removal disconnects it.
*/
function validateForeachDominance(
nodes: WorkflowIrNode[],
edges: WorkflowIrEdge[],
outgoing: Map<string, WorkflowIrEdge[]>,
): void {
const startNode = nodes.find((n) => n.kind === "start");
if (!startNode) return; // parse-time guarantees exactly one start.
const foreaches = nodes.filter(
(n) => n.kind === "foreach" && (n.config as { source?: unknown } | undefined)?.source === "task-steps",
);
if (foreaches.length === 0) return;
const parseStepsNodes = nodes.filter((n) => n.kind === "parse-steps");
for (const fe of foreaches) {
// Reachable from start at all?
if (!reachableFrom(startNode.id, outgoing).has(fe.id)) {
throw new WorkflowIrError(
`foreach node '${fe.id}' is not reachable from the start node`,
);
}
const dominated = parseStepsNodes.some((ps) => {
if (ps.id === fe.id) return false;
// Build outgoing with ps removed (as both source and target).
const trimmed = buildOutgoing(
edges.filter((e) => e.from !== ps.id && e.to !== ps.id),
);
return !reachableFrom(startNode.id, trimmed).has(fe.id);
});
if (!dominated) {
throw new WorkflowIrError(
`foreach node '${fe.id}' (source:'task-steps') must be dominated by a parse-steps node on every path from start`,
);
}
}
}
/** Validate `parse-steps` node config (KTD-12). */
function validateParseStepsNodes(ir: WorkflowIrV2): void {
const declaredArtifacts = new Set((ir.artifacts ?? []).map((a) => a.key));
const hasDeclaredArtifacts = (ir.artifacts ?? []).length > 0;
for (const node of ir.nodes) {
if (node.kind !== "parse-steps") continue;
const cfg = node.config as { artifact?: unknown; parser?: unknown } | undefined;
const artifact = cfg?.artifact;
const parser = cfg?.parser;
if (typeof parser !== "string" || parser.trim() === "") {
throw new WorkflowIrError(
`parse-steps node '${node.id}' must declare a non-empty parser`,
);
}
if (typeof artifact !== "string" || artifact.trim() === "") {
throw new WorkflowIrError(
`parse-steps node '${node.id}' must declare a non-empty artifact`,
);
}
if (hasDeclaredArtifacts) {
if (!declaredArtifacts.has(artifact)) {
throw new WorkflowIrError(
`parse-steps node '${node.id}' references undeclared artifact '${artifact}'`,
);
}
} else if (artifact !== IMPLICIT_DEFAULT_ARTIFACT) {
throw new WorkflowIrError(
`parse-steps node '${node.id}' references artifact '${artifact}', but only '${IMPLICIT_DEFAULT_ARTIFACT}' is allowed when no artifacts are declared`,
);
}
}
}
/** Validate `code` node config (KTD-15). TS is NOT compiled in core (esbuild
* check is engine/editor side). */
function validateCodeNodes(nodes: WorkflowIrNode[]): void {
const MAX_SOURCE = 65536;
for (const node of nodes) {
if (node.kind !== "code") continue;
const cfg = node.config as { source?: unknown; timeoutMs?: unknown } | undefined;
const source = cfg?.source;
if (typeof source !== "string" || source.length === 0) {
throw new WorkflowIrError(`code node '${node.id}' must declare a non-empty source`);
}
if (source.length > MAX_SOURCE) {
throw new WorkflowIrError(
`code node '${node.id}' source exceeds ${MAX_SOURCE} characters`,
);
}
if (cfg?.timeoutMs !== undefined) {
const t = cfg.timeoutMs;
if (typeof t !== "number" || !Number.isInteger(t) || t < 1000 || t > 300000) {
throw new WorkflowIrError(
`code node '${node.id}' timeoutMs must be an integer in 1000..300000`,
);
}
}
}
}
/** Validate `fields` declarations (KTD-13). */
function validateFields(fields: WorkflowFieldDefinition[] | undefined): void {
if (fields === undefined) return;
if (!Array.isArray(fields)) {
throw new WorkflowIrError("Workflow IR fields must be an array");
}
const seen = new Set<string>();
for (const field of fields) {
if (!field || typeof field.id !== "string" || field.id === "") {
throw new WorkflowIrError("Workflow field must have a non-empty id");
}
if (seen.has(field.id)) {
throw new WorkflowIrError(`Workflow IR has duplicate field id '${field.id}'`);
}
seen.add(field.id);
if (typeof field.name !== "string" || field.name === "") {
throw new WorkflowIrError(`Workflow field '${field.id}' must have a non-empty name`);
}
if (!WORKFLOW_FIELD_TYPES.has(field.type)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' has unknown type '${String(field.type)}'`,
);
}
const isEnum = field.type === "enum" || field.type === "multi-enum";
if (isEnum) {
if (!Array.isArray(field.options) || field.options.length === 0) {
throw new WorkflowIrError(
`Workflow field '${field.id}' of type '${field.type}' must declare non-empty options`,
);
}
const optSeen = new Set<string>();
for (const opt of field.options) {
if (!opt || typeof opt.value !== "string" || opt.value === "") {
throw new WorkflowIrError(
`Workflow field '${field.id}' option must have a non-empty value`,
);
}
if (typeof opt.label !== "string" || opt.label === "") {
throw new WorkflowIrError(
`Workflow field '${field.id}' option '${opt.value}' must have a non-empty label`,
);
}
if (optSeen.has(opt.value)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' has duplicate option value '${opt.value}'`,
);
}
optSeen.add(opt.value);
}
} else if (field.options !== undefined) {
throw new WorkflowIrError(
`Workflow field '${field.id}' of type '${field.type}' must not declare options`,
);
}
if (field.render !== undefined) {
const r = field.render;
if (r.placement !== undefined && !FIELD_RENDER_PLACEMENTS.has(r.placement)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' render.placement '${String(r.placement)}' is not allowed`,
);
}
if (r.widget !== undefined && !FIELD_RENDER_WIDGETS.has(r.widget)) {
throw new WorkflowIrError(
`Workflow field '${field.id}' render.widget '${String(r.widget)}' is not allowed`,
);
}
}
}
}
function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(ir.columns)) {
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
@@ -223,6 +769,48 @@ function validateV2(ir: WorkflowIrV2): void {
const outgoing = buildOutgoing(ir.edges);
validateParallelism(ir.nodes, outgoing, nodesById);
// Step-inversion (U1) — additive validation. Order matters: validate node
// configs first, then structural rules.
const topLevelIds = new Set(ir.nodes.map((n) => n.id));
validateStepExecutePlacement(ir.nodes);
for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds);
}
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
validateParseStepsNodes(ir);
validateCodeNodes(ir.nodes);
validateFields(ir.fields);
// Rework edges are legal only intra-template; any rework edge at the top level
// is rejected (template rework edges are validated inside validateForeach and
// never appear in ir.edges).
for (const edge of ir.edges) {
if (isReworkEdge(edge)) {
throw new WorkflowIrError(
`rework edge '${edge.from}' -> '${edge.to}' is only legal inside a foreach template`,
);
}
}
validateNoIllegalCycles(ir.nodes, outgoing);
validateForeachDominance(ir.nodes, ir.edges, outgoing);
}
/** Clamp foreach `maxReworkCycles` > cap down to the cap, in place, mirroring the
* maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */
function clampForeachConfigs(ir: WorkflowIrV2): void {
for (const node of ir.nodes) {
if (node.kind !== "foreach") continue;
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if (
cfg &&
typeof cfg.maxReworkCycles === "number" &&
cfg.maxReworkCycles > MAX_REWORK_CYCLES_CAP
) {
cfg.maxReworkCycles = MAX_REWORK_CYCLES_CAP;
}
}
}
export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
@@ -249,6 +837,7 @@ export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr {
return upgradeV1ToV2(ir);
}
clampForeachConfigs(ir);
validateV2(ir);
return ir;
}
@@ -280,6 +869,11 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
if (!V1_NODE_KINDS.has(node.kind)) return ir;
}
// Step-inversion declarations (artifacts/fields) are v2-only features.
if ((ir.artifacts && ir.artifacts.length > 0) || (ir.fields && ir.fields.length > 0)) {
return ir;
}
// Columns must be exactly the synthesized default set, same ids, same order,
// with the minimal (placement-only) empty trait set. Any custom column, rename,
// reorder, or applied trait forces v2.