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