feat(FN-0000): add workflow loop nodes

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

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add first-class workflow loop nodes with bounded template repetition, exit conditions, editor support, and plugin SDK type exports.

View File

@@ -0,0 +1,174 @@
---
title: Workflow Loop Nodes
type: feat
status: active
date: 2026-06-08
---
# Workflow Loop Nodes
## Summary
Add a first-class `loop` workflow node that repeats an inline node sequence until a configured exit condition is met or a configured budget expires. The loop should be available through the shared workflow IR, graph executor, editor, docs, plugin SDK exports, and published package bundle so extension workflows can use it without engine forks.
---
## Problem Frame
Workflow IR currently supports bounded corrective loops through `kind: "rework"` edges and per-step iteration through `foreach`. Those mechanisms are not a general workflow loop: authors cannot model "run these nodes until the agent says a stop token" or "retry this agentic sequence for at most N iterations or M milliseconds" without abusing rework semantics or loosening the global cycle guard. A dedicated loop node preserves the graph's acyclic safety while making bounded repeat-until behavior explicit and inspectable.
---
## Requirements
**Loop contract**
- R1. Workflow IR accepts `kind: "loop"` with an inline template graph that has exactly one entry and one exit.
- R2. A loop exits successfully when a configured agent-output string or regex matches the selected node output.
- R3. A loop exits with a routable budget outcome when `maxIterations` or `timeoutMs` is reached before the exit condition matches.
- R4. Loop execution records iteration count, exit reason, final value, and per-iteration node outcomes in workflow context for downstream routing and diagnostics.
**Safety and compatibility**
- R5. General non-rework cycles remain illegal; loop repetition is implemented inside the loop node's bounded sub-walk, not by allowing top-level cyclic edges.
- R6. Loop templates reject unsafe nesting and invalid placement in the same parse-time style as `foreach` templates.
- R7. Existing workflows, built-ins, and v1 upgrade/downgrade behavior remain unchanged unless they explicitly use `kind: "loop"`.
- R8. The public plugin SDK exports the loop node types so extension-authored workflows can declare loop nodes through the same package contract as core workflows.
**Authoring and docs**
- R9. The workflow editor can render, round-trip, copy, delete, and configure loop groups without dropping template nodes or loop settings.
- R10. Documentation explains loop semantics, exit conditions, budgets, routable outcomes, and the distinction between `loop`, `foreach`, and `rework`.
- R11. Because the published CLI package bundles the shared engine and dashboard, the release includes a changeset for `@runfusion/fusion`.
---
## High-Level Technical Design
```mermaid
flowchart TB
A[Top-level graph reaches loop node] --> B[Initialize loop state]
B --> C[Run template entry to template exit]
C --> D{Exit condition matched?}
D -->|yes| E[Emit success with exit reason matched]
D -->|no| F{Budget exhausted?}
F -->|no| G[Increment iteration and rerun template]
G --> C
F -->|iteration cap| H[Emit failure value loop-iteration-exhausted]
F -->|time cap| I[Emit failure value loop-timeout]
E --> J[Route loop outgoing edge]
H --> J
I --> J
```
The loop template should use the same "group node with inline subgraph" shape as `foreach`, but it is not step-source driven. Runtime expansion is one loop instance with repeated sub-walks, not one instance per planned task step.
---
## Key Technical Decisions
- KTD1. Add a distinct `loop` node kind instead of overloading `foreach`: `foreach` binds to a collection source and per-item state, while `loop` binds to a termination policy and a single repeated region.
- KTD2. Keep top-level graph cycle validation strict: the executor repeats loop templates internally, so normal edges still form an acyclic graph except existing `rework` edges.
- KTD3. Model exit conditions as explicit config: `exitWhen` should support at least `{ type: "output-contains", nodeId?, value }` and `{ type: "output-matches", nodeId?, pattern }`, with the default source being the template exit node's `value`.
- KTD4. Treat budget exhaustion as routable failure values: `loop-iteration-exhausted` and `loop-timeout` let authors park, escalate, or fail using existing `outcome:<value>` edges.
- KTD5. Reuse the editor's group-node mechanics: loop authoring should mirror `foreach` template rendering and round-trip behavior rather than creating a second bespoke canvas model.
- KTD6. Export additive types through `@fusion/core` and `@fusion/plugin-sdk`: extension packages should consume the loop contract from the shared package rather than relying on local structural copies.
---
## Implementation Units
### U1. Core IR Types and Validation
- **Goal:** Add the `loop` node contract to shared workflow IR and validate loop templates at parse time.
- **Files:** `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/index.ts`, `packages/plugin-sdk/src/index.ts`
- **Patterns:** Follow `WorkflowForeachConfig`, `validateForeach`, `validateNoIllegalCycles`, `serializeWorkflowIr`, and extension metadata exports.
- **Test Scenarios:** Add `packages/core/src/__tests__/workflow-ir-loop.test.ts` covering valid loop parsing, duplicate template node rejection, external template edge rejection, nested loop/foreach policy, invalid exit condition config, max iteration bounds, timeout bounds, serialization round-trip, and illegal top-level cycles still rejected.
- **Verification:** `pnpm --filter @fusion/core exec vitest run src/__tests__/workflow-ir-loop.test.ts --silent=passed-only --reporter=dot`
### U2. Loop Runtime Execution
- **Goal:** Implement loop-node execution in the workflow graph executor with bounded repeat-until semantics.
- **Files:** `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/workflow-graph-loop.ts`
- **Patterns:** Follow the extracted sub-walk style in `packages/engine/src/workflow-graph-foreach.ts` and the top-level rework budget handling in `packages/engine/src/workflow-graph-executor.ts`.
- **Test Scenarios:** Add `packages/engine/src/__tests__/workflow-graph-loop.test.ts` covering immediate match, match after multiple iterations, iteration exhaustion, timeout exhaustion with fake timers or injected clock, context patch propagation between iterations, selected `nodeId` output source, and unchanged behavior for non-loop graphs.
- **Verification:** `pnpm --filter @fusion/engine exec vitest run src/__tests__/workflow-graph-loop.test.ts --silent=passed-only --reporter=dot`
### U3. Routing and Context Semantics
- **Goal:** Define how loop outcomes and context keys participate in existing edge routing.
- **Files:** `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/__tests__/workflow-graph-executor-parity.test.ts`
- **Patterns:** Follow `shouldTraverseEdge`, `node:<id>:outcome`, `node:<id>:value`, and foreach context recording.
- **Test Scenarios:** Cover `condition: "success"` after a matched loop, `condition: "outcome:loop-iteration-exhausted"`, `condition: "outcome:loop-timeout"`, and failure propagation when a template node fails before the exit condition can be evaluated.
- **Verification:** Include these cases in the loop runtime focused test or a small executor parity test update.
### U4. Workflow Editor Loop Authoring
- **Goal:** Add loop group rendering, palette entry, inspector controls, flow-to-IR round-trip, delete/copy behavior, and node summary text.
- **Files:** `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/components/workflow-flow-mapping.ts`, `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx`, `packages/dashboard/app/components/nodes/node-summary.ts`, `packages/dashboard/app/components/WorkflowNodeEditor.css`
- **Patterns:** Follow existing `foreach` group node rendering, `foreachChildFlowId`, template remapping, `cascadeDelete`, fragment insertion, and inspector field patterns.
- **Test Scenarios:** Extend `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts`, `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx`, and `packages/dashboard/app/components/nodes/__tests__/node-summary.test.ts` for palette insertion, config editing, template child preservation, copy/remap, delete cascade, condition editability, and summary display.
- **Verification:** Run the focused dashboard tests named above.
### U5. Built-In Documentation and Authoring Reference
- **Goal:** Document loop node semantics and update authoring guidance so workflow authors use the right repeat primitive.
- **Files:** `docs/workflow-steps.md`, `docs/PLUGIN_AUTHORING.md`, `docs/cli-reference.md`
- **Patterns:** Follow the existing node sections for `foreach`, `step-review`, and `code`.
- **Test Scenarios:** Documentation-only assertions should rely on existing docs tests if present; otherwise no new test is needed.
- **Verification:** `pnpm lint` should catch markdown-adjacent import/doc inventory issues if any are covered by lint.
### U6. Packaging and Release Metadata
- **Goal:** Ensure the loop contract ships through the published package and remains available to extension-authored workflows.
- **Files:** `.changeset/<loop-node-name>.md`, `packages/cli/src` bundle entry points if needed, `packages/plugin-sdk/src/index.ts`
- **Patterns:** Follow `.changeset/workflow-extension-plugins.md` and the current plugin SDK export surface.
- **Test Scenarios:** Existing build coverage should prove package exports compile; add a small SDK type-export assertion only if current tests do not cover exported workflow IR types.
- **Verification:** `pnpm --filter @fusion/plugin-sdk typecheck`, `pnpm build`
### U7. Extension Workflow Consumption Pass
- **Goal:** Update extension-authored workflow templates to use `loop` where they currently need bounded repeat-until behavior.
- **Files:** Extension package workflow template files that declare repeat-until review or response regions.
- **Patterns:** Consume `WorkflowLoopConfig` and related exported types from `@fusion/plugin-sdk`; do not duplicate loop config shapes locally.
- **Test Scenarios:** Add template parse/round-trip tests in the extension package and a focused runtime dispatch test for a loop-backed extension workflow.
- **Verification:** Run that package's typecheck/build and focused tests after the shared engine change is available.
---
## Acceptance Examples
- AE1. Given a loop template whose final prompt node returns `DONE`, when `exitWhen.value` is `DONE`, then the loop runs once, records `matched`, and follows its success edge.
- AE2. Given a loop template that returns `KEEP_GOING` twice and `DONE` on the third run, when `maxIterations` is at least 3, then the loop runs three iterations and exits successfully.
- AE3. Given a loop template that never emits the configured stop string, when `maxIterations` is 2, then the loop emits `value: "loop-iteration-exhausted"` and follows an `outcome:loop-iteration-exhausted` edge if present.
- AE4. Given a loop template that runs longer than `timeoutMs`, when the timeout elapses, then the loop emits `value: "loop-timeout"` without running another iteration.
- AE5. Given a workflow with a top-level non-rework cycle, when it is parsed, then parse still rejects it even if the workflow also contains a valid loop node.
---
## Scope Boundaries
- This plan does not add unbounded graph cycles.
- This plan does not change `foreach(source:"task-steps")` semantics.
- This plan does not replace existing PR review `rework` loops; it adds a separate authoring primitive for repeat-until workflows.
- This plan does not introduce persistent loop-instance tables unless implementation discovers a restart-resume requirement that cannot be met from existing run context.
---
## Risks and Dependencies
- **Loop output source ambiguity:** Authors may expect any agent text to count as loop output. The initial contract should define one source clearly: the selected template node's `WorkflowNodeResult.value`, defaulting to the template exit node.
- **Timeout testing risk:** Real timers would slow the suite. Use fake timers or an injectable clock/deadline helper for timeout exhaustion coverage.
- **Editor complexity:** `foreach` already has careful group-node round-trip behavior. Loop should reuse that mapping machinery or small generalized helpers to avoid a second drift-prone group implementation.
- **Extension timing:** Extension workflow templates can adopt `loop` after the shared package exports land; until then they should continue to parse under the current engine contract.
---
## Sources
- `packages/core/src/workflow-ir-types.ts` defines current node kinds, `WorkflowForeachConfig`, and rework budget helpers.
- `packages/core/src/workflow-ir.ts` validates `foreach` templates and rejects non-rework cycles.
- `packages/engine/src/workflow-graph-executor.ts` owns graph traversal, top-level rework handling, and node outcome routing.
- `packages/engine/src/workflow-graph-foreach.ts` provides the closest runtime pattern for a bounded inline sub-walk.
- `packages/dashboard/app/components/workflow-flow-mapping.ts` and `packages/dashboard/app/components/WorkflowNodeEditor.tsx` provide the existing group-node authoring pattern.
- `docs/workflow-steps.md` documents current `foreach`, `rework`, and `code` node semantics.

View File

@@ -91,7 +91,7 @@ A v2 column can optionally name a **permanent agent** from the agent registry, s
**Write-time validation.** Saving a workflow validates agent references: an unknown `agentId` is rejected with a typed 4xx naming the column. Binding an agent whose permission policy is broader than the project default requires an explicit policy-escalation confirmation (`confirmPolicyEscalation`) at save time, so override cannot silently re-key action gates to a more-privileged agent. **Write-time validation.** Saving a workflow validates agent references: an unknown `agentId` is rejected with a typed 4xx naming the column. Binding an agent whose permission policy is broader than the project default requires an explicit policy-escalation confirmation (`confirmPolicyEscalation`) at save time, so override cannot silently re-key action gates to a more-privileged agent.
### Workflow IR v2 — step inversion (foreach, step-review, parse-steps, code) ### Workflow IR v2 — step inversion (foreach, loop, step-review, parse-steps, code)
The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**. The **step-inversion** track makes task *steps* themselves workflow-modelable. Today the engine owns step policy end-to-end (PROMPT.md parsing, per-step review verdicts, RETHINK/REVISE control flow, merge blocking). Step inversion extracts exactly one new substrate capability — *run one step inside a task's session, and reset one step to its baseline* — and exposes everything else as authored graph structure. It is additive to IR v2 and gated by `experimentalFeatures.workflowGraphExecutor`. The default coding workflow is untouched and byte-identical (it keeps its monolithic `execute` seam and is the parity oracle); inversion is opt-in via custom workflows and a new built-in **stepwise coding workflow**.
@@ -119,6 +119,26 @@ The **step-inversion** track makes task *steps* themselves workflow-modelable. T
- Expansion happens when the walk reaches the node; the step count is **pinned** at expansion and persisted (PROMPT.md edits afterward do not re-expand — a `pin-mismatch` failure surfaces if the live step list later disagrees on resume). - Expansion happens when the walk reaches the node; the step count is **pinned** at expansion and persisted (PROMPT.md edits afterward do not re-expand — a `pin-mismatch` failure surfaces if the live step list later disagrees on resume).
- Zero steps → the foreach traverses its `success` edge immediately (no merge blocker, matching today). - Zero steps → the foreach traverses its `success` edge immediately (no merge blocker, matching today).
#### `loop` node — a bounded repeated template region
`loop` repeats an inline template subgraph until a configured output condition matches or a budget is exhausted. Config:
```
{ template: { nodes, edges },
exitWhen: {
type: "output-contains", value: string, nodeId?: string
} | {
type: "output-matches", pattern: string, flags?: string, nodeId?: string
},
maxIterations?: number, // default 3, cap 50
timeoutMs?: number } // default 300000, cap 3600000
```
- The template has exactly one entry and one exit. If `exitWhen.nodeId` is omitted, the loop tests the template exit node's output.
- Loop templates may contain ordinary workflow nodes, but not nested `loop`/`foreach` regions, foreach-only `step-execute` seam nodes, rework edges, or normal cycles. The repeated execution is represented by the loop node itself.
- Success emits the normal `success` outcome and writes `node:<loopId>:loop` context with `iterations`, `exitReason: "matched"`, `finalValue`, and per-iteration history.
- Exhausting `maxIterations` emits `failure` with value `loop-iteration-exhausted`; exceeding `timeoutMs` emits `failure` with value `loop-timeout`. Authors can route those via `outcome:loop-iteration-exhausted` or `outcome:loop-timeout` edges.
#### Parallel mode & the `(depends:)` annotation #### Parallel mode & the `(depends:)` annotation
`mode` and `isolation` are independent axes. `parallel + shared` is rejected (concurrent writers in one worktree are unguardable). Under `worktree` isolation each instance runs in its own worktree/branch off a common base, with an **ordered integration stage** that lands step branches in step order (done iff integrated); a rebase conflict routes `outcome:integration-conflict` (default: rework on the updated base, budget-counted). `mode` and `isolation` are independent axes. `parallel + shared` is rejected (concurrent writers in one worktree are unguardable). Under `worktree` isolation each instance runs in its own worktree/branch off a common base, with an **ordered integration stage** that lands step branches in step order (done iff integrated); a rebase conflict routes `outcome:integration-conflict` (default: rework on the updated base, budget-counted).

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import {
parseWorkflowIr,
serializeWorkflowIr,
} from "../workflow-ir.js";
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowIrV2 } from "../workflow-ir-types.js";
const columns: WorkflowIrV2["columns"] = [{ id: "work", name: "Work", traits: [] }];
function loopTemplate(): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
return {
nodes: [
{ id: "ask", kind: "prompt", config: { prompt: "try" } },
{ id: "check", kind: "gate", config: { prompt: "done?" } },
],
edges: [{ from: "ask", to: "check" }],
};
}
function loopIr(config: Record<string, unknown> = {}): WorkflowIrV2 {
return {
version: "v2",
name: "loop-test",
columns,
nodes: [
{ id: "start", kind: "start" },
{
id: "repeat",
kind: "loop",
config: {
maxIterations: 3,
exitWhen: { type: "output-contains", value: "DONE" },
template: loopTemplate(),
...config,
},
},
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "repeat" },
{ from: "repeat", to: "end" },
],
};
}
describe("loop validation", () => {
it("parses and round-trips a valid loop node", () => {
const parsed = parseWorkflowIr(loopIr()) as WorkflowIrV2;
const loop = parsed.nodes.find((n) => n.id === "repeat");
expect(loop?.kind).toBe("loop");
expect(parseWorkflowIr(serializeWorkflowIr(parsed))).toEqual(parsed);
});
it("rejects a loop with an empty template", () => {
expect(() => parseWorkflowIr(loopIr({ template: { nodes: [], edges: [] } }))).toThrow(/non-empty/);
});
it("rejects duplicate template node ids", () => {
const template = loopTemplate();
template.nodes.push({ id: "ask", kind: "script" });
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/duplicate node ids/);
});
it("rejects template edges that leave the template", () => {
const template = loopTemplate();
template.edges.push({ from: "check", to: "end" });
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/references a node outside/);
});
it("rejects nested loop and foreach regions", () => {
const template = loopTemplate();
template.nodes.push({
id: "nested",
kind: "loop",
config: {
exitWhen: { type: "output-contains", value: "DONE" },
template: loopTemplate(),
},
});
template.edges.push({ from: "check", to: "nested" });
expect(() => parseWorkflowIr(loopIr({ template }))).toThrow(/nested loop\/foreach/);
});
it("rejects foreach-only seams and normal cycles inside loop templates", () => {
const seamTemplate = loopTemplate();
seamTemplate.nodes[0] = { id: "ask", kind: "prompt", config: { seam: "step-execute" } };
expect(() => parseWorkflowIr(loopIr({ template: seamTemplate }))).toThrow(/only legal inside a foreach/);
const cyclicTemplate = loopTemplate();
cyclicTemplate.nodes.unshift({ id: "init", kind: "prompt", config: { prompt: "init" } });
cyclicTemplate.nodes.push({ id: "finish", kind: "gate", config: { prompt: "finished?" } });
cyclicTemplate.edges.unshift({ from: "init", to: "ask" });
cyclicTemplate.edges.push({ from: "check", to: "ask", condition: "failure" });
cyclicTemplate.edges.push({ from: "check", to: "finish", condition: "success" });
expect(() => parseWorkflowIr(loopIr({ template: cyclicTemplate }))).toThrow(/illegal cycle/);
});
it("rejects an invalid exit condition", () => {
expect(() => parseWorkflowIr(loopIr({ exitWhen: { type: "output-contains", value: "" } }))).toThrow(
/exitWhen.value/,
);
expect(() => parseWorkflowIr(loopIr({ exitWhen: { type: "output-matches", pattern: "[" } }))).toThrow(
/exitWhen.pattern is invalid/,
);
});
it("clamps high maxIterations and rejects invalid budgets", () => {
const parsed = parseWorkflowIr(loopIr({ maxIterations: 99 })) as WorkflowIrV2;
expect(parsed.nodes.find((n) => n.id === "repeat")?.config?.maxIterations).toBe(50);
expect(() => parseWorkflowIr(loopIr({ maxIterations: 0 }))).toThrow(/maxIterations/);
expect(() => parseWorkflowIr(loopIr({ timeoutMs: 0 }))).toThrow(/timeoutMs/);
});
it("still rejects illegal top-level cycles", () => {
const ir = loopIr();
ir.edges.push({ from: "repeat", to: "start", condition: "failure" });
expect(() => parseWorkflowIr(ir)).toThrow(/illegal cycle/);
});
});

View File

@@ -70,6 +70,8 @@ export type {
WorkflowJoinBranchFailure, WorkflowJoinBranchFailure,
// Step-inversion (KTD-3/12/13): foreach / artifacts / custom-field IR types. // Step-inversion (KTD-3/12/13): foreach / artifacts / custom-field IR types.
WorkflowForeachConfig, WorkflowForeachConfig,
WorkflowLoopConfig,
WorkflowLoopExitCondition,
WorkflowIrArtifact, WorkflowIrArtifact,
WorkflowFieldDefinition, WorkflowFieldDefinition,
WorkflowFieldType, WorkflowFieldType,

View File

@@ -3,7 +3,8 @@
* step-inversion additions (FN step-inversion, KTD-3/4/12/15): `foreach` * step-inversion additions (FN step-inversion, KTD-3/4/12/15): `foreach`
* (runtime-expanding per-step template region), `step-review` (per-step review * (runtime-expanding per-step template region), `step-review` (per-step review
* verdicts as outcome edges), `parse-steps` (graph-native step-list parsing), * 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 * `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the
* review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */ * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */
export type WorkflowIrNodeKind = export type WorkflowIrNodeKind =
@@ -16,6 +17,7 @@ export type WorkflowIrNodeKind =
| "split" | "split"
| "join" | "join"
| "foreach" | "foreach"
| "loop"
| "step-review" | "step-review"
| "parse-steps" | "parse-steps"
| "code" | "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 /** Step-inversion (KTD-12): a workflow-declared task document. Artifacts ride the
* existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */ * existing task-documents machinery; `step-source` artifacts feed `parse-steps`. */
export interface WorkflowIrArtifact { export interface WorkflowIrArtifact {

View File

@@ -8,6 +8,7 @@ import type {
WorkflowIrV2, WorkflowIrV2,
WorkflowHoldRelease, WorkflowHoldRelease,
WorkflowForeachConfig, WorkflowForeachConfig,
WorkflowLoopConfig,
WorkflowFieldDefinition, WorkflowFieldDefinition,
WorkflowFieldType, WorkflowFieldType,
WorkflowSettingDefinition, WorkflowSettingDefinition,
@@ -95,6 +96,8 @@ const MAX_REWORK_CYCLES_CAP = 10;
/** Parallel concurrency bounds (KTD-3): range 1..8. */ /** Parallel concurrency bounds (KTD-3): range 1..8. */
const MAX_FOREACH_CONCURRENCY = 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 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. */ /** 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): /** 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 * reject any at the top level. (Inside-split-branch rejection is handled by
* SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */ * SEAM_FORBIDDEN_IN_BRANCH within validateParallelism.) */
@@ -1049,6 +1187,7 @@ function validateV2(ir: WorkflowIrV2): void {
validateStepExecutePlacement(ir.nodes); validateStepExecutePlacement(ir.nodes);
for (const node of ir.nodes) { for (const node of ir.nodes) {
if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds); if (node.kind === "foreach") validateForeach(node, topLevelIds, columnIds);
if (node.kind === "loop") validateLoop(node, topLevelIds, columnIds);
} }
validateStepReviewRouting(ir.nodes, outgoing, nodesById, false); validateStepReviewRouting(ir.nodes, outgoing, nodesById, false);
validateParseStepsNodes(ir); validateParseStepsNodes(ir);
@@ -1083,6 +1222,17 @@ function validateV2(ir: WorkflowIrV2): void {
* maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */ * maxRetries clamp posture (KTD-5). Reject-of-<1 happens in validation. */
function clampForeachConfigs(ir: WorkflowIrV2): void { function clampForeachConfigs(ir: WorkflowIrV2): void {
for (const node of ir.nodes) { 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; if (node.kind !== "foreach") continue;
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined; const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
if ( if (

View File

@@ -180,6 +180,7 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
{ kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } }, { kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } },
// Step-inversion (KTD-3/4/12/15). // Step-inversion (KTD-3/4/12/15).
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } }, { 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: "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: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } }, { kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
@@ -229,6 +230,7 @@ const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEdi
"split", "split",
"join", "join",
"foreach", "foreach",
"loop",
"step-review", "step-review",
"parse-steps", "parse-steps",
"merge", "merge",
@@ -1197,19 +1199,24 @@ function InnerEditor({
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {}; const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig; const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
if (kind === "foreach") { if (kind === "foreach" || kind === "loop") {
// A foreach renders as a React Flow group node. It auto-populates ONE // Template groups render as React Flow group nodes. Foreach seeds the
// step-execute child (a prompt node with seam=step-execute) so the group // required step-execute seam; loop seeds a regular prompt so authors can
// is never confusingly empty (KTD-3 / U8). The group node must precede // wire the repeated body immediately. The group node must precede its
// its child in the array for React Flow's parent extent to apply. // child for React Flow's parent extent to apply.
const childId = foreachChildFlowId(id, newNodeId()); 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) => [ setNodes((ns) => [
...ns, ...ns,
{ {
id, id,
type: "foreach", type: kind,
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 }, 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 }, style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true, deletable: true,
}, },
@@ -1221,8 +1228,8 @@ function InnerEditor({
extent: "parent", extent: "parent",
data: { data: {
kind: "prompt", kind: "prompt",
label: t("workflowNodes.stepExecuteLabel", "Step execute"), label: childLabel,
config: { seam: "step-execute" }, config: childConfig,
}, },
deletable: true, deletable: true,
}, },
@@ -1754,21 +1761,25 @@ function InnerEditor({
// (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge. // (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge.
const nodesForRender = useMemo(() => { const nodesForRender = useMemo(() => {
const unplacedSet = new Set(unplaced); const unplacedSet = new Set(unplaced);
// Count current template children per foreach group so the empty-state hint // Count current template children per template group so the empty-state hint
// (KTD-3 / U8) reflects live deletions even though the palette seeds one. // reflects live deletions even though the palette seeds one.
const childCount = new Map<string, number>(); const childCount = new Map<string, number>();
for (const n of nodes) { for (const n of nodes) {
if (n.parentId) childCount.set(n.parentId, (childCount.get(n.parentId) ?? 0) + 1); 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) => { return nodes.map((n) => {
let errorBadge: string | undefined; let errorBadge: string | undefined;
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column"); if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message; 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 ( if (
errorBadge === n.data.errorBadge && 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 n;
return { return {
@@ -1776,7 +1787,7 @@ function InnerEditor({
data: { data: {
...n.data, ...n.data,
errorBadge, errorBadge,
...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}), ...(isTemplateGroup ? { templateEmpty, emptyHint } : {}),
}, },
}; };
}); });
@@ -3166,6 +3177,178 @@ function InnerEditor({
})() })()
) : null} ) : 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" ? ( {selectedNode.data.kind === "step-review" ? (
<> <>
<label className="wf-field"> <label className="wf-field">

View File

@@ -412,6 +412,96 @@ describe("workflow-flow-mapping foreach + rework round-trip", () => {
expect(parse?.config).toMatchObject({ artifact: "PROMPT.md", parser: "step-headings" }); 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)", () => { it("round-trips a code node config (source + timeoutMs)", () => {
const codeIr: WorkflowDefinition["ir"] = { const codeIr: WorkflowDefinition["ir"] = {
version: "v1", version: "v1",

View File

@@ -9,7 +9,8 @@ import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
* step-inversion additions (KTD-3/4/12/15): "foreach" (runtime-expanding * 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 template region, rendered as a React Flow group), "step-review"
* (per-step review verdicts as outcome edges), "parse-steps" (graph-native * (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 = export type WorkflowEditorNodeKind =
| "start" | "start"
| "end" | "end"
@@ -21,6 +22,7 @@ export type WorkflowEditorNodeKind =
| "split" | "split"
| "join" | "join"
| "foreach" | "foreach"
| "loop"
| "step-review" | "step-review"
| "parse-steps" | "parse-steps"
| "code"; | "code";
@@ -37,10 +39,10 @@ export interface WorkflowFlowNodeData {
/** When true, render the shared error-state badge on the node (unplaced node /** When true, render the shared error-state badge on the node (unplaced node
* or seam-in-branch). Set by the editor from validation. */ * or seam-in-branch). Set by the editor from validation. */
errorBadge?: string; 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). */ * empty it even though the palette auto-populates one). */
templateEmpty?: boolean; templateEmpty?: boolean;
/** foreach group only: the localized empty-state hint string. */ /** template group only: the localized empty-state hint string. */
emptyHint?: string; emptyHint?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -56,6 +58,7 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
split: Split, split: Split,
join: Merge, join: Merge,
foreach: Repeat, foreach: Repeat,
loop: Repeat,
"step-review": ClipboardCheck, "step-review": ClipboardCheck,
"parse-steps": ListChecks, "parse-steps": ListChecks,
code: Code2, 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 = { export const workflowNodeTypes = {
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />, start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />, 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" />, split: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="split" />,
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />, join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />, 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" />, "step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />, "parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />, 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"); 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", () => { it("step-review node → review type", () => {
const summary = nodeConfigSummary(node("step-review", { type: "design" })); const summary = nodeConfigSummary(node("step-review", { type: "design" }));
expect(summary).toBe("design review"); expect(summary).toBe("design review");

View File

@@ -158,6 +158,24 @@ export function nodeConfigSummary(
const isolation = str(config.isolation) || (mode === "parallel" ? "worktree" : "shared"); const isolation = str(config.isolation) || (mode === "parallel" ? "worktree" : "shared");
return `${mode} · ${isolation}`; 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": { case "step-review": {
const reviewType = str(config.type) || "code"; const reviewType = str(config.type) || "code";
return t("workflowNodes.summaryReviewType", "{{type}} review", { type: reviewType }); return t("workflowNodes.summaryReviewType", "{{type}} review", { type: reviewType });

View File

@@ -23,6 +23,19 @@ interface WorkflowForeachConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }; 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). // WorkflowFieldDefinition is imported from @fusion/core above (KTD-13/14).
// Re-exported so existing importers that reference WorkflowFieldDefinitionShape // Re-exported so existing importers that reference WorkflowFieldDefinitionShape
// can migrate; callers should prefer WorkflowFieldDefinition directly. // can migrate; callers should prefer WorkflowFieldDefinition directly.
@@ -159,6 +172,19 @@ function foreachConfigOf(node: WorkflowIrNode): WorkflowForeachConfig | undefine
return cfg as WorkflowForeachConfig; 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 /** CSS class for an edge given its condition + rework kind. Rework takes
* precedence; failure edges get the distinct failure styling; success and other * precedence; failure edges get the distinct failure styling; success and other
* conditions get no class (default styling). R2's two-channel rule (label always * 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. // layout exists; otherwise we honor the saved absolute position.
const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120; const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120;
const foreachCfg = foreachConfigOf(node); const groupCfg = groupTemplateConfigOf(node);
if (foreachCfg) { if (groupCfg) {
const template = foreachCfg.template; const template = groupCfg.template;
// Render template nodes as children of this group (parentId = group id). // Render template nodes as children of this group (parentId = group id).
template.nodes.forEach((inner, innerIdx) => { template.nodes.forEach((inner, innerIdx) => {
const childFlowId = foreachChildFlowId(node.id, inner.id); 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>; const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return { return {
id: node.id, id: node.id,
type: "foreach", type: kind,
position: pos ?? { x: 80 + index * 180, y: fallbackY }, position: pos ?? { x: 80 + index * 180, y: fallbackY },
data: { data: {
kind: "foreach", kind,
label: nodeLabel(node), label: nodeLabel(node),
config: { ...restCfg }, config: { ...restCfg },
column, column,
@@ -329,7 +355,9 @@ export function flowToIr(
childrenByGroup.set(n.parentId, arr); 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 hasFields = Array.isArray(fields) && fields.length > 0;
const hasSettings = Array.isArray(settings) && settings.length > 0; const hasSettings = Array.isArray(settings) && settings.length > 0;
// Fields and settings are v2-only declarations: a workflow with either but no // Fields and settings are v2-only declarations: a workflow with either but no
@@ -344,7 +372,7 @@ export function flowToIr(
if (data.kind === "merge") { if (data.kind === "merge") {
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "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. // Reassemble the template from this group's children.
const children = childrenByGroup.get(node.id) ?? []; const children = childrenByGroup.get(node.id) ?? [];
const templateNodes: WorkflowIrNode[] = children.map((c) => { const templateNodes: WorkflowIrNode[] = children.map((c) => {
@@ -359,7 +387,7 @@ export function flowToIr(
const baseCfg = (config ?? {}) as Record<string, unknown>; const baseCfg = (config ?? {}) as Record<string, unknown>;
return { return {
id: localId, id: localId,
kind: "foreach", kind: data.kind,
config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } }, 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 * Delete the requested node and/or edge ids from the flow graph, applying R6's
* cascade rules: * cascade rules:
* - Deleting a node removes ALL edges incident to it (no auto-bridging). * - 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 * (nodes with `parentId === groupId`) and every edge incident to those
* children (React Flow does not cascade parents — handled explicitly). * children (React Flow does not cascade parents — handled explicitly).
* - `start`/`end` nodes and column band nodes are never deleted: they are * - `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 requested = new Set(ids);
const nodeById = new Map(nodes.map((n) => [n.id, n])); 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. // their template children. Protected nodes are dropped from the request.
const deleteNodeIds = new Set<string>(); const deleteNodeIds = new Set<string>();
for (const id of requested) { for (const id of requested) {
const node = nodeById.get(id); const node = nodeById.get(id);
if (!node || isProtectedFromDelete(node)) continue; if (!node || isProtectedFromDelete(node)) continue;
deleteNodeIds.add(id); deleteNodeIds.add(id);
if (node.data.kind === "foreach") { if (node.data.kind === "foreach" || node.data.kind === "loop") {
for (const child of nodes) { for (const child of nodes) {
if (child.parentId === id) deleteNodeIds.add(child.id); 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 /** Editor node kinds whose edges expose a success/failure condition select
* (KTD-2). step-review uses verdict controls; all other kinds are read-only. */ * (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`: /** Decide what the edge inspector renders for an edge sourced from `sourceKind`:
* - "verdicts": step-review verdict select + rework checkbox (existing); * - "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 /** 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, * 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. */ * carries config (including any template) through verbatim. */
function irNodeToFlowNode( function irNodeToFlowNode(
node: WorkflowIrNode, node: WorkflowIrNode,
@@ -958,8 +986,8 @@ export function insertFragment(
const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0; const minY = placed.length ? Math.min(...placed.map((p) => p.y)) : 0;
const insertedNodeIds: string[] = []; const insertedNodeIds: string[] = [];
// foreach template children are expanded into parented child flow nodes (the // Template group children are expanded into parented child flow nodes (the
// same way irToFlow does), so an inserted foreach round-trips its full template // same way irToFlow does), so an inserted group round-trips its full template
// through flowToIr instead of dropping config.template (which flowToIr would // through flowToIr instead of dropping config.template (which flowToIr would
// otherwise rebuild as an empty template from the absent children). // otherwise rebuild as an empty template from the absent children).
const childNodes: FlowNode<WorkflowFlowNodeData>[] = []; const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
@@ -971,9 +999,10 @@ export function insertFragment(
const pos = fromLayout const pos = fromLayout
? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) } ? { x: position.x + (fromLayout.x - minX), y: position.y + (fromLayout.y - minY) }
: { x: position.x + index * 180, y: position.y }; : { x: position.x + index * 180, y: position.y };
const foreachCfg = foreachConfigOf(node); const groupCfg = groupTemplateConfigOf(node);
if (foreachCfg) { if (groupCfg) {
const template = foreachCfg.template; const template = groupCfg.template;
const groupKind = editorKind(node);
template.nodes.forEach((inner, innerIdx) => { template.nodes.forEach((inner, innerIdx) => {
const innerKind = editorKind(inner); const innerKind = editorKind(inner);
childNodes.push({ childNodes.push({
@@ -993,10 +1022,10 @@ export function insertFragment(
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>; const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
return { return {
id, id,
type: "foreach", type: groupKind,
position: pos, position: pos,
data: { data: {
kind: "foreach", kind: groupKind,
label: nodeLabel(node), label: nodeLabel(node),
config: { ...restCfg }, config: { ...restCfg },
templateEmpty: template.nodes.length === 0, 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 * 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 * to the template, so a fresh local id space suffices (and keeps config compact
* rather than reusing global ids). */ * rather than reusing global ids). */
function copyForeachTemplate( function copyGroupTemplate(
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] }, template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] },
innerMap: Map<string, string>, innerMap: Map<string, string>,
): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } { ): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
@@ -1049,9 +1078,9 @@ function copyForeachTemplate(
return { nodes, edges }; 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 * 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 * keyed by the node's ORIGINAL id, so the caller can remap namespaced
* `${groupId}::${templateNodeId}` layout keys consistently. */ * `${groupId}::${templateNodeId}` layout keys consistently. */
function copyIrNode( function copyIrNode(
@@ -1060,10 +1089,10 @@ function copyIrNode(
templateMaps?: Map<string, Map<string, string>>, templateMaps?: Map<string, Map<string, string>>,
): WorkflowIrNode { ): WorkflowIrNode {
const config = node.config ? { ...node.config } : undefined; const config = node.config ? { ...node.config } : undefined;
const foreach = foreachConfigOf(node); const group = groupTemplateConfigOf(node);
if (foreach && config) { if (group && config) {
const innerMap = new Map<string, string>(); const innerMap = new Map<string, string>();
config.template = copyForeachTemplate(foreach.template, innerMap); config.template = copyGroupTemplate(group.template, innerMap);
templateMaps?.set(node.id, innerMap); templateMaps?.set(node.id, innerMap);
} }
const copy: WorkflowIrNode = { id: newId, kind: node.kind }; 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 * 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. * 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 * 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. * remapped consistently too. Returns a NEW ir + layout; inputs are not mutated.
*/ */
export function copyIrWithFreshIds( export function copyIrWithFreshIds(
@@ -1086,7 +1115,7 @@ export function copyIrWithFreshIds(
const idMap = new Map<string, string>(); const idMap = new Map<string, string>();
for (const n of ir.nodes) idMap.set(n.id, newNodeId()); 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 // namespaced layout keys `${groupId}::${templateNodeId}` can be remapped to
// `${newGroupId}::${newTemplateNodeId}` consistently. // `${newGroupId}::${newTemplateNodeId}` consistently.
const templateMaps = new Map<string, Map<string, string>>(); 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 ForeachEnvironment,
type WorkflowStepInstancePersistence, type WorkflowStepInstancePersistence,
} from "./workflow-graph-foreach.js"; } from "./workflow-graph-foreach.js";
import { runLoop } from "./workflow-graph-loop.js";
export type WorkflowNodeOutcome = "success" | "failure"; export type WorkflowNodeOutcome = "success" | "failure";
@@ -70,6 +71,8 @@ export interface WorkflowGraphExecutorDeps {
onBranchProgress?: (progress: WorkflowBranchProgress) => void; onBranchProgress?: (progress: WorkflowBranchProgress) => void;
/** Stable identifier for this run, used to key persisted branch state. */ /** Stable identifier for this run, used to key persisted branch state. */
runId?: string; 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` * 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. * 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); 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); const result = await this.executeNodeWithRetries(node, task, settings, context, ir);
if (result.contextPatch) Object.assign(context, result.contextPatch); if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome; context[`node:${node.id}:outcome`] = result.outcome;

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

View File

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