diff --git a/packages/core/src/__tests__/store-parsing.test.ts b/packages/core/src/__tests__/store-parsing.test.ts index cea876e75b..6f407c9793 100644 --- a/packages/core/src/__tests__/store-parsing.test.ts +++ b/packages/core/src/__tests__/store-parsing.test.ts @@ -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" }, + ]); + }); }); diff --git a/packages/core/src/__tests__/workflow-ir-foreach.test.ts b/packages/core/src/__tests__/workflow-ir-foreach.test.ts new file mode 100644 index 0000000000..2bb114a9f6 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-foreach.test.ts @@ -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 { + 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, + extra: Partial = {}, +): 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).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).artifact = "SPEC.md"; + expect(() => parseWorkflowIr(ir)).toThrow(/only 'PROMPT.md' is allowed/); + }); +}); + +describe("code node validation", () => { + function graphWithCode(config: Record): 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(); + }); +}); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 3a436d8903..a477d46f3e 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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(); + 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 { 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); } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 41ea3f2de9..fbf8f693da 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */ diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index d636618f8d..b96e538fb5 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -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). */ diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 95ed2ac100..6a3cc5eff8 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -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 = 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 = 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 = new Set([ + "execute", + "merge", + "step-execute", +]); + +/** Step-inversion field-type whitelist (KTD-13). */ +const WORKFLOW_FIELD_TYPES: ReadonlySet = new Set([ + "string", + "text", + "number", + "boolean", + "enum", + "multi-enum", + "date", + "url", +]); + +const FIELD_RENDER_PLACEMENTS: ReadonlySet = new Set([ + "card", + "detail", + "detail-section", +]); + +const FIELD_RENDER_WIDGETS: ReadonlySet = 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): 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, +): Set { + const seen = new Set(); + 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): void { + const cfg = node.config as Partial | 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(); + const outgoingCount = new Map(); + 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 `#:`, 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, + nodesById: Map, + 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, + nodesById: Map, +): Set { + const inBranch = new Set(); + 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(); + 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, +): void { + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + 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, +): 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(); + 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(); + 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 | 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.