feat(FN-0000): add workflow loop nodes

This commit is contained in:
gsxdsm
2026-06-07 23:56:25 -07:00
parent e0ab3b5ab7
commit a504238d26
17 changed files with 1294 additions and 49 deletions

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

View File

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

View File

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

View File

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