feat(FN-0000): add workflow loop nodes
This commit is contained in:
125
packages/core/src/__tests__/workflow-ir-loop.test.ts
Normal file
125
packages/core/src/__tests__/workflow-ir-loop.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseWorkflowIr,
|
||||
serializeWorkflowIr,
|
||||
} from "../workflow-ir.js";
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowIrV2 } from "../workflow-ir-types.js";
|
||||
|
||||
const columns: WorkflowIrV2["columns"] = [{ id: "work", name: "Work", traits: [] }];
|
||||
|
||||
function loopTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: "ask", kind: "prompt", config: { prompt: "try" } },
|
||||
{ id: "check", kind: "gate", config: { prompt: "done?" } },
|
||||
],
|
||||
edges: [{ from: "ask", to: "check" }],
|
||||
};
|
||||
}
|
||||
|
||||
function loopIr(config: Record<string, unknown> = {}): WorkflowIrV2 {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "loop-test",
|
||||
columns,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{
|
||||
id: "repeat",
|
||||
kind: "loop",
|
||||
config: {
|
||||
maxIterations: 3,
|
||||
exitWhen: { type: "output-contains", value: "DONE" },
|
||||
template: loopTemplate(),
|
||||
...config,
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "repeat" },
|
||||
{ from: "repeat", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("loop validation", () => {
|
||||
it("parses and round-trips a valid loop node", () => {
|
||||
const parsed = parseWorkflowIr(loopIr()) as WorkflowIrV2;
|
||||
const loop = parsed.nodes.find((n) => n.id === "repeat");
|
||||
|
||||
expect(loop?.kind).toBe("loop");
|
||||
expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed);
|
||||
});
|
||||
|
||||
it("rejects a loop with an empty template", () => {
|
||||
expect(() => parseWorkflowIr(loopIr({ template: { nodes: [], edges: [] } }))).toThrow(/non-empty/);
|
||||
});
|
||||
|
||||
it("rejects duplicate template node ids", () => {
|
||||
const template = loopTemplate();
|
||||
template.nodes.push({ id: "ask", kind: "script" });
|
||||
|
||||
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/duplicate node ids/);
|
||||
});
|
||||
|
||||
it("rejects template edges that leave the template", () => {
|
||||
const template = loopTemplate();
|
||||
template.edges.push({ from: "check", to: "end" });
|
||||
|
||||
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/references a node outside/);
|
||||
});
|
||||
|
||||
it("rejects nested loop and foreach regions", () => {
|
||||
const template = loopTemplate();
|
||||
template.nodes.push({
|
||||
id: "nested",
|
||||
kind: "loop",
|
||||
config: {
|
||||
exitWhen: { type: "output-contains", value: "DONE" },
|
||||
template: loopTemplate(),
|
||||
},
|
||||
});
|
||||
template.edges.push({ from: "check", to: "nested" });
|
||||
|
||||
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/nested loop\/foreach/);
|
||||
});
|
||||
|
||||
it("rejects foreach-only seams and normal cycles inside loop templates", () => {
|
||||
const seamTemplate = loopTemplate();
|
||||
seamTemplate.nodes[0] = { id: "ask", kind: "prompt", config: { seam: "step-execute" } };
|
||||
expect(() => parseWorkflowIr(loopIr({ template: seamTemplate }))).toThrow(/only legal inside a foreach/);
|
||||
|
||||
const cyclicTemplate = loopTemplate();
|
||||
cyclicTemplate.nodes.unshift({ id: "init", kind: "prompt", config: { prompt: "init" } });
|
||||
cyclicTemplate.nodes.push({ id: "finish", kind: "gate", config: { prompt: "finished?" } });
|
||||
cyclicTemplate.edges.unshift({ from: "init", to: "ask" });
|
||||
cyclicTemplate.edges.push({ from: "check", to: "ask", condition: "failure" });
|
||||
cyclicTemplate.edges.push({ from: "check", to: "finish", condition: "success" });
|
||||
expect(() => parseWorkflowIr(loopIr({ template: cyclicTemplate }))).toThrow(/illegal cycle/);
|
||||
});
|
||||
|
||||
it("rejects an invalid exit condition", () => {
|
||||
expect(() => parseWorkflowIr(loopIr({ exitWhen: { type: "output-contains", value: "" } }))).toThrow(
|
||||
/exitWhen.value/,
|
||||
);
|
||||
expect(() => parseWorkflowIr(loopIr({ exitWhen: { type: "output-matches", pattern: "[" } }))).toThrow(
|
||||
/exitWhen.pattern is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps high maxIterations and rejects invalid budgets", () => {
|
||||
const parsed = parseWorkflowIr(loopIr({ maxIterations: 99 })) as WorkflowIrV2;
|
||||
expect(parsed.nodes.find((n) => n.id === "repeat")?.config?.maxIterations).toBe(50);
|
||||
|
||||
expect(() => parseWorkflowIr(loopIr({ maxIterations: 0 }))).toThrow(/maxIterations/);
|
||||
expect(() => parseWorkflowIr(loopIr({ timeoutMs: 0 }))).toThrow(/timeoutMs/);
|
||||
});
|
||||
|
||||
it("still rejects illegal top-level cycles", () => {
|
||||
const ir = loopIr();
|
||||
ir.edges.push({ from: "repeat", to: "start", condition: "failure" });
|
||||
|
||||
expect(() => parseWorkflowIr(ir)).toThrow(/illegal cycle/);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,8 @@ export type {
|
||||
WorkflowJoinBranchFailure,
|
||||
// Step-inversion (KTD-3/12/13): foreach / artifacts / custom-field IR types.
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowLoopExitCondition,
|
||||
WorkflowIrArtifact,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 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); and the unified PR-entity additions (U3):
|
||||
* `code` (sandboxed TypeScript), and `loop` (bounded repeat-until region);
|
||||
* and the unified PR-entity additions (U3):
|
||||
* `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the
|
||||
* review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */
|
||||
export type WorkflowIrNodeKind =
|
||||
@@ -16,6 +17,7 @@ export type WorkflowIrNodeKind =
|
||||
| "split"
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "loop"
|
||||
| "step-review"
|
||||
| "parse-steps"
|
||||
| "code"
|
||||
@@ -123,6 +125,32 @@ export interface WorkflowForeachConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkflowLoopExitCondition =
|
||||
| {
|
||||
type: "output-contains";
|
||||
/** Template node id whose result value is inspected. Defaults to template exit node. */
|
||||
nodeId?: string;
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: "output-matches";
|
||||
/** Template node id whose result value is inspected. Defaults to template exit node. */
|
||||
nodeId?: string;
|
||||
pattern: string;
|
||||
flags?: string;
|
||||
};
|
||||
|
||||
/** Config for a bounded repeat-until workflow region. */
|
||||
export interface WorkflowLoopConfig {
|
||||
maxIterations?: number;
|
||||
timeoutMs?: number;
|
||||
exitWhen: WorkflowLoopExitCondition;
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
WorkflowIrV2,
|
||||
WorkflowHoldRelease,
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
WorkflowSettingDefinition,
|
||||
@@ -95,6 +96,8 @@ const MAX_REWORK_CYCLES_CAP = 10;
|
||||
|
||||
/** Parallel concurrency bounds (KTD-3): range 1..8. */
|
||||
const MAX_FOREACH_CONCURRENCY = 8;
|
||||
const MAX_LOOP_ITERATIONS_CAP = 50;
|
||||
const MAX_LOOP_TIMEOUT_MS = 3_600_000;
|
||||
const WORKFLOW_EXTENSION_KEY_PATTERN = /^plugin:[a-z0-9]([a-z0-9-]*[a-z0-9])?:[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
|
||||
/** The implicit step-source artifact allowed when no artifacts are declared. */
|
||||
@@ -445,6 +448,141 @@ function validateForeach(
|
||||
}
|
||||
}
|
||||
|
||||
function validateLoop(
|
||||
node: WorkflowIrNode,
|
||||
topLevelNodeIds: Set<string>,
|
||||
columnIds: Set<string>,
|
||||
): void {
|
||||
const cfg = node.config as Partial<WorkflowLoopConfig> | undefined;
|
||||
const template = cfg?.template;
|
||||
if (
|
||||
!cfg ||
|
||||
!template ||
|
||||
!Array.isArray(template.nodes) ||
|
||||
!Array.isArray(template.edges)
|
||||
) {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' must declare a template with nodes and edges arrays`,
|
||||
);
|
||||
}
|
||||
if (template.nodes.length === 0) {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' template must be non-empty`);
|
||||
}
|
||||
if (cfg.maxIterations !== undefined) {
|
||||
const m = cfg.maxIterations;
|
||||
if (typeof m !== "number" || !Number.isInteger(m) || m < 1) {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' maxIterations must be an integer >= 1`);
|
||||
}
|
||||
}
|
||||
if (cfg.timeoutMs !== undefined) {
|
||||
const t = cfg.timeoutMs;
|
||||
if (typeof t !== "number" || !Number.isInteger(t) || t < 1 || t > MAX_LOOP_TIMEOUT_MS) {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' timeoutMs must be an integer in 1..${MAX_LOOP_TIMEOUT_MS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const exitWhen = cfg.exitWhen as WorkflowLoopConfig["exitWhen"] | undefined;
|
||||
if (!exitWhen || typeof exitWhen !== "object") {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' must declare exitWhen`);
|
||||
}
|
||||
if (exitWhen.type === "output-contains") {
|
||||
if (typeof exitWhen.value !== "string" || exitWhen.value.length === 0) {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.value must be a non-empty string`);
|
||||
}
|
||||
} else if (exitWhen.type === "output-matches") {
|
||||
if (typeof exitWhen.pattern !== "string" || exitWhen.pattern.length === 0) {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.pattern must be a non-empty string`);
|
||||
}
|
||||
if (exitWhen.flags !== undefined && typeof exitWhen.flags !== "string") {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.flags must be a string when present`);
|
||||
}
|
||||
try {
|
||||
new RegExp(exitWhen.pattern, exitWhen.flags);
|
||||
} catch (err) {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' exitWhen.pattern is invalid: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' exitWhen.type must be output-contains or output-matches`);
|
||||
}
|
||||
|
||||
const templateNodes = template.nodes;
|
||||
const templateIds = new Set(templateNodes.map((n) => n.id));
|
||||
if (templateIds.size !== templateNodes.length) {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' template has duplicate node ids`);
|
||||
}
|
||||
if (exitWhen.nodeId !== undefined && !templateIds.has(exitWhen.nodeId)) {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' exitWhen.nodeId '${exitWhen.nodeId}' is not in the template`,
|
||||
);
|
||||
}
|
||||
for (const inner of templateNodes) {
|
||||
if (inner.kind === "loop" || inner.kind === "foreach") {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' template may not contain nested loop/foreach ('${inner.id}')`,
|
||||
);
|
||||
}
|
||||
if (isStepExecuteNode(inner)) {
|
||||
throw new WorkflowIrError(
|
||||
`step-execute seam node '${inner.id}' is only legal inside a foreach template`,
|
||||
);
|
||||
}
|
||||
if (inner.column !== undefined && !columnIds.has(inner.column)) {
|
||||
throw new WorkflowIrError(
|
||||
`Workflow node '${inner.id}' references undefined column '${inner.column}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const edge of template.edges) {
|
||||
const fromInside = templateIds.has(edge.from);
|
||||
const toInside = templateIds.has(edge.to);
|
||||
if (!fromInside || !toInside) {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' template edge '${edge.from}' -> '${edge.to}' references a node outside the template`,
|
||||
);
|
||||
}
|
||||
if (isReworkEdge(edge)) {
|
||||
throw new WorkflowIrError(`loop node '${node.id}' template may not contain rework edges`);
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = new Map<string, number>();
|
||||
const outgoingCount = new Map<string, number>();
|
||||
for (const edge of template.edges) {
|
||||
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(
|
||||
`loop node '${node.id}' template must have exactly one entry node (found ${entries.length})`,
|
||||
);
|
||||
}
|
||||
if (exits.length !== 1) {
|
||||
throw new WorkflowIrError(
|
||||
`loop node '${node.id}' template must have exactly one exit node (found ${exits.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const templateById = new Map(templateNodes.map((n) => [n.id, n]));
|
||||
const templateOutgoing = buildOutgoing(template.edges);
|
||||
validateNoIllegalCycles(templateNodes, templateOutgoing);
|
||||
validateParallelism(templateNodes, templateOutgoing, templateById);
|
||||
validateStepReviewRouting(templateNodes, templateOutgoing, templateById, false);
|
||||
|
||||
for (const id of templateIds) {
|
||||
if (topLevelNodeIds.has(id)) {
|
||||
throw new WorkflowIrError(
|
||||
`loop 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.) */
|
||||
@@ -1049,6 +1187,7 @@ function validateV2(ir: WorkflowIrV2): void {
|
||||
validateStepExecutePlacement(ir.nodes);
|
||||
for (const node of ir.nodes) {
|
||||
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
|
||||
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);
|
||||
}
|
||||
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
|
||||
validateParseStepsNodes(ir);
|
||||
@@ -1083,6 +1222,17 @@ function validateV2(ir: WorkflowIrV2): void {
|
||||
* 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 === "loop") {
|
||||
const cfg = node.config as Partial<WorkflowLoopConfig> | undefined;
|
||||
if (
|
||||
cfg &&
|
||||
typeof cfg.maxIterations === "number" &&
|
||||
cfg.maxIterations > MAX_LOOP_ITERATIONS_CAP
|
||||
) {
|
||||
cfg.maxIterations = MAX_LOOP_ITERATIONS_CAP;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (node.kind !== "foreach") continue;
|
||||
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user