foottrace 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +41 -2
  2. package/dist/esm/index.d.ts +5 -2
  3. package/dist/esm/index.js +2 -1
  4. package/dist/esm/lib/ids/attemptId.d.ts +63 -0
  5. package/dist/esm/lib/ids/attemptId.js +91 -0
  6. package/dist/esm/lib/memory/EventLog.d.ts +89 -3
  7. package/dist/esm/lib/memory/EventLog.js +242 -3
  8. package/dist/esm/lib/memory/honesty.d.ts +6 -4
  9. package/dist/esm/lib/memory/honesty.js +7 -4
  10. package/dist/esm/lib/memory/sink.d.ts +148 -0
  11. package/dist/esm/lib/memory/sink.js +92 -0
  12. package/dist/esm/lib/time-travel/bundles.d.ts +2 -0
  13. package/dist/esm/lib/time-travel/bundles.js +16 -0
  14. package/dist/esm/lib/time-travel/commitStops.js +21 -2
  15. package/dist/esm/lib/time-travel/index.d.ts +2 -0
  16. package/dist/esm/lib/time-travel/index.js +1 -0
  17. package/dist/esm/lib/time-travel/storedRecord.d.ts +195 -0
  18. package/dist/esm/lib/time-travel/storedRecord.js +494 -0
  19. package/dist/esm/lib/time-travel/timeTravel.js +11 -0
  20. package/dist/esm/lib/time-travel/types.d.ts +4 -1
  21. package/dist/esm/write.d.ts +7 -0
  22. package/dist/esm/write.js +6 -0
  23. package/dist/index.js +6 -2
  24. package/dist/lib/ids/attemptId.js +96 -0
  25. package/dist/lib/memory/EventLog.js +242 -3
  26. package/dist/lib/memory/honesty.js +7 -4
  27. package/dist/lib/memory/sink.js +98 -0
  28. package/dist/lib/time-travel/bundles.js +18 -1
  29. package/dist/lib/time-travel/commitStops.js +21 -2
  30. package/dist/lib/time-travel/index.js +3 -1
  31. package/dist/lib/time-travel/storedRecord.js +498 -0
  32. package/dist/lib/time-travel/timeTravel.js +11 -0
  33. package/dist/types/index.d.ts +5 -2
  34. package/dist/types/lib/ids/attemptId.d.ts +63 -0
  35. package/dist/types/lib/memory/EventLog.d.ts +89 -3
  36. package/dist/types/lib/memory/honesty.d.ts +6 -4
  37. package/dist/types/lib/memory/sink.d.ts +148 -0
  38. package/dist/types/lib/time-travel/bundles.d.ts +2 -0
  39. package/dist/types/lib/time-travel/index.d.ts +2 -0
  40. package/dist/types/lib/time-travel/storedRecord.d.ts +195 -0
  41. package/dist/types/lib/time-travel/types.d.ts +4 -1
  42. package/dist/types/write.d.ts +7 -0
  43. package/dist/write.js +8 -1
  44. package/package.json +1 -1
@@ -0,0 +1,498 @@
1
+ "use strict";
2
+ /**
3
+ * storedRecord.ts — reading back what a sink stored (1.1.0).
4
+ *
5
+ * WHY THIS FILE EXISTS. A log that writes to a `RecordSink` (`foottrace/write`) leaves its record in
6
+ * storage AS IT LANDS: an `opened` entry with the fold base, one `commit` entry per bundle, `note`
7
+ * entries, and a `closed` entry with how the log ended. A run that is killed never writes its
8
+ * `closed` entry. This reader turns stored entries back into logs a person or a model can read —
9
+ * and says, before any fold, which of them never finished (`complete: false`) and what each one is
10
+ * missing.
11
+ *
12
+ * WHAT IT READS. Entries come back from storage unvalidated (a parsed file, a table scan) — untrusted
13
+ * input — so it takes `Iterable<unknown>` and checks each one where it is read: the 9.18.0 law for
14
+ * stored recordings, no cast at the seam. An entry it does not read into a log is never dropped in
15
+ * silence: it is listed in `unread` with its position and the reason.
16
+ *
17
+ * THE LAWS IT KEEPS.
18
+ * 1. Grouped by address. Every entry names its log (`logRunId` + `drillPath`); entries of many
19
+ * logs may arrive interleaved, and each log keeps its own entries in arrival order.
20
+ * 2. A commit sits at its own index. `EventLog · record` stamps `bundle.idx` before the sink sees
21
+ * the bundle, so a commit lost on the way (a sink that failed once — the writer goes on) leaves a
22
+ * GAP at its index instead of shifting every commit after it; so does a bundle that is not a
23
+ * readable bundle. Every gap keeps its index, and the fold reports it (`skipped`) with the same
24
+ * reason the stored log gives (`gaps`). One place, one entry: the first READABLE commit entry
25
+ * for an index stands (a torn first delivery yields to a readable redelivery), the first
26
+ * `opened` stands, and every other entry for a place already held — a redelivery or a conflict,
27
+ * the very same object included — is listed in `unread`. Payloads are never compared, so no
28
+ * stored value is walked.
29
+ * 3. Complete means the writer finished — and the reader read it all. A log is `complete` when a
30
+ * `closed` entry covers every stored commit (`closed.commits` is past the last index); the
31
+ * covering close with the largest `commits` gives the `outcome` and the length, so no loss an
32
+ * earlier close showed can hide behind a later one. A log that was closed and then went on (a
33
+ * later commit) is not complete until it is closed again. A closed log whose store lost commits
34
+ * is complete AND has gaps (`closed.commits` makes even a lost LAST commit a gap). A log a limit
35
+ * cut, and every log once reading stopped at the `entries` limit, is not complete: what the
36
+ * reader did not read could have gone on.
37
+ * 4. Opaque stays opaque. `meta` and a note's `kind` and `data` are handed back as stored, never
38
+ * read or merged.
39
+ * 5. Read, never touched. Nothing here copies, freezes or edits the entries it is given — not the
40
+ * base, not `meta`: the stored log hands back their own values.
41
+ * 6. Bounded work (see {@link StoredRecordLimits}). Every walk is a loop over the input, never a
42
+ * recursion, and never through an input's own iterator except the input itself; the work per
43
+ * entry is bounded by the address limits; the rows a log holds are its stored commits plus gaps
44
+ * from ONE budget for the whole record, spent on the logs that need the fewest gaps first (so a
45
+ * corrupt log asking for the most is the one cut); and reading stops after `entries` entries. Whatever a
46
+ * limit stops is listed in `unread` by name — never a hang, never a silent cut.
47
+ *
48
+ * Entries are data. An entry whose fields throw while it is checked (a getter, a revoked proxy) is
49
+ * listed in `unread`, and what the reader computes with — the kind, a commit's index and bundle, a
50
+ * close's length — is taken once, as checked. An in-memory accessor that changes or throws on a later
51
+ * read is out of contract, as everywhere in the record (docs/extraction.md): parsed JSON and structured
52
+ * clones carry none.
53
+ *
54
+ * DAG position: memory ← time-travel; this file is a reader beside `timeTravel` and imports only
55
+ * record files.
56
+ */
57
+ Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.readStoredRecord = exports.DEFAULT_LIMITS = void 0;
59
+ const runtimeStageId_js_1 = require("../ids/runtimeStageId.js");
60
+ const sink_js_1 = require("../memory/sink.js");
61
+ const bundles_js_1 = require("./bundles.js");
62
+ /** The defaults — internal to the package; documented on {@link StoredRecordLimits}. */
63
+ exports.DEFAULT_LIMITS = Object.freeze({
64
+ entries: 2 ** 24,
65
+ missingCommits: 2 ** 20,
66
+ drillDepth: 1024,
67
+ addressLength: 65_536,
68
+ });
69
+ /** The limits to read with: each a non-negative integer or `Infinity`, or a `RangeError` naming it. */
70
+ function limitsOf(given) {
71
+ if (given === undefined)
72
+ return exports.DEFAULT_LIMITS;
73
+ const limits = { ...exports.DEFAULT_LIMITS };
74
+ for (const name of Object.keys(exports.DEFAULT_LIMITS)) {
75
+ const value = given[name];
76
+ if (value === undefined)
77
+ continue;
78
+ if (typeof value !== 'number' || !(value === Infinity || (Number.isSafeInteger(value) && value >= 0))) {
79
+ throw new RangeError(`readStoredRecord: limits.${name} must be a non-negative integer or Infinity`);
80
+ }
81
+ limits[name] = value;
82
+ }
83
+ return limits;
84
+ }
85
+ /** What an index holds when the record has no readable commit for it. */
86
+ const NO_COMMIT = (0, bundles_js_1.absentRow)('no readable commit entry for this index');
87
+ const KINDS = ['opened', 'commit', 'note', 'closed'];
88
+ /** A value as a reason names it: `missing`, `null`, `an array`, a short quoted string, a number, or `a <type>`. */
89
+ function described(value) {
90
+ if (value === undefined)
91
+ return 'missing';
92
+ if (value === null)
93
+ return 'null';
94
+ if (Array.isArray(value))
95
+ return 'an array';
96
+ if (typeof value === 'string')
97
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
98
+ if (typeof value === 'number')
99
+ return String(value);
100
+ return `a ${typeof value}`;
101
+ }
102
+ /** A position or a count: a non-negative safe integer. */
103
+ function isCount(value) {
104
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
105
+ }
106
+ /**
107
+ * The address an entry names, as a fresh frozen copy — or why it is not one the reader takes. Bounded
108
+ * and iterator-free: the drill path's length is read once and checked before any segment; segments are
109
+ * read by index, checked and copied in one pass that stops at the first one past the length limit.
110
+ */
111
+ function readAddress(address, limits) {
112
+ if (!(0, bundles_js_1.isPlainish)(address))
113
+ return `address is ${described(address)}, not a log address`;
114
+ const { logRunId, drillPath } = address;
115
+ if (typeof logRunId !== 'string')
116
+ return `address.logRunId is ${described(logRunId)}, not a string`;
117
+ if (!Array.isArray(drillPath))
118
+ return 'address.drillPath is not an array of strings';
119
+ const depth = drillPath.length;
120
+ if (depth > limits.drillDepth) {
121
+ return `address.drillPath has ${depth} segments, past the reader's limit of ${limits.drillDepth}`;
122
+ }
123
+ const tooLong = `address is longer than the reader's limit of ${limits.addressLength} characters`;
124
+ let length = logRunId.length;
125
+ if (length > limits.addressLength)
126
+ return tooLong;
127
+ const segments = [];
128
+ for (let i = 0; i < depth; i++) {
129
+ const segment = drillPath[i];
130
+ if (typeof segment !== 'string')
131
+ return 'address.drillPath is not an array of strings';
132
+ length += segment.length;
133
+ if (length > limits.addressLength)
134
+ return tooLong;
135
+ segments.push(segment);
136
+ }
137
+ return Object.freeze({ logRunId, drillPath: Object.freeze(segments) });
138
+ }
139
+ /** Why an optional `meta` field cannot be read, or `undefined`. */
140
+ function metaRefusal(meta) {
141
+ return meta === undefined || (0, bundles_js_1.isPlainish)(meta) ? undefined : `meta is ${described(meta)}, not a plain object`;
142
+ }
143
+ /**
144
+ * What the reader keeps of the fields only one kind carries — each field it computes with read ONCE
145
+ * (a commit's bundle and index, a close's length) — or why they cannot be read.
146
+ */
147
+ function payloadOf(row) {
148
+ switch (row.kind) {
149
+ case 'opened': {
150
+ if (!(0, bundles_js_1.isPlainish)(row.initialState))
151
+ return `initialState is ${described(row.initialState)}, not a plain object`;
152
+ return metaRefusal(row.meta) ?? { count: 0 };
153
+ }
154
+ case 'commit': {
155
+ const bundle = row.bundle;
156
+ if (!(0, bundles_js_1.isPlainish)(bundle))
157
+ return `bundle is ${described(bundle)}, not an object`;
158
+ const idx = bundle.idx;
159
+ return isCount(idx) ? { count: idx, bundle } : `bundle.idx is ${described(idx)}, not a commit index`;
160
+ }
161
+ case 'note':
162
+ if (typeof row.note !== 'string')
163
+ return `note is ${described(row.note)}, not a string`;
164
+ return (0, bundles_js_1.isPlainish)(row.data) ? { count: 0 } : `data is ${described(row.data)}, not a plain object`;
165
+ default: {
166
+ if (!sink_js_1.LOG_OUTCOMES.includes(row.outcome)) {
167
+ return `outcome is ${described(row.outcome)}, not ${sink_js_1.LOG_OUTCOMES.join(' | ')}`;
168
+ }
169
+ const commits = row.commits;
170
+ if (!isCount(commits))
171
+ return `commits is ${described(commits)}, not a commit count`;
172
+ return metaRefusal(row.meta) ?? { count: commits };
173
+ }
174
+ }
175
+ }
176
+ /** Read one stored entry, or say why not: what the reader keeps of it, and its address (the reader's own copy). */
177
+ function readEntry(row, index, limits) {
178
+ if (!(0, bundles_js_1.isPlainish)(row))
179
+ return `not an object (${row === null ? 'null' : Array.isArray(row) ? 'array' : typeof row})`;
180
+ if (typeof row.kind !== 'string' || !KINDS.includes(row.kind)) {
181
+ return `kind is ${described(row.kind)}, not ${KINDS.join(' | ')}`;
182
+ }
183
+ const address = readAddress(row.address, limits);
184
+ if (typeof address === 'string')
185
+ return address;
186
+ if (typeof row.at !== 'number' || !Number.isFinite(row.at))
187
+ return `at is ${described(row.at)}, not a finite number`;
188
+ const payload = payloadOf(row);
189
+ if (typeof payload === 'string')
190
+ return payload;
191
+ const entry = row;
192
+ const kind = row.kind;
193
+ const { count, bundle } = payload;
194
+ if (bundle === undefined)
195
+ return { item: { entry, index, kind, count }, address };
196
+ const refusal = (0, bundles_js_1.bundleRefusal)(bundle);
197
+ const item = refusal === undefined ? { entry, index, kind, count, bundle } : { entry, index, kind, count, bundle, refusal };
198
+ return { item, address };
199
+ }
200
+ /** The key a log is filed under: its address as a JSON array — one spelling per address, whatever the ids hold. */
201
+ function keyOf(logRunId, drillPath) {
202
+ return JSON.stringify([logRunId, ...drillPath]);
203
+ }
204
+ /** The close that covers `last`: the one with the largest `commits` past it (the latest of equals). */
205
+ function coveringClose(closes, last) {
206
+ let best;
207
+ for (const close of closes) {
208
+ if (close.count > last && (best === undefined || close.count >= best.count))
209
+ best = close;
210
+ }
211
+ return best;
212
+ }
213
+ /** One place, one entry: the first readable commit for an index, the first opened. The rest is unread. */
214
+ function groupOf(key, address, arrival, placed, unread) {
215
+ let opened;
216
+ const commits = new Map();
217
+ const closes = [];
218
+ const dropped = new Set();
219
+ for (const item of placed) {
220
+ if (item.kind === 'opened') {
221
+ if (opened === undefined)
222
+ opened = item;
223
+ else {
224
+ dropped.add(item);
225
+ unread.push({
226
+ index: item.index,
227
+ reason: `a second opened entry for this log — the one at ${opened.index} stands`,
228
+ });
229
+ }
230
+ }
231
+ else if (item.kind === 'commit') {
232
+ const held = commits.get(item.count);
233
+ if (held === undefined)
234
+ commits.set(item.count, item);
235
+ else if (held.refusal !== undefined && item.refusal === undefined) {
236
+ // A torn first delivery yields to a readable redelivery.
237
+ commits.set(item.count, item);
238
+ dropped.add(held);
239
+ }
240
+ else
241
+ dropped.add(item);
242
+ }
243
+ else if (item.kind === 'closed')
244
+ closes.push(item);
245
+ }
246
+ // Each commit entry not placed is named against the entry that holds its index in the end.
247
+ for (const item of dropped) {
248
+ if (item.kind !== 'commit')
249
+ continue;
250
+ const holder = commits.get(item.count);
251
+ const reason = item.refusal !== undefined && holder.refusal === undefined
252
+ ? `an unreadable commit entry for index ${item.count} — the readable one at ${holder.index} stands`
253
+ : `a second commit entry for index ${item.count} — the one at ${holder.index} stands`;
254
+ unread.push({ index: item.index, reason });
255
+ }
256
+ const present = [...commits.keys()].sort((a, b) => a - b);
257
+ const last = present.length > 0 ? present[present.length - 1] : -1;
258
+ const close = coveringClose(closes, last);
259
+ const wanted = close !== undefined ? close.count : last + 1;
260
+ return { key, address, arrival, placed, opened, commits, closes, dropped, present, wanted };
261
+ }
262
+ /**
263
+ * The longest prefix of a log the gap budget can hold: with `present` (the stored indices, ascending)
264
+ * below it, a length `n` needs `n - (stored indices below n)` gaps. Returns the longest `n ≤ wanted`
265
+ * whose gaps fit in `budget` — one pass over the stored indices.
266
+ */
267
+ function lengthWithin(wanted, present, budget) {
268
+ for (let i = 0; i < present.length; i++) {
269
+ // Below present[i] lie i stored indices and present[i] - i gaps: it fits while those gaps do.
270
+ if (present[i] - i > budget)
271
+ return Math.min(wanted, budget + i);
272
+ }
273
+ return Math.min(wanted, budget + present.length);
274
+ }
275
+ /**
276
+ * Spend the record's gap budget on one log and read it: a log that asks for more rows than the budget
277
+ * holds is cut — what lies past the cut is unread, by name, and the log is not complete.
278
+ */
279
+ function readGroup(group, limits, budget, stopped, unread) {
280
+ const { commits, closes, present, wanted } = group;
281
+ const left = budget.missing;
282
+ const fits = lengthWithin(wanted, present, left);
283
+ const past = new Set();
284
+ if (fits < wanted) {
285
+ const limit = `the reader's limit of ${limits.missingCommits} missing commits for the record (${left} were left for this log)`;
286
+ for (const [idx, item] of commits) {
287
+ if (idx < fits)
288
+ continue;
289
+ past.add(item);
290
+ commits.delete(idx);
291
+ unread.push({ index: item.index, reason: `bundle.idx is ${idx}, past ${limit}` });
292
+ }
293
+ for (const close of closes) {
294
+ const count = close.count;
295
+ if (count <= fits)
296
+ continue;
297
+ past.add(close);
298
+ unread.push({ index: close.index, reason: `commits is ${count}, past ${limit}` });
299
+ }
300
+ }
301
+ const stillPresent = past.size === 0 ? present : present.filter((idx) => idx < fits);
302
+ const lastKept = stillPresent.length > 0 ? stillPresent[stillPresent.length - 1] : -1;
303
+ const closing = coveringClose(past.size === 0 ? closes : closes.filter((close) => !past.has(close)), lastKept);
304
+ const length = closing !== undefined ? closing.count : lastKept + 1;
305
+ budget.missing -= length - stillPresent.length;
306
+ const rows = [];
307
+ for (let idx = 0; idx < length; idx++) {
308
+ const item = commits.get(idx);
309
+ if (item === undefined)
310
+ rows.push(NO_COMMIT);
311
+ else if (item.refusal !== undefined)
312
+ rows.push((0, bundles_js_1.absentRow)(item.refusal));
313
+ else
314
+ rows.push(item.bundle);
315
+ }
316
+ Object.freeze(rows);
317
+ const { log, gaps } = (0, bundles_js_1.readLog)(rows);
318
+ const inLog = [];
319
+ const notes = [];
320
+ for (const item of group.placed) {
321
+ if (group.dropped.has(item) || past.has(item))
322
+ continue;
323
+ inLog.push(item.entry);
324
+ if (item.entry.kind === 'note') {
325
+ notes.push(Object.freeze({ kind: item.entry.note, at: item.entry.at, data: item.entry.data }));
326
+ }
327
+ }
328
+ // Complete only when the writer closed it AND the reader read it whole.
329
+ const ended = closing !== undefined && past.size === 0 && !stopped ? closing.entry : undefined;
330
+ const opened = group.opened?.entry;
331
+ const meta = {};
332
+ if (opened?.meta !== undefined)
333
+ meta.opened = opened.meta;
334
+ if (ended?.meta !== undefined)
335
+ meta.closed = ended.meta;
336
+ return {
337
+ key: group.key,
338
+ address: group.address,
339
+ rows,
340
+ fields: {
341
+ ...(opened ? { initialState: opened.initialState } : {}),
342
+ commitLog: Object.freeze(log),
343
+ gaps,
344
+ notes: Object.freeze(notes),
345
+ complete: ended !== undefined,
346
+ ...(ended ? { outcome: ended.outcome } : {}),
347
+ meta: Object.freeze(meta),
348
+ entries: Object.freeze(inLog),
349
+ },
350
+ };
351
+ }
352
+ /**
353
+ * The mount logs of one run leg, as a live snapshot's `subflowResults` reads to the cursor: keyed by
354
+ * each mount's runtimeStageId (the last segment of its drill path), ONE flat map for the whole leg —
355
+ * so `drill` reaches a nested mount from any cursor of the leg. Built once per leg, in one pass; the
356
+ * first log stored for a mount id holds it. `undefined` when the leg stored no mount log.
357
+ */
358
+ function mountsOf(groups) {
359
+ let results;
360
+ for (const group of groups) {
361
+ const { drillPath } = group.address;
362
+ if (drillPath.length === 0)
363
+ continue;
364
+ const mount = drillPath[drillPath.length - 1];
365
+ // A null-prototype map: a stored drill segment is a key, never a prototype.
366
+ results ??= Object.create(null);
367
+ if (results[mount] !== undefined)
368
+ continue;
369
+ const { initialState } = group.fields;
370
+ results[mount] = Object.freeze({
371
+ subflowId: (0, runtimeStageId_js_1.stageIdOf)(mount),
372
+ treeContext: Object.freeze({ history: group.rows, ...(initialState ? { initialState } : {}) }),
373
+ });
374
+ }
375
+ return results && Object.freeze(results);
376
+ }
377
+ /**
378
+ * Read a sink's stored entries back into logs.
379
+ *
380
+ * Bounded on untrusted input: no recursion, no payload walk, work per entry bounded by the address
381
+ * limits, at most `limits.missingCommits` gap rows across the record, and at most `limits.entries`
382
+ * entries read — whatever a limit stops is listed in `unread` by name.
383
+ *
384
+ * @param entries What a `RecordSink` stored (`memorySink().entries`, or entries parsed back from a
385
+ * file or a table), in the order they were written — typed `unknown` because stored entries come
386
+ * back unvalidated; each is checked here.
387
+ * @param limits Raise a default to read a larger record ({@link StoredRecordLimits}).
388
+ * @returns Every log by address, the run-leg roots, and every entry not read into a log.
389
+ * @throws RangeError when a limit is not a non-negative integer or `Infinity`; TypeError when
390
+ * `entries` is not iterable; whatever the input's own iterator throws. Never for an entry that is
391
+ * data (parsed JSON, a structured clone).
392
+ *
393
+ * @example
394
+ * ```typescript
395
+ * import { readStoredRecord, stateAt, timeTravel } from 'foottrace';
396
+ *
397
+ * const stored = readStoredRecord(entries); // e.g. JSON lines read back after a crash
398
+ * const run = stored.root('run-1');
399
+ * run?.complete; // false — it never closed
400
+ * run?.notes.map((n) => n.kind); // ['attempt', 'attempt'] — kept as they happened
401
+ * timeTravel(run!.source()).last(); // the cursor over what landed
402
+ * stateAt(run!.source(), run!.commitLog.length - 1).skipped; // a lost commit, by index and reason
403
+ * ```
404
+ */
405
+ function readStoredRecord(entries, limits) {
406
+ const bounds = limitsOf(limits);
407
+ const unread = [];
408
+ const byKey = new Map();
409
+ const seen = new Map();
410
+ let stopped = false;
411
+ let index = 0;
412
+ for (const row of entries) {
413
+ if (index >= bounds.entries) {
414
+ stopped = true;
415
+ unread.push({ index, reason: `entries from here on are past the reader's limit of ${bounds.entries}; not read` });
416
+ break;
417
+ }
418
+ let read;
419
+ try {
420
+ read = readEntry(row, index, bounds);
421
+ }
422
+ catch {
423
+ read = 'the entry threw when it was read';
424
+ }
425
+ if (typeof read === 'string') {
426
+ unread.push({ index, reason: read });
427
+ }
428
+ else if (seen.has(row)) {
429
+ unread.push({ index, reason: `the same entry as at ${seen.get(row)}` });
430
+ }
431
+ else {
432
+ seen.set(row, index);
433
+ // One address per log: an entry's own copy is kept only by the first entry of its log.
434
+ const key = keyOf(read.address.logRunId, read.address.drillPath);
435
+ const log = byKey.get(key);
436
+ if (log === undefined)
437
+ byKey.set(key, { address: read.address, placed: [read.item] });
438
+ else
439
+ log.placed.push(read.item);
440
+ }
441
+ index++;
442
+ }
443
+ // Group, then spend the gap budget on the logs that need the fewest gaps first.
444
+ let arrival = 0;
445
+ const grouped = [];
446
+ for (const [key, { address, placed }] of byKey)
447
+ grouped.push(groupOf(key, address, arrival++, placed, unread));
448
+ const byNeed = [...grouped].sort((a, b) => a.wanted - a.present.length - (b.wanted - b.present.length) || a.arrival - b.arrival);
449
+ const budget = { missing: bounds.missingCommits };
450
+ const read = new Map();
451
+ for (const group of byNeed)
452
+ read.set(group, readGroup(group, bounds, budget, stopped, unread));
453
+ const groups = grouped.map((group) => read.get(group));
454
+ const legs = new Map();
455
+ for (const group of groups) {
456
+ const leg = legs.get(group.address.logRunId);
457
+ if (leg === undefined)
458
+ legs.set(group.address.logRunId, [group]);
459
+ else
460
+ leg.push(group);
461
+ }
462
+ const mountsByLeg = new Map();
463
+ const logs = new Map();
464
+ for (const group of groups) {
465
+ const { logRunId } = group.address;
466
+ const { initialState } = group.fields;
467
+ let cached;
468
+ const log = Object.freeze({
469
+ address: group.address,
470
+ ...group.fields,
471
+ source() {
472
+ if (cached === undefined) {
473
+ if (!mountsByLeg.has(logRunId))
474
+ mountsByLeg.set(logRunId, mountsOf(legs.get(logRunId)));
475
+ const subflowResults = mountsByLeg.get(logRunId);
476
+ cached = Object.freeze({
477
+ commitLog: group.rows,
478
+ ...(initialState ? { initialState } : {}),
479
+ ...(subflowResults ? { subflowResults } : {}),
480
+ });
481
+ }
482
+ return cached;
483
+ },
484
+ });
485
+ logs.set(group.key, log);
486
+ }
487
+ unread.sort((a, b) => a.index - b.index);
488
+ return Object.freeze({
489
+ logs,
490
+ root: (logRunId) => (typeof logRunId === 'string' ? logs.get(keyOf(logRunId, [])) : undefined),
491
+ log: (address) => {
492
+ const own = readAddress(address, bounds);
493
+ return typeof own === 'string' ? undefined : logs.get(keyOf(own.logRunId, own.drillPath));
494
+ },
495
+ unread: Object.freeze(unread.map((item) => Object.freeze({ index: item.index, reason: item.reason }))),
496
+ });
497
+ }
498
+ exports.readStoredRecord = readStoredRecord;
@@ -231,6 +231,13 @@ class Cursor {
231
231
  }
232
232
  // ── Drilling (law 1: a separate cursor, not a second position) ───────────
233
233
  drill(mountRuntimeStageId) {
234
+ // Never back into the mount this cursor is, or one it was drilled out of: a stored or hand-built
235
+ // log can name one (a mount id is unique in a sound run), and a viewer that opens every mount
236
+ // would loop. The walk is as deep as the drill.
237
+ for (let at = this; at !== undefined; at = at.parent) {
238
+ if (at.mountRuntimeStageId === mountRuntimeStageId)
239
+ return undefined;
240
+ }
234
241
  // On a chain, the mount is looked for in every leg — a subflow that ran
235
242
  // before the pause is in the first snapshot's results, one that ran after
236
243
  // is in the second's. Ids are unique across a sound chain (chain.ts
@@ -252,6 +259,10 @@ class Cursor {
252
259
  const tree = result?.treeContext;
253
260
  if (!results || !result || !tree || !Array.isArray(tree.history))
254
261
  return undefined;
262
+ // … nor into the log this cursor already reads (a mount log opened on its own knows no mount id).
263
+ if (this.sources.some((source) => source.commitLog === tree.history || source.history === tree.history)) {
264
+ return undefined;
265
+ }
255
266
  // `path` is the subflow PATH — the same string for every iteration of a
256
267
  // mount inside a loop. `mountRuntimeStageId` is the iteration actually
257
268
  // opened, so a UI can label and key three drill panels over `sfx#2`,
@@ -1,6 +1,8 @@
1
1
  /** Read a recorded execution: replay, time travel, slicing and attribution. */
2
2
  export type { CommitIdx, ExecutionCounter, ExecutionIndex, RuntimeStageId } from './lib/ids/runtimeStageId.js';
3
3
  export { buildRuntimeStageId, createExecutionCounter, parseRuntimeStageId, splitStageId, } from './lib/ids/runtimeStageId.js';
4
+ export type { AttemptId } from './lib/ids/attemptId.js';
5
+ export { buildAttemptId, parseAttemptId } from './lib/ids/attemptId.js';
4
6
  export { UnknownVerbError } from './lib/memory/verbs.js';
5
7
  export type { CommitBundle, MemoryPatch, TraceEntry } from './lib/memory/types.js';
6
8
  export type { ExecutionTree } from './lib/memory/types.js';
@@ -8,8 +10,9 @@ export { applySmartMerge } from './lib/memory/verbs.js';
8
10
  export type { ValueBasis, ValueBasisOptions, ValueWithBasis, WriterBasis, WriterWithBasis, } from './lib/memory/commitLogUtils.js';
9
11
  export { buildCommitIndex, commitIndexOf, commitValueAt, commitValueAtWithBasis, findCommit, findCommits, findLastWriter, findLastWriterWithBasis, inferLegacyPhases, recordsPhases, } from './lib/memory/commitLogUtils.js';
10
12
  export type { CommitPhase } from './lib/memory/types.js';
11
- export type { AxisRefusal, AxisSplit, BookendedAxis, FoldBasis, FoldedState, FoldSource, LogGap, Mark, Move, MoveRefusal, Stop, StopFilter, StopKind, TimeTravel, TimeTravelOptions, TimeTravelSource, TimeTravelStrategy, } from './lib/time-travel/index.js';
12
- export { commitStops, commitStopsStrategy, filterStops, isCommitBundle, splitAxis, stateAt, tagStops, timeTravel, } from './lib/time-travel/index.js';
13
+ export type { AxisRefusal, AxisSplit, BookendedAxis, FoldBasis, FoldedState, FoldSource, LogGap, Mark, Move, MoveRefusal, Stop, StopFilter, StopKind, StoredLog, StoredNote, StoredRecord, StoredRecordLimits, TimeTravel, TimeTravelOptions, TimeTravelSource, TimeTravelStrategy, } from './lib/time-travel/index.js';
14
+ export { commitStops, commitStopsStrategy, filterStops, isCommitBundle, readStoredRecord, splitAxis, stateAt, tagStops, timeTravel, } from './lib/time-travel/index.js';
15
+ export type { LogOutcome, Meta } from './lib/memory/sink.js';
13
16
  export type { CausalChainOptions, CausalEdge, CausalNode, ControlDependency, ControlDepLookup, EdgeWeigher, KeysReadLookup, } from './lib/memory/backtrack.js';
14
17
  export { causalChain, flattenCausalDAG, formatCausalChain } from './lib/memory/backtrack.js';
15
18
  export type { UntrackedSource } from './lib/memory/types.js';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * attemptId — the address of one attempt under a stage execution.
3
+ *
4
+ * Format:
5
+ * <runtimeStageId>@<n> attempt n of the stage execution 'charge#4@2'
6
+ * <runtimeStageId>@<n>.<k>@<m> attempt m of the k-th call made 'charge#4@2.1@3'
7
+ * during stage attempt n
8
+ *
9
+ * Every number after the runtimeStageId is a positive integer, counted from 1: attempt 1 is the first
10
+ * try, call 1 the first call the stage attempt made. Written without leading zeros, so one attempt has
11
+ * one spelling and an id is a safe join key.
12
+ *
13
+ * PARSED FROM THE RIGHT. A runtimeStageId always ends in `#<execution index>`, and the suffix grammar
14
+ * holds no `#`, so the LAST `#` of an attempt id is its execution delimiter whatever the stage id
15
+ * contains — `@`, `.` and `~` stay legal in stage ids, and no byte is newly reserved (the id doors'
16
+ * refusal, FootPrint's `refuseReservedId`, is untouched). The delimiter is read from the grammar's one
17
+ * owner, `runtimeStageId.ts`.
18
+ *
19
+ * Imports nothing but the runtime-ID grammar: a leaf of the record, checked by the package fence.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * buildAttemptId('charge#4', 2); // 'charge#4@2'
24
+ * buildAttemptId('charge#4', 2, { ordinal: 1, attempt: 3 }); // 'charge#4@2.1@3'
25
+ * parseAttemptId('sf/v1.2@x#7@3'); // { runtimeStageId: 'sf/v1.2@x#7', attempt: 3 }
26
+ * parseAttemptId('charge#4'); // undefined — a stage execution, not an attempt
27
+ * ```
28
+ */
29
+ import { type RuntimeStageId } from './runtimeStageId.js';
30
+ declare const ATTEMPT_ID: unique symbol;
31
+ /** A `<runtimeStageId>@<n>[.<k>@<m>]` string the grammar produced. Assignable to `string`. */
32
+ export type AttemptId = string & {
33
+ readonly [ATTEMPT_ID]: true;
34
+ };
35
+ /**
36
+ * Build the id of one attempt.
37
+ *
38
+ * @param runtimeStageId The stage execution the attempt belongs to (`[subflowPath/]stageId#index`).
39
+ * @param attempt The stage attempt, from 1.
40
+ * @param call For an attempt of a call made inside the stage attempt: the call's `ordinal` (1 for the
41
+ * first call that stage attempt made) and its own `attempt` (from 1).
42
+ * @throws TypeError when `runtimeStageId` does not end in `#<execution index>`, and RangeError when a
43
+ * count is not a positive integer — what `parseAttemptId` could not give back is never written.
44
+ */
45
+ export declare function buildAttemptId(runtimeStageId: string, attempt: number, call?: {
46
+ readonly ordinal: number;
47
+ readonly attempt: number;
48
+ }): AttemptId;
49
+ /**
50
+ * Parse an attempt id into its parts, or `undefined` when `id` is not one (a bare runtimeStageId, a
51
+ * count of 0, a leading zero, a missing part). Never throws.
52
+ *
53
+ * `parseAttemptId(buildAttemptId(...parts))` gives the parts back, whatever the stage id contains.
54
+ */
55
+ export declare function parseAttemptId(id: string): {
56
+ runtimeStageId: RuntimeStageId;
57
+ attempt: number;
58
+ call?: {
59
+ ordinal: number;
60
+ attempt: number;
61
+ };
62
+ } | undefined;
63
+ export {};