Merge pull request #1508 from Runfusion/feature/workflow-loop-nodes

Add bounded workflow loop nodes
This commit is contained in:
gsxdsm
2026-06-08 00:33:25 -07:00
committed by GitHub
17 changed files with 1389 additions and 58 deletions

View File

@@ -0,0 +1,166 @@
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 loop nodes inside foreach templates", () => {
const ir: WorkflowIrV2 = {
version: "v2",
name: "foreach-loop-test",
columns,
nodes: [
{ id: "start", kind: "start" },
{
id: "steps",
kind: "foreach",
config: {
source: "task-steps",
template: {
nodes: [
{
id: "nested-loop",
kind: "loop",
config: {
exitWhen: { type: "output-contains", value: "DONE" },
template: loopTemplate(),
},
},
],
edges: [],
},
},
},
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "steps" },
{ from: "steps", to: "end" },
],
};
expect(() => parseWorkflowIr(ir)).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/,
);
expect(() =>
parseWorkflowIr(loopIr({ exitWhen: { type: "output-matches", pattern: "(a+)+" } })),
).toThrow(/potentially unsafe/);
});
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,7 +96,12 @@ 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])?$/;
const MAX_LOOP_REGEX_PATTERN_LENGTH = 256;
const LOOP_REGEX_NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[*+](?:[^()\\]|\\.)*\)\s*(?:[*+]|\{\d+,?\d*\})/;
const LOOP_REGEX_BACKREFERENCE = /\\[1-9]/;
/** The implicit step-source artifact allowed when no artifacts are declared. */
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
@@ -105,6 +111,19 @@ function isStepExecuteNode(node: WorkflowIrNode): boolean {
return node.kind === "prompt" && node.config?.seam === "step-execute";
}
function assertSafeLoopRegexPattern(nodeId: string, pattern: string): void {
if (pattern.length > MAX_LOOP_REGEX_PATTERN_LENGTH) {
throw new WorkflowIrError(
`loop node '${nodeId}' exitWhen.pattern must be ${MAX_LOOP_REGEX_PATTERN_LENGTH} characters or fewer`,
);
}
if (LOOP_REGEX_BACKREFERENCE.test(pattern) || LOOP_REGEX_NESTED_QUANTIFIER.test(pattern)) {
throw new WorkflowIrError(
`loop node '${nodeId}' exitWhen.pattern uses a potentially unsafe regex construct`,
);
}
}
/** Default-workflow column ids in legacy enum order (KTD-1). */
export const DEFAULT_WORKFLOW_COLUMN_IDS = [
"triage",
@@ -371,13 +390,13 @@ function validateForeach(
);
}
// No nested foreach. Also: a template node's declared `column` must resolve to a
// No nested template groups. Also: a template node's declared `column` must resolve to a
// top-level column id (column-agent plan KTD-1) — otherwise a dangling reference
// is a silent no-binding no-op at runtime instead of a typed authoring error.
for (const inner of templateNodes) {
if (inner.kind === "foreach") {
if (inner.kind === "foreach" || inner.kind === "loop") {
throw new WorkflowIrError(
`foreach node '${node.id}' template may not contain a nested foreach ('${inner.id}')`,
`foreach node '${node.id}' template may not contain nested loop/foreach ('${inner.id}')`,
);
}
if (inner.column !== undefined && !columnIds.has(inner.column)) {
@@ -445,6 +464,142 @@ 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)}`,
);
}
assertSafeLoopRegexPattern(node.id, exitWhen.pattern);
} 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 +1204,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);
@@ -1079,10 +1235,21 @@ function validateV2(ir: WorkflowIrV2): void {
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. */
/** Clamp bounded workflow-node configs down to their caps, in place, mirroring
* the maxRetries clamp posture. 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 (
@@ -1201,9 +1368,9 @@ export function serializeWorkflowIr(ir: WorkflowIr): string {
/**
* Strip the trust-escalating `cliSkipApproval`/`autoApprove` flags from every
* node config in an IR, recursing into foreach `config.template.nodes` at any
* nesting depth (foreach-in-foreach). Mutates the passed IR in place and returns
* it alongside a `stripped` flag indicating whether anything was removed.
* node config in an IR, recursing into template-group `config.template.nodes`.
* Mutates the passed IR in place and returns it alongside a `stripped` flag
* indicating whether anything was removed.
*
* These flags bypass the CLI first-run approval gate (see executor.ts). They are
* legitimate only for workflows authored through the trusted dashboard editor /

View File

@@ -200,6 +200,7 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
{ kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } },
// Step-inversion (KTD-3/4/12/15).
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } },
{ kind: "loop", label: "Loop", icon: Repeat, presetConfig: { maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } } },
{ kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } },
{ kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
@@ -249,6 +250,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
"split",
"join",
"foreach",
"loop",
"step-review",
"parse-steps",
"merge",
@@ -1222,19 +1224,24 @@ function InnerEditor({
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
if (kind === "foreach") {
// A foreach renders as a React Flow group node. It auto-populates ONE
// step-execute child (a prompt node with seam=step-execute) so the group
// is never confusingly empty (KTD-3 / U8). The group node must precede
// its child in the array for React Flow's parent extent to apply.
if (kind === "foreach" || kind === "loop") {
// Template groups render as React Flow group nodes. Foreach seeds the
// required step-execute seam; loop seeds a regular prompt so authors can
// wire the repeated body immediately. The group node must precede its
// child for React Flow's parent extent to apply.
const childId = foreachChildFlowId(id, newNodeId());
const childLabel =
kind === "foreach"
? t("workflowNodes.stepExecuteLabel", "Step execute")
: t("workflowNodes.loopStepLabel", "Loop step");
const childConfig = kind === "foreach" ? { seam: "step-execute" } : { prompt: "" };
setNodes((ns) => [
...ns,
{
id,
type: "foreach",
type: kind,
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 },
data: { kind: "foreach", label, config, templateEmpty: false },
data: { kind, label, config, templateEmpty: false },
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true,
},
@@ -1246,8 +1253,8 @@ function InnerEditor({
extent: "parent",
data: {
kind: "prompt",
label: t("workflowNodes.stepExecuteLabel", "Step execute"),
config: { seam: "step-execute" },
label: childLabel,
config: childConfig,
},
deletable: true,
},
@@ -1779,21 +1786,25 @@ function InnerEditor({
// (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge.
const nodesForRender = useMemo(() => {
const unplacedSet = new Set(unplaced);
// Count current template children per foreach group so the empty-state hint
// (KTD-3 / U8) reflects live deletions even though the palette seeds one.
// Count current template children per template group so the empty-state hint
// reflects live deletions even though the palette seeds one.
const childCount = new Map<string, number>();
for (const n of nodes) {
if (n.parentId) childCount.set(n.parentId, (childCount.get(n.parentId) ?? 0) + 1);
}
const emptyHint = t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
return nodes.map((n) => {
let errorBadge: string | undefined;
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message;
const templateEmpty = n.data.kind === "foreach" ? (childCount.get(n.id) ?? 0) === 0 : undefined;
const isTemplateGroup = n.data.kind === "foreach" || n.data.kind === "loop";
const emptyHint =
n.data.kind === "loop"
? t("workflowNodes.loopEmptyHint", "Drag loop steps here")
: t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
const templateEmpty = isTemplateGroup ? (childCount.get(n.id) ?? 0) === 0 : undefined;
if (
errorBadge === n.data.errorBadge &&
(n.data.kind !== "foreach" || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint))
(!isTemplateGroup || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint))
)
return n;
return {
@@ -1801,7 +1812,7 @@ function InnerEditor({
data: {
...n.data,
errorBadge,
...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}),
...(isTemplateGroup ? { templateEmpty, emptyHint } : {}),
},
};
});
@@ -3214,6 +3225,184 @@ function InnerEditor({
})()
) : null}
{selectedNode.data.kind === "loop" ? (
(() => {
const exitWhen =
selectedNode.data.config?.exitWhen &&
typeof selectedNode.data.config.exitWhen === "object"
? (selectedNode.data.config.exitWhen as Record<string, unknown>)
: { type: "output-contains", value: "DONE" };
const exitType = String(exitWhen.type ?? "output-contains");
const exitText =
exitType === "output-matches"
? String(exitWhen.pattern ?? "")
: String(exitWhen.value ?? "");
return (
<>
<label className="wf-field">
<span>{t("workflowNodes.loopExitType", "Exit condition")}</span>
<select
value={exitType}
onChange={(e) => {
const nextType = e.target.value;
updateSelectedData({
config: (prev) => {
const current =
prev.exitWhen && typeof prev.exitWhen === "object"
? (prev.exitWhen as Record<string, unknown>)
: {};
const text =
nextType === "output-matches"
? String(current.pattern ?? current.value ?? "")
: String(current.value ?? current.pattern ?? "");
const nextExitWhen: Record<string, unknown> = {
...current,
type: nextType,
};
delete nextExitWhen.pattern;
delete nextExitWhen.value;
return {
...prev,
exitWhen:
nextType === "output-matches"
? { ...nextExitWhen, pattern: text }
: { ...nextExitWhen, value: text },
};
},
});
}}
>
<option value="output-contains">
{t("workflowNodes.loopOutputContains", "Output contains")}
</option>
<option value="output-matches">
{t("workflowNodes.loopOutputMatches", "Output matches regex")}
</option>
</select>
</label>
<label className="wf-field">
<span>
{exitType === "output-matches"
? t("workflowNodes.loopPattern", "Pattern")
: t("workflowNodes.loopValue", "Value")}
</span>
<input
value={exitText}
placeholder={exitType === "output-matches" ? "DONE|COMPLETE" : "DONE"}
onChange={(e) => {
const value = e.target.value;
updateSelectedData({
config: (prev) => {
const current =
prev.exitWhen && typeof prev.exitWhen === "object"
? (prev.exitWhen as Record<string, unknown>)
: {};
return {
...prev,
exitWhen:
exitType === "output-matches"
? { ...current, type: exitType, pattern: value }
: { ...current, type: exitType, value },
};
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.loopNodeId", "Watch node id (optional)")}</span>
<input
value={String(exitWhen.nodeId ?? "")}
placeholder={t("workflowNodes.loopNodeIdPlaceholder", "Template exit node")}
onChange={(e) => {
const nodeId = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const current =
prev.exitWhen && typeof prev.exitWhen === "object"
? (prev.exitWhen as Record<string, unknown>)
: { type: "output-contains", value: "DONE" };
const next = { ...current };
if (nodeId) next.nodeId = nodeId;
else delete next.nodeId;
return { ...prev, exitWhen: next };
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.loopMaxIterations", "Max iterations")}</span>
<input
type="number"
min={1}
max={50}
placeholder="3"
value={
selectedNode.data.config?.maxIterations != null
? String(selectedNode.data.config.maxIterations)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const next = { ...prev };
if (val === "") delete next.maxIterations;
else {
const num = parseInt(val, 10);
if (!isNaN(num)) next.maxIterations = num;
}
return next;
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.loopTimeoutMs", "Timeout (ms)")}</span>
<input
type="number"
min={1}
max={3600000}
placeholder="300000"
value={
selectedNode.data.config?.timeoutMs != null
? String(selectedNode.data.config.timeoutMs)
: ""
}
onChange={(e) => {
const val = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const next = { ...prev };
if (val === "") delete next.timeoutMs;
else {
const num = parseInt(val, 10);
if (!isNaN(num)) next.timeoutMs = num;
}
return next;
},
});
}}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.loopNote",
"Repeats the template until the selected output matches, an iteration limit is reached, or the timeout expires.",
)}
</p>
</>
);
})()
) : null}
{selectedNode.data.kind === "step-review" ? (
<>
<label className="wf-field">

View File

@@ -412,6 +412,100 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
expect(parse?.config).toMatchObject({ artifact: "PROMPT.md", parser: "step-headings" });
});
it("round-trips loop templates through parented group children", () => {
const loopIr: WorkflowDefinition["ir"] = {
version: "v2",
name: "bounded-loop",
columns: ir.columns,
nodes: [
{ id: "start", kind: "start", column: "plan" },
{
id: "retry",
kind: "loop",
column: "in-progress",
config: {
maxIterations: 4,
timeoutMs: 60000,
exitWhen: { type: "output-matches", nodeId: "check", pattern: "DONE|COMPLETE" },
template: {
nodes: [
{ id: "try", kind: "prompt", config: { prompt: "try once" } },
{ id: "check", kind: "gate", config: { prompt: "done?" } },
],
edges: [{ from: "try", to: "check", condition: "success" }],
},
},
},
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "retry", condition: "success" },
{ from: "retry", to: "end", condition: "success" },
],
};
const { nodes, edges } = irToFlow(makeDef(loopIr));
const group = nodes.find((n) => n.id === "retry");
expect(group?.type).toBe("loop");
expect(group?.data.kind).toBe("loop");
expect(nodes.filter((n) => n.parentId === "retry").map((n) => templateNodeIdFromChild("retry", n.id))).toEqual([
"try",
"check",
]);
const { ir: out } = flowToIr("bounded-loop", nodes, edges, columnsOf(makeDef(loopIr)));
if (out.version !== "v2") throw new Error("expected v2");
const retry = out.nodes.find((n) => n.id === "retry");
expect(retry?.kind).toBe("loop");
expect(retry?.config).toMatchObject({
maxIterations: 4,
timeoutMs: 60000,
exitWhen: { type: "output-matches", nodeId: "check", pattern: "DONE|COMPLETE" },
});
const template = retry?.config?.template as { nodes: { id: string }[]; edges: { from: string; to: string }[] };
expect(template.nodes.map((n) => n.id)).toEqual(["try", "check"]);
expect(template.edges).toEqual([{ from: "try", to: "check", condition: "success" }]);
});
it("inserts loop fragments with their template children intact", () => {
const fragment: WorkflowDefinition["ir"] = {
version: "v2",
name: "fragment",
columns: ir.columns,
nodes: [
{ id: "start", kind: "start" },
{
id: "retry",
kind: "loop",
config: {
maxIterations: 2,
exitWhen: { type: "output-contains", value: "DONE" },
template: {
nodes: [{ id: "try", kind: "prompt", config: { prompt: "again" } }],
edges: [],
},
},
},
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "retry" },
{ from: "retry", to: "end" },
],
};
const inserted = insertFragment([], [], parseWorkflowIr(fragment), { x: 10, y: 20 }, {
[foreachChildFlowId("retry", "try")]: { x: 86, y: 132 },
});
const group = inserted.nodes.find((n) => n.data.kind === "loop");
const child = inserted.nodes.find((n) => n.parentId === group?.id);
expect(group).toBeTruthy();
expect(group?.type).toBe("loop");
expect(child?.position).toEqual({ x: 86, y: 132 });
expect(inserted.nodes.filter((n) => n.parentId === group?.id)).toHaveLength(1);
expect(inserted.edges).toHaveLength(0);
});
it("round-trips a code node config (source + timeoutMs)", () => {
const codeIr: WorkflowDefinition["ir"] = {
version: "v1",

View File

@@ -9,7 +9,8 @@ import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
* step-inversion additions (KTD-3/4/12/15): "foreach" (runtime-expanding
* per-step template region, rendered as a React Flow group), "step-review"
* (per-step review verdicts as outcome edges), "parse-steps" (graph-native
* step-list parsing), and "code" (sandboxed TypeScript). */
* step-list parsing), "loop" (bounded repeated template region), and "code"
* (sandboxed TypeScript). */
export type WorkflowEditorNodeKind =
| "start"
| "end"
@@ -21,6 +22,7 @@ export type WorkflowEditorNodeKind =
| "split"
| "join"
| "foreach"
| "loop"
| "step-review"
| "parse-steps"
| "code";
@@ -37,10 +39,10 @@ export interface WorkflowFlowNodeData {
/** When true, render the shared error-state badge on the node (unplaced node
* or seam-in-branch). Set by the editor from validation. */
errorBadge?: string;
/** foreach group only: true when it has no template children (deletion can
/** template group only: true when it has no template children (deletion can
* empty it even though the palette auto-populates one). */
templateEmpty?: boolean;
/** foreach group only: the localized empty-state hint string. */
/** template group only: the localized empty-state hint string. */
emptyHint?: string;
[key: string]: unknown;
}
@@ -56,6 +58,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
split: Split,
join: Merge,
foreach: Repeat,
loop: Repeat,
"step-review": ClipboardCheck,
"parse-steps": ListChecks,
code: Code2,
@@ -158,6 +161,35 @@ function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
);
}
function LoopGroupNode({ data }: { data: WorkflowFlowNodeData }) {
const maxIterations = data.config?.maxIterations as number | undefined;
const timeoutMs = data.config?.timeoutMs as number | undefined;
const isEmpty = data.templateEmpty === true;
return (
<div
className={`wf-foreach-group wf-loop-group${data.errorBadge ? " wf-node--error" : ""}`}
data-testid="wf-node-loop"
>
<Handle type="target" position={Position.Left} />
<div className="wf-foreach-header">
<span className="wf-node-icon">
<Repeat size={14} aria-hidden />
</span>
<span className="wf-node-label">{data.label || "loop"}</span>
<span className="wf-node-badge">{maxIterations ?? 3}x</span>
{timeoutMs != null && <span className="wf-node-badge">{timeoutMs}ms</span>}
</div>
{isEmpty && (
<div className="wf-foreach-empty" data-testid="wf-loop-empty">
{data.emptyHint || "Drag loop steps here"}
</div>
)}
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
<Handle type="source" position={Position.Right} />
</div>
);
}
export const workflowNodeTypes = {
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
@@ -169,6 +201,7 @@ export const workflowNodeTypes = {
split: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="split" />,
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />,
loop: ({ data }: NodeProps) => <LoopGroupNode data={data as WorkflowFlowNodeData} />,
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,

View File

@@ -148,6 +148,15 @@ describe("nodeConfigSummary", () => {
expect(nodeConfigSummary(node("foreach", { mode: "sequential" }))).toBe("sequential · shared");
});
it("loop node → exit condition and iteration budget", () => {
expect(
nodeConfigSummary(node("loop", { exitWhen: { type: "output-contains", value: "DONE" }, maxIterations: 5 })),
).toBe('until contains "DONE" · 5x');
expect(
nodeConfigSummary(node("loop", { exitWhen: { type: "output-matches", pattern: "READY-\\d+" } })),
).toBe("until matches /READY-\\d+/ · 3x");
});
it("step-review node → review type", () => {
const summary = nodeConfigSummary(node("step-review", { type: "design" }));
expect(summary).toBe("design review");

View File

@@ -158,6 +158,32 @@ export function nodeConfigSummary(
const isolation = str(config.isolation) || (mode === "parallel" ? "worktree" : "shared");
return `${mode} · ${isolation}`;
}
case "loop": {
const exitWhen = config.exitWhen as unknown;
const exit =
exitWhen && typeof exitWhen === "object"
? (() => {
const condition = exitWhen as Record<string, unknown>;
const type = str(condition.type);
if (type === "output-matches") {
return t("workflowNodes.summaryLoopUntilMatches", "until matches /{{pattern}}/", {
pattern: str(condition.pattern),
});
}
if (type === "output-contains") {
return t('workflowNodes.summaryLoopUntilContains', 'until contains "{{value}}"', {
value: str(condition.value),
});
}
return "";
})()
: "";
const maxIterations =
typeof config.maxIterations === "number" && Number.isFinite(config.maxIterations)
? t("workflowNodes.summaryLoopIterations", "{{count}}x", { count: config.maxIterations })
: t("workflowNodes.summaryLoopIterations", "{{count}}x", { count: 3 });
return exit ? `${exit} · ${maxIterations}` : maxIterations;
}
case "step-review": {
const reviewType = str(config.type) || "code";
return t("workflowNodes.summaryReviewType", "{{type}} review", { type: reviewType });

View File

@@ -23,6 +23,19 @@ interface WorkflowForeachConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
}
interface WorkflowLoopConfig {
maxIterations?: number;
timeoutMs?: number;
exitWhen?: {
type: "output-contains" | "output-matches";
nodeId?: string;
value?: string;
pattern?: string;
flags?: string;
};
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
}
// WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape
// can migrate; callers should prefer WorkflowFieldDefinition directly.
@@ -159,6 +172,19 @@ function foreachConfigOf(node: WorkflowIrNode): WorkflowForeachConfig | undefine
return cfg as WorkflowForeachConfig;
}
function loopConfigOf(node: WorkflowIrNode): WorkflowLoopConfig | undefined {
if (node.kind !== "loop") return undefined;
const cfg = node.config as Partial<WorkflowLoopConfig> | undefined;
if (!cfg || !cfg.template) return undefined;
return cfg as WorkflowLoopConfig;
}
function groupTemplateConfigOf(
node: WorkflowIrNode,
): WorkflowForeachConfig | WorkflowLoopConfig | undefined {
return foreachConfigOf(node) ?? loopConfigOf(node);
}
/** CSS class for an edge given its condition + rework kind. Rework takes
* precedence; failure edges get the distinct failure styling; success and other
* conditions get no class (default styling). R2's two-channel rule (label always
@@ -222,9 +248,9 @@ export function irToFlow(def: WorkflowDefinition): {
// layout exists; otherwise we honor the saved absolute position.
const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120;
const foreachCfg = foreachConfigOf(node);
if (foreachCfg) {
const template = foreachCfg.template;
const groupCfg = groupTemplateConfigOf(node);
if (groupCfg) {
const template = groupCfg.template;
// Render template nodes as children of this group (parentId = group id).
template.nodes.forEach((inner, innerIdx) => {
const childFlowId = foreachChildFlowId(node.id, inner.id);
@@ -252,10 +278,10 @@ export function irToFlow(def: WorkflowDefinition): {
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return {
id: node.id,
type: "foreach",
type: kind,
position: pos ?? { x: 80 + index * 180, y: fallbackY },
data: {
kind: "foreach",
kind,
label: nodeLabel(node),
config: { ...restCfg },
column,
@@ -329,7 +355,9 @@ export function flowToIr(
childrenByGroup.set(n.parentId, arr);
}
}
const groupIds = new Set(topNodes.filter((n) => n.data.kind === "foreach").map((n) => n.id));
const groupIds = new Set(
topNodes.filter((n) => n.data.kind === "foreach" || n.data.kind === "loop").map((n) => n.id),
);
const hasFields = Array.isArray(fields) && fields.length > 0;
const hasSettings = Array.isArray(settings) && settings.length > 0;
// Fields and settings are v2-only declarations: a workflow with either but no
@@ -344,7 +372,7 @@ export function flowToIr(
if (data.kind === "merge") {
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
}
if (data.kind === "foreach") {
if (data.kind === "foreach" || data.kind === "loop") {
// Reassemble the template from this group's children.
const children = childrenByGroup.get(node.id) ?? [];
const templateNodes: WorkflowIrNode[] = children.map((c) => {
@@ -359,7 +387,7 @@ export function flowToIr(
const baseCfg = (config ?? {}) as Record<string, unknown>;
return {
id: localId,
kind: "foreach",
kind: data.kind,
config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } },
};
}
@@ -466,7 +494,7 @@ function isProtectedFromDelete(node: FlowNode<WorkflowFlowNodeData>): boolean {
* Delete the requested node and/or edge ids from the flow graph, applying R6's
* cascade rules:
* - Deleting a node removes ALL edges incident to it (no auto-bridging).
* - Deleting a `foreach` group node also deletes its template children
* - Deleting a `foreach`/`loop` group node also deletes its template children
* (nodes with `parentId === groupId`) and every edge incident to those
* children (React Flow does not cascade parents — handled explicitly).
* - `start`/`end` nodes and column band nodes are never deleted: they are
@@ -484,14 +512,14 @@ export function cascadeDelete(
const requested = new Set(ids);
const nodeById = new Map(nodes.map((n) => [n.id, n]));
// Resolve which node ids are actually deletable, expanding foreach groups to
// Resolve which node ids are actually deletable, expanding template groups to
// their template children. Protected nodes are dropped from the request.
const deleteNodeIds = new Set<string>();
for (const id of requested) {
const node = nodeById.get(id);
if (!node || isProtectedFromDelete(node)) continue;
deleteNodeIds.add(id);
if (node.data.kind === "foreach") {
if (node.data.kind === "foreach" || node.data.kind === "loop") {
for (const child of nodes) {
if (child.parentId === id) deleteNodeIds.add(child.id);
}
@@ -518,7 +546,7 @@ export function cascadeDelete(
/** Editor node kinds whose edges expose a success/failure condition select
* (KTD-2). step-review uses verdict controls; all other kinds are read-only. */
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach"]);
const CONDITION_EDITABLE_KINDS = new Set<string>(["prompt", "script", "gate", "code", "foreach", "loop"]);
/** Decide what the edge inspector renders for an edge sourced from `sourceKind`:
* - "verdicts": step-review verdict select + rework checkbox (existing);
@@ -899,7 +927,7 @@ export function fragmentSeamConflicts(
/** Build a React Flow node from a single IR node at an absolute position — the
* same mapping irToFlow applies (kind→type via editorKind, data {kind,label,
* config}, deletable). foreach template bodies are remapped by the caller; this
* config}, deletable). Template group bodies are remapped by the caller; this
* carries config (including any template) through verbatim. */
function irNodeToFlowNode(
node: WorkflowIrNode,
@@ -958,8 +986,8 @@ export function insertFragment(
const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0;
const insertedNodeIds: string[] = [];
// foreach template children are expanded into parented child flow nodes (the
// same way irToFlow does), so an inserted foreach round-trips its full template
// Template group children are expanded into parented child flow nodes (the
// same way irToFlow does), so an inserted group round-trips its full template
// through flowToIr instead of dropping config.template (which flowToIr would
// otherwise rebuild as an empty template from the absent children).
const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
@@ -971,15 +999,21 @@ export function insertFragment(
const pos = fromLayout
? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) }
: { x: position.x + index * 180, y: position.y };
const foreachCfg = foreachConfigOf(node);
if (foreachCfg) {
const template = foreachCfg.template;
const groupCfg = groupTemplateConfigOf(node);
if (groupCfg) {
const template = groupCfg.template;
const groupKind = editorKind(node);
template.nodes.forEach((inner, innerIdx) => {
const innerKind = editorKind(inner);
const childPos =
layout?.[foreachChildFlowId(node.id, inner.id)] ?? {
x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
y: FOREACH_CHILD_Y,
};
childNodes.push({
id: foreachChildFlowId(id, inner.id),
type: innerKind,
position: { x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X, y: FOREACH_CHILD_Y },
position: childPos,
parentId: id,
extent: "parent",
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
@@ -993,10 +1027,10 @@ export function insertFragment(
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return {
id,
type: "foreach",
type: groupKind,
position: pos,
data: {
kind: "foreach",
kind: groupKind,
label: nodeLabel(node),
config: { ...restCfg },
templateEmpty: template.nodes.length === 0,
@@ -1031,11 +1065,11 @@ export function insertFragment(
};
}
/** Remap a foreach template's internal node ids + edges to fresh ids. Returns a
/** Remap a template group's internal node ids + edges to fresh ids. Returns a
* new template object; the original is untouched. Template-local ids are scoped
* to the template, so a fresh local id space suffices (and keeps config compact
* rather than reusing global ids). */
function copyForeachTemplate(
function copyGroupTemplate(
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] },
innerMap: Map<string, string>,
): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
@@ -1049,9 +1083,9 @@ function copyForeachTemplate(
return { nodes, edges };
}
/** Deep-ish copy of an IR node under a new id, recursing into a foreach
/** Deep-ish copy of an IR node under a new id, recursing into a template group
* template's internal node references so they remain self-consistent. When the
* node is a foreach, its template-local id remap is recorded in `templateMaps`
* node is a template group, its template-local id remap is recorded in `templateMaps`
* keyed by the node's ORIGINAL id, so the caller can remap namespaced
* `${groupId}::${templateNodeId}` layout keys consistently. */
function copyIrNode(
@@ -1060,10 +1094,10 @@ function copyIrNode(
templateMaps?: Map<string, Map<string, string>>,
): WorkflowIrNode {
const config = node.config ? { ...node.config } : undefined;
const foreach = foreachConfigOf(node);
if (foreach && config) {
const group = groupTemplateConfigOf(node);
if (group && config) {
const innerMap = new Map<string, string>();
config.template = copyForeachTemplate(foreach.template, innerMap);
config.template = copyGroupTemplate(group.template, innerMap);
templateMaps?.set(node.id, innerMap);
}
const copy: WorkflowIrNode = { id: newId, kind: node.kind };
@@ -1076,7 +1110,7 @@ function copyIrNode(
* Full-graph copy with fresh ids (R7): every top-level node id is remapped to a
* fresh id, edges are rewired, and the layout map's keys are remapped to match.
* v2 columns/fields/artifacts are preserved untouched (they hold no node id
* references). foreach template bodies have their internal node ids + edges
* references). Template group bodies have their internal node ids + edges
* remapped consistently too. Returns a NEW ir + layout; inputs are not mutated.
*/
export function copyIrWithFreshIds(
@@ -1086,7 +1120,7 @@ export function copyIrWithFreshIds(
const idMap = new Map<string, string>();
for (const n of ir.nodes) idMap.set(n.id, newNodeId());
// Per foreach group (by ORIGINAL group id): its template-local id remap, so
// Per template group (by ORIGINAL group id): its template-local id remap, so
// namespaced layout keys `${groupId}::${templateNodeId}` can be remapped to
// `${newGroupId}::${newTemplateNodeId}` consistently.
const templateMaps = new Map<string, Map<string, string>>();

View File

@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
const task = { id: "FN-LOOP" } as TaskDetail;
function loopIr(config: Record<string, unknown>, extraEdges: WorkflowIr["edges"] = []): WorkflowIr {
return {
version: "v2",
name: "loop-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "loop",
kind: "loop",
config: {
template: {
nodes: [
{ id: "ask", kind: "prompt", config: { prompt: "try" } },
{ id: "check", kind: "gate", config: { prompt: "done?" } },
],
edges: [{ from: "ask", to: "check" }],
},
...config,
},
},
{ id: "exhausted", kind: "hold", config: { release: "manual" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "loop" },
{ from: "loop", to: "end", condition: "success" },
...extraEdges,
],
};
}
describe("WorkflowGraphExecutor loop", () => {
it("exits successfully when the template output matches immediately", async () => {
const calls: string[] = [];
const prompt: WorkflowNodeHandler = async (node) => {
calls.push(node.id);
return { outcome: "success", value: node.id === "check" ? "DONE" : "working" };
};
const executor = new WorkflowGraphExecutor({ handlers: { prompt, gate: prompt } });
const result = await executor.run(
task,
settingsOn(),
loopIr({ maxIterations: 3, exitWhen: { type: "output-contains", value: "DONE" } }),
);
expect(result.outcome).toBe("success");
expect(calls).toEqual(["ask", "check"]);
expect(result.visitedNodeIds).toEqual(expect.arrayContaining(["loop", "loop#1:ask", "loop#1:check"]));
expect(result.context["node:loop:loop"]).toMatchObject({ iterations: 1, exitReason: "matched" });
expect(result.context["loop:active"]).toBeUndefined();
});
it("keeps iterating until the configured output string appears", async () => {
let checks = 0;
const handler: WorkflowNodeHandler = async (node) => {
if (node.id !== "check") return { outcome: "success", value: "working" };
checks += 1;
return { outcome: "success", value: checks === 3 ? "DONE" : "KEEP_GOING" };
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler, gate: handler, hold: async () => ({ outcome: "success" }) },
});
const result = await executor.run(
task,
settingsOn(),
loopIr({ maxIterations: 4, exitWhen: { type: "output-contains", value: "DONE" } }),
);
expect(result.outcome).toBe("success");
expect(checks).toBe(3);
expect(result.context["node:loop:loop"]).toMatchObject({ iterations: 3, exitReason: "matched" });
expect(result.context["node:check:value"]).toBe("DONE");
});
it("routes iteration exhaustion as a failure outcome value", async () => {
const handler = vi.fn(async () => ({ outcome: "success" as const, value: "not yet" }));
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler, gate: handler, hold: async () => ({ outcome: "success" }) },
});
const result = await executor.run(
task,
settingsOn(),
loopIr(
{ maxIterations: 2, exitWhen: { type: "output-contains", value: "DONE" } },
[{ from: "loop", to: "exhausted", condition: "outcome:loop-iteration-exhausted" }],
),
);
expect(result.outcome).toBe("success");
expect(handler).toHaveBeenCalledTimes(4);
expect(result.context["node:loop:outcome"]).toBe("failure");
expect(result.context["node:loop:value"]).toBe("loop-iteration-exhausted");
});
it("routes timeout as a failure outcome value", async () => {
let now = 0;
const handler: WorkflowNodeHandler = async () => {
now += 10;
return { outcome: "success", value: "not yet" };
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: handler, gate: handler, hold: async () => ({ outcome: "success" }) },
runLoopNowForTests: () => now,
});
const result = await executor.run(
task,
settingsOn(),
loopIr(
{ maxIterations: 10, timeoutMs: 15, exitWhen: { type: "output-contains", value: "DONE" } },
[{ from: "loop", to: "exhausted", condition: "outcome:loop-timeout" }],
),
);
expect(result.outcome).toBe("success");
expect(result.context["node:loop:value"]).toBe("loop-timeout");
});
it("can match a regex against a selected template node value", async () => {
const handler: WorkflowNodeHandler = async (node: WorkflowIrNode) => ({
outcome: "success",
value: node.id === "ask" ? "ticket READY-42" : "ignored",
});
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler, gate: handler } });
const result = await executor.run(
task,
settingsOn(),
loopIr({
maxIterations: 2,
exitWhen: { type: "output-matches", nodeId: "ask", pattern: "READY-\\d+" },
}),
);
expect(result.outcome).toBe("success");
expect(result.context["node:loop:loop"]).toMatchObject({ exitReason: "matched" });
});
});

View File

@@ -25,6 +25,7 @@ import {
type ForeachEnvironment,
type WorkflowStepInstancePersistence,
} from "./workflow-graph-foreach.js";
import { runLoop } from "./workflow-graph-loop.js";
export type WorkflowNodeOutcome = "success" | "failure";
@@ -70,6 +71,8 @@ export interface WorkflowGraphExecutorDeps {
onBranchProgress?: (progress: WorkflowBranchProgress) => void;
/** Stable identifier for this run, used to key persisted branch state. */
runId?: string;
/** Test seam for bounded loop timeout checks. Defaults to Date.now. */
runLoopNowForTests?: () => number;
/**
* Step-inversion (KTD-3, U3): fresh `Task.steps[]` accessor used by a `foreach`
* node at expansion time. Defaults to reading `task.steps` off the run's task.
@@ -338,6 +341,25 @@ export class WorkflowGraphExecutor {
return await traverseChildren(node, result);
}
if (node.kind === "loop") {
const loopResult = await runLoop(node, {
context,
runTemplateNode: (tNode, sig, contextOverride) =>
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
signal: this.deps.signal,
now: this.deps.runLoopNowForTests,
});
visitedNodeIds.push(...loopResult.visitedNodeIds);
const result: WorkflowNodeResult = {
outcome: loopResult.outcome,
value: loopResult.value,
};
context[`node:${node.id}:outcome`] = result.outcome;
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
return await traverseChildren(node, result);
}
const result = await this.executeNodeWithRetries(node, task, settings, context, ir);
if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome;

View File

@@ -0,0 +1,210 @@
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig } from "@fusion/core";
import { WorkflowIrError } from "@fusion/core";
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
const DEFAULT_MAX_ITERATIONS = 3;
const MAX_ITERATIONS_CAP = 50;
const DEFAULT_TIMEOUT_MS = 300_000;
const MAX_TIMEOUT_MS = 3_600_000;
interface LoopConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
exitWhen: WorkflowLoopConfig["exitWhen"];
exitRegex?: RegExp;
maxIterations: number;
timeoutMs: number;
}
export interface LoopEnvironment {
context: Record<string, unknown>;
runTemplateNode: (
node: WorkflowIrNode,
signal?: AbortSignal,
contextOverride?: Record<string, unknown>,
) => Promise<WorkflowNodeResult>;
shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean;
signal?: AbortSignal;
now?: () => number;
}
export interface LoopRunResult {
outcome: WorkflowNodeOutcome;
value?: string;
visitedNodeIds: string[];
}
function resolveLoopConfig(node: WorkflowIrNode): LoopConfig {
const cfg = (node.config ?? {}) as Partial<WorkflowLoopConfig>;
if (!cfg.template || !Array.isArray(cfg.template.nodes) || !Array.isArray(cfg.template.edges)) {
throw new WorkflowIrError(`loop node '${node.id}' has no template subgraph`);
}
if (!cfg.exitWhen) {
throw new WorkflowIrError(`loop node '${node.id}' has no exitWhen condition`);
}
const maxIterations =
typeof cfg.maxIterations === "number" && Number.isFinite(cfg.maxIterations)
? Math.max(1, Math.min(MAX_ITERATIONS_CAP, Math.floor(cfg.maxIterations)))
: DEFAULT_MAX_ITERATIONS;
const timeoutMs =
typeof cfg.timeoutMs === "number" && Number.isFinite(cfg.timeoutMs)
? Math.max(1, Math.min(MAX_TIMEOUT_MS, Math.floor(cfg.timeoutMs)))
: DEFAULT_TIMEOUT_MS;
const exitRegex =
cfg.exitWhen.type === "output-matches" ? new RegExp(cfg.exitWhen.pattern, cfg.exitWhen.flags) : undefined;
return {
template: cfg.template,
exitWhen: cfg.exitWhen,
exitRegex,
maxIterations,
timeoutMs,
};
}
function buildOutgoing(edges: WorkflowIrEdge[]): Map<string, WorkflowIrEdge[]> {
const outgoing = new Map<string, WorkflowIrEdge[]>();
for (const edge of edges) {
const list = outgoing.get(edge.from) ?? [];
list.push(edge);
outgoing.set(edge.from, list);
}
return outgoing;
}
function findTemplateEntry(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[], loopId: string): WorkflowIrNode {
const incoming = new Map<string, number>();
for (const edge of edges) incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
const entries = nodes.filter((n) => (incoming.get(n.id) ?? 0) === 0);
if (entries.length !== 1) {
throw new WorkflowIrError(`loop node '${loopId}' template must have exactly one entry node`);
}
return entries[0];
}
function exitNodeId(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[], loopId: string): string {
const outgoing = new Map<string, number>();
for (const edge of edges) outgoing.set(edge.from, (outgoing.get(edge.from) ?? 0) + 1);
const exits = nodes.filter((n) => (outgoing.get(n.id) ?? 0) === 0);
if (exits.length !== 1) {
throw new WorkflowIrError(`loop node '${loopId}' template must have exactly one exit node`);
}
return exits[0].id;
}
function matchesExit(config: LoopConfig, value: unknown): boolean {
const text = typeof value === "string" ? value : value == null ? "" : String(value);
const condition = config.exitWhen;
if (condition.type === "output-contains") {
return text.includes(condition.value);
}
return (config.exitRegex ?? new RegExp(condition.pattern, condition.flags)).test(text);
}
function publishIterationContext(
target: Record<string, unknown>,
iterationContext: Record<string, unknown>,
): void {
const { ["loop:active"]: _active, ...publicContext } = iterationContext;
Object.assign(target, publicContext);
}
export async function runLoop(
loopNode: WorkflowIrNode,
env: LoopEnvironment,
): Promise<LoopRunResult> {
const config = resolveLoopConfig(loopNode);
const templateById = new Map(config.template.nodes.map((n) => [n.id, n]));
const outgoing = buildOutgoing(config.template.edges);
const entry = findTemplateEntry(config.template.nodes, config.template.edges, loopNode.id);
const defaultExitNodeId = exitNodeId(config.template.nodes, config.template.edges, loopNode.id);
const sourceNodeId = config.exitWhen.nodeId ?? defaultExitNodeId;
const now = env.now ?? (() => Date.now());
const deadline = now() + config.timeoutMs;
const visitedNodeIds: string[] = [];
const iterationSummaries: Array<{ iteration: number; outcome: string; value?: string }> = [];
for (let iteration = 1; iteration <= config.maxIterations; iteration++) {
if (env.signal?.aborted) {
return { outcome: "failure", value: "aborted", visitedNodeIds };
}
if (now() >= deadline) {
env.context[`node:${loopNode.id}:loop`] = {
iterations: iteration - 1,
exitReason: "timeout",
history: iterationSummaries,
};
return { outcome: "failure", value: "loop-timeout", visitedNodeIds };
}
const iterationContext: Record<string, unknown> = {
...env.context,
"loop:active": {
loopNodeId: loopNode.id,
iteration,
},
};
let current: WorkflowIrNode | undefined = entry;
let lastResult: WorkflowNodeResult = { outcome: "success" };
while (current) {
if (env.signal?.aborted) {
return { outcome: "failure", value: "aborted", visitedNodeIds };
}
if (now() >= deadline) {
env.context[`node:${loopNode.id}:loop`] = {
iterations: iteration - 1,
exitReason: "timeout",
history: iterationSummaries,
};
return { outcome: "failure", value: "loop-timeout", visitedNodeIds };
}
const materializedId = `${loopNode.id}#${iteration}:${current.id}`;
visitedNodeIds.push(materializedId);
lastResult = await env.runTemplateNode(current, env.signal, iterationContext);
if (lastResult.contextPatch) Object.assign(iterationContext, lastResult.contextPatch);
iterationContext[`node:${current.id}:outcome`] = lastResult.outcome;
if (lastResult.value !== undefined) iterationContext[`node:${current.id}:value`] = lastResult.value;
if (lastResult.outcome === "failure") {
publishIterationContext(env.context, iterationContext);
env.context[`node:${loopNode.id}:loop`] = {
iterations: iteration,
exitReason: "node-failure",
history: iterationSummaries,
};
return { outcome: "failure", value: lastResult.value, visitedNodeIds };
}
const edges: WorkflowIrEdge[] = outgoing.get(current.id) ?? [];
const matching: WorkflowIrEdge[] = edges.filter((edge: WorkflowIrEdge) =>
env.shouldTraverseEdge(edge, lastResult),
);
current = matching.length > 0 ? templateById.get(matching[0].to) : undefined;
}
const sourceValue = iterationContext[`node:${sourceNodeId}:value`];
const finalValue = sourceValue ?? lastResult.value;
iterationSummaries.push({
iteration,
outcome: lastResult.outcome,
...(finalValue !== undefined ? { value: String(finalValue) } : {}),
});
publishIterationContext(env.context, iterationContext);
if (matchesExit(config, finalValue)) {
env.context[`node:${loopNode.id}:loop`] = {
iterations: iteration,
exitReason: "matched",
finalValue,
history: iterationSummaries,
};
return { outcome: "success", visitedNodeIds };
}
}
env.context[`node:${loopNode.id}:loop`] = {
iterations: config.maxIterations,
exitReason: "iteration-exhausted",
history: iterationSummaries,
};
return { outcome: "failure", value: "loop-iteration-exhausted", visitedNodeIds };
}

View File

@@ -155,6 +155,8 @@ export type {
WorkflowColumnAgent,
// Foreach / artifacts / custom fields (step inversion).
WorkflowForeachConfig,
WorkflowLoopConfig,
WorkflowLoopExitCondition,
WorkflowIrArtifact,
WorkflowFieldDefinition,
WorkflowFieldType,