@loomwork/replay-vm 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +70 -2
- package/dist/ReplayVM.d.ts +59 -0
- package/dist/ReplayVM.d.ts.map +1 -0
- package/dist/ReplayVM.js +160 -0
- package/dist/ReplayVM.js.map +1 -0
- package/dist/eventHandlers.d.ts +65 -0
- package/dist/eventHandlers.d.ts.map +1 -0
- package/dist/eventHandlers.js +1563 -0
- package/dist/eventHandlers.js.map +1 -0
- package/dist/foldEvents.d.ts +23 -0
- package/dist/foldEvents.d.ts.map +1 -0
- package/dist/foldEvents.js +119 -0
- package/dist/foldEvents.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -3
- package/src/ReplayVM.ts +179 -0
- package/src/eventHandlers.ts +1760 -0
- package/src/foldEvents.ts +128 -0
- package/src/index.ts +18 -0
|
@@ -0,0 +1,1760 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@loomwork/core";
|
|
2
|
+
import type { ExecutionFrame, ExecutionState, SuspendWait } from "@loomwork/core";
|
|
3
|
+
|
|
4
|
+
type Handler = (state: ExecutionState, event: EventEnvelope) => ExecutionState;
|
|
5
|
+
|
|
6
|
+
// Copy-on-write a single frame into the frames container, with per-fold ownership
|
|
7
|
+
// (mirrors `appendAudit` below). The first step event of a fold pass spreads the
|
|
8
|
+
// caller's input container once — O(S) key refs, untouched frames shared — and marks
|
|
9
|
+
// the result fold-owned; every later step event in the same pass writes in place onto
|
|
10
|
+
// that owned container (O(1)). `foldEvents` resets ownership at the start of each call,
|
|
11
|
+
// and the input state (snapshot / prior state) is never mutated, so copy-on-write —
|
|
12
|
+
// and the async snapshot saves that hold prior states — are preserved. Byte-identical
|
|
13
|
+
// output; per-replay frame allocations drop from O(S²) to O(S). Handlers never mutate a
|
|
14
|
+
// frame in place; they always write a new frame object, so sharing untouched frames is safe.
|
|
15
|
+
//
|
|
16
|
+
// Uses the assignment form (`frames[stepId] = frame`), so reserved keys must be rejected
|
|
17
|
+
// (see assertSafeKey) — unlike a computed-key object literal, `frames["__proto__"] = …`
|
|
18
|
+
// would invoke the prototype setter and corrupt this execution's own fold.
|
|
19
|
+
function setFrame(
|
|
20
|
+
frames: Record<string, ExecutionFrame>,
|
|
21
|
+
stepId: string,
|
|
22
|
+
frame: ExecutionFrame
|
|
23
|
+
): Record<string, ExecutionFrame> {
|
|
24
|
+
assertSafeKey(stepId);
|
|
25
|
+
if (foldOwned.has(frames)) {
|
|
26
|
+
frames[stepId] = frame;
|
|
27
|
+
return frames;
|
|
28
|
+
}
|
|
29
|
+
const next = { ...frames, [stepId]: frame };
|
|
30
|
+
foldOwned.add(next);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Defense-in-depth: reject prototype-related keys as fold container keys. They are never
|
|
35
|
+
// valid stepIds/decisionIds, and the assignment form (`frames[key] = …`) would otherwise
|
|
36
|
+
// invoke the prototype setter / shadow `constructor` and corrupt that execution's own fold.
|
|
37
|
+
// (No global pollution is reachable — see the audit — but fail-closed on a reserved key is
|
|
38
|
+
// cheap and clear.)
|
|
39
|
+
const RESERVED_FOLD_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
40
|
+
function assertSafeKey(key: string): void {
|
|
41
|
+
if (RESERVED_FOLD_KEYS.has(key)) {
|
|
42
|
+
throw new Error(`fold: reserved key "${key}" is not a valid identifier`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── O(1)-amortized audit-list append (per-fold ownership) ────────────────────
|
|
47
|
+
//
|
|
48
|
+
// The audit handlers below append to durable lists (`_outcomes`, `_timers`, …). A naive
|
|
49
|
+
// `[...existing, item]` per event makes a full-log fold O(k²) for k events of one type.
|
|
50
|
+
// Instead we track the arrays THIS fold pass created in `foldOwned` and push in place onto
|
|
51
|
+
// those, copying only when the array came from the caller's input state (snapshot / prior
|
|
52
|
+
// state) — which we must never mutate. `foldEvents` resets the set at the start of every
|
|
53
|
+
// call; folding is synchronous and non-reentrant, so a module-level set is safe, and the
|
|
54
|
+
// returned state's arrays are always fold-owned (never aliased into the input). Output is
|
|
55
|
+
// byte-identical; only intermediate allocations drop from O(k²) to O(k).
|
|
56
|
+
let foldOwned = new WeakSet<object>();
|
|
57
|
+
|
|
58
|
+
export function resetFoldOwnership(): void {
|
|
59
|
+
foldOwned = new WeakSet<object>();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* R1a — install the ownership set a fold pass writes against. A one-shot fold (`foldEvents`
|
|
64
|
+
* with no set) starts empty every call. A LIVE fold (`ReplayVM.applyEvent`) hands in the set
|
|
65
|
+
* its VM keeps between commits, so containers the VM itself created stay writable in place
|
|
66
|
+
* across commits (O(1) per event) instead of being re-copied by every single-event fold
|
|
67
|
+
* (O(size) per event — quadratic over a run). The VM must drop the set the moment it lets a
|
|
68
|
+
* state escape to a caller (`ReplayVM.getState`), so an escaped state is never written to.
|
|
69
|
+
* Folding is synchronous and non-reentrant, so swapping the module-level reference is safe.
|
|
70
|
+
*/
|
|
71
|
+
export function useFoldOwnership(owned: WeakSet<object>): void {
|
|
72
|
+
foldOwned = owned;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function appendAudit<T>(existing: T[] | undefined, item: T): T[] {
|
|
76
|
+
if (existing !== undefined && foldOwned.has(existing)) {
|
|
77
|
+
existing.push(item);
|
|
78
|
+
return existing;
|
|
79
|
+
}
|
|
80
|
+
const next = existing === undefined ? [item] : [...existing, item];
|
|
81
|
+
foldOwned.add(next);
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── 1.19.0 (ADR 0102) — `_flowEmits`: stamped flow-level emits counted per digest ──
|
|
86
|
+
//
|
|
87
|
+
// Shape: a fixed-depth, 16-way trie over the digest's LAST three hex digits (FNV-1a's
|
|
88
|
+
// best-mixed bits). Inner nodes are 16-slot arrays (`null` = empty), leaves are
|
|
89
|
+
// `{ [digest]: count }`:
|
|
90
|
+
//
|
|
91
|
+
// _flowEmits[hex(d[15])][hex(d[14])][hex(d[13])][d] = count
|
|
92
|
+
//
|
|
93
|
+
// Why a trie and not a flat `{ [digest]: count }`: a live commit is its own fold pass, so
|
|
94
|
+
// copy-on-write must copy what it touches, and a flat map costs O(k) per emit (worse in
|
|
95
|
+
// practice: a many-key object is a dictionary, and its spread is slow). The trie copies
|
|
96
|
+
// the root-to-leaf path — three 16-slot arrays (`slice`, no hidden-class churn) plus one
|
|
97
|
+
// leaf of ~k/4096 entries — O(1) for any realistic k, still plain JSON (it rides snapshots
|
|
98
|
+
// and survives `truncatePrefix` compaction), and O(1) amortized within a multi-event
|
|
99
|
+
// replay pass, where fold-owned nodes are updated in place (`appendAudit`'s ownership
|
|
100
|
+
// rule). Size is one leaf entry per DISTINCT stamped emit plus the touched inner arrays.
|
|
101
|
+
export const FLOW_EMIT_DIGEST = /^[0-9a-f]{16}$/;
|
|
102
|
+
export type FlowEmitLeaf = Record<string, number>;
|
|
103
|
+
/** A 16-slot inner node of the `_flowEmits` trie; the third level holds leaves. */
|
|
104
|
+
export type FlowEmitCounts = Array<FlowEmitCounts | FlowEmitLeaf | null>;
|
|
105
|
+
const TRIE_PATH = [15, 14, 13] as const;
|
|
106
|
+
|
|
107
|
+
function ownedNode(existing: FlowEmitCounts | null | undefined): FlowEmitCounts {
|
|
108
|
+
if (existing != null && foldOwned.has(existing)) return existing;
|
|
109
|
+
const next: FlowEmitCounts = existing == null ? new Array(16).fill(null) : existing.slice();
|
|
110
|
+
foldOwned.add(next);
|
|
111
|
+
return next;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ownedLeaf(existing: FlowEmitLeaf | null | undefined): FlowEmitLeaf {
|
|
115
|
+
if (existing != null && foldOwned.has(existing)) return existing;
|
|
116
|
+
const next: FlowEmitLeaf = existing == null ? {} : { ...existing };
|
|
117
|
+
foldOwned.add(next);
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function countFlowEmit(
|
|
122
|
+
variables: Record<string, unknown>,
|
|
123
|
+
digest: string
|
|
124
|
+
): Record<string, unknown> {
|
|
125
|
+
const root = variables._flowEmits as FlowEmitCounts | undefined;
|
|
126
|
+
const nextRoot = ownedNode(root);
|
|
127
|
+
const a = Number.parseInt(digest[15] as string, 16);
|
|
128
|
+
const b = Number.parseInt(digest[14] as string, 16);
|
|
129
|
+
const c = Number.parseInt(digest[13] as string, 16);
|
|
130
|
+
const mid = ownedNode(nextRoot[a] as FlowEmitCounts | null);
|
|
131
|
+
nextRoot[a] = mid;
|
|
132
|
+
const low = ownedNode(mid[b] as FlowEmitCounts | null);
|
|
133
|
+
mid[b] = low;
|
|
134
|
+
const leaf = ownedLeaf(low[c] as FlowEmitLeaf | null);
|
|
135
|
+
low[c] = leaf;
|
|
136
|
+
leaf[digest] = (leaf[digest] ?? 0) + 1;
|
|
137
|
+
// A fold-owned root is already inside a fold-created `variables`; only a fresh root
|
|
138
|
+
// needs a new `variables` object (never mutate the caller's).
|
|
139
|
+
return nextRoot === root ? variables : { ...variables, _flowEmits: nextRoot };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** How many stamped flow-level emits with this digest the folded log holds. */
|
|
143
|
+
export function flowEmitCount(variables: Record<string, unknown>, digest: string): number {
|
|
144
|
+
let node: unknown = variables._flowEmits;
|
|
145
|
+
for (const i of TRIE_PATH) {
|
|
146
|
+
if (!Array.isArray(node)) return 0;
|
|
147
|
+
node = node[Number.parseInt(digest[i] as string, 16)];
|
|
148
|
+
}
|
|
149
|
+
if (node == null || typeof node !== "object" || !Object.hasOwn(node, digest)) return 0;
|
|
150
|
+
const count = (node as FlowEmitLeaf)[digest];
|
|
151
|
+
return typeof count === "number" ? count : 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 1.20.0 (ADR 0104) — a `null` EXECUTION_RESUMED that answers no suspend point re-opens the
|
|
156
|
+
* execution and records NO gate passthrough. The kernel writes one on `resumeAs` onto a
|
|
157
|
+
* terminal execution and on an in-doubt verdict (whose answer is recorded as a decision):
|
|
158
|
+
* `null` because every store round-trips it (SQLite's payload column is NOT NULL, so an
|
|
159
|
+
* absent payload cannot be written). Keyed on the state being answered, not on `null` alone,
|
|
160
|
+
* so a caller's `null` approval of a real gate is still that gate's answer. The one pre-0104 log
|
|
161
|
+
* this changes is one where a caller resumed `null` into either position: that entry answered
|
|
162
|
+
* no suspend point and would have been served to the NEXT gate as `null` — the stray this fixes.
|
|
163
|
+
*/
|
|
164
|
+
const IN_DOUBT_GATE = "EffectInDoubtResolved";
|
|
165
|
+
/**
|
|
166
|
+
* ADR 0113 (R7 fix round 1) — the reserved gate a REFUSED re-run parks on. Like the in-doubt gate it
|
|
167
|
+
* is answered by the kernel's own status-only `null` resume (a re-drive after the code is fixed):
|
|
168
|
+
* the answer must NOT enter `_resumePayloads`, or the body's next gate would take it as its approval.
|
|
169
|
+
*/
|
|
170
|
+
export const FLOW_DIVERGED_GATE = "__loom_diverged__";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 1.21.0 (ADR 0105) — reserved fold key: `true` while the execution is PARKED and a
|
|
174
|
+
* `STEP_FAILED` has landed since it parked (a parallel sibling failing after the flow reached
|
|
175
|
+
* its gate). Set only by that event in that state; removed by whatever ends the park — the next
|
|
176
|
+
* `EXECUTION_SUSPENDED` or `EXECUTION_RESUMED`, or a terminal `EXECUTION_COMPLETED` /
|
|
177
|
+
* `_FAILED` / `_CANCELLED`; absent otherwise, so a log without such a failure never gains it.
|
|
178
|
+
*
|
|
179
|
+
* It exists to keep one rule the 1.20.0 fold had implicitly. 1.20.0 folded that failure to
|
|
180
|
+
* "failed", so a following `null` resume was status-only (it answered no gate, and a 1.20.0
|
|
181
|
+
* `resumeAs` wrote exactly that). Since 1.21.0 the execution stays `suspended`; without this
|
|
182
|
+
* key the same `null` would become the gate's answer, and a replay's outcome would depend on
|
|
183
|
+
* whether it was seeded from a 1.20.0 snapshot — breaking snapshot + tail ≡ full log. So a
|
|
184
|
+
* `null` resume in this position stays status-only (fail-closed: the gate re-parks rather than
|
|
185
|
+
* passing without an approval). The cost: a caller's explicit `null` answer to such a gate is
|
|
186
|
+
* not recorded as its answer; the gate re-parks and takes the next non-null one.
|
|
187
|
+
*/
|
|
188
|
+
export const FAILED_WHILE_PARKED = "_failedWhileParked";
|
|
189
|
+
|
|
190
|
+
function isStatusOnlyResume(state: ExecutionState, payload: unknown): boolean {
|
|
191
|
+
return (
|
|
192
|
+
payload === null &&
|
|
193
|
+
(state.status !== "suspended" ||
|
|
194
|
+
state.suspendedOn?.on === IN_DOUBT_GATE ||
|
|
195
|
+
state.suspendedOn?.on === FLOW_DIVERGED_GATE ||
|
|
196
|
+
state.variables[FAILED_WHILE_PARKED] === true)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `variables` without the {@link FAILED_WHILE_PARKED} key (the same object when absent). */
|
|
201
|
+
function withoutFailedWhileParked(variables: Record<string, unknown>): Record<string, unknown> {
|
|
202
|
+
if (!Object.hasOwn(variables, FAILED_WHILE_PARKED)) return variables;
|
|
203
|
+
const { [FAILED_WHILE_PARKED]: _cleared, ...rest } = variables;
|
|
204
|
+
return rest;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── 1.23.0 (Wave 2, W2-0) — reserved shapes, folded only from what the kernel wrote ──────────
|
|
208
|
+
//
|
|
209
|
+
// The new kinds (`EXECUTION_CONTINUED`, `MESSAGE_CONSUMED`) and the PARALLEL_COMPLETED payload
|
|
210
|
+
// fold ONLY an envelope carrying `metadata.kernel === true`. An application event that already
|
|
211
|
+
// carries one of those names in an existing log therefore folds as the no-op it always was
|
|
212
|
+
// (byte-identically), even though a handler now exists and no kernel writer does yet (ADR 0110
|
|
213
|
+
// "Marker"; design §3). The journal stamps the marker for {@link KERNEL_STAMPED_EVENT_TYPES}.
|
|
214
|
+
// Four more shapes ride the same stamp but are stamped by their WRITER through commit
|
|
215
|
+
// `opts.metadata` (the kind also has an unstamped, userspace form, so the journal cannot stamp by
|
|
216
|
+
// type): a wait-addressed EXECUTION_RESUMED (ADR 0110 D3), an EXECUTION_SUSPENDED carrying
|
|
217
|
+
// `condition.waits` (D4 — the R2 writer adds `waits` AFTER the commit site's strip and stamps
|
|
218
|
+
// the event; an unstamped park's `waits` is dropped by the fold), and an OUTCOME_RECORDED
|
|
219
|
+
// carrying `recordedBy` / `late` (ADR 0116; the `recordOutcome` writer).
|
|
220
|
+
|
|
221
|
+
/** Kinds whose fold requires the kernel stamp, and which the journal stamps when it writes one. */
|
|
222
|
+
export const KERNEL_STAMPED_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
223
|
+
"EXECUTION_CONTINUED",
|
|
224
|
+
"MESSAGE_CONSUMED",
|
|
225
|
+
"PARALLEL_COMPLETED",
|
|
226
|
+
// 1.25.0 (ADR 0130 residuals D9): written by quarantineInbox / releaseInbox / auto-release (slice S5).
|
|
227
|
+
"INBOX_QUARANTINED",
|
|
228
|
+
"INBOX_RELEASED",
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
function isKernelWritten(event: EventEnvelope): boolean {
|
|
232
|
+
return event.metadata?.kernel === true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isPlain(v: unknown): v is Record<string, unknown> {
|
|
236
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Every inbox dedupe window the fold keeps is capped here; the runtime may apply a smaller one. */
|
|
240
|
+
export const INBOX_DEDUPE_CAP = 1024;
|
|
241
|
+
/**
|
|
242
|
+
* 1.25.0 (ADR 0130 residuals D9) — the fold keeps at most this many quarantined entries in total
|
|
243
|
+
* (`_quarantined`, across topics). A fold cannot read a runtime setting (the lesson of ADR 0110 D6),
|
|
244
|
+
* so the ceiling is a fold constant; the runtime's own `quarantineMax` (default 256) may be raised
|
|
245
|
+
* to it, never above. A fact that would exceed it is ignored.
|
|
246
|
+
*/
|
|
247
|
+
export const QUARANTINE_FOLD_CAP = 1024;
|
|
248
|
+
/** ADR 0115 D2 — custom metering dimension names, count and per-value ceiling. */
|
|
249
|
+
export const CUSTOM_DIMENSION_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
250
|
+
export const CUSTOM_DIMENSION_MAX = 32;
|
|
251
|
+
export const CUSTOM_DIMENSION_CEILING = 1e15;
|
|
252
|
+
|
|
253
|
+
/** A fold-owned copy-on-write record (the same ownership rule as `_decisions`). */
|
|
254
|
+
function ownedRecord<T>(prior: Record<string, T> | undefined): Record<string, T> {
|
|
255
|
+
if (prior !== undefined && foldOwned.has(prior)) return prior;
|
|
256
|
+
const next: Record<string, T> = prior === undefined ? {} : { ...prior };
|
|
257
|
+
foldOwned.add(next);
|
|
258
|
+
return next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function safeMapKey(key: unknown): key is string {
|
|
262
|
+
return typeof key === "string" && key.length > 0 && !RESERVED_FOLD_KEYS.has(key);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* ADR 0115 D2 — add the valid entries of a `custom` metering map onto the running one. Never
|
|
267
|
+
* throws: a bad name, a prototype name, a non-finite / negative / over-ceiling value, or the
|
|
268
|
+
* 33rd distinct name is skipped deterministically and the rest folds, so every replica folds the
|
|
269
|
+
* same state from a log it did not write the guard for. Returns `prior` when nothing changed.
|
|
270
|
+
*/
|
|
271
|
+
function addCustomDimensions(
|
|
272
|
+
prior: Record<string, number> | undefined,
|
|
273
|
+
raw: unknown
|
|
274
|
+
): Record<string, number> | undefined {
|
|
275
|
+
if (!isPlain(raw)) return prior;
|
|
276
|
+
let next: Record<string, number> | undefined;
|
|
277
|
+
let names = prior === undefined ? 0 : Object.keys(prior).length;
|
|
278
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
279
|
+
if (!CUSTOM_DIMENSION_PATTERN.test(name) || RESERVED_FOLD_KEYS.has(name)) continue;
|
|
280
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > CUSTOM_DIMENSION_CEILING) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const known = (next ?? prior) !== undefined && Object.hasOwn((next ?? prior) as object, name);
|
|
284
|
+
if (!known) {
|
|
285
|
+
if (names >= CUSTOM_DIMENSION_MAX) continue;
|
|
286
|
+
names++;
|
|
287
|
+
}
|
|
288
|
+
next ??= { ...prior };
|
|
289
|
+
next[name] = (Object.hasOwn(next, name) ? (next[name] as number) : 0) + v;
|
|
290
|
+
}
|
|
291
|
+
return next ?? prior;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* ADR 0115 D3 — the valid entries of a decision's descriptive `estimatedCost` map (a name
|
|
296
|
+
* matching the dimension pattern or a built-in, a finite non-negative value, at most 32), or
|
|
297
|
+
* `undefined` when none survive. Never throws, so a log written without the guard folds the same
|
|
298
|
+
* on every replica; null-prototype so a caller-chosen name cannot reach a prototype member.
|
|
299
|
+
*/
|
|
300
|
+
function sanitizeEstimatedCost(raw: unknown): Record<string, number> | undefined {
|
|
301
|
+
if (!isPlain(raw)) return undefined;
|
|
302
|
+
let out: Record<string, number> | undefined;
|
|
303
|
+
let names = 0;
|
|
304
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
305
|
+
if (names >= CUSTOM_DIMENSION_MAX) break;
|
|
306
|
+
if (!BUILTIN_DIMENSION_NAMES.has(name)) {
|
|
307
|
+
if (!CUSTOM_DIMENSION_PATTERN.test(name) || RESERVED_FOLD_KEYS.has(name)) continue;
|
|
308
|
+
}
|
|
309
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) continue;
|
|
310
|
+
out ??= {};
|
|
311
|
+
out[name] = v;
|
|
312
|
+
names++;
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const BUILTIN_DIMENSION_NAMES: ReadonlySet<string> = new Set([
|
|
318
|
+
"tokens",
|
|
319
|
+
"costUsd",
|
|
320
|
+
"ms",
|
|
321
|
+
"memBytes",
|
|
322
|
+
"wallMs",
|
|
323
|
+
"netBytes",
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
/** A validated `_resources` seed from `continuedFrom.carried.resources`; invalid parts are ignored. */
|
|
327
|
+
function seedResources(raw: unknown): Record<string, unknown> | undefined {
|
|
328
|
+
if (!isPlain(raw)) return undefined;
|
|
329
|
+
const num = (v: unknown): number =>
|
|
330
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
331
|
+
const custom = addCustomDimensions(undefined, raw.custom);
|
|
332
|
+
const memBytes = num(raw.memBytes);
|
|
333
|
+
const wallMs = num(raw.wallMs);
|
|
334
|
+
const netBytes = num(raw.netBytes);
|
|
335
|
+
// A quota denial survives a rotation: the successor of an execution that exceeded its budget
|
|
336
|
+
// starts exceeded, with the reason it was given (a continue-as-new cannot reset a quota).
|
|
337
|
+
const exceeded = raw.exceeded === true;
|
|
338
|
+
const reason = exceeded && typeof raw.reason === "string" ? raw.reason : undefined;
|
|
339
|
+
return {
|
|
340
|
+
tokens: num(raw.tokens),
|
|
341
|
+
costUsd: num(raw.costUsd),
|
|
342
|
+
ms: num(raw.ms),
|
|
343
|
+
count: num(raw.count),
|
|
344
|
+
...(memBytes > 0 && { memBytes }),
|
|
345
|
+
...(wallMs > 0 && { wallMs }),
|
|
346
|
+
...(netBytes > 0 && { netBytes }),
|
|
347
|
+
...(custom !== undefined && { custom }),
|
|
348
|
+
...(exceeded && { exceeded: true }),
|
|
349
|
+
...(reason !== undefined && { reason }),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A validated `_inbox` seed from `continuedFrom.carried.inbox`; invalid parts are ignored. */
|
|
354
|
+
function seedInbox(
|
|
355
|
+
raw: unknown
|
|
356
|
+
): { cursors: Record<string, number>; dedupe: string[] } | undefined {
|
|
357
|
+
if (!isPlain(raw)) return undefined;
|
|
358
|
+
const cursors: Record<string, number> = {};
|
|
359
|
+
if (isPlain(raw.cursors)) {
|
|
360
|
+
for (const [topic, seq] of Object.entries(raw.cursors)) {
|
|
361
|
+
if (safeMapKey(topic) && Number.isSafeInteger(seq) && (seq as number) >= 0) {
|
|
362
|
+
cursors[topic] = seq as number;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const dedupe = Array.isArray(raw.dedupe)
|
|
367
|
+
? raw.dedupe.filter((id): id is string => typeof id === "string").slice(-INBOX_DEDUPE_CAP)
|
|
368
|
+
: [];
|
|
369
|
+
return { cursors, dedupe };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** One held (or released, not yet delivered) inbox message of a topic's quarantine queue (1.25.0). */
|
|
373
|
+
interface QuarantineEntry {
|
|
374
|
+
key: string;
|
|
375
|
+
inboxSeq: number;
|
|
376
|
+
heldAt: number;
|
|
377
|
+
state: "held" | "released";
|
|
378
|
+
}
|
|
379
|
+
type Quarantined = Record<string, QuarantineEntry[]>;
|
|
380
|
+
|
|
381
|
+
function quarantinedOf(state: ExecutionState): Quarantined | undefined {
|
|
382
|
+
const q = state.variables._quarantined;
|
|
383
|
+
return isPlain(q) ? (q as Quarantined) : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** `variables` with `_quarantined` set to `q`, or ABSENT when `q` is empty (never-quarantined shape). */
|
|
387
|
+
function withQuarantined(
|
|
388
|
+
variables: Record<string, unknown>,
|
|
389
|
+
q: Quarantined
|
|
390
|
+
): Record<string, unknown> {
|
|
391
|
+
const { _quarantined: _dropped, ...rest } = variables;
|
|
392
|
+
return Object.keys(q).length === 0 ? rest : { ...rest, _quarantined: q };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function quarantineTotal(q: Quarantined | undefined): number {
|
|
396
|
+
let n = 0;
|
|
397
|
+
if (q !== undefined) for (const queue of Object.values(q)) n += queue.length;
|
|
398
|
+
return n;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A validated `_quarantined` seed from `continuedFrom.carried.quarantined` (1.25.0); invalid parts
|
|
403
|
+
* are ignored, never thrown. A topic's queue must read `released? held*` (at most ONE released entry: the fold can only produce one) (the shape release
|
|
404
|
+
* preserves); a queue that does not is dropped whole. The total is capped at {@link QUARANTINE_FOLD_CAP}.
|
|
405
|
+
*/
|
|
406
|
+
function seedQuarantined(raw: unknown): Quarantined | undefined {
|
|
407
|
+
if (!isPlain(raw)) return undefined;
|
|
408
|
+
const out: Quarantined = {};
|
|
409
|
+
let total = 0;
|
|
410
|
+
for (const [topic, queue] of Object.entries(raw)) {
|
|
411
|
+
if (!safeMapKey(topic) || !Array.isArray(queue)) continue;
|
|
412
|
+
const entries: QuarantineEntry[] = [];
|
|
413
|
+
let sawHeld = false;
|
|
414
|
+
let sawReleased = false;
|
|
415
|
+
let ok = true;
|
|
416
|
+
for (const e of queue as unknown[]) {
|
|
417
|
+
if (
|
|
418
|
+
!isPlain(e) ||
|
|
419
|
+
typeof e.key !== "string" ||
|
|
420
|
+
e.key.length === 0 ||
|
|
421
|
+
!Number.isSafeInteger(e.inboxSeq) ||
|
|
422
|
+
(e.inboxSeq as number) < 0 ||
|
|
423
|
+
!Number.isSafeInteger(e.heldAt) ||
|
|
424
|
+
(e.heldAt as number) < 0 ||
|
|
425
|
+
(e.state !== "held" && e.state !== "released") ||
|
|
426
|
+
(sawHeld && e.state === "released") ||
|
|
427
|
+
(sawReleased && e.state === "released")
|
|
428
|
+
) {
|
|
429
|
+
ok = false;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
if (e.state === "held") sawHeld = true;
|
|
433
|
+
else sawReleased = true;
|
|
434
|
+
entries.push({
|
|
435
|
+
key: e.key,
|
|
436
|
+
inboxSeq: e.inboxSeq as number,
|
|
437
|
+
heldAt: e.heldAt as number,
|
|
438
|
+
state: e.state,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
if (!ok || entries.length === 0 || total + entries.length > QUARANTINE_FOLD_CAP) continue;
|
|
442
|
+
total += entries.length;
|
|
443
|
+
out[topic] = entries;
|
|
444
|
+
}
|
|
445
|
+
return total > 0 ? out : undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The chain identity a `forkChain` stamp or the start payload names; `undefined` if malformed. */
|
|
449
|
+
function chainIdentity(v: unknown): { chainId: string; generation: number } | undefined {
|
|
450
|
+
if (!isPlain(v)) return undefined;
|
|
451
|
+
const { chainId, generation } = v;
|
|
452
|
+
if (typeof chainId !== "string" || chainId.length === 0) return undefined;
|
|
453
|
+
if (!Number.isSafeInteger(generation) || (generation as number) < 0) return undefined;
|
|
454
|
+
return { chainId, generation: generation as number };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** ADR 0110 D3 — a wait-addressed resume: kernel-stamped, payload `{ waitId, payload }`. */
|
|
458
|
+
type WaitResume =
|
|
459
|
+
| { answer: true; waitId: string; wait: SuspendWait; inner: unknown }
|
|
460
|
+
| { answer: false };
|
|
461
|
+
function parseWaitResume(state: ExecutionState, event: EventEnvelope): WaitResume | undefined {
|
|
462
|
+
if (!isKernelWritten(event)) return undefined;
|
|
463
|
+
const p = event.payload;
|
|
464
|
+
if (!isPlain(p) || typeof p.waitId !== "string" || !Object.hasOwn(p, "payload")) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
// The table is the fold-owned `suspendedOn.waits`: it exists ONLY when the park was a
|
|
468
|
+
// kernel-stamped EXECUTION_SUSPENDED (the handler drops `waits` from an unstamped one), and
|
|
469
|
+
// holds only well-formed entries, so a userspace park can never be answered as a multi-wait.
|
|
470
|
+
// Total: a non-array or malformed entry is skipped, never thrown.
|
|
471
|
+
const table: unknown = state.status === "suspended" ? state.suspendedOn?.waits : undefined;
|
|
472
|
+
const wait = Array.isArray(table)
|
|
473
|
+
? (table as unknown[]).find((w): w is SuspendWait => isPlain(w) && w.waitId === p.waitId)
|
|
474
|
+
: undefined;
|
|
475
|
+
if (wait === undefined || !safeMapKey(p.waitId)) return { answer: false };
|
|
476
|
+
return { answer: true, waitId: p.waitId, wait, inner: p.payload };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const handlers: Record<string, Handler> = {
|
|
480
|
+
EXECUTION_STARTED: (state, event) => {
|
|
481
|
+
// `flowHash` (K11.1, additive — absent on legacy logs) is the content hash
|
|
482
|
+
// of the flow code this execution started under; resume/recovery compares
|
|
483
|
+
// it against the registered flow so changed code never silently folds an
|
|
484
|
+
// in-flight log. Reserved fold key: `variables._flowHash`.
|
|
485
|
+
//
|
|
486
|
+
// 1.23.0 (Wave 2) — further additive fields, each folded to a reserved key and each absent
|
|
487
|
+
// from every older log: `principal` → `_principal` (ADR 0111; a fork's `forkedBy` stamp
|
|
488
|
+
// wins), `parent` → `_parent`, `chainId` + `generation` (+ `continuedFrom`) → `_chain`
|
|
489
|
+
// (ADR 0109; a fork's `forkChain` stamp wins and drops `continuedFrom`), `codeHash` →
|
|
490
|
+
// `_codeHash` (ADR 0113). A successor's validated `continuedFrom.carried` seeds `_resources`
|
|
491
|
+
// and `_inbox` (never for a fork, which starts a fresh chain); an invalid carry is ignored,
|
|
492
|
+
// never thrown.
|
|
493
|
+
const payload = event.payload as {
|
|
494
|
+
input?: unknown;
|
|
495
|
+
flowHash?: string;
|
|
496
|
+
principal?: unknown;
|
|
497
|
+
parent?: unknown;
|
|
498
|
+
chainId?: unknown;
|
|
499
|
+
generation?: unknown;
|
|
500
|
+
continuedFrom?: unknown;
|
|
501
|
+
codeHash?: unknown;
|
|
502
|
+
};
|
|
503
|
+
const meta = event.metadata;
|
|
504
|
+
const vars: Record<string, unknown> = {};
|
|
505
|
+
if (payload.input !== undefined) vars._input = payload.input;
|
|
506
|
+
if (payload.flowHash !== undefined) vars._flowHash = payload.flowHash;
|
|
507
|
+
const principal = isPlain(meta?.forkedBy) ? meta.forkedBy : payload.principal;
|
|
508
|
+
if (isPlain(principal)) vars._principal = principal;
|
|
509
|
+
if (typeof payload.parent === "string" && payload.parent.length > 0) {
|
|
510
|
+
vars._parent = payload.parent;
|
|
511
|
+
}
|
|
512
|
+
const forkChain = chainIdentity(meta?.forkChain);
|
|
513
|
+
let carriedFrom: Record<string, unknown> | undefined;
|
|
514
|
+
let forkResources: unknown;
|
|
515
|
+
const own = forkChain ?? chainIdentity(payload);
|
|
516
|
+
if (own !== undefined) {
|
|
517
|
+
const from = payload.continuedFrom;
|
|
518
|
+
const continuedFrom =
|
|
519
|
+
forkChain === undefined &&
|
|
520
|
+
isPlain(from) &&
|
|
521
|
+
typeof from.executionId === "string" &&
|
|
522
|
+
(from.carried === undefined || isPlain(from.carried))
|
|
523
|
+
? {
|
|
524
|
+
executionId: from.executionId,
|
|
525
|
+
...(from.carried !== undefined && { carried: from.carried }),
|
|
526
|
+
}
|
|
527
|
+
: undefined;
|
|
528
|
+
vars._chain = { ...own, ...(continuedFrom !== undefined && { continuedFrom }) };
|
|
529
|
+
carriedFrom = continuedFrom?.carried as Record<string, unknown> | undefined;
|
|
530
|
+
// A fork of a chain member leaves the chain (no `continuedFrom`, no carried inbox cursors or
|
|
531
|
+
// deadline), but it keeps what is the execution's OWN history rather than the chain's
|
|
532
|
+
// membership: the running resource totals, so a carried `exceeded` quota is not shed by
|
|
533
|
+
// forking (a fork of an ordinary execution keeps it through its copied prefix).
|
|
534
|
+
if (forkChain !== undefined && isPlain(payload.continuedFrom)) {
|
|
535
|
+
const carried = (payload.continuedFrom as { carried?: unknown }).carried;
|
|
536
|
+
if (isPlain(carried)) forkResources = carried.resources;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (typeof payload.codeHash === "string") vars._codeHash = payload.codeHash;
|
|
540
|
+
const resources = seedResources(carriedFrom?.resources ?? forkResources);
|
|
541
|
+
if (resources !== undefined) vars._resources = resources;
|
|
542
|
+
const inbox = seedInbox(carriedFrom?.inbox);
|
|
543
|
+
if (inbox !== undefined) vars._inbox = inbox;
|
|
544
|
+
// 1.25.0 (ADR 0130 residuals D9) — the quarantine queues ride the same carry as the inbox.
|
|
545
|
+
const quarantined = seedQuarantined(carriedFrom?.quarantined);
|
|
546
|
+
if (quarantined !== undefined) vars._quarantined = quarantined;
|
|
547
|
+
return {
|
|
548
|
+
...state,
|
|
549
|
+
status: "running",
|
|
550
|
+
startedAt: event.timestamp,
|
|
551
|
+
updatedAt: event.timestamp,
|
|
552
|
+
...(Object.keys(vars).length > 0 && { variables: { ...state.variables, ...vars } }),
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
STEP_STARTED: (state, event) => {
|
|
557
|
+
const payload = event.payload as { stepId: string; input?: unknown; scope?: unknown };
|
|
558
|
+
const existing = state.frames[payload.stepId];
|
|
559
|
+
// ADR 0113 D3 — the log's first-start order, and (kernel-stamped only) the scope it started in.
|
|
560
|
+
const scope =
|
|
561
|
+
existing?.scope ??
|
|
562
|
+
(isKernelWritten(event) && typeof payload.scope === "string" ? payload.scope : undefined);
|
|
563
|
+
const firstSeq = existing === undefined ? event.sequence : existing.firstSeq;
|
|
564
|
+
const frame: ExecutionFrame = {
|
|
565
|
+
stepId: payload.stepId,
|
|
566
|
+
// Set ONLY when the frame is new: a frame folded before `firstSeq` existed (an old snapshot)
|
|
567
|
+
// that is re-started in the tail must not be stamped with the LATER sequence, or it would
|
|
568
|
+
// rank last and an unchanged body would be refused. It stays unranked (sorts first).
|
|
569
|
+
...(firstSeq !== undefined && { firstSeq }),
|
|
570
|
+
...(scope !== undefined && { scope }),
|
|
571
|
+
status: "running",
|
|
572
|
+
attempts: (existing?.attempts ?? 0) + 1,
|
|
573
|
+
startedAt: event.timestamp,
|
|
574
|
+
// K8.3 — the first attempt's logged start survives re-runs, so deadlines
|
|
575
|
+
// anchored here are never reset by crash recovery.
|
|
576
|
+
firstStartedAt: existing?.firstStartedAt ?? existing?.startedAt ?? event.timestamp,
|
|
577
|
+
...(payload.input !== undefined && { input: payload.input }),
|
|
578
|
+
};
|
|
579
|
+
return {
|
|
580
|
+
...state,
|
|
581
|
+
frames: setFrame(state.frames, payload.stepId, frame),
|
|
582
|
+
updatedAt: event.timestamp,
|
|
583
|
+
};
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
STEP_COMPLETED: (state, event) => {
|
|
587
|
+
const payload = event.payload as { stepId: string; output?: unknown };
|
|
588
|
+
const existing = state.frames[payload.stepId];
|
|
589
|
+
const frame: ExecutionFrame = {
|
|
590
|
+
stepId: payload.stepId,
|
|
591
|
+
status: "completed",
|
|
592
|
+
attempts: existing?.attempts ?? 1,
|
|
593
|
+
...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
|
|
594
|
+
...(existing?.scope !== undefined && { scope: existing.scope }),
|
|
595
|
+
completedAt: event.timestamp,
|
|
596
|
+
...(existing?.startedAt !== undefined && { startedAt: existing.startedAt }),
|
|
597
|
+
...(existing?.firstStartedAt !== undefined && { firstStartedAt: existing.firstStartedAt }),
|
|
598
|
+
...(payload.output !== undefined && { output: payload.output }),
|
|
599
|
+
};
|
|
600
|
+
return {
|
|
601
|
+
...state,
|
|
602
|
+
frames: setFrame(state.frames, payload.stepId, frame),
|
|
603
|
+
updatedAt: event.timestamp,
|
|
604
|
+
};
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
STEP_FAILED: (state, event) => {
|
|
608
|
+
const payload = event.payload as {
|
|
609
|
+
stepId: string;
|
|
610
|
+
error?: unknown;
|
|
611
|
+
// DE-4 (1.17.0) — present only for typed failures on a DECLARED channel;
|
|
612
|
+
// absence folds byte-identically to pre-1.17 logs (K5 payload-extension rule).
|
|
613
|
+
channel?: string;
|
|
614
|
+
payload?: unknown;
|
|
615
|
+
};
|
|
616
|
+
const existing = state.frames[payload.stepId];
|
|
617
|
+
const frame: ExecutionFrame = {
|
|
618
|
+
stepId: payload.stepId,
|
|
619
|
+
status: "failed",
|
|
620
|
+
attempts: existing?.attempts ?? 1,
|
|
621
|
+
...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
|
|
622
|
+
...(existing?.scope !== undefined && { scope: existing.scope }),
|
|
623
|
+
...(existing?.startedAt !== undefined && { startedAt: existing.startedAt }),
|
|
624
|
+
...(existing?.firstStartedAt !== undefined && { firstStartedAt: existing.firstStartedAt }),
|
|
625
|
+
...(payload.error !== undefined && { error: payload.error }),
|
|
626
|
+
...(payload.channel !== undefined && {
|
|
627
|
+
errorChannel: payload.channel,
|
|
628
|
+
errorPayload: payload.payload,
|
|
629
|
+
}),
|
|
630
|
+
};
|
|
631
|
+
// ADR 0105 — the STEP failed, not the execution: leave execution `status` alone. A flow
|
|
632
|
+
// may catch the throw (try/catch, a saga compensation, a typed-failure fallback) and carry
|
|
633
|
+
// on; folding "failed" here made a live execution read terminal, so a crash after the
|
|
634
|
+
// catch was never re-driven and `cancel` refused it. A failure that escapes the runner is
|
|
635
|
+
// recorded by the EXECUTION_FAILED the kernel commits after it, which owns the terminal
|
|
636
|
+
// status (as EXECUTION_CANCELLED does for STEP_CANCELLED below). A failure that lands while
|
|
637
|
+
// the execution is parked (a parallel sibling) marks FAILED_WHILE_PARKED — see there.
|
|
638
|
+
const parked = state.status === "suspended" && state.variables[FAILED_WHILE_PARKED] !== true;
|
|
639
|
+
return {
|
|
640
|
+
...state,
|
|
641
|
+
frames: setFrame(state.frames, payload.stepId, frame),
|
|
642
|
+
...(parked && { variables: { ...state.variables, [FAILED_WHILE_PARKED]: true } }),
|
|
643
|
+
updatedAt: event.timestamp,
|
|
644
|
+
};
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
STEP_CANCELLED: (state, event) => {
|
|
648
|
+
// Marks the in-flight step as cancelled WITHOUT touching execution status —
|
|
649
|
+
// the surrounding EXECUTION_CANCELLED event owns the execution-level status,
|
|
650
|
+
// and this must not overwrite it back to a failure/other state.
|
|
651
|
+
const payload = event.payload as { stepId: string };
|
|
652
|
+
const existing = state.frames[payload.stepId];
|
|
653
|
+
const frame: ExecutionFrame = {
|
|
654
|
+
stepId: payload.stepId,
|
|
655
|
+
status: "cancelled",
|
|
656
|
+
attempts: existing?.attempts ?? 1,
|
|
657
|
+
...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
|
|
658
|
+
...(existing?.scope !== undefined && { scope: existing.scope }),
|
|
659
|
+
...(existing?.startedAt !== undefined && { startedAt: existing.startedAt }),
|
|
660
|
+
...(existing?.firstStartedAt !== undefined && { firstStartedAt: existing.firstStartedAt }),
|
|
661
|
+
};
|
|
662
|
+
return {
|
|
663
|
+
...state,
|
|
664
|
+
frames: setFrame(state.frames, payload.stepId, frame),
|
|
665
|
+
updatedAt: event.timestamp,
|
|
666
|
+
};
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
STEP_RETRYING: (state, event) => {
|
|
670
|
+
const payload = event.payload as { stepId: string; attempt: number };
|
|
671
|
+
const existing = state.frames[payload.stepId];
|
|
672
|
+
const frame: ExecutionFrame = {
|
|
673
|
+
stepId: payload.stepId,
|
|
674
|
+
status: "pending",
|
|
675
|
+
attempts: payload.attempt,
|
|
676
|
+
...(existing?.firstSeq !== undefined && { firstSeq: existing.firstSeq }),
|
|
677
|
+
...(existing?.scope !== undefined && { scope: existing.scope }),
|
|
678
|
+
...(existing?.input !== undefined && { input: existing.input }),
|
|
679
|
+
};
|
|
680
|
+
return {
|
|
681
|
+
...state,
|
|
682
|
+
frames: setFrame(state.frames, payload.stepId, frame),
|
|
683
|
+
updatedAt: event.timestamp,
|
|
684
|
+
};
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
PARALLEL_STARTED: (state, event) => {
|
|
688
|
+
const payload = event.payload as { stepIds: string[] };
|
|
689
|
+
// One fresh container, then write each new frame into it (the container is
|
|
690
|
+
// not shared, so in-place writes are safe). Mark it fold-owned so subsequent
|
|
691
|
+
// step events in the same fold pass mutate it in place instead of re-spreading.
|
|
692
|
+
const frames = { ...state.frames };
|
|
693
|
+
foldOwned.add(frames);
|
|
694
|
+
for (const stepId of payload.stepIds) {
|
|
695
|
+
assertSafeKey(stepId);
|
|
696
|
+
frames[stepId] = {
|
|
697
|
+
stepId,
|
|
698
|
+
status: "pending",
|
|
699
|
+
attempts: 0,
|
|
700
|
+
startedAt: event.timestamp,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return { ...state, frames, updatedAt: event.timestamp };
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
// 1.23.0 (ADR 0112 D2) — the journaled fail-fast scope outcome. Folds `_scopes[scopePath]` ONLY
|
|
707
|
+
// from a kernel-stamped envelope; an unstamped one keeps the pre-1.23.0 behaviour (no variable
|
|
708
|
+
// change, `updatedAt` only), so a log that already holds such an event replays identically.
|
|
709
|
+
PARALLEL_COMPLETED: (state, event) => {
|
|
710
|
+
if (!isKernelWritten(event)) return { ...state, updatedAt: event.timestamp };
|
|
711
|
+
const p = event.payload as {
|
|
712
|
+
scopePath?: unknown;
|
|
713
|
+
failedBranch?: unknown;
|
|
714
|
+
error?: unknown;
|
|
715
|
+
cancelled?: unknown;
|
|
716
|
+
errorName?: unknown;
|
|
717
|
+
errorChannel?: unknown;
|
|
718
|
+
errorPayload?: unknown;
|
|
719
|
+
launched?: unknown;
|
|
720
|
+
consumed?: unknown;
|
|
721
|
+
cursor?: unknown;
|
|
722
|
+
};
|
|
723
|
+
if (!isPlain(p) || !safeMapKey(p.scopePath)) return { ...state, updatedAt: event.timestamp };
|
|
724
|
+
const scopes = ownedRecord(state.variables._scopes as Record<string, unknown> | undefined);
|
|
725
|
+
// Amended 2026-10-06 (R6 review, user decision) — ADDITIVE optional fields, each folded only
|
|
726
|
+
// when well-formed; a record without them folds exactly as before (and a re-run serves it
|
|
727
|
+
// as before: it launches every branch and takes gate answers by position).
|
|
728
|
+
const nonNegInt = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0;
|
|
729
|
+
const consumed = Array.isArray(p.consumed)
|
|
730
|
+
? p.consumed.filter(
|
|
731
|
+
(e): e is [string, number, number] =>
|
|
732
|
+
Array.isArray(e) &&
|
|
733
|
+
e.length === 3 &&
|
|
734
|
+
typeof e[0] === "string" &&
|
|
735
|
+
nonNegInt(e[1]) &&
|
|
736
|
+
nonNegInt(e[2]) &&
|
|
737
|
+
e[2] >= 1
|
|
738
|
+
)
|
|
739
|
+
: undefined;
|
|
740
|
+
scopes[p.scopePath] = {
|
|
741
|
+
...(typeof p.failedBranch === "number" && { failedBranch: p.failedBranch }),
|
|
742
|
+
...(p.error !== undefined && { error: p.error }),
|
|
743
|
+
...(typeof p.errorName === "string" && { errorName: p.errorName }),
|
|
744
|
+
...(typeof p.errorChannel === "string" && { errorChannel: p.errorChannel }),
|
|
745
|
+
...(p.errorPayload !== undefined && { errorPayload: p.errorPayload }),
|
|
746
|
+
cancelled: Array.isArray(p.cancelled)
|
|
747
|
+
? p.cancelled.filter((id): id is string => typeof id === "string")
|
|
748
|
+
: [],
|
|
749
|
+
...(nonNegInt(p.launched) && { launched: p.launched }),
|
|
750
|
+
...(consumed !== undefined && { consumed }),
|
|
751
|
+
...(nonNegInt(p.cursor) && { cursor: p.cursor }),
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
...state,
|
|
755
|
+
variables: { ...state.variables, _scopes: scopes },
|
|
756
|
+
updatedAt: event.timestamp,
|
|
757
|
+
};
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
// 1.23.0 (ADR 0109 D2) — the predecessor of a continue-as-new ends, naming its successor. A NEW
|
|
761
|
+
// terminal status, `continued` (never `completed`: there is no output), and `_continuedTo`.
|
|
762
|
+
// Kernel-stamped envelopes only; an unstamped one is the no-op an unknown kind always was.
|
|
763
|
+
EXECUTION_CONTINUED: (state, event) => {
|
|
764
|
+
if (!isKernelWritten(event)) return state;
|
|
765
|
+
const p = event.payload as { successorId?: unknown };
|
|
766
|
+
if (!isPlain(p) || typeof p.successorId !== "string" || p.successorId.length === 0) {
|
|
767
|
+
return state;
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
...state,
|
|
771
|
+
status: "continued",
|
|
772
|
+
variables: { ...withoutFailedWhileParked(state.variables), _continuedTo: p.successorId },
|
|
773
|
+
updatedAt: event.timestamp,
|
|
774
|
+
};
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
// 1.23.0 (ADR 0110 D5/D6) — the owner consumed an inbox message: the per-topic cursor advances,
|
|
778
|
+
// the bounded dedupe window remembers the id, and the delivered payload answers its wait
|
|
779
|
+
// (`_waitAnswers[waitId]`, first answer wins). A `messageId` already in the window is a
|
|
780
|
+
// duplicate: the cursor advances, nothing is delivered. Kernel-stamped envelopes only.
|
|
781
|
+
MESSAGE_CONSUMED: (state, event) => {
|
|
782
|
+
if (!isKernelWritten(event)) return state;
|
|
783
|
+
const p = event.payload as {
|
|
784
|
+
waitId?: unknown;
|
|
785
|
+
topic?: unknown;
|
|
786
|
+
messageId?: unknown;
|
|
787
|
+
inboxSeq?: unknown;
|
|
788
|
+
payload?: unknown;
|
|
789
|
+
};
|
|
790
|
+
if (
|
|
791
|
+
!isPlain(p) ||
|
|
792
|
+
!safeMapKey(p.waitId) ||
|
|
793
|
+
!safeMapKey(p.topic) ||
|
|
794
|
+
typeof p.messageId !== "string" ||
|
|
795
|
+
!Number.isSafeInteger(p.inboxSeq) ||
|
|
796
|
+
(p.inboxSeq as number) < 0
|
|
797
|
+
) {
|
|
798
|
+
return state;
|
|
799
|
+
}
|
|
800
|
+
const prior = state.variables._inbox as
|
|
801
|
+
| { cursors?: Record<string, number>; dedupe?: string[] }
|
|
802
|
+
| undefined;
|
|
803
|
+
const cursors = ownedRecord(prior?.cursors);
|
|
804
|
+
// Own-property read: a topic named `toString` / `valueOf` must not read a prototype member.
|
|
805
|
+
const was = Object.hasOwn(cursors, p.topic) ? (cursors[p.topic] as number) : -1;
|
|
806
|
+
cursors[p.topic] = Math.max(was, p.inboxSeq as number);
|
|
807
|
+
const window = prior?.dedupe;
|
|
808
|
+
const duplicate = window?.includes(p.messageId) === true;
|
|
809
|
+
let dedupe = window;
|
|
810
|
+
if (!duplicate) {
|
|
811
|
+
dedupe = appendAudit(window, p.messageId);
|
|
812
|
+
if (dedupe.length > INBOX_DEDUPE_CAP) dedupe.splice(0, dedupe.length - INBOX_DEDUPE_CAP);
|
|
813
|
+
}
|
|
814
|
+
const variables: Record<string, unknown> = {
|
|
815
|
+
...state.variables,
|
|
816
|
+
_inbox: { cursors, dedupe: dedupe ?? [] },
|
|
817
|
+
};
|
|
818
|
+
if (!duplicate) {
|
|
819
|
+
const answers = ownedRecord(
|
|
820
|
+
state.variables._waitAnswers as Record<string, unknown> | undefined
|
|
821
|
+
);
|
|
822
|
+
if (!Object.hasOwn(answers, p.waitId)) {
|
|
823
|
+
answers[p.waitId] = { kind: "inbox", name: p.topic, payload: p.payload };
|
|
824
|
+
}
|
|
825
|
+
variables._waitAnswers = answers;
|
|
826
|
+
}
|
|
827
|
+
// 1.25.0 (ADR 0130 residuals D9) — gated on `_quarantined`: a log that never quarantines folds
|
|
828
|
+
// byte-identically. Delivering the topic's first RELEASED entry (matched by its dedupe key, which
|
|
829
|
+
// is this fact's `messageId`) drops it from the queue; the cursor already sits at or above its
|
|
830
|
+
// original `inboxSeq`, so it does not move.
|
|
831
|
+
const held = quarantinedOf(state);
|
|
832
|
+
if (held !== undefined && Object.hasOwn(held, p.topic)) {
|
|
833
|
+
const queue = held[p.topic] as QuarantineEntry[];
|
|
834
|
+
const first = queue[0];
|
|
835
|
+
if (first !== undefined && first.state === "released" && first.key === p.messageId) {
|
|
836
|
+
const next = ownedRecord(held);
|
|
837
|
+
if (queue.length === 1) delete next[p.topic];
|
|
838
|
+
else next[p.topic] = queue.slice(1);
|
|
839
|
+
// An emptied record is deleted, so a full hold/release/consume cycle folds to the
|
|
840
|
+
// never-quarantined shape.
|
|
841
|
+
return {
|
|
842
|
+
...state,
|
|
843
|
+
variables: withQuarantined(variables, next),
|
|
844
|
+
updatedAt: event.timestamp,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return { ...state, variables, updatedAt: event.timestamp };
|
|
849
|
+
},
|
|
850
|
+
|
|
851
|
+
// 1.25.0 (ADR 0130 residuals D9; written by `quarantineInbox`) — the topic's head message is HELD: the
|
|
852
|
+
// cursor advances past the original entry (so it no longer pins the stream), the entry joins the
|
|
853
|
+
// topic's FIFO queue `_quarantined[topic]`, and the dedupe window is NOT touched (the release
|
|
854
|
+
// delivery must not read as a duplicate). Kernel-stamped envelopes only; a malformed fact, or one
|
|
855
|
+
// that would take the total past QUARANTINE_FOLD_CAP, is ignored.
|
|
856
|
+
INBOX_QUARANTINED: (state, event) => {
|
|
857
|
+
if (!isKernelWritten(event)) return state;
|
|
858
|
+
const p = event.payload as Record<string, unknown> | null;
|
|
859
|
+
if (
|
|
860
|
+
!isPlain(p) ||
|
|
861
|
+
!safeMapKey(p.topic) ||
|
|
862
|
+
!Number.isSafeInteger(p.inboxSeq) ||
|
|
863
|
+
(p.inboxSeq as number) < 0 ||
|
|
864
|
+
typeof p.key !== "string" ||
|
|
865
|
+
p.key.length === 0 ||
|
|
866
|
+
typeof p.messageId !== "string" ||
|
|
867
|
+
!Number.isSafeInteger(p.heldAt) ||
|
|
868
|
+
(p.heldAt as number) < 0 ||
|
|
869
|
+
!isPlain(p.operator)
|
|
870
|
+
) {
|
|
871
|
+
return state;
|
|
872
|
+
}
|
|
873
|
+
const prior = quarantinedOf(state);
|
|
874
|
+
if (quarantineTotal(prior) + 1 > QUARANTINE_FOLD_CAP) return state;
|
|
875
|
+
const inbox = state.variables._inbox as
|
|
876
|
+
| { cursors?: Record<string, number>; dedupe?: string[] }
|
|
877
|
+
| undefined;
|
|
878
|
+
const cursors = ownedRecord(inbox?.cursors);
|
|
879
|
+
const was = Object.hasOwn(cursors, p.topic) ? (cursors[p.topic] as number) : -1;
|
|
880
|
+
cursors[p.topic] = Math.max(was, p.inboxSeq as number);
|
|
881
|
+
const quarantined = ownedRecord(prior);
|
|
882
|
+
const queue = Object.hasOwn(quarantined, p.topic)
|
|
883
|
+
? (quarantined[p.topic] as QuarantineEntry[])
|
|
884
|
+
: [];
|
|
885
|
+
quarantined[p.topic] = [
|
|
886
|
+
...queue,
|
|
887
|
+
{
|
|
888
|
+
key: p.key,
|
|
889
|
+
inboxSeq: p.inboxSeq as number,
|
|
890
|
+
heldAt: p.heldAt as number,
|
|
891
|
+
state: "held",
|
|
892
|
+
},
|
|
893
|
+
];
|
|
894
|
+
return {
|
|
895
|
+
...state,
|
|
896
|
+
variables: {
|
|
897
|
+
...state.variables,
|
|
898
|
+
_inbox: { cursors, dedupe: inbox?.dedupe ?? [] },
|
|
899
|
+
_quarantined: quarantined,
|
|
900
|
+
},
|
|
901
|
+
updatedAt: event.timestamp,
|
|
902
|
+
};
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
// 1.25.0 (ADR 0130 residuals D9; written by `releaseInbox` and the capable-node auto-release) — the topic's OLDEST held entry is
|
|
906
|
+
// released or dropped. EXACTLY ONE of `operator` (a manual release) / `auto` (an automatic one,
|
|
907
|
+
// only with `deliver`) is present; a fact with both or neither is ignored, as is one that does not
|
|
908
|
+
// name the topic's first entry while it is `held`. "deliver" marks it `released`; "discard" removes
|
|
909
|
+
// it and adds its key to the dedupe window (a sender's retry of that id is then a duplicate).
|
|
910
|
+
INBOX_RELEASED: (state, event) => {
|
|
911
|
+
if (!isKernelWritten(event)) return state;
|
|
912
|
+
const p = event.payload as Record<string, unknown> | null;
|
|
913
|
+
if (
|
|
914
|
+
!isPlain(p) ||
|
|
915
|
+
!safeMapKey(p.topic) ||
|
|
916
|
+
typeof p.key !== "string" ||
|
|
917
|
+
p.key.length === 0 ||
|
|
918
|
+
typeof p.messageId !== "string" ||
|
|
919
|
+
(p.disposition !== "deliver" && p.disposition !== "discard")
|
|
920
|
+
) {
|
|
921
|
+
return state;
|
|
922
|
+
}
|
|
923
|
+
const hasOperator = p.operator !== undefined;
|
|
924
|
+
const hasAuto = p.auto !== undefined;
|
|
925
|
+
if (hasOperator === hasAuto) return state; // both, or neither
|
|
926
|
+
if (hasOperator && !isPlain(p.operator)) return state;
|
|
927
|
+
if (hasAuto && (p.auto !== true || p.disposition !== "deliver")) return state;
|
|
928
|
+
const prior = quarantinedOf(state);
|
|
929
|
+
if (prior === undefined || !Object.hasOwn(prior, p.topic)) return state;
|
|
930
|
+
const queue = prior[p.topic] as QuarantineEntry[];
|
|
931
|
+
const first = queue[0];
|
|
932
|
+
if (first === undefined || first.state !== "held" || first.key !== p.key) return state;
|
|
933
|
+
const quarantined = ownedRecord(prior);
|
|
934
|
+
if (p.disposition === "deliver") {
|
|
935
|
+
quarantined[p.topic] = [{ ...first, state: "released" }, ...queue.slice(1)];
|
|
936
|
+
return {
|
|
937
|
+
...state,
|
|
938
|
+
variables: { ...state.variables, _quarantined: quarantined },
|
|
939
|
+
updatedAt: event.timestamp,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
if (queue.length === 1) delete quarantined[p.topic];
|
|
943
|
+
else quarantined[p.topic] = queue.slice(1);
|
|
944
|
+
const inbox = state.variables._inbox as
|
|
945
|
+
| { cursors?: Record<string, number>; dedupe?: string[] }
|
|
946
|
+
| undefined;
|
|
947
|
+
const window = inbox?.dedupe;
|
|
948
|
+
let dedupe = window;
|
|
949
|
+
if (window?.includes(first.key) !== true) {
|
|
950
|
+
dedupe = appendAudit(window, first.key);
|
|
951
|
+
if (dedupe.length > INBOX_DEDUPE_CAP) dedupe.splice(0, dedupe.length - INBOX_DEDUPE_CAP);
|
|
952
|
+
}
|
|
953
|
+
const variables = withQuarantined(
|
|
954
|
+
{ ...state.variables, _inbox: { cursors: inbox?.cursors ?? {}, dedupe: dedupe ?? [] } },
|
|
955
|
+
quarantined
|
|
956
|
+
);
|
|
957
|
+
return { ...state, variables, updatedAt: event.timestamp };
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
EXECUTION_SUSPENDED: (state, event) => {
|
|
961
|
+
const payload = event.payload as {
|
|
962
|
+
condition: {
|
|
963
|
+
on: string;
|
|
964
|
+
timeout?: number;
|
|
965
|
+
correlationKey?: string;
|
|
966
|
+
waits?: SuspendWait[];
|
|
967
|
+
};
|
|
968
|
+
};
|
|
969
|
+
let variables = withoutFailedWhileParked(state.variables);
|
|
970
|
+
// 1.23.0 (ADR 0110 D4) — `condition.waits` is honoured ONLY on a kernel-stamped park (the R2
|
|
971
|
+
// writer stamps `metadata.kernel` on every EXECUTION_SUSPENDED that carries waits; the
|
|
972
|
+
// runtime's commit site strips `waits` from every userspace park, so no unstamped event
|
|
973
|
+
// carries them — this is the fold's own guard for a log it did not write). An unstamped
|
|
974
|
+
// park's `waits` is dropped from `suspendedOn` too, so nothing downstream (the wait-addressed
|
|
975
|
+
// resume's lookup, `loom` introspection) can read a table userspace wrote.
|
|
976
|
+
let condition: unknown = payload.condition;
|
|
977
|
+
if (isPlain(condition) && Object.hasOwn(condition, "waits")) {
|
|
978
|
+
const { waits: raw, ...rest } = condition;
|
|
979
|
+
condition = rest;
|
|
980
|
+
if (isKernelWritten(event) && Array.isArray(raw)) {
|
|
981
|
+
const clean = (raw as unknown[]).filter(
|
|
982
|
+
(w): w is SuspendWait =>
|
|
983
|
+
isPlain(w) &&
|
|
984
|
+
safeMapKey(w.waitId) &&
|
|
985
|
+
(w.kind === "gate" || w.kind === "inbox" || w.kind === "timer")
|
|
986
|
+
);
|
|
987
|
+
condition = { ...rest, waits: clean };
|
|
988
|
+
// A timer arm's `wakeAt` is recorded at its FIRST registration and served on every
|
|
989
|
+
// later one, so sibling traffic cannot push a deadline out.
|
|
990
|
+
let recorded: Record<string, unknown> | undefined;
|
|
991
|
+
for (const w of clean) {
|
|
992
|
+
if (w.kind !== "timer") continue;
|
|
993
|
+
if (typeof w.wakeAt !== "number" || !Number.isFinite(w.wakeAt)) continue;
|
|
994
|
+
recorded ??= ownedRecord(variables._waits as Record<string, unknown> | undefined);
|
|
995
|
+
if (!Object.hasOwn(recorded, w.waitId)) {
|
|
996
|
+
recorded[w.waitId] = { kind: "timer", wakeAt: w.wakeAt };
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (recorded !== undefined) variables = { ...variables, _waits: recorded };
|
|
1000
|
+
// A park lists EVERY unanswered arm of every pending select, so a recorded timer whose
|
|
1001
|
+
// waitId this park does not list belongs to a select another arm already settled: drop
|
|
1002
|
+
// it, or a loop of `select([inbox, timer])` grows `_waits` (state and every snapshot)
|
|
1003
|
+
// by one entry per iteration, forever.
|
|
1004
|
+
const priorWaits = variables._waits;
|
|
1005
|
+
if (isPlain(priorWaits)) {
|
|
1006
|
+
const listed = new Set(clean.map((w) => w.waitId));
|
|
1007
|
+
const stale = Object.keys(priorWaits).filter((id) => !listed.has(id));
|
|
1008
|
+
if (stale.length > 0) {
|
|
1009
|
+
const kept = ownedRecord(priorWaits as Record<string, unknown>);
|
|
1010
|
+
for (const id of stale) delete kept[id];
|
|
1011
|
+
variables = { ...variables, _waits: kept };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
...state,
|
|
1018
|
+
status: "suspended",
|
|
1019
|
+
suspendedOn: condition as typeof payload.condition,
|
|
1020
|
+
...(variables !== state.variables && { variables }),
|
|
1021
|
+
updatedAt: event.timestamp,
|
|
1022
|
+
};
|
|
1023
|
+
},
|
|
1024
|
+
|
|
1025
|
+
EXECUTION_RESUMED: (state, event) => {
|
|
1026
|
+
const { suspendedOn: _removed, ...rest } = state;
|
|
1027
|
+
// 1.23.0 (ADR 0110 D3) — a wait-addressed resume answers ONE wait of a multi-wait park: its
|
|
1028
|
+
// payload is `{ waitId, payload }` and the fold unwraps it into `_waitAnswers[waitId]`
|
|
1029
|
+
// (kind and name from the park's wait; first answer wins) and drops the wait's pinned
|
|
1030
|
+
// `_waits` entry. It never enters the positional `_resumePayloads` (the two mechanisms do
|
|
1031
|
+
// not share entries). One naming no wait of the current park is a status-only re-open.
|
|
1032
|
+
const addressed = parseWaitResume(state, event);
|
|
1033
|
+
if (addressed !== undefined) {
|
|
1034
|
+
const variables = { ...withoutFailedWhileParked(state.variables) };
|
|
1035
|
+
if (addressed.answer) {
|
|
1036
|
+
const { waitId, wait, inner } = addressed;
|
|
1037
|
+
const answers = ownedRecord(variables._waitAnswers as Record<string, unknown> | undefined);
|
|
1038
|
+
if (!Object.hasOwn(answers, waitId)) {
|
|
1039
|
+
answers[waitId] = {
|
|
1040
|
+
kind: wait.kind,
|
|
1041
|
+
name: wait.on ?? wait.topic ?? wait.kind,
|
|
1042
|
+
payload: inner,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
variables._waitAnswers = answers;
|
|
1046
|
+
if (isPlain(variables._waits) && Object.hasOwn(variables._waits, waitId)) {
|
|
1047
|
+
const waits = ownedRecord(variables._waits as Record<string, unknown>);
|
|
1048
|
+
delete waits[waitId];
|
|
1049
|
+
variables._waits = waits;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return { ...rest, status: "running", variables, updatedAt: event.timestamp };
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
...rest,
|
|
1056
|
+
status: "running",
|
|
1057
|
+
variables: {
|
|
1058
|
+
...withoutFailedWhileParked(state.variables),
|
|
1059
|
+
...(event.payload !== undefined &&
|
|
1060
|
+
!isStatusOnlyResume(state, event.payload) && {
|
|
1061
|
+
// Latest payload (legacy key, kept for compat with pre-1.16 snapshots)…
|
|
1062
|
+
_resumePayload: event.payload,
|
|
1063
|
+
// …and the ORDERED list of every resume payload (1.16.0): a flow with
|
|
1064
|
+
// N suspend gates passes gate i through with payload i on replay —
|
|
1065
|
+
// the single-latest key silently fed gate 1 the LAST gate's payload.
|
|
1066
|
+
_resumePayloads: appendAudit(
|
|
1067
|
+
state.variables._resumePayloads as unknown[] | undefined,
|
|
1068
|
+
event.payload
|
|
1069
|
+
),
|
|
1070
|
+
}),
|
|
1071
|
+
},
|
|
1072
|
+
updatedAt: event.timestamp,
|
|
1073
|
+
};
|
|
1074
|
+
},
|
|
1075
|
+
|
|
1076
|
+
// Loom 2.0 (P9) — a durable record of an advisory/optimizer choice. Stored in a
|
|
1077
|
+
// reserved `_decisions` map keyed by decisionId so replay serves the recorded
|
|
1078
|
+
// choice instead of re-consulting the optimizer, keeping the fold pure (P2).
|
|
1079
|
+
DECISION_RECORDED: (state, event) => {
|
|
1080
|
+
const payload = event.payload as {
|
|
1081
|
+
decisionId: string;
|
|
1082
|
+
choice: unknown;
|
|
1083
|
+
rationale?: unknown;
|
|
1084
|
+
source?: string;
|
|
1085
|
+
// D1 (ADR 0030) — descriptive materialization fields; folded verbatim but NEVER
|
|
1086
|
+
// read by getDecision, so `choice` remains the sole replay input (P9). Absence = legacy.
|
|
1087
|
+
alternatives?: unknown;
|
|
1088
|
+
estimatedCostUsd?: number;
|
|
1089
|
+
// ADR 0115 D3 — the open spelling of `estimatedCostUsd`: descriptive, never a replay input.
|
|
1090
|
+
estimatedCost?: unknown;
|
|
1091
|
+
estimatedLatencyMs?: number;
|
|
1092
|
+
confidence?: number;
|
|
1093
|
+
// ADR 0079 — the observation the policy saw and which policy saw it. Same
|
|
1094
|
+
// descriptive contract as the fields above: folded verbatim, never a replay
|
|
1095
|
+
// input. Absence = legacy (or a decision no policy produced).
|
|
1096
|
+
context?: Record<string, unknown>;
|
|
1097
|
+
policy?: { id: string; version: string; propensity?: number };
|
|
1098
|
+
// ADR 0113 D3 (fix round 1) — the step ids the decider's own logic ran. Kernel-stamped only
|
|
1099
|
+
// (an app event naming it folds as before); a served decision exempts exactly these from the
|
|
1100
|
+
// replay-divergence detector. Descriptive: never a replay input.
|
|
1101
|
+
innerSteps?: unknown;
|
|
1102
|
+
// Past the inner-step cap: the cap's worth of ids plus the journal sequence the decider began at
|
|
1103
|
+
// (a served overflow decision also exempts the frames first started between it and this record).
|
|
1104
|
+
innerStepsOverflow?: unknown;
|
|
1105
|
+
innerFrom?: unknown;
|
|
1106
|
+
};
|
|
1107
|
+
assertSafeKey(payload.decisionId);
|
|
1108
|
+
// R1a — the decision map is a per-fold-owned container, written in place (the same rule
|
|
1109
|
+
// as `setFrame`/`appendAudit`): spreading it per event made a fold of D decisions O(D²)
|
|
1110
|
+
// (20k decisions = 42 s). The caller's map (snapshot / prior state / an escaped state) is
|
|
1111
|
+
// copied once, then owned. Insertion order is unchanged: a new id appends, a repeated id
|
|
1112
|
+
// keeps its slot — exactly what `{ ...existing, [id]: v }` did.
|
|
1113
|
+
const prior = state.variables._decisions as Record<string, unknown> | undefined;
|
|
1114
|
+
let decisions: Record<string, unknown>;
|
|
1115
|
+
if (prior !== undefined && foldOwned.has(prior)) {
|
|
1116
|
+
decisions = prior;
|
|
1117
|
+
} else {
|
|
1118
|
+
decisions = prior === undefined ? {} : { ...prior };
|
|
1119
|
+
foldOwned.add(decisions);
|
|
1120
|
+
}
|
|
1121
|
+
const estimatedCost = sanitizeEstimatedCost(payload.estimatedCost);
|
|
1122
|
+
// 1.24.0 (ADR 0128) — the record's own envelope sequence, so the ORDER decisions were made in
|
|
1123
|
+
// survives a checkpoint (the map's key order does not: array-index-like ids sort first, and a
|
|
1124
|
+
// normalising snapshot store such as Postgres `jsonb` reorders keys). From the ENVELOPE, never
|
|
1125
|
+
// the payload; a repeated id keeps its FIRST `seq` (as it keeps its slot). A repeat of a legacy
|
|
1126
|
+
// entry (folded before 1.24.0, no `seq`) stays without one: its first sequence is unknowable.
|
|
1127
|
+
const had = Object.hasOwn(decisions, payload.decisionId)
|
|
1128
|
+
? (decisions[payload.decisionId] as { seq?: unknown } | undefined)
|
|
1129
|
+
: undefined;
|
|
1130
|
+
const seq = had === undefined ? event.sequence : had.seq;
|
|
1131
|
+
decisions[payload.decisionId] = {
|
|
1132
|
+
choice: payload.choice,
|
|
1133
|
+
...(payload.rationale !== undefined && { rationale: payload.rationale }),
|
|
1134
|
+
...(payload.source !== undefined && { source: payload.source }),
|
|
1135
|
+
...(payload.alternatives !== undefined && { alternatives: payload.alternatives }),
|
|
1136
|
+
...(payload.estimatedCostUsd !== undefined && {
|
|
1137
|
+
estimatedCostUsd: payload.estimatedCostUsd,
|
|
1138
|
+
}),
|
|
1139
|
+
...(estimatedCost !== undefined && { estimatedCost }),
|
|
1140
|
+
...(payload.estimatedLatencyMs !== undefined && {
|
|
1141
|
+
estimatedLatencyMs: payload.estimatedLatencyMs,
|
|
1142
|
+
}),
|
|
1143
|
+
...(payload.confidence !== undefined && { confidence: payload.confidence }),
|
|
1144
|
+
...(payload.context !== undefined && { context: payload.context }),
|
|
1145
|
+
...(payload.policy !== undefined && { policy: payload.policy }),
|
|
1146
|
+
...(isKernelWritten(event) &&
|
|
1147
|
+
Array.isArray(payload.innerSteps) && {
|
|
1148
|
+
innerSteps: payload.innerSteps.filter((id): id is string => typeof id === "string"),
|
|
1149
|
+
...(payload.innerStepsOverflow === true &&
|
|
1150
|
+
Number.isSafeInteger(payload.innerFrom) && {
|
|
1151
|
+
innerStepsOverflow: true,
|
|
1152
|
+
innerFrom: payload.innerFrom as number,
|
|
1153
|
+
recordedAtSeq: event.sequence,
|
|
1154
|
+
}),
|
|
1155
|
+
}),
|
|
1156
|
+
...(Number.isSafeInteger(seq) && { seq: seq as number }),
|
|
1157
|
+
};
|
|
1158
|
+
return {
|
|
1159
|
+
...state,
|
|
1160
|
+
variables: { ...state.variables, _decisions: decisions },
|
|
1161
|
+
updatedAt: event.timestamp,
|
|
1162
|
+
};
|
|
1163
|
+
},
|
|
1164
|
+
|
|
1165
|
+
// Loom 2.0 (K2) — a capability denial: the EffectRuntime refused an ungranted
|
|
1166
|
+
// effect. Recorded as a durable audit fact in a reserved `_denials` list (the
|
|
1167
|
+
// step itself still fails via the STEP_FAILED that follows). Pure audit — it does
|
|
1168
|
+
// not change frame/execution status.
|
|
1169
|
+
EFFECT_DENIED: (state, event) => {
|
|
1170
|
+
const payload = event.payload as { toolId: string; stepId?: string; reason?: string };
|
|
1171
|
+
return {
|
|
1172
|
+
...state,
|
|
1173
|
+
variables: {
|
|
1174
|
+
...state.variables,
|
|
1175
|
+
_denials: appendAudit(state.variables._denials as unknown[] | undefined, {
|
|
1176
|
+
toolId: payload.toolId,
|
|
1177
|
+
...(payload.stepId !== undefined && { stepId: payload.stepId }),
|
|
1178
|
+
...(payload.reason !== undefined && { reason: payload.reason }),
|
|
1179
|
+
}),
|
|
1180
|
+
},
|
|
1181
|
+
updatedAt: event.timestamp,
|
|
1182
|
+
};
|
|
1183
|
+
},
|
|
1184
|
+
|
|
1185
|
+
// C.1 (execution-lifecycle authz) — an EXECUTION_DENIED fact: an ExecutionAuthorizer
|
|
1186
|
+
// refused a lifecycle operation (resume/cancel) on an existing execution. Recorded as
|
|
1187
|
+
// a durable audit fact in a reserved `_executionDenials` list, symmetric to
|
|
1188
|
+
// EFFECT_DENIED (which gates effects). Pure audit — the op is refused by a thrown
|
|
1189
|
+
// error; this fold does not change frame/execution status.
|
|
1190
|
+
EXECUTION_DENIED: (state, event) => {
|
|
1191
|
+
const payload = event.payload as { op: string; reason?: string; actor?: string };
|
|
1192
|
+
return {
|
|
1193
|
+
...state,
|
|
1194
|
+
variables: {
|
|
1195
|
+
...state.variables,
|
|
1196
|
+
_executionDenials: appendAudit(state.variables._executionDenials as unknown[] | undefined, {
|
|
1197
|
+
op: payload.op,
|
|
1198
|
+
...(payload.reason !== undefined && { reason: payload.reason }),
|
|
1199
|
+
...(payload.actor !== undefined && { actor: payload.actor }),
|
|
1200
|
+
}),
|
|
1201
|
+
},
|
|
1202
|
+
updatedAt: event.timestamp,
|
|
1203
|
+
};
|
|
1204
|
+
},
|
|
1205
|
+
|
|
1206
|
+
// K10.1 (R5) — retention control facts on a dedicated lifecycle stream. When
|
|
1207
|
+
// `applyRetention` archives or deletes a terminal execution it journals the action
|
|
1208
|
+
// so the audit trail records its own pruning (P9): the values are gone but the FACT
|
|
1209
|
+
// that they were pruned survives. Pure audit into `_lifecycle`; replay is byte-identical
|
|
1210
|
+
// for any log that never prunes.
|
|
1211
|
+
EXECUTION_ARCHIVED: (state, event) => {
|
|
1212
|
+
const p = event.payload as { executionId: string };
|
|
1213
|
+
return {
|
|
1214
|
+
...state,
|
|
1215
|
+
variables: {
|
|
1216
|
+
...state.variables,
|
|
1217
|
+
_lifecycle: appendAudit(state.variables._lifecycle as unknown[] | undefined, {
|
|
1218
|
+
op: "archived",
|
|
1219
|
+
executionId: p.executionId,
|
|
1220
|
+
at: event.timestamp,
|
|
1221
|
+
}),
|
|
1222
|
+
},
|
|
1223
|
+
updatedAt: event.timestamp,
|
|
1224
|
+
};
|
|
1225
|
+
},
|
|
1226
|
+
EXECUTION_DELETED: (state, event) => {
|
|
1227
|
+
const p = event.payload as { executionId: string };
|
|
1228
|
+
return {
|
|
1229
|
+
...state,
|
|
1230
|
+
variables: {
|
|
1231
|
+
...state.variables,
|
|
1232
|
+
_lifecycle: appendAudit(state.variables._lifecycle as unknown[] | undefined, {
|
|
1233
|
+
op: "deleted",
|
|
1234
|
+
executionId: p.executionId,
|
|
1235
|
+
at: event.timestamp,
|
|
1236
|
+
}),
|
|
1237
|
+
},
|
|
1238
|
+
updatedAt: event.timestamp,
|
|
1239
|
+
};
|
|
1240
|
+
},
|
|
1241
|
+
// ADR 0107 — an operator repair re-homed events the pre-0106 compaction bug had written to
|
|
1242
|
+
// the blank execution `""` into the execution they belong to. Journaled on the lifecycle
|
|
1243
|
+
// stream beside the retention facts (P9): the log records its own repair. Pure audit into
|
|
1244
|
+
// `_lifecycle`; a log that is never repaired replays byte-identically.
|
|
1245
|
+
EVENTS_REHOMED: (state, event) => {
|
|
1246
|
+
const p = event.payload as {
|
|
1247
|
+
executionId: string;
|
|
1248
|
+
fromSequence: number;
|
|
1249
|
+
throughSequence: number;
|
|
1250
|
+
proof: string;
|
|
1251
|
+
};
|
|
1252
|
+
return {
|
|
1253
|
+
...state,
|
|
1254
|
+
variables: {
|
|
1255
|
+
...state.variables,
|
|
1256
|
+
_lifecycle: appendAudit(state.variables._lifecycle as unknown[] | undefined, {
|
|
1257
|
+
op: "rehomed",
|
|
1258
|
+
executionId: p.executionId,
|
|
1259
|
+
fromSequence: p.fromSequence,
|
|
1260
|
+
throughSequence: p.throughSequence,
|
|
1261
|
+
proof: p.proof,
|
|
1262
|
+
at: event.timestamp,
|
|
1263
|
+
}),
|
|
1264
|
+
},
|
|
1265
|
+
updatedAt: event.timestamp,
|
|
1266
|
+
};
|
|
1267
|
+
},
|
|
1268
|
+
// T3-R4.5 — an erasure was requested but deferred because an execution of the subject was
|
|
1269
|
+
// still live (never shred a fold in flight). Folding the erasure control stream recovers the
|
|
1270
|
+
// set of subjects awaiting completion, so a parked erasure survives a restart.
|
|
1271
|
+
ERASURE_PENDING: (state, event) => {
|
|
1272
|
+
const p = event.payload as { subjectId: string };
|
|
1273
|
+
return {
|
|
1274
|
+
...state,
|
|
1275
|
+
variables: {
|
|
1276
|
+
...state.variables,
|
|
1277
|
+
_erasure: appendAudit(state.variables._erasure as unknown[] | undefined, {
|
|
1278
|
+
op: "pending",
|
|
1279
|
+
subjectId: p.subjectId,
|
|
1280
|
+
at: event.timestamp,
|
|
1281
|
+
}),
|
|
1282
|
+
},
|
|
1283
|
+
updatedAt: event.timestamp,
|
|
1284
|
+
};
|
|
1285
|
+
},
|
|
1286
|
+
|
|
1287
|
+
// Loom 2.0 (learning seam) — an OUTCOME_RECORDED fact: how a prior choice (a
|
|
1288
|
+
// route/capability/tool, by `key`) actually turned out. The symmetric counterpart
|
|
1289
|
+
// to DECISION_RECORDED ("what was chosen"): this records "how it went", so
|
|
1290
|
+
// @loomwork/analytics can mine it into a ranking fed back to the router — closing the
|
|
1291
|
+
// loop while keeping every future route a logged decision (P9). Folded into a
|
|
1292
|
+
// reserved `_outcomes` audit list; pure audit, does not change status.
|
|
1293
|
+
OUTCOME_RECORDED: (state, event) => {
|
|
1294
|
+
const kernel = isKernelWritten(event);
|
|
1295
|
+
const p = event.payload as {
|
|
1296
|
+
key: string;
|
|
1297
|
+
success?: boolean;
|
|
1298
|
+
score?: number;
|
|
1299
|
+
decisionId?: string;
|
|
1300
|
+
// 1.23.0 (ADR 0116) — written only by `recordOutcome`, which stamps `metadata.kernel`;
|
|
1301
|
+
// the fold reads them ONLY from a stamped envelope (a userspace emit is refused by
|
|
1302
|
+
// `guardUserspaceEmit`, and a log it did not write is not trusted either). Absent = a
|
|
1303
|
+
// live, in-run outcome.
|
|
1304
|
+
recordedBy?: string;
|
|
1305
|
+
late?: boolean;
|
|
1306
|
+
};
|
|
1307
|
+
return {
|
|
1308
|
+
...state,
|
|
1309
|
+
variables: {
|
|
1310
|
+
...state.variables,
|
|
1311
|
+
_outcomes: appendAudit(state.variables._outcomes as unknown[] | undefined, {
|
|
1312
|
+
key: p.key,
|
|
1313
|
+
...(p.success !== undefined && { success: p.success }),
|
|
1314
|
+
...(p.score !== undefined && { score: p.score }),
|
|
1315
|
+
...(p.decisionId !== undefined && { decisionId: p.decisionId }),
|
|
1316
|
+
...(kernel && typeof p.recordedBy === "string" && { recordedBy: p.recordedBy }),
|
|
1317
|
+
...(kernel && p.late === true && { late: true }),
|
|
1318
|
+
}),
|
|
1319
|
+
},
|
|
1320
|
+
updatedAt: event.timestamp,
|
|
1321
|
+
};
|
|
1322
|
+
},
|
|
1323
|
+
|
|
1324
|
+
// Loom 2.0 (K6) — a behavioral-safety block: a safety validator refused an
|
|
1325
|
+
// effect's input or output. Recorded as a durable audit fact in a reserved
|
|
1326
|
+
// `_safety` list (the step itself still fails via the STEP_FAILED that follows).
|
|
1327
|
+
// Pure audit — it does not change frame/execution status, so a blocked run
|
|
1328
|
+
// reconstructs its safety verdict exactly on replay.
|
|
1329
|
+
SAFETY_BLOCKED: (state, event) => {
|
|
1330
|
+
const p = event.payload as {
|
|
1331
|
+
validatorId: string;
|
|
1332
|
+
stepId?: string;
|
|
1333
|
+
phase?: string;
|
|
1334
|
+
reason?: string;
|
|
1335
|
+
category?: string;
|
|
1336
|
+
};
|
|
1337
|
+
return {
|
|
1338
|
+
...state,
|
|
1339
|
+
variables: {
|
|
1340
|
+
...state.variables,
|
|
1341
|
+
_safety: appendAudit(state.variables._safety as unknown[] | undefined, {
|
|
1342
|
+
validatorId: p.validatorId,
|
|
1343
|
+
...(p.stepId !== undefined && { stepId: p.stepId }),
|
|
1344
|
+
...(p.phase !== undefined && { phase: p.phase }),
|
|
1345
|
+
...(p.reason !== undefined && { reason: p.reason }),
|
|
1346
|
+
...(p.category !== undefined && { category: p.category }),
|
|
1347
|
+
}),
|
|
1348
|
+
},
|
|
1349
|
+
updatedAt: event.timestamp,
|
|
1350
|
+
};
|
|
1351
|
+
},
|
|
1352
|
+
|
|
1353
|
+
// Loom 2.1 (K9) — an in-doubt effect: recovery found a tool declared
|
|
1354
|
+
// `idempotent: false` whose prior attempt is in an UNKNOWN state (STEP_STARTED
|
|
1355
|
+
// with no terminal step event) and refused to silently re-run it. Recorded in a
|
|
1356
|
+
// reserved `_indoubt` audit list; the EXECUTION_SUSPENDED that follows owns the
|
|
1357
|
+
// status change. Pure audit — replay reconstructs the parking exactly, and the
|
|
1358
|
+
// list length per step is the durable epoch for fresh idempotency keys after a
|
|
1359
|
+
// "retry-fresh" verdict.
|
|
1360
|
+
EFFECT_INDOUBT: (state, event) => {
|
|
1361
|
+
const p = event.payload as {
|
|
1362
|
+
stepId: string;
|
|
1363
|
+
toolId?: string;
|
|
1364
|
+
attempts?: number;
|
|
1365
|
+
idempotencyKey?: string;
|
|
1366
|
+
};
|
|
1367
|
+
return {
|
|
1368
|
+
...state,
|
|
1369
|
+
variables: {
|
|
1370
|
+
...state.variables,
|
|
1371
|
+
_indoubt: appendAudit(state.variables._indoubt as unknown[] | undefined, {
|
|
1372
|
+
stepId: p.stepId,
|
|
1373
|
+
...(p.toolId !== undefined && { toolId: p.toolId }),
|
|
1374
|
+
...(p.attempts !== undefined && { attempts: p.attempts }),
|
|
1375
|
+
...(p.idempotencyKey !== undefined && { idempotencyKey: p.idempotencyKey }),
|
|
1376
|
+
}),
|
|
1377
|
+
},
|
|
1378
|
+
updatedAt: event.timestamp,
|
|
1379
|
+
};
|
|
1380
|
+
},
|
|
1381
|
+
|
|
1382
|
+
// Loom 2.1 (K11.3) — a durable migration verdict: the execution was explicitly
|
|
1383
|
+
// resumed on DIFFERENT flow code (resumeAs, replay-check or operator-ack).
|
|
1384
|
+
// Re-points the recorded flow-code fingerprint (`_flowHash`) and the flow
|
|
1385
|
+
// resolution pointer (`_migratedTo` — the flow id this execution now resolves
|
|
1386
|
+
// to; unconditional so the fold never depends on seed identity), and appends
|
|
1387
|
+
// an audit entry to the reserved `_migrations` list. Replay re-applies the
|
|
1388
|
+
// same verdict at the same sequence (G1).
|
|
1389
|
+
EXECUTION_MIGRATED: (state, event) => {
|
|
1390
|
+
const p = event.payload as {
|
|
1391
|
+
toFlowId: string;
|
|
1392
|
+
toHash?: string;
|
|
1393
|
+
fromHash?: string;
|
|
1394
|
+
strategy?: string;
|
|
1395
|
+
migratedInput?: unknown;
|
|
1396
|
+
// ADR 0113 D2 (fix round 1) — the target flow's body fingerprint ("" = it has none): re-arms
|
|
1397
|
+
// the `_codeHash` comparison against the code the execution now runs.
|
|
1398
|
+
toCodeHash?: unknown;
|
|
1399
|
+
};
|
|
1400
|
+
return {
|
|
1401
|
+
...state,
|
|
1402
|
+
variables: {
|
|
1403
|
+
...state.variables,
|
|
1404
|
+
...(p.toHash !== undefined && { _flowHash: p.toHash }),
|
|
1405
|
+
...(typeof p.toCodeHash === "string" && { _codeHash: p.toCodeHash }),
|
|
1406
|
+
// K11.5 — contract-versioned state migration: the recorded TRANSFORMED
|
|
1407
|
+
// input replaces `_input`, so every later resume/restore/replay folds
|
|
1408
|
+
// the same migrated value (the outcome is durable, not the function).
|
|
1409
|
+
...(p.migratedInput !== undefined && { _input: p.migratedInput }),
|
|
1410
|
+
_migratedTo: p.toFlowId,
|
|
1411
|
+
_migrations: appendAudit(state.variables._migrations as unknown[] | undefined, {
|
|
1412
|
+
toFlowId: p.toFlowId,
|
|
1413
|
+
...(p.toHash !== undefined && { toHash: p.toHash }),
|
|
1414
|
+
...(p.fromHash !== undefined && { fromHash: p.fromHash }),
|
|
1415
|
+
...(p.strategy !== undefined && { strategy: p.strategy }),
|
|
1416
|
+
...(p.migratedInput !== undefined && { stateMigrated: true }),
|
|
1417
|
+
}),
|
|
1418
|
+
},
|
|
1419
|
+
updatedAt: event.timestamp,
|
|
1420
|
+
};
|
|
1421
|
+
},
|
|
1422
|
+
|
|
1423
|
+
// Loom 2.1 (K12) — a VERIFIED approval: a ResumeAuthenticator confirmed the
|
|
1424
|
+
// identity behind an authority-gating resume, so "who approved" is durable
|
|
1425
|
+
// evidence, not an unverified claim. Folded into a reserved `_approvals`
|
|
1426
|
+
// audit list. Pure audit — the accompanying EXECUTION_RESUMED owns the
|
|
1427
|
+
// lifecycle; a REJECTED approval never reaches the log (the resume is refused
|
|
1428
|
+
// before any event is committed).
|
|
1429
|
+
APPROVAL_GRANTED: (state, event) => {
|
|
1430
|
+
const p = event.payload as { actor?: string; verifiedBy?: string; on?: string };
|
|
1431
|
+
return {
|
|
1432
|
+
...state,
|
|
1433
|
+
variables: {
|
|
1434
|
+
...state.variables,
|
|
1435
|
+
_approvals: appendAudit(state.variables._approvals as unknown[] | undefined, {
|
|
1436
|
+
...(p.actor !== undefined && { actor: p.actor }),
|
|
1437
|
+
...(p.verifiedBy !== undefined && { verifiedBy: p.verifiedBy }),
|
|
1438
|
+
...(p.on !== undefined && { on: p.on }),
|
|
1439
|
+
at: event.timestamp,
|
|
1440
|
+
}),
|
|
1441
|
+
},
|
|
1442
|
+
updatedAt: event.timestamp,
|
|
1443
|
+
};
|
|
1444
|
+
},
|
|
1445
|
+
|
|
1446
|
+
// Loom 2.1 (K10) — a crypto-shred tombstone: the subject's encryption key was
|
|
1447
|
+
// destroyed, so this execution's sensitive payload fields are permanently
|
|
1448
|
+
// unrecoverable (GDPR erasure of an append-only log). Written AT SHRED TIME by
|
|
1449
|
+
// the lifecycle service (never at replay). Marks `variables._shredded` so the
|
|
1450
|
+
// folded state records the erasure; a decrypting store read of the affected
|
|
1451
|
+
// fields fails loudly with PayloadShreddedError (the erasure is not silent).
|
|
1452
|
+
PAYLOAD_SHREDDED: (state, event) => {
|
|
1453
|
+
const p = event.payload as { subjectId?: string; reason?: string };
|
|
1454
|
+
return {
|
|
1455
|
+
...state,
|
|
1456
|
+
variables: {
|
|
1457
|
+
...state.variables,
|
|
1458
|
+
_shredded: {
|
|
1459
|
+
...(p.subjectId !== undefined && { subjectId: p.subjectId }),
|
|
1460
|
+
...(p.reason !== undefined && { reason: p.reason }),
|
|
1461
|
+
at: event.timestamp,
|
|
1462
|
+
},
|
|
1463
|
+
},
|
|
1464
|
+
updatedAt: event.timestamp,
|
|
1465
|
+
};
|
|
1466
|
+
},
|
|
1467
|
+
|
|
1468
|
+
// Loom 2.1 (K7) — an egress denial: the kernel-side broker refused an
|
|
1469
|
+
// isolated tool's network target (not in its static allowlist), BEFORE any
|
|
1470
|
+
// connection was opened. Folded into a reserved `_egress` audit list,
|
|
1471
|
+
// mirroring EFFECT_DENIED's `_denials`. Pure audit — whether the step fails
|
|
1472
|
+
// is the tool's error path (an uncaught denial fails via STEP_FAILED).
|
|
1473
|
+
EGRESS_DENIED: (state, event) => {
|
|
1474
|
+
const p = event.payload as {
|
|
1475
|
+
toolId?: string;
|
|
1476
|
+
stepId?: string;
|
|
1477
|
+
target?: string;
|
|
1478
|
+
reason?: string;
|
|
1479
|
+
};
|
|
1480
|
+
return {
|
|
1481
|
+
...state,
|
|
1482
|
+
variables: {
|
|
1483
|
+
...state.variables,
|
|
1484
|
+
_egress: appendAudit(state.variables._egress as unknown[] | undefined, {
|
|
1485
|
+
...(p.toolId !== undefined && { toolId: p.toolId }),
|
|
1486
|
+
...(p.stepId !== undefined && { stepId: p.stepId }),
|
|
1487
|
+
...(p.target !== undefined && { target: p.target }),
|
|
1488
|
+
...(p.reason !== undefined && { reason: p.reason }),
|
|
1489
|
+
}),
|
|
1490
|
+
},
|
|
1491
|
+
updatedAt: event.timestamp,
|
|
1492
|
+
};
|
|
1493
|
+
},
|
|
1494
|
+
|
|
1495
|
+
// Loom 2.1 (T1) — a durable timer fired: the wake that ends a
|
|
1496
|
+
// `ctx.sleep`/`sleepUntil` (or a timer-service-backed `suspend({ timeout })`).
|
|
1497
|
+
// Pure audit into the reserved `_timers` list — the EXECUTION_RESUMED that
|
|
1498
|
+
// accompanies it owns the status change. `wakeAt` is the SCHEDULED time (from
|
|
1499
|
+
// the log); `firedAt` (event timestamp) records actual delivery, so drift is
|
|
1500
|
+
// observable but never enters control flow.
|
|
1501
|
+
TIMER_FIRED: (state, event) => {
|
|
1502
|
+
const p = event.payload as { wakeAt?: number; scheduleId?: string; occurrence?: number };
|
|
1503
|
+
return {
|
|
1504
|
+
...state,
|
|
1505
|
+
variables: {
|
|
1506
|
+
...state.variables,
|
|
1507
|
+
_timers: appendAudit(state.variables._timers as unknown[] | undefined, {
|
|
1508
|
+
firedAt: event.timestamp,
|
|
1509
|
+
...(p.wakeAt !== undefined && { wakeAt: p.wakeAt }),
|
|
1510
|
+
...(p.scheduleId !== undefined && { scheduleId: p.scheduleId }),
|
|
1511
|
+
...(p.occurrence !== undefined && { occurrence: p.occurrence }),
|
|
1512
|
+
}),
|
|
1513
|
+
},
|
|
1514
|
+
updatedAt: event.timestamp,
|
|
1515
|
+
};
|
|
1516
|
+
},
|
|
1517
|
+
|
|
1518
|
+
// Loom 2.0 (K3) — accumulate metered resource usage (tokens / cost / wall-time)
|
|
1519
|
+
// into a reserved `_resources` running total. Emitted by accounting middleware
|
|
1520
|
+
// when an effect actually runs, so the total is deterministic on replay.
|
|
1521
|
+
RESOURCE_METERED: (state, event) => {
|
|
1522
|
+
const p = event.payload as {
|
|
1523
|
+
tokens?: number;
|
|
1524
|
+
costUsd?: number;
|
|
1525
|
+
ms?: number;
|
|
1526
|
+
// ADR 0032 (R1) — additional dimensions, folded additively.
|
|
1527
|
+
memBytes?: number;
|
|
1528
|
+
wallMs?: number;
|
|
1529
|
+
netBytes?: number;
|
|
1530
|
+
// 1.23.0 (ADR 0115) — open metering dimensions, folded into `_resources.custom`.
|
|
1531
|
+
custom?: unknown;
|
|
1532
|
+
};
|
|
1533
|
+
const r = (state.variables._resources as
|
|
1534
|
+
| {
|
|
1535
|
+
tokens: number;
|
|
1536
|
+
costUsd: number;
|
|
1537
|
+
ms: number;
|
|
1538
|
+
count: number;
|
|
1539
|
+
memBytes?: number;
|
|
1540
|
+
wallMs?: number;
|
|
1541
|
+
netBytes?: number;
|
|
1542
|
+
custom?: Record<string, number>;
|
|
1543
|
+
exceeded?: boolean;
|
|
1544
|
+
}
|
|
1545
|
+
| undefined) ?? { tokens: 0, costUsd: 0, ms: 0, count: 0 };
|
|
1546
|
+
// The new dimensions are included only when non-zero, so a log that never meters
|
|
1547
|
+
// them folds to exactly the pre-0032 `_resources` shape (byte-identical replay).
|
|
1548
|
+
const memBytes = (r.memBytes ?? 0) + (p.memBytes ?? 0);
|
|
1549
|
+
const wallMs = (r.wallMs ?? 0) + (p.wallMs ?? 0);
|
|
1550
|
+
const netBytes = (r.netBytes ?? 0) + (p.netBytes ?? 0);
|
|
1551
|
+
const custom = addCustomDimensions(r.custom, p.custom);
|
|
1552
|
+
return {
|
|
1553
|
+
...state,
|
|
1554
|
+
variables: {
|
|
1555
|
+
...state.variables,
|
|
1556
|
+
_resources: {
|
|
1557
|
+
tokens: r.tokens + (p.tokens ?? 0),
|
|
1558
|
+
costUsd: r.costUsd + (p.costUsd ?? 0),
|
|
1559
|
+
ms: r.ms + (p.ms ?? 0),
|
|
1560
|
+
count: r.count + 1,
|
|
1561
|
+
...(memBytes > 0 && { memBytes }),
|
|
1562
|
+
...(wallMs > 0 && { wallMs }),
|
|
1563
|
+
...(netBytes > 0 && { netBytes }),
|
|
1564
|
+
...(custom !== undefined && { custom }),
|
|
1565
|
+
...(r.exceeded === true && { exceeded: true }),
|
|
1566
|
+
},
|
|
1567
|
+
},
|
|
1568
|
+
updatedAt: event.timestamp,
|
|
1569
|
+
};
|
|
1570
|
+
},
|
|
1571
|
+
|
|
1572
|
+
// A quota admission-control denial: the execution exceeded its budget, so the
|
|
1573
|
+
// effect was refused. Marks `_resources.exceeded` (the STEP_FAILED that follows
|
|
1574
|
+
// fails the step). Pure audit.
|
|
1575
|
+
QUOTA_EXCEEDED: (state, event) => {
|
|
1576
|
+
const p = event.payload as { reason?: string };
|
|
1577
|
+
const r = (state.variables._resources as
|
|
1578
|
+
| { tokens: number; costUsd: number; ms: number; count: number; exceeded?: boolean }
|
|
1579
|
+
| undefined) ?? { tokens: 0, costUsd: 0, ms: 0, count: 0 };
|
|
1580
|
+
return {
|
|
1581
|
+
...state,
|
|
1582
|
+
variables: {
|
|
1583
|
+
...state.variables,
|
|
1584
|
+
_resources: { ...r, exceeded: true, ...(p.reason !== undefined && { reason: p.reason }) },
|
|
1585
|
+
},
|
|
1586
|
+
updatedAt: event.timestamp,
|
|
1587
|
+
};
|
|
1588
|
+
},
|
|
1589
|
+
|
|
1590
|
+
// The three terminal handlers also end any open park, so they drop FAILED_WHILE_PARKED
|
|
1591
|
+
// (ADR 0105): the mark describes a park, and must not outlive it into a terminal state (where
|
|
1592
|
+
// a 1.20.0 snapshot, repaired, would not carry it — snapshot + tail ≡ full log).
|
|
1593
|
+
EXECUTION_COMPLETED: (state, event) => {
|
|
1594
|
+
const payload = event.payload as { output?: unknown };
|
|
1595
|
+
const variables = withoutFailedWhileParked(state.variables);
|
|
1596
|
+
return {
|
|
1597
|
+
...state,
|
|
1598
|
+
status: "completed",
|
|
1599
|
+
...(payload.output !== undefined
|
|
1600
|
+
? { variables: { ...variables, _output: payload.output } }
|
|
1601
|
+
: variables !== state.variables && { variables }),
|
|
1602
|
+
updatedAt: event.timestamp,
|
|
1603
|
+
};
|
|
1604
|
+
},
|
|
1605
|
+
|
|
1606
|
+
EXECUTION_FAILED: (state, event) => {
|
|
1607
|
+
const payload = event.payload as { error?: unknown };
|
|
1608
|
+
const variables = withoutFailedWhileParked(state.variables);
|
|
1609
|
+
return {
|
|
1610
|
+
...state,
|
|
1611
|
+
status: "failed",
|
|
1612
|
+
...(payload.error !== undefined
|
|
1613
|
+
? { variables: { ...variables, _error: payload.error } }
|
|
1614
|
+
: variables !== state.variables && { variables }),
|
|
1615
|
+
updatedAt: event.timestamp,
|
|
1616
|
+
};
|
|
1617
|
+
},
|
|
1618
|
+
|
|
1619
|
+
EXECUTION_CANCELLED: (state, event) => {
|
|
1620
|
+
const payload = event.payload as { reason?: unknown };
|
|
1621
|
+
const variables = withoutFailedWhileParked(state.variables);
|
|
1622
|
+
return {
|
|
1623
|
+
...state,
|
|
1624
|
+
status: "cancelled",
|
|
1625
|
+
...(payload.reason !== undefined
|
|
1626
|
+
? { variables: { ...variables, _cancelReason: payload.reason } }
|
|
1627
|
+
: variables !== state.variables && { variables }),
|
|
1628
|
+
updatedAt: event.timestamp,
|
|
1629
|
+
};
|
|
1630
|
+
},
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
export function getHandler(eventType: string): Handler | undefined {
|
|
1634
|
+
// `Object.hasOwn`, not plain indexing: `eventType` is caller-controlled all the way from an
|
|
1635
|
+
// ingress adapter (`runtime.resume`, and A2A `provideInput` passes a network-supplied
|
|
1636
|
+
// ResumeEvent straight through), and `handlers["__proto__"]` resolves up the prototype chain
|
|
1637
|
+
// to `Object.prototype` — TRUTHY, so `foldEvents`' `if (handler)` guard admits it and then
|
|
1638
|
+
// calls a non-function. An unknown event type must fold to a no-op, whatever it is named.
|
|
1639
|
+
if (!Object.hasOwn(handlers, eventType)) return undefined;
|
|
1640
|
+
return handlers[eventType];
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// ── Event-schema ABI governance (Loom 2.0 K5) ────────────────────────────────
|
|
1644
|
+
//
|
|
1645
|
+
// The event log is the kernel's on-disk ABI: state is `fold(events)`, and stored
|
|
1646
|
+
// logs must replay across deploys (this is what `@loomwork/deploy-sim` guards at the
|
|
1647
|
+
// flow level). This names and versions the *set of event types the fold understands*.
|
|
1648
|
+
// Governance: adding an event type is additive (bump MINOR, update the pinned list);
|
|
1649
|
+
// removing or repurposing one changes how historical logs fold and is a MAJOR break
|
|
1650
|
+
// that must be deliberate — the conformance test pins `knownEventTypes()`.
|
|
1651
|
+
// 1.4.0: additive optional `flowHash` payload field on EXECUTION_STARTED (K11.1) —
|
|
1652
|
+
// per the K5 payload-extension rule, folds treat absence as legacy.
|
|
1653
|
+
// 1.5.0: added EXECUTION_MIGRATED (K11.3).
|
|
1654
|
+
// 1.6.0: added TIMER_FIRED (T1 — durable timers).
|
|
1655
|
+
// 1.7.0: added EGRESS_DENIED (K7 — data-plane isolation & egress control).
|
|
1656
|
+
// 1.8.0: added PAYLOAD_SHREDDED (K10 — crypto-shredding).
|
|
1657
|
+
// 1.9.0: added APPROVAL_GRANTED (K12 — verified approvals).
|
|
1658
|
+
// 1.10.0: additive optional DECISION_RECORDED payload fields `alternatives`,
|
|
1659
|
+
// `estimatedCostUsd`, `estimatedLatencyMs`, `confidence` (ADR 0030 D1 — decision
|
|
1660
|
+
// materialization). Descriptive only; `getDecision` still returns just `choice`, so
|
|
1661
|
+
// folds treat absence as legacy and replay is byte-identical.
|
|
1662
|
+
// 1.11.0: additive optional RESOURCE_METERED payload fields `memBytes`, `wallMs`,
|
|
1663
|
+
// `netBytes` (ADR 0032 R1 — resource dimensions). Folded into `_resources` only when
|
|
1664
|
+
// non-zero, so a log that never meters them replays byte-identically.
|
|
1665
|
+
// 1.12.0: added EXECUTION_DENIED (C.1 — execution-lifecycle authz). Pure audit into
|
|
1666
|
+
// `_executionDenials`; a log that never denies replays byte-identically.
|
|
1667
|
+
// 1.13.0: added EXECUTION_ARCHIVED / EXECUTION_DELETED (K10.1 R5 — retention control
|
|
1668
|
+
// facts). Pure audit into `_lifecycle` on a dedicated stream; a log that never prunes
|
|
1669
|
+
// replays byte-identically.
|
|
1670
|
+
// 1.14.0: added ERASURE_PENDING (T3-R4.5 — deferred-erasure control fact). Pure audit into
|
|
1671
|
+
// `_erasure` on a dedicated stream; a log that never defers an erasure replays identically.
|
|
1672
|
+
// 1.15.0: additive optional EXECUTION_MIGRATED payload field `migratedInput` (K11.5,
|
|
1673
|
+
// ADR 0056 — contract-versioned state migration on resumeAs). When present it replaces
|
|
1674
|
+
// `_input` in the fold and stamps `stateMigrated: true` on the `_migrations` audit
|
|
1675
|
+
// entry; a log that never migrates state replays byte-identically.
|
|
1676
|
+
// 1.16.0: EXECUTION_RESUMED additionally folds `_resumePayloads` — the ORDERED list of
|
|
1677
|
+
// every resume payload (ADR 0057). Fixes the multi-gate suspend bug: the single-latest
|
|
1678
|
+
// `_resumePayload` (kept for compat) fed gate 1 the LAST gate's payload on replay.
|
|
1679
|
+
// Additive fold key from existing events; no payload or kind changes.
|
|
1680
|
+
// 1.17.0: additive optional STEP_FAILED payload fields `channel`, `payload` (DE-4,
|
|
1681
|
+
// ADR 0060 — typed failure channels). Folded into frame `errorChannel`/`errorPayload`;
|
|
1682
|
+
// presence makes the execute seam SUBSTITUTE the recorded failure on replay instead of
|
|
1683
|
+
// re-executing the step. A log that never fails typed replays byte-identically.
|
|
1684
|
+
// 1.18.0: additive optional DECISION_RECORDED payload fields `context`, `policy` (T2,
|
|
1685
|
+
// ADR 0079 — the observation a policy saw and the policy that saw it, incl. the
|
|
1686
|
+
// propensity that makes unbiased off-policy evaluation possible). Same descriptive
|
|
1687
|
+
// contract as the 1.10.0 D1 fields: folded verbatim into `_decisions`, NEVER read by
|
|
1688
|
+
// getDecision, so `choice` stays the sole replay input. A log whose decisions carry
|
|
1689
|
+
// neither field replays byte-identically.
|
|
1690
|
+
// 1.19.0: additive fold key `_flowEmits` from an additive envelope stamp (ADR 0102 —
|
|
1691
|
+
// flow-level emits append once per execution). A flow-level `ctx.emit` / `ctx.stream` /
|
|
1692
|
+
// `ctx.ui` envelope may carry `metadata.flowEmit = <16-hex content digest>`; the fold
|
|
1693
|
+
// counts stamped emits per digest for ANY event type (a generic step in `foldEvents`,
|
|
1694
|
+
// not a handler), so a re-run serves the emits the log already holds. No new kind, no
|
|
1695
|
+
// payload change; a log that never stamps replays byte-identically.
|
|
1696
|
+
// 1.20.0: EXECUTION_RESUMED with a `null` payload that answers no suspend point (the execution
|
|
1697
|
+
// was not parked, or was parked in doubt on "EffectInDoubtResolved") re-opens the execution
|
|
1698
|
+
// WITHOUT adding a gate passthrough (ADR 0104). Every other resume folds as before, so a log
|
|
1699
|
+
// that never carries such a resume replays byte-identically. Also additive: a human step's
|
|
1700
|
+
// EXECUTION_SUSPENDED condition carries the kernel-set `stepId` (folded verbatim into
|
|
1701
|
+
// `suspendedOn`, as the condition always was). No new event kind.
|
|
1702
|
+
// 1.21.0: STEP_FAILED no longer folds the EXECUTION status to "failed" (ADR 0105) — the flow may
|
|
1703
|
+
// catch it, and EXECUTION_FAILED is the execution's failure. No new kind or payload field; the
|
|
1704
|
+
// kernel writes the same events. A STEP_FAILED landing while the execution is parked sets the
|
|
1705
|
+
// reserved fold key `_failedWhileParked` (cleared by whatever ends the park), and a `null`
|
|
1706
|
+
// EXECUTION_RESUMED in that position stays status-only, exactly as 1.20.0 folded it — so every
|
|
1707
|
+
// gate passthrough (`_resumePayloads`) is identical under both. What differs from 1.20.0: the
|
|
1708
|
+
// derived `status` after a STEP_FAILED no lifecycle event has followed (`running`/`suspended`,
|
|
1709
|
+
// not `failed`), and the new key while such a park is open. Snapshots saved by an older fold
|
|
1710
|
+
// are read through `normalizeSnapshotState`.
|
|
1711
|
+
// 1.22.0: added EVENTS_REHOMED (ADR 0107 — the operator repair of the pre-0106 blank-stream
|
|
1712
|
+
// events). Pure audit into `_lifecycle` on the lifecycle stream; a log that is never repaired
|
|
1713
|
+
// replays byte-identically. The re-homed events themselves are the kinds they always were.
|
|
1714
|
+
// 1.23.0: Wave 2 (W2-0, ADRs 0109–0116) — every new fold handler and reserved shape, with no
|
|
1715
|
+
// writer yet. New kinds `EXECUTION_CONTINUED` (→ status `continued`, `_continuedTo`) and
|
|
1716
|
+
// `MESSAGE_CONSUMED` (→ `_inbox`, `_waitAnswers`); a payload and handler for the existing
|
|
1717
|
+
// `PARALLEL_COMPLETED` (→ `_scopes`); additive `EXECUTION_STARTED.{principal, parent, chainId,
|
|
1718
|
+
// generation, continuedFrom, codeHash}` (→ `_principal`, `_parent`, `_chain`, `_codeHash`; the
|
|
1719
|
+
// carry seeds `_resources` / `_inbox`), `EXECUTION_SUSPENDED.condition.waits[]` (→ `_waits`),
|
|
1720
|
+
// a wait-addressed `EXECUTION_RESUMED` `{ waitId, payload }` (→ `_waitAnswers`),
|
|
1721
|
+
// `RESOURCE_METERED.custom` (→ `_resources.custom`), `DECISION_RECORDED.estimatedCost` (the
|
|
1722
|
+
// descriptive open spelling of `estimatedCostUsd`, never a replay input),
|
|
1723
|
+
// `OUTCOME_RECORDED.{recordedBy, late}`.
|
|
1724
|
+
// The envelope metadata the fold now reads: `kernel` (the new kinds, `PARALLEL_COMPLETED` and a
|
|
1725
|
+
// wait-addressed resume fold ONLY a kernel-stamped envelope), `forkChain`, `forkedBy`. A log
|
|
1726
|
+
// carrying none of these replays byte-identically.
|
|
1727
|
+
// 1.24.0: ADR 0128 — each folded `_decisions` entry carries `seq`, the envelope sequence of the
|
|
1728
|
+
// FIRST `DECISION_RECORDED` for that id, so decision ORDER survives a checkpoint (key order does
|
|
1729
|
+
// not survive array-index-like ids or a normalising snapshot store such as Postgres `jsonb`).
|
|
1730
|
+
// Descriptive only: `getDecision` still returns just `choice`. A snapshot written by a 1.23 fold
|
|
1731
|
+
// has no `seq` on its absorbed entries; those are LEGACY and read in map order (no migration, the
|
|
1732
|
+
// same documented exception to snapshot+tail == full fold as 1.21.0). Log bytes are unchanged.
|
|
1733
|
+
// 1.25.0: ADR 0130 residuals (D7-D9), RESERVED - shapes frozen, handlers present, NO writer yet, so
|
|
1734
|
+
// a log written before this version replays byte-identically. (1) The envelope key
|
|
1735
|
+
// `metadata.seal` (`{ v: 1, cipher, covers }`, D7: a sealing decorator's marker): the fold
|
|
1736
|
+
// IGNORES it, so state equals the same log without it. (2) New kinds `INBOX_QUARANTINED`
|
|
1737
|
+
// (`{ topic, inboxSeq, key, messageId, heldAt, operator, reason? }` -> cursor advance by `max`,
|
|
1738
|
+
// an entry pushed onto `_quarantined[topic]`, the dedupe window untouched) and `INBOX_RELEASED`
|
|
1739
|
+
// (`{ topic, key, messageId, disposition: "deliver" | "discard", operator? | auto?, reason? }`:
|
|
1740
|
+
// exactly one of `operator` / `auto`, `auto` only with `deliver`; a fact with both or neither is
|
|
1741
|
+
// ignored; acts only on the topic's FIRST entry while `held`), BOTH folded only from a
|
|
1742
|
+
// kernel-stamped envelope (`metadata.kernel === true`) and both `ignorable` for the park fence.
|
|
1743
|
+
// (3) The fold variable `_quarantined` (`{ [topic]: Array<{ key, inboxSeq, heldAt, state }> }`,
|
|
1744
|
+
// queue shape `released? held*`, at most QUARANTINE_FOLD_CAP = 1024 entries in total). (4)
|
|
1745
|
+
// `MESSAGE_CONSUMED` additionally drops the topic's first `released` entry whose `key` equals the
|
|
1746
|
+
// fact's `messageId`, gated on `_quarantined` existing. (5) `EXECUTION_STARTED.continuedFrom.
|
|
1747
|
+
// carried.quarantined`, a validated seed (invalid parts ignored). Byte-identity: a log carrying
|
|
1748
|
+
// none of these shapes folds exactly as under 1.24.0.
|
|
1749
|
+
// 1.26.0: ADR 0138 D8, NO new kind and NO payload change. The fold stops copying `input` into a frame
|
|
1750
|
+
// when it SETTLES (STEP_COMPLETED / STEP_FAILED / STEP_CANCELLED); STEP_STARTED and STEP_RETRYING keep it,
|
|
1751
|
+
// so an in-flight frame still shows what is running. The folded BYTES change (state is linear in turns, not
|
|
1752
|
+
// quadratic); `normalizeSnapshotState` strips `input` from the settled frames of an older snapshot in one
|
|
1753
|
+
// O(frames) pass, so `fold(snapshot, tail) == fold(events)` holds. Nothing reads a settled frame's input
|
|
1754
|
+
// from the fold; the log (or the compaction archive, ADR 0138) holds it. A pre-1.26 node re-adds an input
|
|
1755
|
+
// to a frame it settles (harmless; stripped by the next normalize) - the documented mixed-fleet exception.
|
|
1756
|
+
export const EVENT_SCHEMA_VERSION = "1.26.0";
|
|
1757
|
+
|
|
1758
|
+
export function knownEventTypes(): string[] {
|
|
1759
|
+
return Object.keys(handlers).sort();
|
|
1760
|
+
}
|