footprintjs 9.3.0 → 9.5.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 (61) hide show
  1. package/AGENTS.md +2 -1
  2. package/CLAUDE.md +2 -1
  3. package/dist/advanced.js +1 -1
  4. package/dist/esm/advanced.js +1 -1
  5. package/dist/esm/index.js +1 -1
  6. package/dist/esm/lib/builder/FlowChartBuilder.js +15 -2
  7. package/dist/esm/lib/capture/envelope.js +187 -0
  8. package/dist/esm/lib/capture/index.js +16 -0
  9. package/dist/esm/lib/capture/policies.js +29 -0
  10. package/dist/esm/lib/capture/summarize.js +58 -0
  11. package/dist/esm/lib/engine/handlers/ContinuationResolver.js +23 -4
  12. package/dist/esm/lib/engine/handlers/SubflowExecutor.js +9 -1
  13. package/dist/esm/lib/engine/types.js +1 -1
  14. package/dist/esm/lib/memory/StageContext.js +62 -44
  15. package/dist/esm/lib/memory/index.js +2 -2
  16. package/dist/esm/lib/memory/types.js +7 -4
  17. package/dist/esm/lib/observer-queue/deferredDispatcher.js +226 -0
  18. package/dist/esm/lib/observer-queue/flushDriver.js +163 -0
  19. package/dist/esm/lib/observer-queue/index.js +22 -0
  20. package/dist/esm/lib/observer-queue/mergedQueue.js +91 -0
  21. package/dist/esm/lib/observer-queue/ring.js +122 -0
  22. package/dist/esm/lib/runner/ExecutionRuntime.js +13 -1
  23. package/dist/esm/lib/runner/FlowChartExecutor.js +20 -1
  24. package/dist/index.js +1 -1
  25. package/dist/lib/builder/FlowChartBuilder.js +15 -2
  26. package/dist/lib/capture/envelope.js +192 -0
  27. package/dist/lib/capture/index.js +23 -0
  28. package/dist/lib/capture/policies.js +30 -0
  29. package/dist/lib/capture/summarize.js +63 -0
  30. package/dist/lib/engine/handlers/ContinuationResolver.js +23 -4
  31. package/dist/lib/engine/handlers/SubflowExecutor.js +9 -1
  32. package/dist/lib/engine/types.js +1 -1
  33. package/dist/lib/memory/StageContext.js +63 -45
  34. package/dist/lib/memory/index.js +3 -2
  35. package/dist/lib/memory/types.js +10 -5
  36. package/dist/lib/observer-queue/deferredDispatcher.js +230 -0
  37. package/dist/lib/observer-queue/flushDriver.js +167 -0
  38. package/dist/lib/observer-queue/index.js +36 -0
  39. package/dist/lib/observer-queue/mergedQueue.js +95 -0
  40. package/dist/lib/observer-queue/ring.js +126 -0
  41. package/dist/lib/runner/ExecutionRuntime.js +13 -1
  42. package/dist/lib/runner/FlowChartExecutor.js +20 -1
  43. package/dist/types/advanced.d.ts +1 -1
  44. package/dist/types/index.d.ts +13 -0
  45. package/dist/types/lib/capture/envelope.d.ts +169 -0
  46. package/dist/types/lib/capture/index.d.ts +16 -0
  47. package/dist/types/lib/capture/policies.d.ts +42 -0
  48. package/dist/types/lib/capture/summarize.d.ts +65 -0
  49. package/dist/types/lib/engine/handlers/ContinuationResolver.d.ts +15 -2
  50. package/dist/types/lib/engine/types.d.ts +3 -0
  51. package/dist/types/lib/memory/StageContext.d.ts +38 -1
  52. package/dist/types/lib/memory/index.d.ts +2 -2
  53. package/dist/types/lib/memory/types.d.ts +33 -22
  54. package/dist/types/lib/observer-queue/deferredDispatcher.d.ts +169 -0
  55. package/dist/types/lib/observer-queue/flushDriver.d.ts +124 -0
  56. package/dist/types/lib/observer-queue/index.d.ts +25 -0
  57. package/dist/types/lib/observer-queue/mergedQueue.d.ts +85 -0
  58. package/dist/types/lib/observer-queue/ring.d.ts +99 -0
  59. package/dist/types/lib/runner/ExecutionRuntime.d.ts +11 -1
  60. package/dist/types/lib/runner/FlowChartExecutor.d.ts +43 -1
  61. package/package.json +1 -1
@@ -0,0 +1,169 @@
1
+ /**
2
+ * observer-queue/deferredDispatcher.ts — RFC-001 Block 5: deferred delivery façade.
3
+ *
4
+ * Pattern: capture → enqueue → (microtask) flush → invoke, with per-listener
5
+ * error isolation. Composes the whole pure pipeline: MergedQueue
6
+ * (Block 3, which captures via Block 1) + FlushDriver (Block 4) +
7
+ * a listener registry with timing/inflight accounting.
8
+ * Role: The object the engine wiring (Block 6) will hold. Producers call
9
+ * `capture()` (cheap, never throws, never blocks); listeners
10
+ * receive envelopes at the next checkpoint, "one beat behind".
11
+ * Pure module — zero engine imports.
12
+ *
13
+ * Delivery semantics (normative, RFC-001 §5 + amendments A2/A4):
14
+ * - Per-listener FIFO: every listener sees envelopes in seq order
15
+ * (invocation order; an async listener's COMPLETION order is its own
16
+ * concern) — EXCEPT under `'block'` overflow, where a refused enqueue
17
+ * is delivered inline and overtakes the queued backlog. `seq` always
18
+ * records true arrival order, so order-sensitive consumers re-sort;
19
+ * see the `'block'` caveat below.
20
+ * - Error isolation: a throwing listener (sync) or rejecting listener
21
+ * (async) never affects siblings or the producer. Both failure modes
22
+ * route to the injected `onError`; a throwing `onError` is itself
23
+ * swallowed.
24
+ * - The flush NEVER awaits a listener. Async continuations are tracked in
25
+ * an inflight set; `drain({ timeoutMs })` settles them
26
+ * (`Promise.allSettled` + deadline, shaped like `flushAllDetached`).
27
+ * - `'block'` overflow: a refused enqueue is delivered synchronously
28
+ * INLINE from `capture()` — re-introducing blocking delivery by the
29
+ * consumer's explicit choice. Ordering caveat (documented + tested): an
30
+ * inline event overtakes the queued backlog — `'block'` trades global
31
+ * ordering for zero loss and bounded memory. `seq` still tells the
32
+ * true arrival order.
33
+ * - Listener registry is idempotent by id (same id replaces, different
34
+ * ids coexist) — mirrors the repo-wide recorder ID contract. Stats
35
+ * accumulate per id across replacement; `removeListener` keeps the
36
+ * id's accumulated stats for post-run reports.
37
+ * - Events captured BEFORE any listener attaches stay queued — a listener
38
+ * attached before the next checkpoint still receives the backlog.
39
+ *
40
+ * Per-listener time accounting (amendment A2 — "name the hog"): cumulative
41
+ * `totalMs` and per-checkpoint `lastFlushMs` of SYNC time per listener id —
42
+ * the time that actually blocks the flush. An async listener's continuation
43
+ * time is intentionally not attributed (it does not block delivery).
44
+ */
45
+ import { type CaptureEnvelope, type CaptureHooks, type CapturePolicy } from '../capture/envelope.js';
46
+ import { type FlushSyncResult } from './flushDriver.js';
47
+ import { type EnqueueInput } from './mergedQueue.js';
48
+ import { type OverflowPolicy } from './ring.js';
49
+ /**
50
+ * One deferred observer. May return a Promise — the dispatcher tracks it in
51
+ * the inflight set but NEVER awaits it during a flush.
52
+ */
53
+ export type DeferredListener = (envelope: CaptureEnvelope) => void | Promise<void>;
54
+ export interface DispatchErrorContext {
55
+ readonly listenerId: string;
56
+ readonly envelope: CaptureEnvelope;
57
+ /** `'sync'` = listener threw; `'async'` = returned promise rejected. */
58
+ readonly phase: 'sync' | 'async';
59
+ }
60
+ /** Injected error sink — the wiring layer routes these (Block 6). */
61
+ export type DispatchErrorHandler = (error: unknown, context: DispatchErrorContext) => void;
62
+ export interface DeferredDispatcherOptions {
63
+ /** Queue bound — default 10 000 (see `MergedQueue`). */
64
+ readonly maxQueue?: number;
65
+ /** Overflow policy — default `'drop-oldest'`. */
66
+ readonly overflow?: OverflowPolicy;
67
+ /** `'sample'` overflow only — admit 1 in this many saturated arrivals. */
68
+ readonly sampleEvery?: number;
69
+ /** Default capture policy — default `'summary'`. */
70
+ readonly capturePolicy?: CapturePolicy;
71
+ /** Per-flush time budget, ms (A1) — default 2; `Infinity` = full drain. */
72
+ readonly flushBudgetMs?: number;
73
+ /** Listener-failure sink. No default — without it, failures are silent. */
74
+ readonly onError?: DispatchErrorHandler;
75
+ /** Capture seams (dev-warn, capturedAt clock) — see `CaptureHooks`. */
76
+ readonly hooks?: CaptureHooks;
77
+ /** Timing clock for budget + per-listener accounting. Injectable. */
78
+ readonly now?: () => number;
79
+ /** Checkpoint primitive — default `queueMicrotask`. Injectable. */
80
+ readonly schedule?: (cb: () => void) => void;
81
+ }
82
+ /** Per-listener accounting (A2/A4). */
83
+ export interface ListenerStats {
84
+ /** Envelopes delivered (invocations, including ones that threw). */
85
+ readonly events: number;
86
+ /** Cumulative sync delivery time, ms. */
87
+ readonly totalMs: number;
88
+ /** Sync delivery time since the last flush started, ms. */
89
+ readonly lastFlushMs: number;
90
+ }
91
+ /** The Block 9 observability surface (amendment A4) — pure getter. */
92
+ export interface DispatcherStats {
93
+ /** Current backlog. */
94
+ readonly depth: number;
95
+ /** Events LOST (overflow) — never silent; also visible as seq gaps. */
96
+ readonly drops: number;
97
+ /** Completed checkpoint flushes. */
98
+ readonly flushes: number;
99
+ /** Flushes cut short by `flushBudgetMs` (A1). */
100
+ readonly budgetExhausted: number;
101
+ /** p95 flush duration, ms (rolling window). */
102
+ readonly p95FlushMs: number;
103
+ /** `'block'`-policy refusals delivered synchronously inline. */
104
+ readonly inlineDeliveries: number;
105
+ /** Async listener continuations not yet settled. */
106
+ readonly inflight: number;
107
+ /** Per-listener time accounting — "name the hog" (A2). */
108
+ readonly perListener: Readonly<Record<string, ListenerStats>>;
109
+ }
110
+ /** Result of {@link DeferredDispatcher.drain} — `flushAllDetached` shape. */
111
+ export interface DrainResult {
112
+ /** Async continuations seen settling fulfilled. Best-effort count — a
113
+ * continuation that settles between checks is drained but may not be
114
+ * counted (same semantics as `flushAllDetached`). */
115
+ readonly done: number;
116
+ /** Continuations whose listener promise rejected (routed to onError). */
117
+ readonly failed: number;
118
+ /** Still in flight (or queued) when the deadline expired. `0` = drained. */
119
+ readonly pending: number;
120
+ }
121
+ export declare class DeferredDispatcher {
122
+ private readonly queue;
123
+ private readonly driver;
124
+ private readonly listeners;
125
+ private readonly listenerStats;
126
+ /** Tracked async continuations — resolve `true` (ok) / `false` (failed). */
127
+ private readonly inflight;
128
+ private readonly onError?;
129
+ private readonly now;
130
+ private inlineDeliveries;
131
+ constructor(opts?: DeferredDispatcherOptions);
132
+ /** Idempotent by id — same id replaces (stats continue), ids coexist. */
133
+ addListener(id: string, listener: DeferredListener): void;
134
+ /** Stop delivering to `id`. Accumulated stats are kept for reports. */
135
+ removeListener(id: string): void;
136
+ /**
137
+ * Producer entry point: capture the event (seq-stamped, payload per
138
+ * policy) and stage it for the next checkpoint. Cheap; NEVER throws;
139
+ * never blocks — except under `'block'` overflow, where a refused
140
+ * enqueue is delivered synchronously inline (explicit consumer choice).
141
+ */
142
+ capture(input: EnqueueInput, policy?: CapturePolicy): void;
143
+ /**
144
+ * Terminal flush — synchronously deliver everything queued (end of run /
145
+ * shutdown). Async listener continuations are NOT awaited; follow with
146
+ * `drain()` for that.
147
+ */
148
+ flushNow(opts?: {
149
+ maxRounds?: number;
150
+ }): FlushSyncResult;
151
+ /**
152
+ * Flush the backlog, then settle all inflight async continuations —
153
+ * `Promise.allSettled` under a deadline, shaped like `flushAllDetached`.
154
+ * Loops while continuations spawn new captures, until quiescent or the
155
+ * deadline expires.
156
+ */
157
+ drain(opts?: {
158
+ timeoutMs?: number;
159
+ }): Promise<DrainResult>;
160
+ /** A4 — the stats object Block 9 consumes. Pure getter, fresh snapshot. */
161
+ getStats(): DispatcherStats;
162
+ private deliverNext;
163
+ /** Invoke every listener with full error isolation + time accounting. */
164
+ private deliver;
165
+ /** Track an async continuation; route its rejection; never reject. */
166
+ private track;
167
+ /** The error sink must never become an error source. */
168
+ private safeOnError;
169
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * observer-queue/flushDriver.ts — RFC-001 Block 4: armed-once microtask batcher.
3
+ *
4
+ * Pattern: Kernel-style bottom-half. Producers only set a flag ("work
5
+ * pending") and return; the actual work runs at the next
6
+ * scheduling checkpoint (a microtask), drains under a time
7
+ * budget, and re-arms itself if backlog remains. Same shape as
8
+ * the detach module's `microtaskBatchDriver` — accumulate during
9
+ * the current sync slice, drain at the boundary.
10
+ * Role: The scheduler of the deferred-observer pipeline. Owns WHEN
11
+ * delivery happens; knows nothing about envelopes or listeners
12
+ * (the dispatcher, Block 5, injects `depth`/`processNext`).
13
+ * Pure module — zero imports, zero engine knowledge.
14
+ *
15
+ * Scheduling semantics (normative, RFC-001 §5 + amendment A1):
16
+ * - `arm()` is idempotent: at most ONE pending flush exists (armed flag).
17
+ * N captures between checkpoints ⇒ exactly 1 flush.
18
+ * - A flush drains a SNAPSHOT: at most `depth()`-at-flush-start items.
19
+ * Events enqueued BY listeners during the flush exceed the snapshot and
20
+ * land at the NEXT checkpoint — listener-driven cascades cannot starve
21
+ * the event loop.
22
+ * - `flushBudgetMs` (default 2; `Infinity` = full snapshot drain): the
23
+ * flush stops once the budget is exhausted, counts `budgetExhausted`,
24
+ * and re-arms. At least ONE item is processed per flush regardless of
25
+ * budget — guaranteed progress under any clock.
26
+ * - If backlog remains after the flush (budget cut OR listener enqueues),
27
+ * the driver re-arms for the next checkpoint.
28
+ *
29
+ * Why stage boundaries make this safe: the engine `await`s every stage, so
30
+ * the microtask queue runs at EVERY stage boundary — flushes are at most
31
+ * "one beat behind" the producing stage. See
32
+ * `docs/guides/execution-model.md` ("Stage boundaries are scheduling
33
+ * points") and the FAQ in `docs/design/rfc-001-deferred-observers.md`.
34
+ *
35
+ * Testability: `now` (clock) and `schedule` (checkpoint primitive) are
36
+ * injectable — tests pump flushes deterministically with a fake clock and
37
+ * a captured-callback scheduler; production uses `performance.now` and
38
+ * `queueMicrotask`.
39
+ */
40
+ /** Result of one flush (also delivered to `onFlushEnd`). */
41
+ export interface FlushOutcome {
42
+ /** Items processed in this flush. */
43
+ readonly processed: number;
44
+ /** True when the time budget cut the flush before the snapshot drained. */
45
+ readonly budgetExhausted: boolean;
46
+ /** True when backlog remained and the driver re-armed itself. */
47
+ readonly rearmed: boolean;
48
+ }
49
+ /** Result of a synchronous {@link FlushDriver.flushSync} drain. */
50
+ export interface FlushSyncResult {
51
+ /** Items processed across all rounds. */
52
+ readonly drained: number;
53
+ /** Items still queued when `maxRounds` stopped a runaway cascade. */
54
+ readonly remaining: number;
55
+ }
56
+ export interface FlushDriverOptions {
57
+ /** Current backlog of the queue this driver drains. */
58
+ readonly depth: () => number;
59
+ /** Process exactly ONE queued item. Precondition: `depth() > 0`. */
60
+ readonly processNext: () => void;
61
+ /**
62
+ * Per-flush time budget in ms. Default 2. `Infinity` drains the full
63
+ * snapshot every checkpoint. Must be > 0.
64
+ */
65
+ readonly flushBudgetMs?: number;
66
+ /** Clock — default `performance.now` (falls back to `Date.now`). */
67
+ readonly now?: () => number;
68
+ /** Checkpoint primitive — default `queueMicrotask`. */
69
+ readonly schedule?: (cb: () => void) => void;
70
+ /** Fires before the first item of every flush (incl. `flushSync`). */
71
+ readonly onFlushStart?: () => void;
72
+ /** Fires after every flush with its outcome (incl. `flushSync`). */
73
+ readonly onFlushEnd?: (outcome: FlushOutcome) => void;
74
+ }
75
+ export interface FlushDriverStats {
76
+ /** Completed flushes (zero-work wakeups are not counted). */
77
+ readonly flushes: number;
78
+ /** Flushes cut short by `flushBudgetMs` (A1 — backlog visibility). */
79
+ readonly budgetExhausted: number;
80
+ /** Duration of the most recent flush, ms. */
81
+ readonly lastFlushMs: number;
82
+ /** p95 over the last {@link FLUSH_SAMPLE_WINDOW} flush durations, ms. */
83
+ readonly p95FlushMs: number;
84
+ /** True while a flush is scheduled but not yet run. */
85
+ readonly armed: boolean;
86
+ }
87
+ /** Rolling sample window for the p95 flush-duration stat (A4). */
88
+ export declare const FLUSH_SAMPLE_WINDOW = 128;
89
+ export declare class FlushDriver {
90
+ private readonly depth;
91
+ private readonly processNext;
92
+ private readonly flushBudgetMs;
93
+ private readonly now;
94
+ private readonly schedule;
95
+ private readonly onFlushStart?;
96
+ private readonly onFlushEnd?;
97
+ private armed;
98
+ private flushes;
99
+ private budgetExhaustedCount;
100
+ private lastFlushMs;
101
+ private readonly samples;
102
+ private sampleWriteIdx;
103
+ constructor(opts: FlushDriverOptions);
104
+ /**
105
+ * Request a flush at the next checkpoint. Idempotent — while one flush
106
+ * is pending, further arms are free no-ops (the armed-once invariant).
107
+ */
108
+ arm(): void;
109
+ /**
110
+ * Synchronous full drain — the terminal-flush primitive (end of run /
111
+ * shutdown). Repeats snapshot rounds until the queue is empty so
112
+ * listener-enqueued cascades drain too, capped at `maxRounds` so a
113
+ * listener that enqueues forever cannot hang the process (`remaining`
114
+ * reports what the cap left behind).
115
+ */
116
+ flushSync(opts?: {
117
+ maxRounds?: number;
118
+ }): FlushSyncResult;
119
+ getStats(): FlushDriverStats;
120
+ /** The microtask body — see the module-header semantics. */
121
+ private flush;
122
+ private recordFlush;
123
+ private p95FlushMs;
124
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * observer-queue/ — RFC-001 deferred observer delivery, Blocks 2–5.
3
+ *
4
+ * The pure "one beat behind" pipeline:
5
+ *
6
+ * producer ─► capture (Block 1, `capture/envelope`) ─► MergedQueue
7
+ * (Block 3, seq-stamped over a BoundedRing, Block 2) ─► FlushDriver
8
+ * (Block 4, armed-once microtask checkpoints, flushBudgetMs) ─►
9
+ * DeferredDispatcher (Block 5, isolated listeners + inflight + stats)
10
+ *
11
+ * INTERNAL MODULE — deliberately NOT exported from the public footprintjs
12
+ * barrels yet. The engine wiring + public surface land with Blocks 6–10
13
+ * (see docs/design/rfc-001-deferred-observers.md). Zero engine imports:
14
+ * this directory may import only `../capture/` and its own files.
15
+ */
16
+ export type { CaptureChannel, CaptureEnvelope, CaptureHooks, CapturePolicy, CaptureRequest, PayloadSummary, PayloadSummaryNode, PayloadSummaryType, } from '../capture/envelope.js';
17
+ export { capture, PAYLOAD_SUMMARY_MAX_DEPTH, PAYLOAD_SUMMARY_MAX_ENTRIES, PAYLOAD_SUMMARY_MAX_NODES, summarizePayload, } from '../capture/envelope.js';
18
+ export type { DeferredDispatcherOptions, DeferredListener, DispatchErrorContext, DispatchErrorHandler, DispatcherStats, DrainResult, ListenerStats, } from './deferredDispatcher.js';
19
+ export { DeferredDispatcher } from './deferredDispatcher.js';
20
+ export type { FlushDriverOptions, FlushDriverStats, FlushOutcome, FlushSyncResult } from './flushDriver.js';
21
+ export { FLUSH_SAMPLE_WINDOW, FlushDriver } from './flushDriver.js';
22
+ export type { EnqueueInput, EnqueueOutcome, EnqueueResult, MergedQueueOptions } from './mergedQueue.js';
23
+ export { DEFAULT_MAX_QUEUE, MergedQueue } from './mergedQueue.js';
24
+ export type { OverflowPolicy, RingCounters, RingOptions, RingPushResult } from './ring.js';
25
+ export { BoundedRing } from './ring.js';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * observer-queue/mergedQueue.ts — RFC-001 Block 3: seq stamping + multi-channel merge.
3
+ *
4
+ * Pattern: Single totally-ordered staging queue. All three observer
5
+ * channels (`scope` / `flow` / `emit`) funnel through ONE queue;
6
+ * the `seq` counter is assigned at capture under the single JS
7
+ * thread, so drain order == arrival order ACROSS channels with no
8
+ * cross-queue merge logic ever needed.
9
+ * Role: Glue between the capture tier (Block 1) and the flush driver
10
+ * (Block 4). Pure module — imports only `capture/envelope` and
11
+ * the ring (Block 2); zero engine knowledge.
12
+ *
13
+ * Seq semantics (normative, RFC-001 §5):
14
+ * - Stamped BEFORE admission — an event that is then dropped (overflow)
15
+ * or refused (`'block'`) still consumed its seq. Drops therefore leave
16
+ * VISIBLE gaps in the delivered stream (honest loss accounting), and
17
+ * `'block'`-refused events delivered inline keep their true arrival
18
+ * stamp even though they overtake the queued backlog.
19
+ * - Monotonic, starts at 0, never reused for the lifetime of the queue.
20
+ *
21
+ * Enqueue outcomes:
22
+ * - `'queued'` — staged for the next flush (drop-oldest may have evicted
23
+ * an older event to make room; that loss is counted, never silent).
24
+ * - `'dropped'` — the event was sampled out at saturation. Lost; counted.
25
+ * - `'inline'` — `'block'` policy refused the enqueue. NOT lost: the
26
+ * caller (the dispatcher, Block 5) must deliver the returned envelope
27
+ * synchronously inline — blocking delivery by explicit consumer choice.
28
+ */
29
+ import { type CaptureChannel, type CaptureEnvelope, type CaptureHooks, type CapturePolicy } from '../capture/envelope.js';
30
+ import { type OverflowPolicy, type RingCounters } from './ring.js';
31
+ /** RFC-001 §5 default queue bound. */
32
+ export declare const DEFAULT_MAX_QUEUE = 10000;
33
+ /** One observer event to merge — {@link capture}'s request minus `seq`. */
34
+ export interface EnqueueInput {
35
+ readonly channel: CaptureChannel;
36
+ readonly method: string;
37
+ readonly runtimeStageId: string;
38
+ readonly runId: string;
39
+ /** LIVE payload — materialized per capture policy at enqueue time. */
40
+ readonly payload: unknown;
41
+ }
42
+ /** Fate of one enqueued event — see the module header. */
43
+ export type EnqueueOutcome = 'queued' | 'dropped' | 'inline';
44
+ export interface EnqueueResult {
45
+ /** The captured, seq-stamped envelope (built even when not queued). */
46
+ readonly envelope: CaptureEnvelope;
47
+ readonly outcome: EnqueueOutcome;
48
+ }
49
+ export interface MergedQueueOptions {
50
+ /** Ring capacity. Default {@link DEFAULT_MAX_QUEUE} (10 000). */
51
+ readonly maxQueue?: number;
52
+ /** Overflow policy at capacity. Default `'drop-oldest'`. */
53
+ readonly overflow?: OverflowPolicy;
54
+ /** `'sample'` only — admit 1 in this many saturated arrivals. */
55
+ readonly sampleEvery?: number;
56
+ /** Default capture policy when `enqueue` gets none. Default `'summary'`. */
57
+ readonly capturePolicy?: CapturePolicy;
58
+ /** Engine-free seams (dev-warn, clock) passed through to {@link capture}. */
59
+ readonly hooks?: CaptureHooks;
60
+ }
61
+ export declare class MergedQueue {
62
+ private readonly ring;
63
+ private readonly overflow;
64
+ private readonly defaultPolicy;
65
+ private readonly hooks?;
66
+ /** Arrival stamp — monotonic across ALL channels (see module header). */
67
+ private seq;
68
+ constructor(opts?: MergedQueueOptions);
69
+ /**
70
+ * Capture one event (seq-stamped at arrival) and stage it for deferred
71
+ * delivery. `policy` overrides the queue default per call — e.g. `'ref'`
72
+ * for payloads the caller proved immutable. Never throws.
73
+ */
74
+ enqueue(input: EnqueueInput, policy?: CapturePolicy): EnqueueResult;
75
+ /** Pop the oldest staged envelope (total arrival order across channels). */
76
+ shift(): CaptureEnvelope | undefined;
77
+ /** Current backlog. */
78
+ get depth(): number;
79
+ /** Ring capacity (the `maxQueue` bound). */
80
+ get capacity(): number;
81
+ /** The next seq to be assigned == total events captured so far. */
82
+ get nextSeq(): number;
83
+ /** Lifetime loss/delivery accounting — delegated to the ring. */
84
+ getCounters(): RingCounters;
85
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * observer-queue/ring.ts — RFC-001 Block 2: bounded ring with overflow policies.
3
+ *
4
+ * Pattern: Fixed-capacity circular buffer with explicit, COUNTED overflow
5
+ * behavior. The deferred-observer queue must never grow without
6
+ * bound (a slow consumer cannot OOM the producer), and must never
7
+ * lose an event silently (every loss increments `drops`).
8
+ * Role: Storage primitive under the merged queue (Block 3). Pure data
9
+ * structure — zero imports, zero engine knowledge, generic over T.
10
+ *
11
+ * Overflow policies (RFC-001 §5, with the accepted 'block' resolution):
12
+ * - `'drop-oldest'` — evict the oldest queued item to admit the new one.
13
+ * The evicted item is LOST (`drops`++) and returned on the push result
14
+ * so the caller can account for it. Sequence stamps on surviving items
15
+ * keep loss visible as seq gaps (honest loss accounting). DEFAULT
16
+ * posture for telemetry-grade delivery.
17
+ * - `'sample'` — while saturated, admit 1 in `sampleEvery` arrivals
18
+ * (evicting the oldest to make room — that eviction is also a counted
19
+ * loss); refuse the rest (each a counted loss). Keeps a thinned,
20
+ * still-fresh stream under sustained overload. The saturation counter
21
+ * is episode-scoped: it resets whenever a push succeeds through the
22
+ * non-full path.
23
+ * - `'block'` — the ring REFUSES the new item (`accepted: false`,
24
+ * `rejections`++) and drops NOTHING. In a single-threaded runtime a
25
+ * queue cannot literally block its producer; the dispatcher (Block 5)
26
+ * interprets a refusal as "deliver this event synchronously inline" —
27
+ * re-introducing blocking delivery by the consumer's EXPLICIT choice.
28
+ * Rejections are NOT losses: the event is still delivered (inline), so
29
+ * `drops` stays untouched.
30
+ *
31
+ * Conservation invariant (property-tested):
32
+ * pushes === delivered + drops + rejections + size
33
+ *
34
+ * CURSOR-READY (amendment A2): v1 consumes destructively through ONE cursor
35
+ * (`shift()` advances `head`). The designed v1.1 path keeps items in the
36
+ * ring and gives each listener its own read cursor; `head` then advances to
37
+ * `min(cursors)` (the reclaim watermark) instead of on read. The storage
38
+ * layout (contiguous circular window, `head` + `count`) already supports
39
+ * that — only the consumption surface changes. Documented, not implemented.
40
+ */
41
+ /** How the ring treats a push when it is at capacity (RFC-001 §5). */
42
+ export type OverflowPolicy = 'block' | 'drop-oldest' | 'sample';
43
+ export interface RingOptions {
44
+ /** Max queued items. Positive integer. */
45
+ readonly capacity: number;
46
+ /** Overflow behavior at capacity — see the module header. */
47
+ readonly policy: OverflowPolicy;
48
+ /**
49
+ * `'sample'` only: admit 1 in this many arrivals while saturated.
50
+ * Positive integer; default 10.
51
+ */
52
+ readonly sampleEvery?: number;
53
+ }
54
+ /** Outcome of one {@link BoundedRing.push}. */
55
+ export interface RingPushResult<T> {
56
+ /** True when the pushed item is now queued. */
57
+ readonly accepted: boolean;
58
+ /**
59
+ * The oldest item, when admitting the new one evicted it
60
+ * (`'drop-oldest'`, or a `'sample'` admission). Already counted in
61
+ * `drops` — surfaced so callers can do their own loss accounting.
62
+ */
63
+ readonly evicted?: T;
64
+ }
65
+ /** Monotonic counters — never reset for the lifetime of the ring. */
66
+ export interface RingCounters {
67
+ /** Total `push()` calls. */
68
+ readonly pushes: number;
69
+ /** `shift()` calls that returned an item. */
70
+ readonly delivered: number;
71
+ /** Items LOST — evictions plus sampled-out refusals. Never silent. */
72
+ readonly drops: number;
73
+ /** `'block'` refusals — NOT losses; the caller delivers these inline. */
74
+ readonly rejections: number;
75
+ }
76
+ export declare class BoundedRing<T> {
77
+ private readonly buffer;
78
+ private readonly policy;
79
+ private readonly sampleEvery;
80
+ /** Index of the oldest queued item — the single v1 cursor (see header). */
81
+ private head;
82
+ private count;
83
+ /** Arrivals seen while saturated in the current episode (`'sample'`). */
84
+ private saturatedArrivals;
85
+ private pushes;
86
+ private delivered;
87
+ private drops;
88
+ private rejections;
89
+ constructor(opts: RingOptions);
90
+ get size(): number;
91
+ get capacity(): number;
92
+ /** Lifetime counters — see {@link RingCounters}. */
93
+ getCounters(): RingCounters;
94
+ /** Admit, evict-and-admit, refuse, or sample per policy — never throws. */
95
+ push(item: T): RingPushResult<T>;
96
+ /** Pop the oldest queued item (FIFO). `undefined` when empty. */
97
+ shift(): T | undefined;
98
+ private store;
99
+ }
@@ -12,7 +12,7 @@
12
12
  import { EventLog } from '../memory/EventLog.js';
13
13
  import { SharedMemory } from '../memory/SharedMemory.js';
14
14
  import { StageContext } from '../memory/StageContext.js';
15
- import type { CommitBundle, ReadTrackingMode, StageSnapshot } from '../memory/types.js';
15
+ import type { CommitBundle, ReadTrackingMode, StageSnapshot, WriteTrackingMode } from '../memory/types.js';
16
16
  /** Snapshot of a single recorder's collected data. */
17
17
  export interface RecorderSnapshot {
18
18
  id: string;
@@ -72,6 +72,16 @@ export declare class ExecutionRuntime {
72
72
  * the freshly-created continuation root.
73
73
  */
74
74
  useReadTracking(mode: ReadTrackingMode): void;
75
+ /**
76
+ * Set the write-tracking policy (#13c-A) on the root stage context — the
77
+ * sibling of {@link useReadTracking}, with identical plumbing: descendant
78
+ * contexts inherit via `createNext`/`createChild`; subflow root contexts
79
+ * inherit from their parent-mount context via `SubflowExecutor`. Called by
80
+ * `FlowChartExecutor.createTraverser()` when the executor's policy is not
81
+ * the default `'full'` — including the resume path, where it is applied to
82
+ * the freshly-created continuation root.
83
+ */
84
+ useWriteTracking(mode: WriteTrackingMode): void;
75
85
  /** Preserve the current rootStageContext for snapshots before changing it for resume. */
76
86
  preserveSnapshotRoot(): void;
77
87
  getPipelines(): string[];
@@ -22,7 +22,7 @@ import type { CombinedNarrativeEntry } from '../engine/narrative/narrativeTypes.
22
22
  import type { ManifestEntry } from '../engine/narrative/recorders/ManifestFlowRecorder.js';
23
23
  import type { FlowRecorder } from '../engine/narrative/types.js';
24
24
  import { type ExecutorResult, type RunOptions, type ScopeFactory, type SerializedPipelineStructure, type StageNode, type StreamHandlers, type SubflowResult } from '../engine/types.js';
25
- import type { ReadTrackingMode } from '../memory/types.js';
25
+ import type { ReadTrackingMode, WriteTrackingMode } from '../memory/types.js';
26
26
  import type { FlowchartCheckpoint } from '../pause/types.js';
27
27
  import type { CombinedRecorder } from '../recorder/CombinedRecorder.js';
28
28
  import type { EmitRecorder } from '../recorder/EmitRecorder.js';
@@ -77,6 +77,40 @@ export interface FlowChartExecutorOptions<TScope = any> {
77
77
  * Equivalent to calling `executor.setReadTracking(mode)` before `run()`.
78
78
  */
79
79
  readTracking?: ReadTrackingMode;
80
+ /**
81
+ * Policy for `StageSnapshot.stageWrites` (#13c-A) — the sibling of
82
+ * {@link readTracking}; the two dials are independent. Default `'full'` —
83
+ * every tracked write `structuredClone`s the value into the stage's write
84
+ * view (the historical behavior). `'summary'` records a cheap
85
+ * `WriteSummaryMarker` (type/size/preview) per write; `'off'` records
86
+ * nothing — `stageWrites` is absent from the snapshot.
87
+ *
88
+ * Observable consequences — what the policy DOES govern:
89
+ * - `StageSnapshot.stageWrites` (markers under `'summary'`, absent under
90
+ * `'off'`).
91
+ * - The commit observer payload: `ScopeRecorder.onCommit(mutations)`
92
+ * receives the retained `_stageWrites` entries, so it carries the same
93
+ * markers under `'summary'` and an empty mutations bag under `'off'` —
94
+ * deferred/observer consumers see exactly what retention stored.
95
+ *
96
+ * What it does NOT govern:
97
+ * - The writes themselves: shared state, the transaction buffer, and the
98
+ * COMMIT LOG are identical in every mode (commitLog values keep their
99
+ * full payloads — the lossless linear-cost fix for those is #13c-B's
100
+ * delta verb, out of scope here).
101
+ * - Per-op `ScopeRecorder.onWrite` events — they fire with live values
102
+ * regardless (delivery tier, RFC-001's concern), so narrative output is
103
+ * identical in every mode.
104
+ * - Redaction: a policy/per-call-redacted write stores `'[REDACTED]'`
105
+ * under `'full'` AND `'summary'` (redaction takes precedence over the
106
+ * dial; a marker would leak size/preview), and nothing under `'off'`.
107
+ *
108
+ * Caveat: under `'off'` a stage's SNAPSHOT is indistinguishable from one
109
+ * that wrote nothing — but unlike `readTracking: 'off'`, the commit log
110
+ * still records every net change, so "did it write?" stays answerable.
111
+ * Equivalent to calling `executor.setWriteTracking(mode)` before `run()`.
112
+ */
113
+ writeTracking?: WriteTrackingMode;
80
114
  /**
81
115
  * Custom error classifier for throttling detection. Return `true` if the
82
116
  * error represents a rate-limit or backpressure condition (the executor will
@@ -163,6 +197,14 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
163
197
  * for the mode semantics ('full' default / 'summary' / 'off').
164
198
  */
165
199
  setReadTracking(mode: ReadTrackingMode): void;
200
+ /**
201
+ * Set the write-tracking policy for `StageSnapshot.stageWrites` (#13c-A).
202
+ * Must be called before run(). Equivalent to the `writeTracking`
203
+ * constructor option — see {@link FlowChartExecutorOptions.writeTracking}
204
+ * for the mode semantics ('full' default / 'summary' / 'off'), the
205
+ * onCommit-payload consequence, and the redaction-precedence rule.
206
+ */
207
+ setWriteTracking(mode: WriteTrackingMode): void;
166
208
  /**
167
209
  * Returns a compliance-friendly report of all redaction activity from the
168
210
  * most recent run. Never includes actual values.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprintjs",
3
- "version": "9.3.0",
3
+ "version": "9.5.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",