footprintjs 9.40.0 → 9.41.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.
Files changed (60) hide show
  1. package/CLAUDE.md +3 -3
  2. package/dist/esm/lib/builder/FlowChartBuilder.d.ts +10 -3
  3. package/dist/esm/lib/builder/FlowChartBuilder.js +93 -86
  4. package/dist/esm/lib/capture/freeze.d.ts +1 -1
  5. package/dist/esm/lib/capture/freeze.js +2 -2
  6. package/dist/esm/lib/engine/traversal/FlowchartTraverser.d.ts +12 -0
  7. package/dist/esm/lib/engine/traversal/FlowchartTraverser.js +49 -15
  8. package/dist/esm/lib/memory/StageContext.d.ts +16 -0
  9. package/dist/esm/lib/memory/StageContext.js +35 -40
  10. package/dist/esm/lib/reactive/types.js +4 -4
  11. package/dist/esm/lib/recorder/hooks.d.ts +2 -2
  12. package/dist/esm/lib/recorder/hooks.js +2 -2
  13. package/dist/esm/lib/recorder/snapshot.d.ts +1 -1
  14. package/dist/esm/lib/recorder/snapshot.js +2 -2
  15. package/dist/esm/lib/runner/FlowChartExecutor.d.ts +93 -377
  16. package/dist/esm/lib/runner/FlowChartExecutor.js +184 -1059
  17. package/dist/esm/lib/runner/attach.d.ts +95 -0
  18. package/dist/esm/lib/runner/attach.js +247 -0
  19. package/dist/esm/lib/runner/checkpoint.d.ts +69 -0
  20. package/dist/esm/lib/runner/checkpoint.js +254 -0
  21. package/dist/esm/lib/runner/index.d.ts +1 -1
  22. package/dist/esm/lib/runner/index.js +1 -1
  23. package/dist/esm/lib/runner/options.d.ts +79 -0
  24. package/dist/esm/lib/runner/options.js +31 -0
  25. package/dist/esm/lib/runner/resume.d.ts +73 -0
  26. package/dist/esm/lib/runner/resume.js +301 -0
  27. package/dist/esm/lib/runner/snapshot.d.ts +41 -0
  28. package/dist/esm/lib/runner/snapshot.js +93 -0
  29. package/dist/lib/builder/FlowChartBuilder.js +93 -86
  30. package/dist/lib/capture/freeze.js +2 -2
  31. package/dist/lib/engine/traversal/FlowchartTraverser.js +49 -15
  32. package/dist/lib/memory/StageContext.js +35 -40
  33. package/dist/lib/reactive/types.js +4 -4
  34. package/dist/lib/recorder/hooks.js +2 -2
  35. package/dist/lib/recorder/snapshot.js +2 -2
  36. package/dist/lib/runner/FlowChartExecutor.js +182 -1057
  37. package/dist/lib/runner/attach.js +251 -0
  38. package/dist/lib/runner/checkpoint.js +258 -0
  39. package/dist/lib/runner/index.js +1 -1
  40. package/dist/lib/runner/options.js +35 -0
  41. package/dist/lib/runner/resume.js +307 -0
  42. package/dist/lib/runner/snapshot.js +98 -0
  43. package/dist/types/lib/builder/FlowChartBuilder.d.ts +10 -3
  44. package/dist/types/lib/capture/freeze.d.ts +1 -1
  45. package/dist/types/lib/engine/traversal/FlowchartTraverser.d.ts +12 -0
  46. package/dist/types/lib/memory/StageContext.d.ts +16 -0
  47. package/dist/types/lib/recorder/hooks.d.ts +2 -2
  48. package/dist/types/lib/recorder/snapshot.d.ts +1 -1
  49. package/dist/types/lib/runner/FlowChartExecutor.d.ts +93 -377
  50. package/dist/types/lib/runner/attach.d.ts +95 -0
  51. package/dist/types/lib/runner/checkpoint.d.ts +69 -0
  52. package/dist/types/lib/runner/index.d.ts +1 -1
  53. package/dist/types/lib/runner/options.d.ts +79 -0
  54. package/dist/types/lib/runner/resume.d.ts +73 -0
  55. package/dist/types/lib/runner/snapshot.d.ts +41 -0
  56. package/package.json +1 -1
  57. package/dist/esm/lib/runner/checkpointSanitize.d.ts +0 -44
  58. package/dist/esm/lib/runner/checkpointSanitize.js +0 -133
  59. package/dist/lib/runner/checkpointSanitize.js +0 -138
  60. package/dist/types/lib/runner/checkpointSanitize.d.ts +0 -44
@@ -14,6 +14,11 @@
14
14
  * // 2-param form (accepts a ScopeFactory directly, for backward compatibility):
15
15
  * const executor = new FlowChartExecutor(chart, myFactory);
16
16
  *
17
+ *
18
+ * The executor composes four modules, one job each (F9): `attach.ts` (who
19
+ * observes a run), `resume.ts` (plan and announce a re-entry), `checkpoint.ts`
20
+ * (the pause checkpoint), `snapshot.ts` (what `getSnapshot()` serves). What
21
+ * stays here is the per-run state and the run lifecycle that threads it.
17
22
  * const result = await executor.run({ input: data, env: { traceId: 'req-123' } });
18
23
  */
19
24
  import type { FlowChart } from '../builder/types.js';
@@ -21,123 +26,39 @@ import type { CombinedNarrativeRecorderOptions } from '../engine/narrative/Combi
21
26
  import type { CombinedNarrativeEntry } from '../engine/narrative/narrativeTypes.js';
22
27
  import type { ManifestEntry } from '../engine/narrative/recorders/ManifestFlowRecorder.js';
23
28
  import type { FlowRecorder } from '../engine/narrative/types.js';
24
- import { type ExecutorResult, type RunOptions, type ScopeFactory, type SerializedPipelineStructure, type StageNode, type StreamHandlers, type SubflowResult } from '../engine/types.js';
25
- import type { RunDials } from '../memory/runPolicy.js';
29
+ import { type ExecutorResult, type RunOptions, type ScopeFactory, type SerializedPipelineStructure, type StageNode, type SubflowResult } from '../engine/types.js';
26
30
  import type { CommitValuesMode, ReadTrackingMode, WriteTrackingMode } from '../memory/types.js';
27
31
  import type { FlowchartCheckpoint } from '../pause/types.js';
28
32
  import type { CombinedRecorder } from '../recorder/CombinedRecorder.js';
29
33
  import type { EmitRecorder } from '../recorder/EmitRecorder.js';
30
- import type { ScopeProtectionMode } from '../scope/protection/types.js';
31
34
  import type { RedactionPolicy, RedactionReport, ScopeRecorder } from '../scope/types.js';
32
- import { type AttachRecorderOptions, type ObserverDrainResult } from './DeferredObserverTier.js';
35
+ import type { AttachRecorderOptions, ObserverDrainResult } from './DeferredObserverTier.js';
33
36
  import { type RuntimeSnapshot } from './ExecutionRuntime.js';
34
- /**
35
- * Options object for `FlowChartExecutor` — preferred over positional params.
36
- *
37
- * ```typescript
38
- * const ex = new FlowChartExecutor(chart, {
39
- * scopeFactory: myFactory,
40
- * defaultValuesForContext: { ... },
41
- * });
42
- * ```
43
- *
44
- * **Sync note for maintainers:** Every field added here must also appear in the
45
- * `flowChartArgs` private field type and in the constructor's options-resolution
46
- * block (the `else if` branch that reads from `opts`). Missing any one of the
47
- * three causes silent omission — the option is accepted but never applied.
48
- * The observability DIALS are the exception: they come from `RunDials`
49
- * (memory/runPolicy.ts) and travel as one `dials` field, so a new dial is
50
- * added there and nowhere here.
51
- *
52
- * **TScope inference note:** When using the options-object form with a custom scope,
53
- * TypeScript cannot infer `TScope` through the options object. Pass the type
54
- * explicitly: `new FlowChartExecutor<TOut, MyScope>(chart, { scopeFactory })`.
55
- */
56
- export interface FlowChartExecutorOptions<TScope = any> extends RunDials {
57
- /** Custom scope factory. Defaults to TypedScope or ScopeFacade auto-detection. */
58
- scopeFactory?: ScopeFactory<TScope>;
59
- /**
60
- * Default values pre-populated into the shared context before **each** stage
61
- * (re-applied every stage, acting as baseline defaults).
62
- */
63
- defaultValuesForContext?: unknown;
64
- /**
65
- * Initial context values merged into the shared context **once** at startup
66
- * (applied before the first stage, not repeated on subsequent stages).
67
- * Distinct from `defaultValuesForContext`, which is re-applied every stage.
68
- */
69
- initialContext?: unknown;
70
- /** Read-only input accessible via `scope.getArgs()` — never tracked or written. */
71
- readOnlyContext?: unknown;
72
- /**
73
- * Custom error classifier for throttling detection. Return `true` if a fork
74
- * child's error represents a rate-limit or backpressure condition; the
75
- * executor then fires `FlowRecorder.onThrottled` for that child (9.39.0 —
76
- * an EVENT, not a state key: the `monitor.isThrottled` write it replaced
77
- * never landed). The child's failure is otherwise handled as before.
78
- * Defaults to no throttling classification.
79
- */
80
- throttlingErrorChecker?: (error: unknown) => boolean;
81
- /** Handlers for streaming stage lifecycle events (see `addStreamingFunction`). */
82
- streamHandlers?: StreamHandlers;
83
- /** Scope protection mode for TypedScope direct-assignment detection. */
84
- scopeProtectionMode?: ScopeProtectionMode;
85
- }
37
+ import { type FlowChartExecutorOptions } from './options.js';
86
38
  export declare class FlowChartExecutor<TOut = any, TScope = any> {
87
39
  private traverser;
88
- /** Shared execution counter — survives pause/resume. Reset on fresh run(). */
40
+ /** Shared execution counter and per-stage visit counts (loopIteration) — shared BY
41
+ * REFERENCE with every traverser of the run; survive pause/resume, reset on run(). */
89
42
  private _executionCounter;
90
- /** Shared per-run visit counts (by stageId) driving TraversalContext.loopIteration.
91
- * Twin of _executionCounter: survives pause/resume, reset on fresh run(). */
92
43
  private _visitCounts;
93
- /** Per-`run()` identifier — generated fresh per run + per resume. Threaded
94
- * through every TraversalContext so recorders can scope state to a single
95
- * run. See `runId.ts`. */
44
+ /** Fresh per run() and per resume() — stamped on every TraversalContext (`runId.ts`). */
96
45
  private _currentRunId;
97
- private narrativeEnabled;
98
- private narrativeOptions?;
99
- private combinedRecorder;
100
- private flowRecorders;
101
- private scopeRecorders;
102
- /**
103
- * RFC-001 deferred-observer wiring — created LAZILY on the first
104
- * `delivery: 'deferred'` attach. `undefined` for every executor that never
105
- * opts in: zero allocation, zero per-event cost, byte-identical behavior
106
- * (the emit fast-path precedent).
107
- */
108
- private deferredTier?;
46
+ /** Who observes the run — narrative, inline lists, deferred tier (`attach.ts`). */
47
+ private readonly observers;
109
48
  private redactionPolicy;
110
- /**
111
- * The run's ONE redaction rule (`memory/redaction.ts`): built fresh per
112
- * `createTraverser` (per-call marks reset per run and per resume, as the
113
- * shared set always did), installed on the runtime root so every context
114
- * retains under it, and read by every `ScopeFacade` through its context.
115
- */
49
+ /** The run's ONE redaction rule (`memory/redaction.ts`), built fresh per leg. */
116
50
  private redactionRule;
117
51
  private lastCheckpoint;
118
52
  /**
119
- * `true` once `run()` (or a previous `resume()`) has executed on
120
- * this instance. `resume()` branches on it:
121
- *
122
- * • true → reuse the constructor-time runtime (same-executor
123
- * continuity: execution tree, recorders, narrative
124
- * accumulate across pause/resume cycles)
125
- * • false → seed a fresh runtime from `checkpoint.sharedState`
126
- * (cross-executor / cross-process resume: new instance
127
- * reconstructed from a serialized checkpoint)
128
- *
129
- * Without this flag, fresh executors silently discarded the
130
- * checkpoint's sharedState and resume handlers couldn't read pre-pause
131
- * scope. See `test/lib/pause/cross-executor-resume.test.ts`.
53
+ * `true` once run() (or a resume) executed here. `resume()` branches on it:
54
+ * true → reuse the runtime (execution tree, recorders, narrative accumulate);
55
+ * false → seed a fresh runtime from `checkpoint.sharedState` (cross-executor
56
+ * resume). See `test/lib/pause/cross-executor-resume.test.ts`.
132
57
  */
133
58
  private _hasRunBefore;
134
59
  /**
135
- * Re-entrancy guard. `run()` and `resume()` mutate per-run instance state
136
- * (traverser, runId, execution counter, checkpoint) and clear attached
137
- * recorders — a second concurrent entry on the SAME executor would
138
- * interleave runIds and cross-contaminate recorder/narrative state, and
139
- * `getCheckpoint()` would return whichever run paused last. One executor =
140
- * one in-flight execution; create an executor per concurrent run.
60
+ * Re-entrancy guard: run()/resume() mutate per-run state (traverser, runId,
61
+ * counters, checkpoint, recorders) — one executor = one in-flight execution.
141
62
  * See docs/guides/execution-model.md.
142
63
  */
143
64
  private _isExecuting;
@@ -166,28 +87,11 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
166
87
  * Must be called before run().
167
88
  */
168
89
  setRedactionPolicy(policy: RedactionPolicy): void;
169
- /**
170
- * Set the read-tracking policy for `StageSnapshot.stageReads` (#14).
171
- * Must be called before run(). Equivalent to the `readTracking`
172
- * constructor option — see {@link FlowChartExecutorOptions.readTracking}
173
- * for the mode semantics ('full' default / 'summary' / 'off').
174
- */
90
+ /** The `readTracking` dial (see {@link FlowChartExecutorOptions.readTracking}). Call before run(). */
175
91
  setReadTracking(mode: ReadTrackingMode): void;
176
- /**
177
- * Set the write-tracking policy for `StageSnapshot.stageWrites` (#13c-A).
178
- * Must be called before run(). Equivalent to the `writeTracking`
179
- * constructor option — see {@link FlowChartExecutorOptions.writeTracking}
180
- * for the mode semantics ('full' default / 'summary' / 'off'), the
181
- * onCommit-payload consequence, and the redaction-precedence rule.
182
- */
92
+ /** The `writeTracking` dial (see {@link FlowChartExecutorOptions.writeTracking}). Call before run(). */
183
93
  setWriteTracking(mode: WriteTrackingMode): void;
184
- /**
185
- * Set the commit-values encoding policy for the commit log (#13c-B).
186
- * Must be called before run(). Equivalent to the `commitValues`
187
- * constructor option — see {@link FlowChartExecutorOptions.commitValues}
188
- * for the mode semantics ('full' default / 'delta'), the verb-qualified
189
- * `overwrite` consequence, and the `commitValueAt` migration helper.
190
- */
94
+ /** The `commitValues` dial (see {@link FlowChartExecutorOptions.commitValues}). Call before run(). */
191
95
  setCommitValues(mode: CommitValuesMode): void;
192
96
  /**
193
97
  * Returns a compliance-friendly report of all redaction activity from the
@@ -198,12 +102,9 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
198
102
  * Returns the checkpoint from the most recent paused execution, or `undefined`
199
103
  * if the last run completed without pausing.
200
104
  *
201
- * The checkpoint is JSON-serializable — store it in Redis, Postgres, localStorage, etc.
202
- *
203
- * It is fully DETACHED from engine state: every field was deep-copied at
204
- * pause time (see `buildPauseCheckpoint`). Holding, mutating, or persisting
205
- * it cannot affect the executor, and a later same-executor resume cannot
206
- * mutate a checkpoint you already stored.
105
+ * JSON-serializable (store it in Redis, Postgres, localStorage…) and fully
106
+ * DETACHED from engine state (`checkpoint.ts`): mutating or persisting it
107
+ * cannot affect the executor, nor a later resume a checkpoint you stored.
207
108
  *
208
109
  * @example
209
110
  * ```typescript
@@ -226,38 +127,23 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
226
127
  *
227
128
  * Returns 0 before any run; after, returns the cumulative commit
228
129
  * count across the executor's lifetime (including resumes).
229
- *
230
- * IMPLEMENTATION NOTE: this returns `runtime.executionHistory.length`,
231
- * which is the same value as `getSnapshot().commitLog.length`. The
232
- * naming asymmetry is historical — the underlying `EventLog` field
233
- * is named `executionHistory` but stores the `CommitBundle[]` that
234
- * `commitLog` exposes. They always report the same LENGTH (verified by the
235
- * "matches commitLog.length" integration test); since 9.17.0 the snapshot
236
- * serves a detached frozen COPY of the array, not the live one.
130
+ * Always equals `getSnapshot().commitLog.length` (the log is the
131
+ * `EventLog`'s `executionHistory`; the snapshot serves a frozen copy).
237
132
  */
238
133
  getCommitCount(): number;
239
134
  /**
240
135
  * Resume a paused flowchart from a checkpoint.
241
136
  *
242
- * Restores the scope state, calls the paused stage's `resumeFn` with the
243
- * provided input (or, for an `interrupt()` pause, re-runs the stage with the
244
- * answer), then continues with whatever ran after that stage on the run —
245
- * its own `next`, or the continuation of the decider, selector or fork that
246
- * dispatched it, at every level of the pause path — and from there walks the
247
- * chart as built. When parallel siblings paused in the same fan-out
248
- * (`checkpoint.pendingPauses`), the run pauses again with the next sibling's
249
- * question once this one's branch is done; the fan-out's join runs after the
250
- * last answer. See `ResumeEntry` (`footprintjs/advanced`).
137
+ * Restores the scope state, runs the paused stage's `resumeFn` with the input
138
+ * (an `interrupt()` pause re-runs the stage with the answer), then continues
139
+ * with whatever ran after that stage on the run — its `next`, or its
140
+ * dispatcher's continuation, at every level of the pause path. Parallel
141
+ * siblings that paused together (`checkpoint.pendingPauses`) are asked in
142
+ * turn; the join runs after the last answer. See `ResumeEntry`.
251
143
  *
252
- * The checkpoint can come from `getCheckpoint()` on a previous run, or from
253
- * a serialized checkpoint stored in Redis/Postgres/localStorage.
254
- *
255
- * **Recorder/narrative state depends on the resume mode.** Resuming on the SAME
256
- * executor that ran preserves and accumulates narrative/metrics/debug across the
257
- * pause/resume cycle (preserveRecorders). Resuming on a FRESH executor
258
- * (reconstructed from a stored checkpoint) starts with empty recorder state —
259
- * collect what you need before discarding the paused executor. A fresh `runId`
260
- * is generated either way.
144
+ * The checkpoint comes from `getCheckpoint()` or from storage. On the SAME
145
+ * executor, narrative/recorder state accumulates across the pause; on a
146
+ * FRESH executor it starts empty. A fresh `runId` either way.
261
147
  *
262
148
  * @example
263
149
  * ```typescript
@@ -273,139 +159,37 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
273
159
  */
274
160
  resume(checkpoint: FlowchartCheckpoint, resumeInput?: unknown, options?: Pick<RunOptions, 'signal' | 'env' | 'maxDepth' | 'maxIterations'>): Promise<ExecutorResult>;
275
161
  /**
276
- * Build a fully DETACHED checkpoint from a caught PauseSignal.
277
- *
278
- * Every field is deep-copied via one `structuredClone` of the assembled
279
- * checkpoint, because the raw pieces alias live engine state:
280
- *
281
- * - `sharedState` IS `SharedMemory`'s current generation — never edited,
282
- * but (copy-on-write, 9.29.0) every later generation shares its
283
- * unchanged subtrees, so a checkpoint that aliased it would alias the
284
- * resumed run's state too.
285
- * - `executionTree` nodes are fresh, but their `logs`/`errors`/`metrics`/
286
- * `evals`/`stageReads`/`flowMessages` fields reference live
287
- * `DiagnosticCollector` bags that keep accumulating on same-executor
288
- * resume.
289
- * - `subflowStates` values are shallow copies whose NESTED objects alias
290
- * subflow memory, and they get seeded back into live runtimes on resume.
291
- * - `subflowResults` values stay referenced by the traverser's results map.
292
- *
293
- * The checkpoint is persisted by contract ("store in Redis/Postgres") — it
294
- * must never share structure with the engine. Pause is not a hot path; the
295
- * clone cost is irrelevant.
296
- *
297
- * The JSON-safe checkpoint contract (no functions, no class instances)
298
- * governs CONSUMER-owned data — but the executionTree's diagnostic bags
299
- * accept ANY value at write time without cloning ($debug/$error/$metric/
300
- * $eval store raw references), so a contract-compliant run can still carry
301
- * a non-cloneable diagnostic. Observability side-bags never abort traversal
302
- * anywhere else in the library, so they must not abort the pause either:
303
- * on clone failure we sanitize the diagnostic bags (non-cloneable values
304
- * become '[non-serializable: …]' markers — the live engine bags are never
305
- * touched) and retry. If the retry STILL fails, the violation is in
306
- * consumer-owned data (realistically `pauseData` — a function can never
307
- * reach shared state in the first place: TransactionBuffer clones every
308
- * written value at write time, so the offending write already rejected)
309
- * and we throw a DESCRIPTIVE contract error naming the offending
310
- * checkpoint field(s). A naked DataCloneError never escapes.
311
- *
312
- * Subflow scope capture (`subflowStates`) survives ONLY on the signal — the
313
- * nested runtimes are GC'd as the stack unwinds. Promoting it onto the
314
- * checkpoint here lets cross-executor resume restore pre-pause subflow
315
- * scope (e.g. an Agent's `scope.history`). Empty `{}` for root-level pauses.
316
- */
317
- private buildPauseCheckpoint;
318
- /**
319
- * Find a StageNode in the compiled graph by ID.
320
- * Handles subflow paths by drilling into registered subflows.
162
+ * The settle of a leg that threw: flush the deferred tier (the OUTERMOST
163
+ * handler — a pause re-throws through subflow traversers without exit
164
+ * events, so per-traverser hooks would miss it), then turn a pause into a
165
+ * detached checkpoint (`checkpoint.ts`) and a `PausedResult`; anything else
166
+ * is rethrown.
321
167
  */
322
- private findNodeInGraph;
323
- /** DFS search for a node by ID in the StageNode graph. Cycle-safe via visited set. */
324
- private dfsFind;
168
+ private pausedOrThrow;
169
+ /** The re-entrancy guard: one executor = one in-flight execution. */
170
+ private assertIdle;
325
171
  /**
326
- * Attach a scope ScopeRecorder to observe data operations (reads, writes, commits).
327
- * Automatically attached to every ScopeFacade created during traversal.
328
- * Must be called before run().
329
- *
330
- * **Idempotent by ID:** If a recorder with the same `id` is already attached,
331
- * it is replaced (not duplicated). This prevents double-counting when both
332
- * a framework and the user attach the same recorder type.
333
- *
334
- * Built-in recorders use auto-increment IDs (`metrics-1`, `debug-1`, ...) by
335
- * default, so multiple instances with different configs coexist. To override
336
- * a framework-attached recorder, pass the same well-known ID.
172
+ * Attach a ScopeRecorder to observe data operations (reads, writes, commits)
173
+ * on every stage scope. Call before run(). **Idempotent by `id`** (replaced,
174
+ * never duplicated). `{ delivery: 'deferred' }` moves it off the hot path —
175
+ * delivered at the next microtask checkpoint; re-attaching an id on the
176
+ * other tier SWAPS tiers (`docs/guides/observers-deferred.md`).
337
177
  *
338
178
  * @example
339
179
  * ```typescript
340
- * // Multiple recorders with different configs — each gets a unique ID
341
180
  * executor.attachScopeRecorder(new MetricRecorder());
342
- * executor.attachScopeRecorder(new DebugRecorder({ verbosity: 'minimal' }));
343
- *
344
- * // Override a framework-attached recorder by passing its well-known ID
345
- * executor.attachScopeRecorder(new MetricRecorder('metrics'));
346
- *
347
- * // Attaching twice with same ID replaces (no double-counting)
348
- * executor.attachScopeRecorder(new MetricRecorder('my-metrics'));
349
- * executor.attachScopeRecorder(new MetricRecorder('my-metrics')); // replaces previous
181
+ * executor.attachScopeRecorder(new MetricRecorder('my-metrics')); // replaces same id
350
182
  * ```
351
- *
352
- * **Delivery tier (RFC-001):** pass `{ delivery: 'deferred' }` to take the
353
- * recorder out of the engine's hot path — events are captured into a
354
- * bounded queue and delivered at the next microtask checkpoint ("one beat
355
- * behind"). Omitting `delivery` keeps the historical synchronous call,
356
- * byte-identical to previous releases. Re-attaching the same `id` with a
357
- * different tier SWAPS tiers cleanly — never double delivery. See
358
- * `docs/guides/observers-deferred.md`.
359
183
  */
360
184
  attachScopeRecorder(recorder: ScopeRecorder, options?: AttachRecorderOptions): void;
361
- /**
362
- * Lazily create the executor's ONE deferred-observer tier (one merged
363
- * queue, total event order across all three channels). The FIRST deferred
364
- * attach's options configure the dispatcher; later differing options are
365
- * dev-warned and ignored (see `AttachRecorderOptions`).
366
- */
367
- private ensureDeferredTier;
368
- /**
369
- * Detach a child flowchart on the given driver and return a `DetachHandle`
370
- * the caller can `wait()` on (Promise) or read `.status` from (sync).
371
- *
372
- * The driver is a REQUIRED first argument — there is no library-default,
373
- * to keep the engine free of driver imports and to make the choice of
374
- * scheduling algorithm explicit at the call site.
375
- *
376
- * @example
377
- * ```typescript
378
- * import { microtaskBatchDriver } from 'footprintjs/detach';
379
- *
380
- * const exec = new FlowChartExecutor(parentChart);
381
- * const handle = exec.detachAndJoinLater(microtaskBatchDriver, telemetryChart, { event: 'x' });
382
- * await handle.wait(); // optional
383
- * ```
384
- */
385
- detachAndJoinLater(driver: import('../detach/types.js').DetachDriver, child: import('../builder/types.js').FlowChart, input?: unknown): import('../detach/types.js').DetachHandle;
386
- /**
387
- * Detach a child flowchart on the given driver and DISCARD the handle.
388
- * Use for telemetry exports / fire-and-forget side effects where the
389
- * caller doesn't care about the result.
390
- *
391
- * Errors raised by the child still land on the (discarded) handle — they
392
- * go silent unless surfaced through a recorder. For observable detach,
393
- * prefer `detachAndJoinLater` and surface failures via `.wait().catch()`.
394
- */
395
- detachAndForget(driver: import('../detach/types.js').DetachDriver, child: import('../builder/types.js').FlowChart, input?: unknown): void;
396
185
  /** Detach all scope Recorders with the given ID — both delivery tiers. */
397
186
  detachScopeRecorder(id: string): void;
398
187
  /** Returns a defensive copy of attached scope Recorders (both tiers). */
399
188
  getScopeRecorders(): ScopeRecorder[];
400
189
  /**
401
- * Attach a FlowRecorder to observe control flow events.
402
- * Automatically enables narrative if not already enabled.
403
- * Must be called before run() — recorders are passed to the traverser at creation time.
404
- *
405
- * **Idempotent by ID:** replaces existing recorder with same `id`.
406
- *
407
- * **Delivery tier (RFC-001):** pass `{ delivery: 'deferred' }` for
408
- * next-checkpoint delivery off the hot path — see `attachScopeRecorder`.
190
+ * Attach a FlowRecorder to observe control flow events. Automatically
191
+ * enables narrative. Must be called before run(). Idempotent by `id`;
192
+ * `{ delivery: 'deferred' }` as for `attachScopeRecorder`.
409
193
  */
410
194
  attachFlowRecorder(recorder: FlowRecorder, options?: AttachRecorderOptions): void;
411
195
  /** Detach all FlowRecorders with the given ID — both delivery tiers. */
@@ -413,39 +197,11 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
413
197
  /** Returns a defensive copy of attached FlowRecorders (both tiers). */
414
198
  getFlowRecorders(): FlowRecorder[];
415
199
  /**
416
- * Attach a recorder that may observe multiple event streams (scope
417
- * data-flow, control-flow, or both). Detects at runtime which streams the
418
- * recorder has methods for and routes it to the correct internal channels.
419
- *
420
- * Preferred over calling `attachScopeRecorder` and `attachFlowRecorder`
421
- * separately, because forgetting one of the two is a silent foot-gun —
422
- * half your events never fire and there is no runtime warning. With
423
- * `attachCombinedRecorder` the library guarantees the recorder's declared
424
- * methods all fire, and adds no overhead versus two explicit calls.
425
- *
426
- * ## Idempotency
427
- *
428
- * Idempotent by `id` across ALL channels — re-attaching with the same `id`
429
- * replaces the previous instance everywhere it was registered. Mixing
430
- * `attachCombinedRecorder(x)` with a prior `attachScopeRecorder(y)` or
431
- * `attachFlowRecorder(y)` that share `x.id === y.id` is also safe: the
432
- * combined attach replaces the single-channel registration on whichever
433
- * channel(s) `x` has methods for. No duplicate firings occur.
434
- *
435
- * ## Narrative activation
436
- *
437
- * If the recorder has any control-flow methods, `enableNarrative()` is
438
- * called as a side effect (the narrative subsystem is required to emit
439
- * control-flow events). Data-flow-only recorders do NOT activate the
440
- * narrative.
441
- *
442
- * ## Detection rule
443
- *
444
- * Only **own** event methods count (see `hasRecorderMethods`). Methods
445
- * inherited via the prototype chain are ignored — this protects against
446
- * accidental `Object.prototype` pollution attaching handlers you never
447
- * declared. A recorder that provides only `clear`/`toSnapshot` is a
448
- * no-op and emits a dev-mode warning to surface the likely mistake.
200
+ * Attach a recorder to every channel (scope, flow, emit) it has OWN `on*`
201
+ * methods for — preferred over single-channel calls, where forgetting one
202
+ * silently loses events. Idempotent by `id` across all channels; a flow
203
+ * method enables the narrative; `delivery` comes from the options or the
204
+ * recorder's own field.
449
205
  *
450
206
  * @example
451
207
  * ```typescript
@@ -458,24 +214,11 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
458
214
  * ```
459
215
  */
460
216
  attachCombinedRecorder(recorder: CombinedRecorder, options?: AttachRecorderOptions): void;
461
- /**
462
- * Detach a combined recorder from all channels it was attached to.
463
- * Safe to call if the recorder was only on one channel or never attached.
464
- */
217
+ /** Detach a combined recorder from every channel it was attached to (safe if never attached). */
465
218
  detachCombinedRecorder(id: string): void;
466
219
  /**
467
- * Attach an `EmitRecorder` — an observer for consumer-emitted structured
468
- * events fired via `scope.$emit(name, payload)`.
469
- *
470
- * Internally, emit recorders share the scope-recorder channel because
471
- * emit events fire from inside `ScopeFacade` during stage execution,
472
- * same timing as `onRead`/`onWrite`. This method is a convenience that
473
- * delegates to `attachScopeRecorder` — consumers can also use
474
- * `attachScopeRecorder` directly for a recorder that implements BOTH
475
- * `onWrite` and `onEmit`. Either approach places the recorder on the
476
- * same underlying list, so `onEmit` fires exactly once per event.
477
- *
478
- * **Idempotent by `id`:** replaces existing recorder with same `id`.
220
+ * Attach an `EmitRecorder` for `scope.$emit(name, payload)` events. It rides
221
+ * the scope list, so `onEmit` fires exactly once per event. Idempotent by `id`.
479
222
  *
480
223
  * @example
481
224
  * ```typescript
@@ -490,25 +233,34 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
490
233
  attachEmitRecorder(recorder: EmitRecorder, options?: AttachRecorderOptions): void;
491
234
  /** Detach an `EmitRecorder` by id. Safe to call if never attached. */
492
235
  detachEmitRecorder(id: string): void;
236
+ /** Returns a defensive copy of attached recorders (both tiers) that implement `onEmit`. */
237
+ getEmitRecorders(): EmitRecorder[];
493
238
  /**
494
- * Returns a defensive copy of attached recorders (both delivery tiers)
495
- * filtered to those that implement `onEmit`. Useful for inspection during
496
- * testing.
239
+ * Detach a child flowchart on the given driver and return a `DetachHandle`
240
+ * the caller can `wait()` on (Promise) or read `.status` from (sync). The
241
+ * driver is REQUIRED — there is no library default.
242
+ *
243
+ * @example
244
+ * ```typescript
245
+ * import { microtaskBatchDriver } from 'footprintjs/detach';
246
+ *
247
+ * const exec = new FlowChartExecutor(parentChart);
248
+ * const handle = exec.detachAndJoinLater(microtaskBatchDriver, telemetryChart, { event: 'x' });
249
+ * await handle.wait(); // optional
250
+ * ```
497
251
  */
498
- getEmitRecorders(): EmitRecorder[];
252
+ detachAndJoinLater(driver: import('../detach/types.js').DetachDriver, child: import('../builder/types.js').FlowChart, input?: unknown): import('../detach/types.js').DetachHandle;
499
253
  /**
500
- * Returns structured narrative entries — the single public narrative API.
501
- * Each entry has a type (stage, step, condition, fork, etc.), text, and
502
- * depth. Consumers render however they want; call `.map(e => e.text)`
503
- * if a flat `string[]` is needed locally.
254
+ * Detach a child flowchart on the given driver and DISCARD the handle — for
255
+ * fire-and-forget side effects. A child's error lands on the discarded handle;
256
+ * for observable detach prefer `detachAndJoinLater` + `.wait().catch()`.
504
257
  */
505
- getNarrativeEntries(): CombinedNarrativeEntry[];
258
+ detachAndForget(driver: import('../detach/types.js').DetachDriver, child: import('../builder/types.js').FlowChart, input?: unknown): void;
506
259
  /**
507
- * Returns the combined FlowRecorders list. When narrative is enabled,
508
- * includes the CombinedNarrativeRecorder (which builds merged flow+data
509
- * entries inline). Plus any user-attached recorders.
260
+ * Structured narrative entries (type, text, depth) — the one public narrative
261
+ * API; `.map(e => e.text)` for a flat `string[]`.
510
262
  */
511
- private buildFlowRecordersList;
263
+ getNarrativeEntries(): CombinedNarrativeEntry[];
512
264
  /**
513
265
  * Execute the chart. Resolves when the run finishes — or pauses, if a
514
266
  * pausable stage returned data (check `isPaused()` afterward).
@@ -528,13 +280,10 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
528
280
  */
529
281
  run(options?: RunOptions): Promise<ExecutorResult>;
530
282
  /**
531
- * Flush the deferred-observer backlog, then await async listener
532
- * completions under a deadline (RFC-001 Block 8 — the serverless /
533
- * graceful-shutdown pattern: call before the process freezes or exits so
534
- * "one beat behind" work is not lost). Resolves immediately with zeros
535
- * when no deferred observer was ever attached. `pending === 0` means a
536
- * full drain; a non-zero `pending` reports continuations (plus any queued
537
- * events) still outstanding at the deadline — honest, never silent.
283
+ * Flush the deferred-observer backlog, then await async listener completions
284
+ * under a deadline — call before a serverless process freezes or exits.
285
+ * Zeros when no deferred observer was attached; `pending > 0` reports what
286
+ * was still outstanding at the deadline.
538
287
  */
539
288
  drainObservers(opts?: {
540
289
  timeoutMs?: number;
@@ -542,50 +291,17 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
542
291
  /**
543
292
  * Returns the runtime snapshot.
544
293
  *
545
- * @param options.redact When `true`, `sharedState` comes from the parallel
546
- * redacted mirror (if maintained — see `setRedactionPolicy`). This is
547
- * the safe view for exporting traces externally (paste into a viewer,
548
- * share with support). When no redaction policy is configured the
549
- * redacted mirror is not maintained, so this flag is a no-op —
550
- * `sharedState` is the raw working memory either way. Default `false`.
551
- *
552
- * The commit log is already redacted at write-time regardless of this
553
- * flag, and the execution tree carries only structural metadata.
554
- * `subflowResults[*].treeContext.globalContext` follows the flag too
555
- * (9.20.0): under `redact: true` it is each subflow's own redacted
556
- * mirror; plain, the subflow's live heap.
294
+ * @param options.redact `true` serves `sharedState` (and each subflow's
295
+ * `globalContext`) from the redacted mirror — the safe view to export. A
296
+ * no-op without a redaction policy. The commit log is redacted at write
297
+ * time regardless. Default `false`.
557
298
  *
558
299
  * **Treat `sharedState` as READ-ONLY.** In production it is a live view of
559
- * the engine's working memory (zero copy cost) — mutating it corrupts
560
- * engine state. In dev mode (`enableDevMode()`) it is a deep-frozen CLONE,
561
- * so any consumer mutation throws loudly instead of corrupting silently.
300
+ * engine memory; in dev mode a deep-frozen clone, so a mutation throws.
562
301
  */
563
302
  getSnapshot(options?: {
564
303
  redact?: boolean;
565
304
  }): RuntimeSnapshot;
566
- /**
567
- * Collect `toSnapshot()` bundles from every attached recorder — ONE entry
568
- * per recorder id, across all channels and both delivery tiers.
569
- *
570
- * The dedupe is load-bearing, not tidiness. A recorder that implements a
571
- * shared-name hook (`onError` / `onPause` / `onResume` — declared on BOTH
572
- * the scope and flow interfaces) is legitimately registered on BOTH inline
573
- * lists by `attachCombinedRecorder`, and that is by design: each channel
574
- * calls the hook with its own payload variant. `MetricRecorder.onPause` is
575
- * the everyday case. Walking the lists without a shared `seen` set turned
576
- * that into a DUPLICATED snapshot entry (same id twice), which breaks every
577
- * consumer that indexes `snapshot.recorders` by id.
578
- *
579
- * Ordering is scope list → flow list → deferred tier, and the FIRST bundle
580
- * for an id wins. A recorder with no `toSnapshot` never claims an id, so it
581
- * cannot shadow a same-id recorder that does have one.
582
- *
583
- * The row is REBUILT field by field by the one copier (`recorder/snapshot.ts
584
- * · copyBundle`, shared with `CompositeRecorder`) rather than spread, so a
585
- * recorder cannot smuggle an `id` of its own choosing into the snapshot (the
586
- * id is the executor's, and consumers index by it).
587
- */
588
- private collectRecorderSnapshots;
589
305
  /** @internal */
590
306
  getRuntime(): import("../engine/types.js").IExecutionRuntime;
591
307
  /** @internal */