/** * FlowchartTraverser — Pre-order DFS traversal of StageNode graph. * * Unified traversal algorithm for all node shapes. `executeNode` is a * TRAMPOLINE driver: it runs `executeNodeStep` (one node, all 7 phases) in * a flat loop, following tail continuations (linear `next`, loop edges, * dynamic next, flat decider dispatch) iteratively — so chain length and * loop iterations never grow the call stack. Only true tree nesting (fork * children, with-continuation decider/selector branches, subflow mounts) * recurses. * * For each node, executeNodeStep follows 7 phases: * 0. CLASSIFY — subflow detection, early delegation * 1. VALIDATE — node invariants, role markers * 2. EXECUTE — run stage fn, commit, break check * 3. DYNAMIC — StageNode return detection, subflow auto-registration, structure updates * 4. CHILDREN — fork/selector/decider dispatch * 5. CONTINUE — dynamic next / linear next resolution * 6. LEAF — no continuation, return output * * Break semantics: If a stage calls breakFn(), commit and STOP. * Patch model: Stage writes into local patch; commitPatch() after return or throw. */ import type { ScopeProtectionMode } from '../../scope/protection/types.js'; import { FlowRecorderDispatcher } from '../narrative/FlowRecorderDispatcher.js'; import type { FlowRecorder, IControlFlowNarrative } from '../narrative/types.js'; import type { IExecutionRuntime, ILogger, ScopeFactory, Selector, SerializedPipelineStructure, StageFunction, StageNode, StreamHandlers, SubflowMountOptions, SubflowResult, TraversalResult } from '../types.js'; export interface TraverserOptions { root: StageNode; stageMap: Map>; scopeFactory: ScopeFactory; executionRuntime: IExecutionRuntime; readOnlyContext?: unknown; /** Execution environment — propagates to subflows automatically. */ executionEnv?: import('../../engine/types').ExecutionEnv; throttlingErrorChecker?: (error: unknown) => boolean; streamHandlers?: StreamHandlers; scopeProtectionMode?: ScopeProtectionMode; subflows?: Record; }>; narrativeEnabled?: boolean; buildTimeStructure?: SerializedPipelineStructure; logger: ILogger; signal?: AbortSignal; /** Pre-configured FlowRecorders to attach when narrative is enabled. */ flowRecorders?: FlowRecorder[]; /** * Pre-configured narrative generator. If provided, takes precedence over * flowRecorders and narrativeEnabled. Used by the subflow traverser factory * to share the parent's narrative generator with subflow traversers. */ narrativeGenerator?: IControlFlowNarrative; /** * Maximum nested executeNode depth (tree nesting — branch/fork dispatch and * dynamic recursion, NOT linear chains or loop iterations, which run flat). * Defaults to FlowchartTraverser.MAX_EXECUTE_DEPTH (500). */ maxDepth?: number; /** * Maximum loop iterations per node (the ContinuationResolver guard). * Defaults to DEFAULT_MAX_ITERATIONS (1000). Propagated to subflow * traversers. Must be >= 1. */ maxIterations?: number; /** * When this traverser runs inside a subflow, set this to the subflow's ID. * Propagated to TraversalContext so narrative entries carry the correct subflowId. */ parentSubflowId?: string; /** * When this traverser runs inside a subflow, the runtimeStageId of the * subflow MOUNT stage in the parent traverser. Used as the * `parentRuntimeStageId` fallback for stages whose StageContext has no * parent (the subflow's own root context is created fresh by * SubflowExecutor) so runtime ancestor chains cross subflow boundaries * (RFC-003 D1). */ parentMountRuntimeStageId?: string; /** Shared execution counter from parent traverser. Subflows continue the parent's numbering. */ executionCounter?: { value: number; }; /** * Shared per-run visit-count map (keyed by stageId) from the parent traverser. * Drives `TraversalContext.loopIteration`. Shared with subflows so a stage * re-entered across a subflow re-mount keeps a correct, monotonic iteration * count — the same single-map semantics the narrative recorder uses. */ visitCounts?: Map; /** * Per-subflow scope captures from a checkpoint, on the resume path. * Forwarded to `HandlerDeps.subflowStatesForResume` so SubflowExecutor * can re-seed nested runtimes from pre-pause state instead of running * the inputMapper. Undefined on normal `run()` paths. */ subflowStatesForResume?: Record>; /** * Per-`executor.run()` identifier. Threaded into every TraversalContext * this traverser produces so recorders can scope state to a single run. * Subflow traversers inherit the parent's runId (the subflow is part of * the same run from the consumer's POV). Required field — every event * needs it. See `runner/runId.ts`. */ runId: string; } /** * Traverser-local overlay entry for a node whose stage function returned a * StageNode (dynamic continuation). Holds the dynamic values that earlier * versions wrote DIRECTLY onto the shared built-chart node — which leaked the * dynamic shape into every later run of the same built chart and raced * concurrent executors. The overlay keeps the built graph immutable: patches * live in a per-traverser Map keyed by `node.id` and die with the run. * * `next` is intentionally ABSENT: a dynamic `next` only ever applies to the * visit that produced it (the old code wrote `node.next` and restored it * before anything could observe the write), so it stays a local variable in * `executeNode` and is routed through `ContinuationResolver` directly. */ export interface DynamicNodePatch { /** * Subflow mount metadata from a dynamic-subflow return. Grouped so the * merged view reproduces the old field-wise overwrite exactly — including * `subflowName`/`subflowMountOptions` becoming undefined when the dynamic * return omitted them. */ subflowMeta?: { isSubflowRoot: true; subflowId: string; subflowName: string | undefined; subflowMountOptions: SubflowMountOptions | undefined; }; /** Dynamic fork children (replaces the built node's children for this run). */ children?: StageNode[]; /** Dynamic output-based selector accompanying dynamic children. */ nextNodeSelector?: Selector; } export declare class FlowchartTraverser { private readonly root; private stageMap; private readonly executionRuntime; private subflows; private readonly logger; private readonly signal?; private readonly parentSubflowId?; /** RFC-003 D1: runtimeStageId of the subflow mount stage in the parent * traverser. Fallback `parentRuntimeStageId` for stages whose context has * no parent (the subflow root). Undefined at the top level. */ private readonly parentMountRuntimeStageId?; /** Frozen value passed via `run({input})`. Surfaced on `onRunStart` at the * top-level traversal so consumers (e.g. `InOutRecorder`) can bracket * the run with the same payload shape that subflows already have. */ private readonly readOnlyContext?; /** Per-`executor.run()` identifier. Stamped onto every TraversalContext. * Inherited by subflow traversers so all events of one run share one runId. */ private readonly runId; private readonly nodeResolver; private readonly childrenExecutor; private readonly subflowExecutor; private readonly stageRunner; private readonly continuationResolver; private readonly deciderHandler; private readonly selectorHandler; private readonly structureManager; private readonly narrativeGenerator; private readonly flowRecorderDispatcher; private subflowResults; /** * Per-traverser set of lazy subflow IDs that have been resolved by THIS run. * Used instead of writing `node.subflowResolver = undefined` back to the shared * StageNode graph — avoids a race where a concurrent traverser clears the shared * resolver before another traverser has finished using it. */ private readonly resolvedLazySubflows; /** * Per-traverser overlay of dynamic StageNode returns, keyed by `node.id`. * Phase 4 writes patches HERE instead of mutating the shared built-chart * node objects (same isolation convention as `resolvedLazySubflows`). * All engine reads of the patched fields go through the `eff*` accessors * below. The map dies with the traverser — one run, one overlay — so a * fresh executor over the same built chart always sees the original graph. * * Keyed by the node OBJECT (WeakMap), not `node.id`: a dynamic child that * reuses a built node's id must NOT make the built node inherit the patch * (id-keyed lookup caused phantom double-execution). `patchCount` is the * fast-path check — WeakMap has no `size`. */ private readonly dynamicPatches; private patchCount; /** * TREE-nesting depth counter for executeNode (the trampoline driver). * Each driver invocation increments this; decrements on exit (try/finally). * * Linear `next` chains, loop edges, and dynamic continuations are followed * ITERATIVELY inside one driver invocation, so they never grow this * counter. Only true tree recursion does: fork children, decider/selector * branch dispatch (when the decider has its own continuation), and * unbounded dynamic recursion. Prevents call-stack overflow on runaway * recursive composition. */ private _executeDepth; /** * Memoized parent-chain depth per StageContext. The context tree deepens * by one per executed stage along a chain, so the naive parent-walk in * `computeContextDepth` is O(chain length) per stage — O(n²) per run once * the trampoline allows chains of tens of thousands of stages. Contexts * are visited parent-before-child, so the memo makes each lookup O(1) * amortized. WeakMap — dies with the traverser. */ private readonly contextDepthCache; /** * Shared mutable execution counter — monotonic, incremented per stage execution. * Shared with child traversers (subflows) so indices are globally unique within a run. */ private readonly _executionCounter; /** * Shared per-run visit counts keyed by stageId — how many times each stage * has executed in this run. Shared with child traversers (subflows) so a * looped-back stage's iteration count is monotonic across subflow re-mounts, * matching the narrative recorder's single-map semantics. Drives * `TraversalContext.loopIteration`. */ private readonly _visitCounts; /** * Per-instance maximum depth (set from TraverserOptions.maxDepth or the class default). */ private readonly _maxDepth; /** * Per-instance loop-iteration limit forwarded to the ContinuationResolver * and propagated to subflow traversers. Undefined → resolver default (1000). */ private readonly _maxIterations?; /** * Default maximum nested executeNode depth before an error is thrown. * * **What counts as depth (trampoline model):** `executeNode` is an iterative * driver — linear `next` hops, loop edges (`loopTo`/dynamic next), and * dynamic-subflow re-entry are followed in a flat loop and consume NO depth. * Depth grows only with true tree nesting: one tick per fork child, one per * decider/selector branch dispatch that must return to its invoker (decider * with its own `next`), one per subflow mount frame in the parent (the * subflow body itself runs on a FRESH traverser with its own budget). * * 500 therefore covers any realistic chart — it bounds recursive * COMPOSITION, not chain length or loop count. Loops are bounded by * `ContinuationResolver`'s independent iteration limit (default 1000, * configurable via `RunOptions.maxIterations`), which is now the binding * constraint for loop-heavy pipelines. * * @remarks Not safe for concurrent `.execute()` calls on the same instance — concurrent * executions race on `_executeDepth`. Use a separate `FlowchartTraverser` per concurrent * execution. `FlowChartExecutor.run()` always creates a fresh traverser per call. */ static readonly MAX_EXECUTE_DEPTH = 500; constructor(opts: TraverserOptions); /** * Create a factory that produces FlowchartTraverser instances for subflow execution. * Captures parent config in closure — SubflowExecutor provides subflow-specific overrides. * Each subflow gets a full traverser with all 7 phases (deciders, selectors, loops, etc.). */ private createSubflowTraverserFactory; private createDeps; /** * Holds the top-level break flag for the duration of `execute()`. Kept as * a field (not a local) so `getBreakState()` can surface the final state * for callers like `SubflowExecutor` that implement `propagateBreak`. */ private _topBreakFlag; execute(branchPath?: string): Promise; /** * Break state captured at the top-level of the most recent `execute()`. * `shouldBreak` is true when a stage called `scope.$break(reason)`; the * optional `reason` carries the string passed to `$break`. * * Used by `SubflowExecutor` to propagate an inner subflow's break up to * the parent traverser when the mount sets `propagateBreak: true`. */ getBreakState(): { shouldBreak: boolean; reason?: string; }; getRuntimeStructure(): SerializedPipelineStructure | undefined; getSnapshot(options?: { redact?: boolean; }): { sharedState: Record; executionTree: unknown; commitLog: unknown[]; subflowResults?: Record | undefined; recorders?: { id: string; name: string; data: unknown; }[] | undefined; }; getRuntime(): IExecutionRuntime; setRootObject(path: string[], key: string, value: unknown): void; getBranchIds(): string[]; getRuntimeRoot(): StageNode; getSubflowResults(): Map; getNarrative(): string[]; /** Returns the FlowRecorderDispatcher, or undefined if narrative is disabled. */ getFlowRecorderDispatcher(): FlowRecorderDispatcher | undefined; /** * Build an O(1) ID→node map from the root graph. * Used by NodeResolver to avoid repeated DFS on every loopTo() call. * Iterative worklist (no recursion) so arbitrarily long chains index fully; * the `map.has` guard handles cyclic refs. First-visited node wins per ID — * worklist order matches the old recursive pre-order (children, then next). * Dynamic subflows and lazy-resolved nodes are added to stageMap at runtime but not to this map — * those use the DFS fallback in NodeResolver. */ private buildNodeIdMap; private getStageFn; private getPatch; private getOrCreatePatch; /** Effective children: dynamic patch first, then the built node's children. */ private effChildren; /** Effective output-based selector: dynamic patch first, then the built node's. */ private effSelector; /** Effective subflow-root marker (true when a dynamic subflow was patched on). */ private effIsSubflowRoot; /** Effective subflow id (patched verbatim by a dynamic subflow return). */ private effSubflowId; /** * Materialize the effective view of a node — field-identical to what the * pre-overlay code produced by mutating the shared node. Used where a node * is handed to a helper executor (NodeResolver / SubflowExecutor / * ChildrenExecutor) so helpers never read stale built fields. Returns the * node itself (no allocation) when it carries no patch. */ private effNode; private executeStage; /** * Trampoline driver — pre-order DFS traversal entry point. * * Runs `executeNodeStep` (one node, all 7 phases) in a flat loop: every * TAIL continuation (linear `next`, loop edge, dynamic next / dynamic * re-entry, no-continuation decider dispatch) comes back as a * `ContinuationHop` and is followed ITERATIVELY — neither the call stack * nor the retained promise chain grows with chain length or loop count. * * Recursion remains ONLY for true tree nesting (each gets a nested driver * call): fork children (`ChildrenExecutor`), selector branches (parallel * fan-out), decider branch dispatch when the decider has its own `next` * (the branch must complete BEFORE the decider's continuation runs), and * subflow mounts (fresh traverser; the mount frame stays in the parent). * `_executeDepth` therefore counts chart COMPOSITION depth only, guarded * by `_maxDepth` (default `MAX_EXECUTE_DEPTH` = 500). * * PauseSignal: a flat decider dispatch records an `InvokerStamp`; if the * continued chain later pauses, the driver stamps the signal during * unwind — same invoker context the recursive dispatch's catch used to * stamp, innermost (most recent dispatch) first. */ private executeNode; /** Build a flat continuation hop for the driver loop. */ private hop; /** * Execute ONE node through all 7 phases — the old recursive `executeNode` * body; only the tail calls became `ContinuationHop` returns. Returns the * node's result, or a hop for the driver loop to follow. */ private executeNodeStep; private captureDynamicChildrenResult; /** * Parent-chain length of a StageContext — same value the pre-trampoline * walk produced, memoized. The context tree deepens by one per executed * stage along a chain, so the naive walk is O(chain length) per stage — * O(n²) per run once chains reach trampoline scale. Contexts are visited * parent-before-child, so the cached parent makes this O(1) amortized. */ private computeContextDepth; private prefixNodeTree; private autoRegisterSubflowDef; }