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:
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
551
packages/core/src/__tests__/workflow-ir-foreach.test.ts
Normal file
551
packages/core/src/__tests__/workflow-ir-foreach.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user