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.
- package/AGENTS.md +2 -1
- package/CLAUDE.md +2 -1
- package/dist/advanced.js +1 -1
- package/dist/esm/advanced.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/lib/builder/FlowChartBuilder.js +15 -2
- package/dist/esm/lib/capture/envelope.js +187 -0
- package/dist/esm/lib/capture/index.js +16 -0
- package/dist/esm/lib/capture/policies.js +29 -0
- package/dist/esm/lib/capture/summarize.js +58 -0
- package/dist/esm/lib/engine/handlers/ContinuationResolver.js +23 -4
- package/dist/esm/lib/engine/handlers/SubflowExecutor.js +9 -1
- package/dist/esm/lib/engine/types.js +1 -1
- package/dist/esm/lib/memory/StageContext.js +62 -44
- package/dist/esm/lib/memory/index.js +2 -2
- package/dist/esm/lib/memory/types.js +7 -4
- package/dist/esm/lib/observer-queue/deferredDispatcher.js +226 -0
- package/dist/esm/lib/observer-queue/flushDriver.js +163 -0
- package/dist/esm/lib/observer-queue/index.js +22 -0
- package/dist/esm/lib/observer-queue/mergedQueue.js +91 -0
- package/dist/esm/lib/observer-queue/ring.js +122 -0
- package/dist/esm/lib/runner/ExecutionRuntime.js +13 -1
- package/dist/esm/lib/runner/FlowChartExecutor.js +20 -1
- package/dist/index.js +1 -1
- package/dist/lib/builder/FlowChartBuilder.js +15 -2
- package/dist/lib/capture/envelope.js +192 -0
- package/dist/lib/capture/index.js +23 -0
- package/dist/lib/capture/policies.js +30 -0
- package/dist/lib/capture/summarize.js +63 -0
- package/dist/lib/engine/handlers/ContinuationResolver.js +23 -4
- package/dist/lib/engine/handlers/SubflowExecutor.js +9 -1
- package/dist/lib/engine/types.js +1 -1
- package/dist/lib/memory/StageContext.js +63 -45
- package/dist/lib/memory/index.js +3 -2
- package/dist/lib/memory/types.js +10 -5
- package/dist/lib/observer-queue/deferredDispatcher.js +230 -0
- package/dist/lib/observer-queue/flushDriver.js +167 -0
- package/dist/lib/observer-queue/index.js +36 -0
- package/dist/lib/observer-queue/mergedQueue.js +95 -0
- package/dist/lib/observer-queue/ring.js +126 -0
- package/dist/lib/runner/ExecutionRuntime.js +13 -1
- package/dist/lib/runner/FlowChartExecutor.js +20 -1
- package/dist/types/advanced.d.ts +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/lib/capture/envelope.d.ts +169 -0
- package/dist/types/lib/capture/index.d.ts +16 -0
- package/dist/types/lib/capture/policies.d.ts +42 -0
- package/dist/types/lib/capture/summarize.d.ts +65 -0
- package/dist/types/lib/engine/handlers/ContinuationResolver.d.ts +15 -2
- package/dist/types/lib/engine/types.d.ts +3 -0
- package/dist/types/lib/memory/StageContext.d.ts +38 -1
- package/dist/types/lib/memory/index.d.ts +2 -2
- package/dist/types/lib/memory/types.d.ts +33 -22
- package/dist/types/lib/observer-queue/deferredDispatcher.d.ts +169 -0
- package/dist/types/lib/observer-queue/flushDriver.d.ts +124 -0
- package/dist/types/lib/observer-queue/index.d.ts +25 -0
- package/dist/types/lib/observer-queue/mergedQueue.d.ts +85 -0
- package/dist/types/lib/observer-queue/ring.d.ts +99 -0
- package/dist/types/lib/runner/ExecutionRuntime.d.ts +11 -1
- package/dist/types/lib/runner/FlowChartExecutor.d.ts +43 -1
- 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