@playola-radio/slipstream 0.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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
@@ -0,0 +1,50 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { open, rename } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { mkdirpDurable, writeAll, fsyncDir, FILE_MODE } from "./storage.js";
5
+ export function generateToken() { return randomBytes(32).toString('hex'); }
6
+ export async function publishDescriptor(storeDir, descriptor) {
7
+ const dir = join(storeDir, 'runtime');
8
+ await mkdirpDurable(dir);
9
+ const path = join(dir, `${randomUUID()}.json`);
10
+ const tmp = `${path}.tmp`;
11
+ const body = Buffer.from(JSON.stringify(descriptor), 'utf8');
12
+ const handle = await open(tmp, 'wx', FILE_MODE);
13
+ try {
14
+ await writeAll(handle, body);
15
+ await handle.sync();
16
+ }
17
+ finally {
18
+ await handle.close();
19
+ }
20
+ await rename(tmp, path);
21
+ await fsyncDir(dir);
22
+ return path;
23
+ }
24
+ export function checkAuth(header, token) {
25
+ if (typeof header !== 'string')
26
+ return false;
27
+ const prefix = 'Bearer ';
28
+ if (!header.startsWith(prefix))
29
+ return false;
30
+ const got = Buffer.from(header.slice(prefix.length));
31
+ const want = Buffer.from(token);
32
+ if (got.length !== want.length)
33
+ return false;
34
+ return timingSafeEqual(got, want);
35
+ }
36
+ export function checkHostOrigin(headers, expectedHostPort, hostCount = 1) {
37
+ // Node collapses duplicate Host headers to the first in `headers.host`; reject
38
+ // if the client actually sent more than one (mirrors the Origin handling).
39
+ if (hostCount > 1)
40
+ return false;
41
+ const host = headers.host;
42
+ if (typeof host !== 'string' || host !== expectedHostPort)
43
+ return false;
44
+ const origin = headers.origin;
45
+ if (origin === undefined)
46
+ return true;
47
+ if (typeof origin !== 'string')
48
+ return false;
49
+ return origin === `http://${expectedHostPort}`;
50
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The language-neutral core of the `interface.v1` projection: the declaration /
3
+ * identity model, the D3 correspondence algorithm, the D10 status-precedence
4
+ * table, D8 row ordering, and the response-envelope builder.
5
+ *
6
+ * This module is pure and has **zero imports** — no parser, no tree-sitter, no
7
+ * worker, no I/O, and zero language branches. Per-language extraction feeds it
8
+ * `Declaration`s and dispositions; how those are produced is not its concern.
9
+ * See INTERFACE-PROJECTION.md for the full contract.
10
+ */
11
+ const INTERFACE_PROJECTION_VERSION = 'interface.v1';
12
+ // ---- Keys ------------------------------------------------------------------
13
+ /**
14
+ * A collision-free string for an identity. Uses `JSON.stringify` over a
15
+ * fixed-order structure so distinct identities never alias (JSON quoting makes
16
+ * the encoding injective — unlike a delimiter join).
17
+ */
18
+ function groupingKey(id) {
19
+ return JSON.stringify([
20
+ id.kind,
21
+ id.scope.map((s) => [s.kind, s.name]),
22
+ id.name,
23
+ id.guards,
24
+ ]);
25
+ }
26
+ /** Identity plus signature: two declarations "are the same" iff these match. */
27
+ function exactKey(d) {
28
+ return JSON.stringify([groupingKey(d.identity), d.signature]);
29
+ }
30
+ export function hasDuplicate(decls) {
31
+ const seen = new Set();
32
+ for (const d of decls) {
33
+ const k = exactKey(d);
34
+ if (seen.has(k))
35
+ return true;
36
+ seen.add(k);
37
+ }
38
+ return false;
39
+ }
40
+ /**
41
+ * Correspond two declaration lists. Callers must have already ruled out
42
+ * per-side duplicates (status precedence 3); this assumes each `exactKey`
43
+ * occurs at most once per side.
44
+ */
45
+ export function compare(before, after) {
46
+ const beforeExact = new Set(before.map(exactKey));
47
+ const afterExact = new Set(after.map(exactKey));
48
+ const unmatchedBefore = before.filter((d) => !afterExact.has(exactKey(d)));
49
+ const unmatchedAfter = after.filter((d) => !beforeExact.has(exactKey(d)));
50
+ const groups = new Map();
51
+ const groupOf = (d) => {
52
+ const k = groupingKey(d.identity);
53
+ let g = groups.get(k);
54
+ if (!g)
55
+ groups.set(k, (g = { before: [], after: [] }));
56
+ return g;
57
+ };
58
+ for (const d of unmatchedBefore)
59
+ groupOf(d).before.push(d);
60
+ for (const d of unmatchedAfter)
61
+ groupOf(d).after.push(d);
62
+ const changes = [];
63
+ for (const { before: b, after: a } of groups.values()) {
64
+ if (b.length > 0 && a.length > 0) {
65
+ if (b.length === 1 && a.length === 1) {
66
+ changes.push(signatureChangedRow(b[0], a[0]));
67
+ }
68
+ else {
69
+ return { ambiguous: true };
70
+ }
71
+ }
72
+ else if (b.length > 0) {
73
+ for (const d of b)
74
+ changes.push(removedRow(d));
75
+ }
76
+ else {
77
+ for (const d of a)
78
+ changes.push(addedRow(d));
79
+ }
80
+ }
81
+ changes.sort(compareRows);
82
+ return { ambiguous: false, changes };
83
+ }
84
+ function outDecl(d) {
85
+ return {
86
+ kind: d.identity.kind,
87
+ display_name: d.displayName,
88
+ signature: d.signature,
89
+ span: { byte_start: d.span.byteStart, byte_end: d.span.byteEnd },
90
+ };
91
+ }
92
+ function outIdentity(id) {
93
+ return {
94
+ kind: id.kind,
95
+ scope: id.scope.map((s) => ({ kind: s.kind, name: s.name })),
96
+ name: id.name,
97
+ guards: [...id.guards],
98
+ };
99
+ }
100
+ function removedRow(d) {
101
+ return { kind: 'removed', identity: outIdentity(d.identity), before: outDecl(d), after: null };
102
+ }
103
+ function addedRow(d) {
104
+ return { kind: 'added', identity: outIdentity(d.identity), before: null, after: outDecl(d) };
105
+ }
106
+ function signatureChangedRow(b, a) {
107
+ return {
108
+ kind: 'signatureChanged',
109
+ identity: outIdentity(b.identity),
110
+ before: outDecl(b),
111
+ after: outDecl(a),
112
+ };
113
+ }
114
+ /** UTF-16 code-unit strings, numeric offsets, lexicographic arrays (shorter
115
+ * prefix first). No normalization, no locale. */
116
+ function cmp(a, b) {
117
+ if (typeof a === 'string' && typeof b === 'string')
118
+ return a < b ? -1 : a > b ? 1 : 0;
119
+ if (typeof a === 'number' && typeof b === 'number')
120
+ return a < b ? -1 : a > b ? 1 : 0;
121
+ if (Array.isArray(a) && Array.isArray(b)) {
122
+ const n = Math.min(a.length, b.length);
123
+ for (let i = 0; i < n; i++) {
124
+ const c = cmp(a[i], b[i]);
125
+ if (c !== 0)
126
+ return c;
127
+ }
128
+ return a.length - b.length;
129
+ }
130
+ throw new Error('interface projection: cmp type mismatch');
131
+ }
132
+ const KIND_RANK = { removed: 0, signatureChanged: 1, added: 2 };
133
+ /** The D(d) ordering tuple for one output declaration. */
134
+ function orderTuple(d, id) {
135
+ const scopePairs = id.scope.flatMap((s) => [s.kind, s.name]);
136
+ return [
137
+ d.display_name,
138
+ d.signature,
139
+ id.kind,
140
+ scopePairs,
141
+ id.name,
142
+ id.guards,
143
+ d.span.byte_start,
144
+ d.span.byte_end,
145
+ ];
146
+ }
147
+ function anchor(row) {
148
+ return row.kind === 'added' ? row.after : row.before;
149
+ }
150
+ function counterpart(row) {
151
+ return row.kind === 'signatureChanged' ? row.after : null;
152
+ }
153
+ function compareRows(x, y) {
154
+ const byKind = KIND_RANK[x.kind] - KIND_RANK[y.kind];
155
+ if (byKind !== 0)
156
+ return byKind;
157
+ const byAnchor = cmp(orderTuple(anchor(x), x.identity), orderTuple(anchor(y), y.identity));
158
+ if (byAnchor !== 0)
159
+ return byAnchor;
160
+ const cx = counterpart(x);
161
+ const cy = counterpart(y);
162
+ if (cx === null && cy === null)
163
+ return 0;
164
+ if (cx === null)
165
+ return -1; // null counterpart sorts first
166
+ if (cy === null)
167
+ return 1;
168
+ return cmp(orderTuple(cx, x.identity), orderTuple(cy, y.identity));
169
+ }
170
+ // ---- Status precedence (D10) + envelope ------------------------------------
171
+ function coverageSide(side, input) {
172
+ switch (side.status) {
173
+ case 'complete':
174
+ return { state: 'complete' };
175
+ case 'absent':
176
+ return { state: 'absent' };
177
+ case 'incomplete':
178
+ return { state: 'incomplete', reason: side.reason };
179
+ case 'unavailable':
180
+ return { state: 'unavailable', reason: side.reason };
181
+ case 'notEvaluated':
182
+ if (!input.admission && input.language === null)
183
+ return { state: 'unsupported' };
184
+ return { state: 'notEvaluated' };
185
+ }
186
+ }
187
+ function classify(input) {
188
+ const { before, after } = input;
189
+ if (before.status === 'incomplete')
190
+ return { status: 'incomplete', reason: before.reason, changes: [] };
191
+ if (after.status === 'incomplete')
192
+ return { status: 'incomplete', reason: after.reason, changes: [] };
193
+ if (before.status === 'complete' && hasDuplicate(before.declarations)) {
194
+ return { status: 'incomplete', reason: 'duplicate-declaration', changes: [] };
195
+ }
196
+ if (after.status === 'complete' && hasDuplicate(after.declarations)) {
197
+ return { status: 'incomplete', reason: 'duplicate-declaration', changes: [] };
198
+ }
199
+ // Ambiguity outranks unavailable/unsupported/admission, but it can only be
200
+ // judged when both sides are comparable; compute the correspondence here and
201
+ // hold its rows for the ready fallback, which is the lowest-precedence outcome.
202
+ const bComparable = before.status === 'complete' || before.status === 'absent';
203
+ const aComparable = after.status === 'complete' || after.status === 'absent';
204
+ let ready = null;
205
+ if (bComparable && aComparable) {
206
+ const bd = before.status === 'complete' ? before.declarations : [];
207
+ const ad = after.status === 'complete' ? after.declarations : [];
208
+ const result = compare(bd, ad);
209
+ if (result.ambiguous)
210
+ return { status: 'incomplete', reason: 'ambiguous-correspondence', changes: [] };
211
+ ready = result.changes;
212
+ }
213
+ if (before.status === 'unavailable')
214
+ return { status: 'unavailable', reason: before.reason, changes: [] };
215
+ if (after.status === 'unavailable')
216
+ return { status: 'unavailable', reason: after.reason, changes: [] };
217
+ if (input.language === null)
218
+ return { status: 'unsupported', reason: 'unsupported-language', changes: [] };
219
+ if (input.admission)
220
+ return { status: 'skipped', reason: input.admission, changes: [] };
221
+ // ready is last: a successful comparison is only reported when nothing else applied.
222
+ if (ready !== null)
223
+ return { status: 'ready', changes: ready };
224
+ throw new Error('interface projection: incoherent disposition inputs');
225
+ }
226
+ export function buildInterfaceProjection(input) {
227
+ const { status, reason, changes } = classify(input);
228
+ const projection = {
229
+ change_seq: input.changeSeq,
230
+ projection_version: INTERFACE_PROJECTION_VERSION,
231
+ language: input.language,
232
+ language_version: input.languageVersion,
233
+ status,
234
+ coverage: {
235
+ before: coverageSide(input.before, input),
236
+ after: coverageSide(input.after, input),
237
+ },
238
+ changes,
239
+ };
240
+ if (status !== 'ready')
241
+ projection.fallback_reason = reason;
242
+ return projection;
243
+ }
@@ -0,0 +1,230 @@
1
+ /** Resolve recorded file identities for one frozen pair of session cutoffs.
2
+ * FD4 owns blob retention, extraction, response pagination and HTTP mapping. */
3
+ import { openLogCursor, LogCorruptError, LogReadAbortedError, LogReadLimitError } from "./log-reader.js";
4
+ import { assertSafePath } from "./recovery.js";
5
+ import { sourceFor } from "./event.js";
6
+ const BASELINED = 'slipstream.file.baselined.v1';
7
+ const CHANGED = 'slipstream.file.changed.v1';
8
+ const COMPLETED = 'slipstream.capture.baseline.completed.v1';
9
+ const GAP = 'slipstream.capture.gap.v1';
10
+ const SHA = /^[0-9a-f]{64}$/;
11
+ const SEQ = /^[1-9][0-9]*$/;
12
+ const POLICY_EXCLUSIONS = ['store-directory', '.git', 'symlinks'];
13
+ function corrupt(message) { throw new LogCorruptError(`interface range: ${message}`); }
14
+ function object(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function snapshot(value, seq) {
18
+ if (!object(value))
19
+ corrupt(`bad snapshot at ${seq}`);
20
+ if (value.kind === 'absent')
21
+ return { kind: 'absent' };
22
+ if (value.kind === 'content' && typeof value.sha256 === 'string' && SHA.test(value.sha256)
23
+ && typeof value.size === 'number' && Number.isSafeInteger(value.size) && value.size >= 0) {
24
+ return { kind: 'content', sha256: value.sha256, size: value.size };
25
+ }
26
+ if (value.kind === 'unavailable' && typeof value.reason === 'string'
27
+ && ['oversize', 'unreadable', 'unstable', 'io-error', 'baseline-unknown'].includes(value.reason)) {
28
+ return { kind: 'unavailable', reason: value.reason };
29
+ }
30
+ return corrupt(`bad snapshot at ${seq}`);
31
+ }
32
+ function sameRecordedState(a, b) {
33
+ // The v2 fixture validator compares both content hash and recorded size for
34
+ // predecessor integrity. Recovery's equality helper suppresses observations
35
+ // by hash alone; it has a different purpose.
36
+ if (a.kind !== b.kind)
37
+ return false;
38
+ if (a.kind === 'content' && b.kind === 'content')
39
+ return a.sha256 === b.sha256 && a.size === b.size;
40
+ if (a.kind === 'unavailable' && b.kind === 'unavailable')
41
+ return a.reason === b.reason;
42
+ return true;
43
+ }
44
+ function endpoint(record, field) {
45
+ const result = {
46
+ kind: 'recorded', record_seq: record.seq, field, snapshot: snapshot(record.data[field], record.seq),
47
+ };
48
+ if (record.type === CHANGED) {
49
+ result.observation = record.data.observation;
50
+ if (record.data.gap_ref !== undefined)
51
+ result.gap_ref = record.data.gap_ref;
52
+ }
53
+ return result;
54
+ }
55
+ function stateEndpoint(record) {
56
+ return endpoint(record, record.type === BASELINED ? 'snapshot' : 'after');
57
+ }
58
+ function endpointsEqual(before, after) {
59
+ if (before.kind !== 'recorded')
60
+ return false;
61
+ if (before.snapshot.kind === 'absent' && after.snapshot.kind === 'absent')
62
+ return true;
63
+ return before.snapshot.kind === 'content' && after.snapshot.kind === 'content'
64
+ && before.snapshot.sha256 === after.snapshot.sha256;
65
+ }
66
+ function checkPath(value, seq) {
67
+ if (typeof value !== 'string' || value.includes('\0'))
68
+ corrupt(`bad path at ${seq}`);
69
+ try {
70
+ assertSafePath(value, `record ${seq}`);
71
+ }
72
+ catch {
73
+ corrupt(`unsafe path at ${seq}`);
74
+ }
75
+ return value;
76
+ }
77
+ function checkGap(data, seq) {
78
+ if (!['coalesced', 'baseline-unreadable', 'watcher-error', 'restart', 'storage'].includes(data.reason)
79
+ || !object(data.scope))
80
+ corrupt(`bad gap at ${seq}`);
81
+ const scope = data.scope;
82
+ if (scope.kind === 'session')
83
+ return { seq, reason: data.reason, scope: { kind: 'session' } };
84
+ if ((scope.kind !== 'directory' && scope.kind !== 'path') || typeof scope.path !== 'string'
85
+ || scope.path.includes('\0') || (scope.kind === 'path' && scope.path === ''))
86
+ corrupt(`bad gap scope at ${seq}`);
87
+ if (scope.path !== '')
88
+ checkPath(scope.path, seq);
89
+ return { seq, reason: data.reason, scope: { kind: scope.kind, path: scope.path } };
90
+ }
91
+ /** A bounded scan of the public log through A. No live worktree or Git access. */
92
+ export async function resolveRecordedRange(options) {
93
+ const { logPath, sessionId, durableSeq, beforeSeq, afterSeq, scanBudget, signal } = options;
94
+ if (beforeSeq < 0n || afterSeq < beforeSeq || durableSeq < 0n)
95
+ throw new RangeError('invalid cutoffs');
96
+ if (afterSeq > durableSeq)
97
+ return { kind: 'beyondDurable' };
98
+ if (!Number.isSafeInteger(scanBudget.records) || scanBudget.records < 0
99
+ || !Number.isSafeInteger(scanBudget.bytes) || scanBudget.bytes < 0)
100
+ throw new RangeError('invalid scan budget');
101
+ const started = performance.now();
102
+ let records = 0;
103
+ let bytes = 0;
104
+ const scan = () => ({ records, bytes, elapsedMs: performance.now() - started });
105
+ if (signal?.aborted)
106
+ return { kind: 'aborted', scan: scan() };
107
+ if (afterSeq === 0n)
108
+ return {
109
+ kind: 'resolved', inventory: { scope: 'observed', baselineCompletedSeq: null, unknownScopes: [], policyExclusions: POLICY_EXCLUSIONS },
110
+ gaps: [], files: [], scan: scan(),
111
+ };
112
+ const states = new Map();
113
+ const gaps = [];
114
+ let baselineCompletedSeq = null;
115
+ let unknownScopes = [];
116
+ let lastSeq = 0n;
117
+ const cursor = await openLogCursor(logPath, 0n);
118
+ try {
119
+ while (lastSeq < afterSeq) {
120
+ if (signal?.aborted)
121
+ return { kind: 'aborted', scan: scan() };
122
+ if (records >= scanBudget.records || bytes >= scanBudget.bytes)
123
+ return { kind: 'scanLimit', scan: scan() };
124
+ let batch;
125
+ try {
126
+ batch = await cursor.readThrough(afterSeq, {
127
+ maxRecords: scanBudget.records - records,
128
+ maxBytes: scanBudget.bytes - bytes,
129
+ signal,
130
+ });
131
+ }
132
+ catch (error) {
133
+ if (error instanceof LogReadAbortedError)
134
+ return { kind: 'aborted', scan: scan() };
135
+ if (error instanceof LogReadLimitError)
136
+ return { kind: 'scanLimit', scan: scan() };
137
+ throw error;
138
+ }
139
+ if (batch.length === 0)
140
+ corrupt(`disk short of durable boundary ${afterSeq}`);
141
+ for (const record of batch) {
142
+ records += 1;
143
+ if (record.bytesRead === undefined)
144
+ corrupt(`missing bytesRead at ${record.seq}`);
145
+ bytes += record.bytesRead;
146
+ lastSeq = record.seq;
147
+ consume(record);
148
+ }
149
+ }
150
+ }
151
+ finally {
152
+ await cursor.close();
153
+ }
154
+ if (signal?.aborted)
155
+ return { kind: 'aborted', scan: scan() };
156
+ function consume(record) {
157
+ const seq = record.seq.toString();
158
+ const data = record.data;
159
+ if (data.session_id !== sessionId)
160
+ corrupt(`session mismatch at ${seq}`);
161
+ if (record.id !== seq)
162
+ corrupt(`envelope id ${record.id} != seq ${seq} at ${seq}`);
163
+ if (record.source !== sourceFor(sessionId))
164
+ corrupt(`envelope source ${record.source} != ${sourceFor(sessionId)} at ${seq}`);
165
+ if (record.type === BASELINED || record.type === CHANGED) {
166
+ const path = checkPath(data.path, seq);
167
+ const state = states.get(path) ?? {};
168
+ if (record.type === BASELINED) {
169
+ const tag = snapshot(data.snapshot, seq);
170
+ state.prior = tag;
171
+ }
172
+ else {
173
+ const before = snapshot(data.before, seq);
174
+ const after = snapshot(data.after, seq);
175
+ if (data.observation !== 'watcher' && data.observation !== 'reconciliation')
176
+ corrupt(`bad observation at ${seq}`);
177
+ if (data.gap_ref !== undefined && (typeof data.gap_ref !== 'string' || !SEQ.test(data.gap_ref)))
178
+ corrupt(`bad gap_ref at ${seq}`);
179
+ if (state.prior !== undefined && !sameRecordedState(state.prior, before))
180
+ corrupt(`contradictory predecessor at ${seq}: ${path}`);
181
+ state.prior = after;
182
+ }
183
+ const fileRecord = { seq, type: record.type, data };
184
+ state.after = fileRecord;
185
+ if (record.seq <= beforeSeq)
186
+ state.before = fileRecord;
187
+ else if (state.firstAfterB === undefined)
188
+ state.firstAfterB = fileRecord;
189
+ states.set(path, state);
190
+ }
191
+ else if (record.type === COMPLETED && baselineCompletedSeq === null) {
192
+ if (!Array.isArray(data.unknown_scopes) || data.unknown_scopes.some((scope) => {
193
+ if (typeof scope !== 'string' || scope.includes('\0'))
194
+ return true;
195
+ if (scope === '')
196
+ return false;
197
+ try {
198
+ assertSafePath(scope, `record ${seq}`);
199
+ return false;
200
+ }
201
+ catch {
202
+ return true;
203
+ }
204
+ }))
205
+ corrupt(`bad unknown scopes at ${seq}`);
206
+ baselineCompletedSeq = seq;
207
+ unknownScopes = [...data.unknown_scopes].sort();
208
+ }
209
+ else if (record.type === GAP) {
210
+ gaps.push(checkGap(data, seq));
211
+ }
212
+ }
213
+ const files = [];
214
+ for (const [path, state] of states) {
215
+ if (options.pathPrefix !== undefined && !path.startsWith(options.pathPrefix))
216
+ continue;
217
+ if (options.afterPath !== undefined && options.afterPath !== null && path <= options.afterPath)
218
+ continue;
219
+ if (state.after === undefined)
220
+ continue;
221
+ const after = stateEndpoint(state.after);
222
+ const before = state.before !== undefined ? stateEndpoint(state.before)
223
+ : baselineCompletedSeq !== null && BigInt(baselineCompletedSeq) <= beforeSeq && state.firstAfterB?.type === CHANGED
224
+ ? endpoint(state.firstAfterB, 'before') : { kind: 'unknownBoundary' };
225
+ files.push({ path, before, after, endpointsEqual: endpointsEqual(before, after) });
226
+ }
227
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
228
+ return { kind: 'resolved', inventory: { scope: 'observed', baselineCompletedSeq, unknownScopes,
229
+ policyExclusions: POLICY_EXCLUSIONS }, gaps, files, scan: scan() };
230
+ }