From a504238d2685cebfef1e89bffb4f012a56504959 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 7 Jun 2026 23:56:25 -0700 Subject: [PATCH] feat(FN-0000): add workflow loop nodes --- .changeset/workflow-loop-nodes.md | 5 + ...06-08-002-feat-workflow-loop-nodes-plan.md | 174 ++++++++++++++ docs/workflow-steps.md | 22 +- .../src/__tests__/workflow-ir-loop.test.ts | 125 ++++++++++ packages/core/src/index.ts | 2 + packages/core/src/workflow-ir-types.ts | 30 ++- packages/core/src/workflow-ir.ts | 150 ++++++++++++ .../app/components/WorkflowNodeEditor.tsx | 213 ++++++++++++++++-- .../__tests__/workflow-flow-mapping.test.ts | 90 ++++++++ .../components/nodes/WorkflowNodeTypes.tsx | 39 +++- .../nodes/__tests__/node-summary.test.ts | 9 + .../app/components/nodes/node-summary.ts | 18 ++ .../app/components/workflow-flow-mapping.ts | 87 ++++--- .../src/__tests__/workflow-graph-loop.test.ts | 150 ++++++++++++ .../engine/src/workflow-graph-executor.ts | 22 ++ packages/engine/src/workflow-graph-loop.ts | 205 +++++++++++++++++ packages/plugin-sdk/src/index.ts | 2 + 17 files changed, 1294 insertions(+), 49 deletions(-) create mode 100644 .changeset/workflow-loop-nodes.md create mode 100644 docs/plans/2026-06-08-002-feat-workflow-loop-nodes-plan.md create mode 100644 packages/core/src/__tests__/workflow-ir-loop.test.ts create mode 100644 packages/engine/src/__tests__/workflow-graph-loop.test.ts create mode 100644 packages/engine/src/workflow-graph-loop.ts diff --git a/.changeset/workflow-loop-nodes.md b/.changeset/workflow-loop-nodes.md new file mode 100644 index 0000000000..86e50211c9 --- /dev/null +++ b/.changeset/workflow-loop-nodes.md @@ -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. diff --git a/docs/plans/2026-06-08-002-feat-workflow-loop-nodes-plan.md b/docs/plans/2026-06-08-002-feat-workflow-loop-nodes-plan.md new file mode 100644 index 0000000000..2610bbb9d7 --- /dev/null +++ b/docs/plans/2026-06-08-002-feat-workflow-loop-nodes-plan.md @@ -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:` 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::outcome`, `node::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/.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. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 698ec0b53b..0c3f71c88f 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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. -### 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**. @@ -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). - 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::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 `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). diff --git a/packages/core/src/__tests__/workflow-ir-loop.test.ts b/packages/core/src/__tests__/workflow-ir-loop.test.ts new file mode 100644 index 0000000000..99b3f7cf37 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-loop.test.ts @@ -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 = {}): 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/); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 20b13d68ae..326972e0d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 5b06f7cd43..222aad0278 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -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 { diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index a0a7f5b7cf..06172011d4 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -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, + columnIds: Set, +): void { + const cfg = node.config as Partial | 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(); + const outgoingCount = new Map(); + 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 | 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 | undefined; if ( diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index d134ee042e..af06376071 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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 = new Set [ ...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(); 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) + : { type: "output-contains", value: "DONE" }; + const exitType = String(exitWhen.type ?? "output-contains"); + const exitText = + exitType === "output-matches" + ? String(exitWhen.pattern ?? "") + : String(exitWhen.value ?? ""); + return ( + <> + + + + + + + + + + +

+ {t( + "workflowNodes.loopNote", + "Repeats the template until the selected output matches, an iteration limit is reached, or the timeout expires.", + )} +

+ + ); + })() + ) : null} + {selectedNode.data.kind === "step-review" ? ( <>