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 (
|
||||
|
||||
@@ -180,6 +180,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: "" } },
|
||||
@@ -229,6 +230,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
|
||||
"split",
|
||||
"join",
|
||||
"foreach",
|
||||
"loop",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"merge",
|
||||
@@ -1197,19 +1199,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,
|
||||
},
|
||||
@@ -1221,8 +1228,8 @@ function InnerEditor({
|
||||
extent: "parent",
|
||||
data: {
|
||||
kind: "prompt",
|
||||
label: t("workflowNodes.stepExecuteLabel", "Step execute"),
|
||||
config: { seam: "step-execute" },
|
||||
label: childLabel,
|
||||
config: childConfig,
|
||||
},
|
||||
deletable: true,
|
||||
},
|
||||
@@ -1754,21 +1761,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 {
|
||||
@@ -1776,7 +1787,7 @@ function InnerEditor({
|
||||
data: {
|
||||
...n.data,
|
||||
errorBadge,
|
||||
...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}),
|
||||
...(isTemplateGroup ? { templateEmpty, emptyHint } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -3166,6 +3177,178 @@ 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 ?? "");
|
||||
return {
|
||||
...prev,
|
||||
exitWhen:
|
||||
nextType === "output-matches"
|
||||
? { type: nextType, pattern: text }
|
||||
: { type: nextType, 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">
|
||||
|
||||
@@ -412,6 +412,96 @@ 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 });
|
||||
const group = inserted.nodes.find((n) => n.data.kind === "loop");
|
||||
|
||||
expect(group).toBeTruthy();
|
||||
expect(group?.type).toBe("loop");
|
||||
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",
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -158,6 +158,24 @@ 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 `until matches /${str(condition.pattern)}/`;
|
||||
if (type === "output-contains") return `until contains "${str(condition.value)}"`;
|
||||
return "";
|
||||
})()
|
||||
: "";
|
||||
const maxIterations =
|
||||
typeof config.maxIterations === "number" && Number.isFinite(config.maxIterations)
|
||||
? `${config.maxIterations}x`
|
||||
: "3x";
|
||||
return exit ? `${exit} · ${maxIterations}` : maxIterations;
|
||||
}
|
||||
case "step-review": {
|
||||
const reviewType = str(config.type) || "code";
|
||||
return t("workflowNodes.summaryReviewType", "{{type}} review", { type: reviewType });
|
||||
|
||||
@@ -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,9 +999,10 @@ 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);
|
||||
childNodes.push({
|
||||
@@ -993,10 +1022,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 +1060,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 +1078,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 +1089,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 +1105,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 +1115,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>>();
|
||||
|
||||
150
packages/engine/src/__tests__/workflow-graph-loop.test.ts
Normal file
150
packages/engine/src/__tests__/workflow-graph-loop.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
205
packages/engine/src/workflow-graph-loop.ts
Normal file
205
packages/engine/src/workflow-graph-loop.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
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"];
|
||||
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;
|
||||
return {
|
||||
template: cfg.template,
|
||||
exitWhen: cfg.exitWhen,
|
||||
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(condition: WorkflowLoopConfig["exitWhen"], value: unknown): boolean {
|
||||
const text = typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
if (condition.type === "output-contains") {
|
||||
return text.includes(condition.value);
|
||||
}
|
||||
return 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.exitWhen, 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 };
|
||||
}
|
||||
@@ -155,6 +155,8 @@ export type {
|
||||
WorkflowColumnAgent,
|
||||
// Foreach / artifacts / custom fields (step inversion).
|
||||
WorkflowForeachConfig,
|
||||
WorkflowLoopConfig,
|
||||
WorkflowLoopExitCondition,
|
||||
WorkflowIrArtifact,
|
||||
WorkflowFieldDefinition,
|
||||
WorkflowFieldType,
|
||||
|
||||
Reference in New Issue
Block a user