footprintjs 8.3.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,15 @@
8
8
  * - String ID → reference to existing node (resolve via NodeResolver)
9
9
  * - StageNode with fn → truly dynamic node (execute directly)
10
10
  * - StageNode without fn → reference by ID (resolve via NodeResolver)
11
+ *
12
+ * Two entry points:
13
+ * - `resolveTarget` — resolves the continuation to `{ node, context }` and
14
+ * fires every side effect (iteration counting, debug logs, `onLoop`
15
+ * narrative) WITHOUT executing. The traverser's trampoline driver uses
16
+ * this to follow loop edges iteratively — flat stack, so the iteration
17
+ * limit (not call-stack depth) is what bounds a loop.
18
+ * - `resolve` — resolveTarget + immediate execution via the provided
19
+ * `executeNode` callback. Kept for direct/advanced callers.
11
20
  */
12
21
  import type { StageContext } from '../../memory/StageContext.js';
13
22
  import type { StageNode } from '../graph/StageNode.js';
@@ -16,6 +25,16 @@ import type { HandlerDeps } from '../types.js';
16
25
  import type { NodeResolver } from './NodeResolver.js';
17
26
  import type { ExecuteNodeFn } from './types.js';
18
27
  export declare const DEFAULT_MAX_ITERATIONS = 1000;
28
+ /**
29
+ * A resolved continuation target — the node to execute next plus the
30
+ * StageContext to execute it in. All side effects (iteration counting,
31
+ * debug logs, `onLoop` narrative) have already fired by the time this
32
+ * is returned.
33
+ */
34
+ export interface ResolvedContinuation<TOut = any, TScope = any> {
35
+ node: StageNode<TOut, TScope>;
36
+ context: StageContext;
37
+ }
19
38
  export declare class ContinuationResolver<TOut = any, TScope = any> {
20
39
  private readonly deps;
21
40
  private readonly nodeResolver;
@@ -28,19 +47,27 @@ export declare class ContinuationResolver<TOut = any, TScope = any> {
28
47
  private readonly maxIterations;
29
48
  constructor(deps: HandlerDeps<TOut, TScope>, nodeResolver: NodeResolver<TOut, TScope>, onIterationUpdate?: (nodeId: string, count: number) => void, maxIterations?: number);
30
49
  /**
31
- * Resolve a dynamic continuation.
32
- * Dispatches to handleStringReference, handleDirectNode, or handleNodeReference
33
- * based on the dynamicNext type.
50
+ * Resolve a dynamic continuation and execute it immediately.
51
+ * Equivalent to `executeNode(...resolveTarget(...))` the traverser's
52
+ * driver loop calls `resolveTarget` directly instead so the continuation
53
+ * becomes a flat trampoline hop rather than a retained recursive frame.
34
54
  */
35
55
  resolve(dynamicNext: string | StageNode<TOut, TScope>, node: StageNode<TOut, TScope>, context: StageContext, breakFlag: {
36
56
  shouldBreak: boolean;
37
57
  }, branchPath: string | undefined, executeNode: ExecuteNodeFn<TOut, TScope>, traversalContext?: TraversalContext): Promise<any>;
38
- /** dynamicNext is a string ID → resolve from graph, track iteration. */
39
- private handleStringReference;
40
- /** dynamicNext is a StageNode with fn execute directly (truly dynamic). */
41
- private handleDirectNode;
42
- /** dynamicNext is a StageNode without fn → reference by ID, resolve + track iteration. */
43
- private handleNodeReference;
58
+ /**
59
+ * Resolve a dynamic continuation to its target node + next StageContext
60
+ * WITHOUT executing it. Fires the same side effects `resolve` always did
61
+ * (iteration counting + limit, `dynamicNext*` logs, loop debug message,
62
+ * `onLoop` narrative), in the same order.
63
+ *
64
+ * Three dynamicNext patterns:
65
+ * - StageNode with fn → truly dynamic node, returned as-is (no iteration
66
+ * tracking — it is a fresh node, not a back-edge).
67
+ * - String ID → reference to an existing node, resolved via NodeResolver.
68
+ * - StageNode without fn → reference by ID, resolved via NodeResolver.
69
+ */
70
+ resolveTarget(dynamicNext: string | StageNode<TOut, TScope>, currentNode: StageNode<TOut, TScope>, context: StageContext, branchPath: string | undefined, traversalContext?: TraversalContext): ResolvedContinuation<TOut, TScope>;
44
71
  /**
45
72
  * Get the next iteration number for a node and increment.
46
73
  * Returns 0 for first visit, 1 for second, etc.
@@ -3,6 +3,16 @@
3
3
  *
4
4
  * Handles scope-based deciders (stage IS the decider, returns branch ID).
5
5
  * Logs flow control decisions and narrative sentences.
6
+ *
7
+ * Two entry points:
8
+ * - `prepareDispatch` — runs the decider stage, commits, resolves the chosen
9
+ * branch, fires narrative, and returns the chosen node + branch context
10
+ * WITHOUT executing it. The traverser's trampoline driver uses this so a
11
+ * decider with no continuation of its own can hand the branch to the
12
+ * driver as a flat hop (loop-heavy decider charts stay flat-stacked).
13
+ * - `handleScopeBased` — prepareDispatch + immediate branch execution via
14
+ * the provided `executeNode` callback. Kept for direct/advanced callers
15
+ * and for deciders whose own `.next` must run after the branch completes.
6
16
  */
7
17
  import type { StageContext } from '../../memory/StageContext.js';
8
18
  import type { StageNode } from '../graph/StageNode.js';
@@ -10,6 +20,19 @@ import type { TraversalContext } from '../narrative/types.js';
10
20
  import type { HandlerDeps, StageFunction } from '../types.js';
11
21
  import type { ExecuteNodeFn, RunStageFn } from './types.js';
12
22
  export type { ExecuteNodeFn, RunStageFn };
23
+ /**
24
+ * Result of `prepareDispatch` — either the decider stage broke (no branch
25
+ * runs; `branchId` is the decider's return value), or a branch was chosen
26
+ * and is ready to execute in `branchContext`.
27
+ */
28
+ export type DeciderDispatch<TOut = any, TScope = any> = {
29
+ kind: 'break';
30
+ branchId: string;
31
+ } | {
32
+ kind: 'dispatch';
33
+ chosen: StageNode<TOut, TScope>;
34
+ branchContext: StageContext;
35
+ };
13
36
  export declare class DeciderHandler<TOut = any, TScope = any> {
14
37
  private readonly deps;
15
38
  constructor(deps: HandlerDeps<TOut, TScope>);
@@ -21,4 +44,13 @@ export declare class DeciderHandler<TOut = any, TScope = any> {
21
44
  handleScopeBased(node: StageNode<TOut, TScope>, stageFunc: StageFunction<TOut, TScope>, context: StageContext, breakFlag: {
22
45
  shouldBreak: boolean;
23
46
  }, branchPath: string | undefined, runStage: RunStageFn<TOut, TScope>, executeNode: ExecuteNodeFn<TOut, TScope>, traversalContext?: TraversalContext): Promise<any>;
47
+ /**
48
+ * Run the decider stage and resolve the chosen branch WITHOUT executing it.
49
+ * Everything up to (and including) the `onDecision`/`onStageExecuted`
50
+ * narrative and the branch context creation happens here — only the
51
+ * branch execution itself is left to the caller.
52
+ */
53
+ prepareDispatch(node: StageNode<TOut, TScope>, stageFunc: StageFunction<TOut, TScope>, context: StageContext, breakFlag: {
54
+ shouldBreak: boolean;
55
+ }, branchPath: string | undefined, runStage: RunStageFn<TOut, TScope>, traversalContext?: TraversalContext): Promise<DeciderDispatch<TOut, TScope>>;
24
56
  }
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * FlowchartTraverser — Pre-order DFS traversal of StageNode graph.
3
3
  *
4
- * Unified traversal algorithm for all node shapes:
5
- * const pre = await prep();
6
- * const [x, y] = await Promise.all([fx(pre), fy(pre)]);
7
- * return await next(x, y);
4
+ * Unified traversal algorithm for all node shapes. `executeNode` is a
5
+ * TRAMPOLINE driver: it runs `executeNodeStep` (one node, all 7 phases) in
6
+ * a flat loop, following tail continuations (linear `next`, loop edges,
7
+ * dynamic next, flat decider dispatch) iteratively — so chain length and
8
+ * loop iterations never grow the call stack. Only true tree nesting (fork
9
+ * children, with-continuation decider/selector branches, subflow mounts)
10
+ * recurses.
8
11
  *
9
- * For each node, executeNode follows 7 phases:
12
+ * For each node, executeNodeStep follows 7 phases:
10
13
  * 0. CLASSIFY — subflow detection, early delegation
11
14
  * 1. VALIDATE — node invariants, role markers
12
15
  * 2. EXECUTE — run stage fn, commit, break check
@@ -49,10 +52,17 @@ export interface TraverserOptions<TOut = any, TScope = any> {
49
52
  */
50
53
  narrativeGenerator?: IControlFlowNarrative;
51
54
  /**
52
- * Maximum recursive executeNode depth. Defaults to FlowchartTraverser.MAX_EXECUTE_DEPTH (500).
53
- * Override in tests or unusually deep pipelines.
55
+ * Maximum nested executeNode depth (tree nesting branch/fork dispatch and
56
+ * dynamic recursion, NOT linear chains or loop iterations, which run flat).
57
+ * Defaults to FlowchartTraverser.MAX_EXECUTE_DEPTH (500).
54
58
  */
55
59
  maxDepth?: number;
60
+ /**
61
+ * Maximum loop iterations per node (the ContinuationResolver guard).
62
+ * Defaults to DEFAULT_MAX_ITERATIONS (1000). Propagated to subflow
63
+ * traversers. Must be >= 1.
64
+ */
65
+ maxIterations?: number;
56
66
  /**
57
67
  * When this traverser runs inside a subflow, set this to the subflow's ID.
58
68
  * Propagated to TraversalContext so narrative entries carry the correct subflowId.
@@ -158,11 +168,26 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
158
168
  private readonly dynamicPatches;
159
169
  private patchCount;
160
170
  /**
161
- * Recursion depth counter for executeNode.
162
- * Each recursive executeNode call increments this; decrements on exit (try/finally).
163
- * Prevents call-stack overflow on infinite loops or excessively deep stage chains.
171
+ * TREE-nesting depth counter for executeNode (the trampoline driver).
172
+ * Each driver invocation increments this; decrements on exit (try/finally).
173
+ *
174
+ * Linear `next` chains, loop edges, and dynamic continuations are followed
175
+ * ITERATIVELY inside one driver invocation, so they never grow this
176
+ * counter. Only true tree recursion does: fork children, decider/selector
177
+ * branch dispatch (when the decider has its own continuation), and
178
+ * unbounded dynamic recursion. Prevents call-stack overflow on runaway
179
+ * recursive composition.
164
180
  */
165
181
  private _executeDepth;
182
+ /**
183
+ * Memoized parent-chain depth per StageContext. The context tree deepens
184
+ * by one per executed stage along a chain, so the naive parent-walk in
185
+ * `computeContextDepth` is O(chain length) per stage — O(n²) per run once
186
+ * the trampoline allows chains of tens of thousands of stages. Contexts
187
+ * are visited parent-before-child, so the memo makes each lookup O(1)
188
+ * amortized. WeakMap — dies with the traverser.
189
+ */
190
+ private readonly contextDepthCache;
166
191
  /**
167
192
  * Shared mutable execution counter — monotonic, incremented per stage execution.
168
193
  * Shared with child traversers (subflows) so indices are globally unique within a run.
@@ -173,18 +198,26 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
173
198
  */
174
199
  private readonly _maxDepth;
175
200
  /**
176
- * Default maximum recursive executeNode depth before an error is thrown.
177
- * 500 comfortably covers any realistic pipeline depth (including deeply nested
178
- * subflows) while preventing call-stack overflow (~10 000 frames in V8).
201
+ * Per-instance loop-iteration limit forwarded to the ContinuationResolver
202
+ * and propagated to subflow traversers. Undefined resolver default (1000).
203
+ */
204
+ private readonly _maxIterations?;
205
+ /**
206
+ * Default maximum nested executeNode depth before an error is thrown.
179
207
  *
180
- * **Note on counting:** the counter increments once per `executeNode` call, not once per
181
- * logical user stage. Subflow root entry and subflow continuation after return each cost
182
- * one tick. For pipelines with many nested subflows, budget roughly 2 × (avg stages per
183
- * subflow) of headroom when computing a custom `maxDepth` via `RunOptions.maxDepth`.
208
+ * **What counts as depth (trampoline model):** `executeNode` is an iterative
209
+ * driver linear `next` hops, loop edges (`loopTo`/dynamic next), and
210
+ * dynamic-subflow re-entry are followed in a flat loop and consume NO depth.
211
+ * Depth grows only with true tree nesting: one tick per fork child, one per
212
+ * decider/selector branch dispatch that must return to its invoker (decider
213
+ * with its own `next`), one per subflow mount frame in the parent (the
214
+ * subflow body itself runs on a FRESH traverser with its own budget).
184
215
  *
185
- * **Note on loops:** for `loopTo()` pipelines, this depth guard and `ContinuationResolver`'s
186
- * iteration limit are independent the lower one fires first. The default depth guard (500)
187
- * fires before the default iteration limit (1000) for loop-heavy pipelines.
216
+ * 500 therefore covers any realistic chart it bounds recursive
217
+ * COMPOSITION, not chain length or loop count. Loops are bounded by
218
+ * `ContinuationResolver`'s independent iteration limit (default 1000,
219
+ * configurable via `RunOptions.maxIterations`), which is now the binding
220
+ * constraint for loop-heavy pipelines.
188
221
  *
189
222
  * @remarks Not safe for concurrent `.execute()` calls on the same instance — concurrent
190
223
  * executions race on `_executeDepth`. Use a separate `FlowchartTraverser` per concurrent
@@ -243,7 +276,9 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
243
276
  /**
244
277
  * Build an O(1) ID→node map from the root graph.
245
278
  * Used by NodeResolver to avoid repeated DFS on every loopTo() call.
246
- * Depth-guarded at MAX_EXECUTE_DEPTH to prevent infinite recursion on cyclic graphs.
279
+ * Iterative worklist (no recursion) so arbitrarily long chains index fully;
280
+ * the `map.has` guard handles cyclic refs. First-visited node wins per ID —
281
+ * worklist order matches the old recursive pre-order (children, then next).
247
282
  * Dynamic subflows and lazy-resolved nodes are added to stageMap at runtime but not to this map —
248
283
  * those use the DFS fallback in NodeResolver.
249
284
  */
@@ -269,11 +304,44 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
269
304
  private effNode;
270
305
  private executeStage;
271
306
  /**
272
- * Pre-order DFS traversal the core algorithm.
273
- * Each call processes one node through all 7 phases.
307
+ * Trampoline driver — pre-order DFS traversal entry point.
308
+ *
309
+ * Runs `executeNodeStep` (one node, all 7 phases) in a flat loop: every
310
+ * TAIL continuation (linear `next`, loop edge, dynamic next / dynamic
311
+ * re-entry, no-continuation decider dispatch) comes back as a
312
+ * `ContinuationHop` and is followed ITERATIVELY — neither the call stack
313
+ * nor the retained promise chain grows with chain length or loop count.
314
+ *
315
+ * Recursion remains ONLY for true tree nesting (each gets a nested driver
316
+ * call): fork children (`ChildrenExecutor`), selector branches (parallel
317
+ * fan-out), decider branch dispatch when the decider has its own `next`
318
+ * (the branch must complete BEFORE the decider's continuation runs), and
319
+ * subflow mounts (fresh traverser; the mount frame stays in the parent).
320
+ * `_executeDepth` therefore counts chart COMPOSITION depth only, guarded
321
+ * by `_maxDepth` (default `MAX_EXECUTE_DEPTH` = 500).
322
+ *
323
+ * PauseSignal: a flat decider dispatch records an `InvokerStamp`; if the
324
+ * continued chain later pauses, the driver stamps the signal during
325
+ * unwind — same invoker context the recursive dispatch's catch used to
326
+ * stamp, innermost (most recent dispatch) first.
274
327
  */
275
328
  private executeNode;
329
+ /** Build a flat continuation hop for the driver loop. */
330
+ private hop;
331
+ /**
332
+ * Execute ONE node through all 7 phases — the old recursive `executeNode`
333
+ * body; only the tail calls became `ContinuationHop` returns. Returns the
334
+ * node's result, or a hop for the driver loop to follow.
335
+ */
336
+ private executeNodeStep;
276
337
  private captureDynamicChildrenResult;
338
+ /**
339
+ * Parent-chain length of a StageContext — same value the pre-trampoline
340
+ * walk produced, memoized. The context tree deepens by one per executed
341
+ * stage along a chain, so the naive walk is O(chain length) per stage —
342
+ * O(n²) per run once chains reach trampoline scale. Contexts are visited
343
+ * parent-before-child, so the cached parent makes this O(1) amortized.
344
+ */
277
345
  private computeContextDepth;
278
346
  private prefixNodeTree;
279
347
  private autoRegisterSubflowDef;
@@ -322,12 +322,24 @@ export interface RunOptions {
322
322
  */
323
323
  env?: ExecutionEnv;
324
324
  /**
325
- * Override the maximum recursive `executeNode` depth for this run.
325
+ * Override the maximum nested `executeNode` depth for this run.
326
326
  * Defaults to `FlowchartTraverser.MAX_EXECUTE_DEPTH` (500).
327
- * Useful when deeply nested subflows or long chains need more headroom.
328
- * Must be >= 1.
327
+ *
328
+ * Depth counts TREE nesting only — fork children, decider/selector branch
329
+ * dispatch, recursive composition. Linear chains and loop iterations run
330
+ * on a flat trampoline and never consume depth, so this rarely needs
331
+ * raising. Must be >= 1.
329
332
  */
330
333
  maxDepth?: number;
334
+ /**
335
+ * Override the maximum loop iterations per node for this run (the
336
+ * `ContinuationResolver` infinite-loop guard). Defaults to 1000.
337
+ * This is the binding constraint for loop-heavy pipelines — raise it for
338
+ * legitimately long loops (`loopTo` chains run with a flat stack, so high
339
+ * values are safe; memory for state/narrative still grows per iteration).
340
+ * Propagates to subflows. Must be >= 1.
341
+ */
342
+ maxIterations?: number;
331
343
  }
332
344
  export type { FlowControlType, FlowMessage };
333
345
  export interface RuntimeStructureMetadata {
@@ -117,4 +117,7 @@ export declare class StageContext {
117
117
  }): void;
118
118
  getStageId(): string;
119
119
  getSnapshot(): StageSnapshot;
120
+ /** Snapshot of THIS context's own fields — `next`/`children` are filled
121
+ * in by the iterative walk in `getSnapshot`. */
122
+ private snapshotSelf;
120
123
  }
@@ -212,7 +212,7 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
212
212
  * const result = await executor.resume(restored, { approved: true });
213
213
  * ```
214
214
  */
215
- resume(checkpoint: FlowchartCheckpoint, resumeInput?: unknown, options?: Pick<RunOptions, 'signal' | 'env' | 'maxDepth'>): Promise<ExecutorResult>;
215
+ resume(checkpoint: FlowchartCheckpoint, resumeInput?: unknown, options?: Pick<RunOptions, 'signal' | 'env' | 'maxDepth' | 'maxIterations'>): Promise<ExecutorResult>;
216
216
  /**
217
217
  * Build a fully DETACHED checkpoint from a caught PauseSignal.
218
218
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprintjs",
3
- "version": "8.3.0",
3
+ "version": "9.0.0",
4
4
  "description": "Explainable backend flows — automatic causal traces, decision evidence, and MCP tool generation for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "Sanjay Krishna Anbalagan",