@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,655 @@
1
+ import { createServer } from 'node:http';
2
+ import { constants } from 'node:fs';
3
+ import { access, open, unlink } from 'node:fs/promises';
4
+ import { listSessions, readTombstone, isValidSessionId, sessionLogPath, onDiskHighWater, blobPath, isValidHex, schemaBytes, projectionSchemaBytes, } from "./store-reader.js";
5
+ import { checkAuth, checkHostOrigin, generateToken, publishDescriptor } from "./http-security.js";
6
+ import { parseCursor, openLogCursor, LogCorruptError } from "./log-reader.js";
7
+ import { parseClipSnapshot } from "./clip-blob-reader.js";
8
+ import { createClipProjectionService } from "./clip-projection-service.js";
9
+ import { languageForPath } from "./clip-language.js";
10
+ import { liveBoundary, staticBoundary } from "./reader-runtime.js";
11
+ import { createBoundaryRegistry } from "./boundary-registry.js";
12
+ import { DISPLAY_FOLD_CONTRACT } from "./display-fold.js";
13
+ import { createProjectionAdmission, PROVISIONAL_SHARED_ADMISSION } from "./projection-admission.js";
14
+ import { createInterfaceService } from "./interface-service.js";
15
+ import { emitProjectionPhase } from "./projection-trace.js";
16
+ const DURABLE_SEQ_HEADER = 'slipstream-durable-seq';
17
+ // The display-rules version every successful /events reply is interpreted under
18
+ // (DA-2). Sourced from the single contract constant, never a second literal.
19
+ const FOLD_CONTRACT_HEADER = 'slipstream-fold-contract';
20
+ const SSE_HEARTBEAT_MS = 15000;
21
+ const DRAIN_DEADLINE_MS = 10000;
22
+ function send(res, status, body, headers = {}, onFinish) {
23
+ res.writeHead(status, { 'cache-control': 'no-store', ...headers });
24
+ if (onFinish)
25
+ res.end(body, onFinish);
26
+ else
27
+ res.end(body);
28
+ }
29
+ function sendJson(res, status, value, trace) {
30
+ const startedAtNs = trace ? process.hrtime.bigint() : undefined;
31
+ const body = JSON.stringify(value);
32
+ if (trace)
33
+ emitProjectionPhase(trace.observer, 'serialization', startedAtNs, { scope: trace.scope, routeKey: trace.routeKey });
34
+ const writeStartedAtNs = trace ? process.hrtime.bigint() : undefined;
35
+ send(res, status, body, { 'content-type': 'application/json; charset=utf-8' }, trace
36
+ ? () => emitProjectionPhase(trace.observer, 'http-completion', writeStartedAtNs, { scope: trace.scope, routeKey: trace.routeKey }) : undefined);
37
+ }
38
+ function isFollow(params) {
39
+ const v = params.get('follow');
40
+ return v === 'true' || v === '1';
41
+ }
42
+ async function writeBackpressured(res, chunk, signal) {
43
+ if (signal.aborted)
44
+ throw new Error('aborted');
45
+ if (res.write(chunk))
46
+ return;
47
+ await new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => { cleanup(); reject(new Error('drain timeout')); }, DRAIN_DEADLINE_MS);
49
+ const onDrain = () => { cleanup(); resolve(); };
50
+ const onAbort = () => { cleanup(); reject(new Error('aborted')); };
51
+ const cleanup = () => { clearTimeout(timer); res.off('drain', onDrain); signal.removeEventListener('abort', onAbort); };
52
+ res.once('drain', onDrain);
53
+ signal.addEventListener('abort', onAbort, { once: true });
54
+ });
55
+ }
56
+ export async function startReaderServer(opts) {
57
+ const token = generateToken();
58
+ const followers = new Set();
59
+ let closing = false;
60
+ // One resolution path for boundaries. In standalone `serve` mode the caller's
61
+ // `active` session becomes a one-entry registry with a live boundary, so the
62
+ // rest of the server never special-cases it. The daemon passes its own registry.
63
+ const registry = opts.registry ?? createBoundaryRegistry();
64
+ if (opts.active && !opts.registry) {
65
+ registry.installIfAbsent(opts.active.id, liveBoundary(opts.active.health));
66
+ }
67
+ // The clip projection is computed on demand from the immutable blobs and cached
68
+ // disposably (no log, no persistence). The service owns the worker pool + bounded
69
+ // admission so a burst of cold-cache requests cannot starve capture; the server
70
+ // closes it on shutdown.
71
+ const admission = createProjectionAdmission(opts.projectionAdmissionConfig ?? PROVISIONAL_SHARED_ADMISSION, opts.projectionTrace);
72
+ const clipService = createClipProjectionService({ storeDir: opts.storeDir, admission,
73
+ projectionTrace: opts.projectionTrace, dispatchBarrier: opts.clipDispatchBarrier });
74
+ const interfaceService = createInterfaceService({ storeDir: opts.storeDir, admission,
75
+ ...opts.interfaceLimits, extractSwift: opts.interfaceExtractSwift,
76
+ onRetentionCheck: opts.interfaceOnRetentionCheck,
77
+ admissionDeadlineMs: opts.interfaceDeadlineMs, projectionTrace: opts.projectionTrace });
78
+ const server = createServer((req, res) => {
79
+ void handle(req, res).catch((err) => {
80
+ console.error('slipstream reader: request failed', err);
81
+ if (!res.headersSent)
82
+ send(res, 500, 'internal error');
83
+ else
84
+ res.destroy();
85
+ });
86
+ });
87
+ // Bind loopback only; a non-loopback bind must be impossible, not configurable.
88
+ // Reject the returned promise on a listen error (e.g. EADDRINUSE) rather than
89
+ // letting it crash the process with no handler.
90
+ try {
91
+ await new Promise((resolve, reject) => {
92
+ server.on('error', reject);
93
+ server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
94
+ });
95
+ }
96
+ catch (err) {
97
+ // Do not leak the clip worker if the listener never bound.
98
+ await admission.close();
99
+ await Promise.all([clipService.close(), interfaceService.close()]);
100
+ throw err;
101
+ }
102
+ const port = server.address().port;
103
+ const hostPort = `127.0.0.1:${port}`;
104
+ const url = `http://${hostPort}`;
105
+ let descriptorPath;
106
+ try {
107
+ descriptorPath = await publishDescriptor(opts.storeDir, { url, token });
108
+ }
109
+ catch (err) {
110
+ // Do not leak the listener or the clip worker if we cannot publish.
111
+ await new Promise((resolve) => server.close(() => resolve()));
112
+ await admission.close();
113
+ await Promise.all([clipService.close(), interfaceService.close()]);
114
+ throw err;
115
+ }
116
+ async function handle(req, res) {
117
+ if (req.method !== 'GET') {
118
+ send(res, 405, 'method not allowed', { allow: 'GET' });
119
+ return;
120
+ }
121
+ if (!checkHostOrigin(req.headers, hostPort, req.headersDistinct.host?.length ?? 0)) {
122
+ send(res, 403, 'forbidden');
123
+ return;
124
+ }
125
+ if (!checkAuth(req.headers.authorization, token)) {
126
+ send(res, 401, 'unauthorized');
127
+ return;
128
+ }
129
+ const { pathname, searchParams } = new URL(req.url ?? '/', url);
130
+ if (pathname === '/v1/sessions') {
131
+ const sessions = await listSessions(opts.storeDir);
132
+ // A registry-known session's authoritative durable high-water is its
133
+ // boundary, not the disk-derived value (a written-but-not-yet-committed
134
+ // record would otherwise advertise H+1 while /events still uses H; a
135
+ // reserved-but-not-yet-active session reads 0 until capture commits).
136
+ sendJson(res, 200, sessions.map((s) => {
137
+ // A removed session advertises no history: report durable_seq 0 regardless
138
+ // of any stale registry entry (a prior detach left one frozen at its final
139
+ // seq, and a delete does not clear it). Otherwise trust the registry over
140
+ // disk for every session it knows (see the reserved/active-window note).
141
+ const runtime = registry.get(s.id);
142
+ const durableSeq = s.removed ? 0n : (runtime ? runtime.boundary.current() : s.durableSeq);
143
+ return { id: s.id, durable_seq: durableSeq.toString(), removed: s.removed };
144
+ }));
145
+ return;
146
+ }
147
+ const eventsMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/events$/);
148
+ if (eventsMatch) {
149
+ await handleEvents(req, res, decodeURIComponent(eventsMatch[1]), searchParams);
150
+ return;
151
+ }
152
+ const clipsMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/changes\/([0-9]+)\/clips$/);
153
+ if (clipsMatch) {
154
+ await handleClips(res, decodeURIComponent(clipsMatch[1]), clipsMatch[2], opts.projectionTrace ? req.url : undefined);
155
+ return;
156
+ }
157
+ const interfacesMatch = pathname.match(/^\/v1\/sessions\/([^/]+)\/interfaces$/);
158
+ if (interfacesMatch) {
159
+ let id;
160
+ try {
161
+ id = decodeURIComponent(interfacesMatch[1]);
162
+ }
163
+ catch {
164
+ send(res, 400, 'invalid session id');
165
+ return;
166
+ }
167
+ await handleInterfaces(req, res, id, searchParams);
168
+ return;
169
+ }
170
+ const blobMatch = pathname.match(/^\/v1\/blobs\/sha256\/([^/]+)$/);
171
+ if (blobMatch) {
172
+ const hex = blobMatch[1];
173
+ if (!isValidHex(hex)) {
174
+ send(res, 400, 'invalid hash');
175
+ return;
176
+ }
177
+ const path = blobPath(opts.storeDir, hex);
178
+ // O_NOFOLLOW: a symlink planted at a valid CAS path must not serve its
179
+ // (out-of-store) target. Size and bytes both come from the one opened fd,
180
+ // closing the stat/open TOCTOU. ELOOP (symlink) maps to 404, like ENOENT.
181
+ let handle;
182
+ try {
183
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
184
+ }
185
+ catch (err) {
186
+ const code = err.code;
187
+ if (code === 'ENOENT' || code === 'ELOOP') {
188
+ send(res, 404, 'not found');
189
+ return;
190
+ }
191
+ throw err;
192
+ }
193
+ let size;
194
+ try {
195
+ size = (await handle.stat()).size;
196
+ }
197
+ catch (err) {
198
+ await handle.close();
199
+ throw err;
200
+ }
201
+ if (res.destroyed) {
202
+ await handle.close();
203
+ return;
204
+ }
205
+ res.writeHead(200, {
206
+ 'cache-control': 'no-store',
207
+ 'content-type': 'application/octet-stream',
208
+ 'content-length': String(size),
209
+ });
210
+ const stream = handle.createReadStream(); // autoClose closes the fd
211
+ stream.on('error', () => res.destroy());
212
+ res.on('close', () => stream.destroy());
213
+ stream.pipe(res);
214
+ return;
215
+ }
216
+ const projectionSchemaMatch = pathname.match(/^\/v1\/schemas\/projections\/([^/]+)$/);
217
+ if (projectionSchemaMatch) {
218
+ let version;
219
+ try {
220
+ version = decodeURIComponent(projectionSchemaMatch[1]);
221
+ }
222
+ catch {
223
+ send(res, 400, 'invalid version');
224
+ return;
225
+ }
226
+ const bytes = await projectionSchemaBytes(version);
227
+ if (!bytes) {
228
+ send(res, 404, 'not found');
229
+ return;
230
+ }
231
+ send(res, 200, bytes, { 'content-type': 'application/json; charset=utf-8' });
232
+ return;
233
+ }
234
+ const schemaMatch = pathname.match(/^\/v1\/schemas\/([^/]+)$/);
235
+ if (schemaMatch) {
236
+ let type;
237
+ try {
238
+ type = decodeURIComponent(schemaMatch[1]);
239
+ }
240
+ catch {
241
+ send(res, 400, 'invalid type');
242
+ return;
243
+ }
244
+ if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(type)) {
245
+ send(res, 400, 'invalid type');
246
+ return;
247
+ }
248
+ const bytes = await schemaBytes(type);
249
+ if (!bytes) {
250
+ send(res, 404, 'not found');
251
+ return;
252
+ }
253
+ send(res, 200, bytes, { 'content-type': 'application/json; charset=utf-8' });
254
+ return;
255
+ }
256
+ send(res, 404, 'not found');
257
+ }
258
+ async function boundaryFor(id) {
259
+ // The registry is authoritative for every session the daemon has touched
260
+ // this run. Disk high-water is the fallback ONLY for daemon-unknown sessions
261
+ // (retained from a prior run, immutable now) — never for one mid-write.
262
+ const runtime = registry.get(id);
263
+ if (runtime)
264
+ return runtime.boundary;
265
+ return staticBoundary(await onDiskHighWater(sessionLogPath(opts.storeDir, id)));
266
+ }
267
+ async function handleClips(res, id, seqStr, traceRouteKey) {
268
+ if (!isValidSessionId(id)) {
269
+ send(res, 404, 'not found');
270
+ return;
271
+ }
272
+ if (await readTombstone(opts.storeDir, id)) {
273
+ send(res, 410, 'gone');
274
+ return;
275
+ }
276
+ const logPath = sessionLogPath(opts.storeDir, id);
277
+ try {
278
+ await access(logPath);
279
+ }
280
+ catch {
281
+ if (await readTombstone(opts.storeDir, id)) {
282
+ send(res, 410, 'gone');
283
+ return;
284
+ }
285
+ send(res, 404, 'not found');
286
+ return;
287
+ }
288
+ // Reject a non-canonical seq (leading zeros): the route regex admits "01",
289
+ // but that resolves via BigInt to change 1 and would then be stamped verbatim
290
+ // into `change_seq`, violating the published `^[1-9][0-9]*$` pattern.
291
+ if (!/^[1-9][0-9]*$/.test(seqStr)) {
292
+ send(res, 404, 'not found');
293
+ return;
294
+ }
295
+ // The change must be within the durable high-water; a seq at or beyond it names
296
+ // no committed change (the same boundary the events feed serves).
297
+ const seq = BigInt(seqStr);
298
+ const H = (await boundaryFor(id)).current();
299
+ if (seq < 1n || seq > H) {
300
+ // A delete racing this request can publish the tombstone and freeze the
301
+ // boundary to 0 after the tombstone check above but before this read: any
302
+ // positive seq then looks "beyond" H. That is removal, not an unknown
303
+ // change: 410 if the marker is now durable, else the genuine 404.
304
+ if (await readTombstone(opts.storeDir, id)) {
305
+ send(res, 410, 'gone');
306
+ return;
307
+ }
308
+ send(res, 404, 'not found');
309
+ return;
310
+ }
311
+ // Read exactly the record at `seq`. openLogCursor positions after seq-1 and
312
+ // readThrough(seq) yields that single event; corruption in the scanned prefix
313
+ // throws LogCorruptError and surfaces as a clean 500 (never a faked projection).
314
+ let ev;
315
+ let cursor;
316
+ try {
317
+ cursor = await openLogCursor(logPath, seq - 1n);
318
+ const batch = await cursor.readThrough(seq);
319
+ ev = batch.find((e) => e.seq === seq);
320
+ }
321
+ finally {
322
+ await cursor?.close();
323
+ }
324
+ // A seq within the durable boundary MUST have a record; its absence means the
325
+ // log is short of its declared high-water — corruption, not an unknown change.
326
+ // Reserve 404 for an existing record that simply isn't a file.changed event.
327
+ if (!ev)
328
+ throw new LogCorruptError('record missing within durable boundary');
329
+ if (ev.type !== 'slipstream.file.changed.v1') {
330
+ send(res, 404, 'not found');
331
+ return;
332
+ }
333
+ const before = parseClipSnapshot(ev.data.before);
334
+ const after = parseClipSnapshot(ev.data.after);
335
+ const path = ev.data.path;
336
+ if (!before || !after || typeof path !== 'string') {
337
+ throw new LogCorruptError('file.changed record missing a path or before/after snapshot');
338
+ }
339
+ // The projection itself carries availability: GC'd blobs yield an `unavailable`
340
+ // status with a reason, served as a normal 200. The HTTP status reports whether
341
+ // the request succeeded, not whether the content is still retained.
342
+ const projection = await clipService.get({ changeSeq: seqStr, before, after,
343
+ language: languageForPath(path), traceRouteKey });
344
+ sendJson(res, 200, projection, opts.projectionTrace && traceRouteKey
345
+ ? { observer: opts.projectionTrace, routeKey: traceRouteKey, scope: 'clip' } : undefined);
346
+ }
347
+ async function handleInterfaces(req, res, id, params) {
348
+ if (!isValidSessionId(id)) {
349
+ send(res, 404, 'not found');
350
+ return;
351
+ }
352
+ if (await readTombstone(opts.storeDir, id)) {
353
+ send(res, 410, 'gone');
354
+ return;
355
+ }
356
+ const logPath = sessionLogPath(opts.storeDir, id);
357
+ try {
358
+ await access(logPath);
359
+ }
360
+ catch {
361
+ if (await readTombstone(opts.storeDir, id)) {
362
+ send(res, 410, 'gone');
363
+ return;
364
+ }
365
+ send(res, 404, 'not found');
366
+ return;
367
+ }
368
+ const allowed = new Set(['before_seq', 'after_seq', 'limit', 'path_prefix', 'after_path', 'include_identical']);
369
+ const values = new Map();
370
+ for (const [key, value] of params) {
371
+ if (!allowed.has(key) || values.has(key)) {
372
+ send(res, 400, 'invalid request');
373
+ return;
374
+ }
375
+ values.set(key, value);
376
+ }
377
+ const beforeText = values.get('before_seq');
378
+ const afterText = values.get('after_seq');
379
+ if (beforeText === undefined || afterText === undefined || !/^(0|[1-9][0-9]*)$/.test(beforeText)
380
+ || !/^(0|[1-9][0-9]*)$/.test(afterText)) {
381
+ send(res, 400, 'invalid cutoffs');
382
+ return;
383
+ }
384
+ const beforeSeq = BigInt(beforeText), afterSeq = BigInt(afterText);
385
+ const limitText = values.get('limit') ?? '16';
386
+ const limit = Number(limitText);
387
+ const prefix = values.get('path_prefix') ?? '';
388
+ const identical = values.get('include_identical');
389
+ if (beforeSeq > afterSeq || !/^[1-9][0-9]*$/.test(limitText) || !Number.isSafeInteger(limit)
390
+ || limit < 1 || limit > 16 || prefix.startsWith('/') || prefix.includes('\0')
391
+ || prefix.split('/').includes('..') || (identical !== undefined && identical !== 'true')) {
392
+ send(res, 400, 'invalid request');
393
+ return;
394
+ }
395
+ const H = (await boundaryFor(id)).current();
396
+ if (afterSeq > H) {
397
+ if (await readTombstone(opts.storeDir, id)) {
398
+ send(res, 410, 'gone');
399
+ return;
400
+ }
401
+ send(res, 409, 'cursor beyond durable high-water', { [DURABLE_SEQ_HEADER]: H.toString() });
402
+ return;
403
+ }
404
+ const abort = new AbortController();
405
+ const onDisconnect = () => abort.abort();
406
+ res.on('close', onDisconnect);
407
+ res.on('error', onDisconnect);
408
+ try {
409
+ const page = await interfaceService.get({ sessionId: id, logPath, durableSeq: H,
410
+ beforeSeq, afterSeq, pathPrefix: prefix, afterPath: values.get('after_path') ?? null,
411
+ includeIdentical: identical === 'true', limit, signal: abort.signal,
412
+ traceRouteKey: opts.projectionTrace ? req.url : undefined });
413
+ if (await readTombstone(opts.storeDir, id)) {
414
+ send(res, 410, 'gone');
415
+ return;
416
+ }
417
+ if (!res.destroyed)
418
+ sendJson(res, 200, page, opts.projectionTrace && req.url
419
+ ? { observer: opts.projectionTrace, routeKey: req.url, scope: 'interface' } : undefined);
420
+ }
421
+ catch (error) {
422
+ if (await readTombstone(opts.storeDir, id)) {
423
+ send(res, 410, 'gone');
424
+ return;
425
+ }
426
+ throw error;
427
+ }
428
+ finally {
429
+ res.off('close', onDisconnect);
430
+ res.off('error', onDisconnect);
431
+ }
432
+ }
433
+ async function handleEvents(req, res, id, params) {
434
+ if (!isValidSessionId(id)) {
435
+ send(res, 404, 'not found');
436
+ return;
437
+ }
438
+ if (await readTombstone(opts.storeDir, id)) {
439
+ send(res, 410, 'gone');
440
+ return;
441
+ }
442
+ const logPath = sessionLogPath(opts.storeDir, id);
443
+ try {
444
+ await access(logPath);
445
+ }
446
+ catch {
447
+ // A delete that removed the history after the tombstone check above still owes
448
+ // this request a 410, not a 404: re-check the marker before reporting missing.
449
+ if (await readTombstone(opts.storeDir, id)) {
450
+ send(res, 410, 'gone');
451
+ return;
452
+ }
453
+ send(res, 404, 'not found');
454
+ return;
455
+ }
456
+ // Select the effective RAW cursor first — for follow, Last-Event-ID overrides
457
+ // the `after` param — THEN parse once, so a malformed `after` that a valid
458
+ // Last-Event-ID overrides does not spuriously 400.
459
+ const follow = isFollow(params);
460
+ let rawCursor = params.get('after') ?? undefined;
461
+ if (follow) {
462
+ const leiRaw = req.headers['last-event-id'];
463
+ if (typeof leiRaw === 'string')
464
+ rawCursor = leiRaw;
465
+ }
466
+ const effectiveAfter = parseCursor(rawCursor);
467
+ if (effectiveAfter === null) {
468
+ send(res, 400, 'invalid cursor');
469
+ return;
470
+ }
471
+ const boundary = await boundaryFor(id);
472
+ const H = boundary.current();
473
+ if (effectiveAfter > H) {
474
+ // A delete that froze this session's boundary to 0 makes any positive cursor
475
+ // look "beyond" it. That is removal, not a stale cursor: 410 if the marker is
476
+ // now durable, else the genuine 409.
477
+ if (await readTombstone(opts.storeDir, id)) {
478
+ send(res, 410, 'gone');
479
+ return;
480
+ }
481
+ send(res, 409, 'cursor beyond durable high-water', { [DURABLE_SEQ_HEADER]: H.toString() });
482
+ return;
483
+ }
484
+ if (!follow) {
485
+ const ac = new AbortController();
486
+ const onDisconnect = () => ac.abort();
487
+ res.on('close', onDisconnect);
488
+ res.on('error', onDisconnect);
489
+ followers.add(ac);
490
+ let cursor;
491
+ try {
492
+ if (closing || res.destroyed)
493
+ return;
494
+ // Validate in bounded batches before headers to preserve a clean 500 on
495
+ // corruption anywhere in (after, H]. Append-only history is then replayed
496
+ // in a second bounded pass; no whole-log array is retained.
497
+ cursor = await openLogCursor(logPath, effectiveAfter);
498
+ for (let seq = effectiveAfter; seq < H;) {
499
+ if (ac.signal.aborted)
500
+ return;
501
+ const batch = await cursor.readThrough(H);
502
+ if (!batch.length)
503
+ throw new LogCorruptError('disk short of durable boundary');
504
+ seq = batch[batch.length - 1].seq;
505
+ }
506
+ await cursor.close();
507
+ cursor = undefined;
508
+ cursor = await openLogCursor(logPath, effectiveAfter);
509
+ if (ac.signal.aborted)
510
+ return;
511
+ res.writeHead(200, {
512
+ 'cache-control': 'no-store',
513
+ 'content-type': 'application/x-ndjson; charset=utf-8',
514
+ [DURABLE_SEQ_HEADER]: H.toString(),
515
+ [FOLD_CONTRACT_HEADER]: DISPLAY_FOLD_CONTRACT,
516
+ });
517
+ for (let seq = effectiveAfter; seq < H;) {
518
+ if (ac.signal.aborted)
519
+ return;
520
+ const batch = await cursor.readThrough(H);
521
+ if (!batch.length)
522
+ throw new LogCorruptError('disk short of durable boundary');
523
+ for (const ev of batch)
524
+ await writeBackpressured(res, ev.raw + '\n', ac.signal);
525
+ seq = batch[batch.length - 1].seq;
526
+ }
527
+ res.end();
528
+ }
529
+ finally {
530
+ await cursor?.close();
531
+ followers.delete(ac);
532
+ res.off('close', onDisconnect);
533
+ res.off('error', onDisconnect);
534
+ if (ac.signal.aborted && !res.writableEnded)
535
+ res.destroy();
536
+ }
537
+ return;
538
+ }
539
+ const ac = new AbortController();
540
+ const onDisconnect = () => ac.abort();
541
+ res.on('close', onDisconnect);
542
+ res.on('error', onDisconnect);
543
+ followers.add(ac);
544
+ // A retained session (known only from disk) has no registry entry, so a delete's
545
+ // freeze(id, 0n) would have nothing to abort. Install a static entry first so
546
+ // the follower we are about to register is reachable by that freeze.
547
+ registry.installIfAbsent(id, boundary);
548
+ // Join the registry's per-session abort set and re-resolve the boundary from
549
+ // the SAME entry, synchronously with no await between: a session transition
550
+ // (activate/freeze) has therefore either already happened (so we read its new
551
+ // boundary here) or has not yet (so it will find us in the set and abort us).
552
+ // A follower is never left pinned to a stale boundary while unregistered.
553
+ registry.addFollower(id, ac);
554
+ const followBoundary = registry.get(id)?.boundary ?? boundary;
555
+ // Final tombstone re-check, AFTER registration: a delete that raced this follower
556
+ // either aborted it via the freeze above (ac now aborted) or made the tombstone
557
+ // durable before we registered (readTombstone sees it). Either way, 410 before
558
+ // any 200 headers rather than streaming a log that is being deleted. An
559
+ // already-started stream cannot be turned into a 410; the client reconnects and
560
+ // this top-of-handler check (or line 191) then 410s it.
561
+ const removedNow = await readTombstone(opts.storeDir, id);
562
+ if (removedNow || ac.signal.aborted) {
563
+ followers.delete(ac);
564
+ registry.removeFollower(id, ac);
565
+ res.off('close', onDisconnect);
566
+ res.off('error', onDisconnect);
567
+ // This recheck runs before writeHead(200), so headers are never sent yet: a
568
+ // durable tombstone becomes a clean 410; a bare abort just tears the socket.
569
+ if (removedNow)
570
+ send(res, 410, 'gone');
571
+ else
572
+ res.destroy();
573
+ return;
574
+ }
575
+ res.writeHead(200, {
576
+ 'cache-control': 'no-store',
577
+ 'content-type': 'text/event-stream; charset=utf-8',
578
+ [FOLD_CONTRACT_HEADER]: DISPLAY_FOLD_CONTRACT,
579
+ });
580
+ res.flushHeaders();
581
+ // Serialize every write (events and heartbeats) through the bounded path so
582
+ // an idle client that stops reading eventually hits DRAIN_DEADLINE_MS and
583
+ // is aborted, and so heartbeats never interleave with an in-flight event write.
584
+ let tail = Promise.resolve();
585
+ const write = (chunk) => {
586
+ tail = tail.then(() => writeBackpressured(res, chunk, ac.signal));
587
+ return tail;
588
+ };
589
+ let cursor;
590
+ let heartbeat;
591
+ try {
592
+ // `closing` guards the connect-during-shutdown race: the add→guard span
593
+ // below has no await, so a `closing` flag set in close() before its abort
594
+ // loop is always visible here, and this request tears down via finally
595
+ // instead of blocking server.close() forever in waitForAdvance.
596
+ if (closing || ac.signal.aborted || res.destroyed)
597
+ return;
598
+ cursor = await openLogCursor(logPath, effectiveAfter);
599
+ if (ac.signal.aborted || res.destroyed)
600
+ return;
601
+ heartbeat = setInterval(() => { write(': heartbeat\n\n').catch(() => ac.abort()); }, SSE_HEARTBEAT_MS);
602
+ let cur = effectiveAfter;
603
+ for (;;) {
604
+ const target = followBoundary.current();
605
+ if (target > cur) {
606
+ while (cur < target) {
607
+ if (ac.signal.aborted)
608
+ return;
609
+ const batch = await cursor.readThrough(target);
610
+ // A nonempty short batch is normal; an empty batch below H is not.
611
+ if (!batch.length)
612
+ throw new LogCorruptError('disk short of durable boundary');
613
+ for (const ev of batch) {
614
+ await write(`id: ${ev.seq}\nevent: slipstream\ndata: ${ev.raw}\n\n`);
615
+ cur = ev.seq;
616
+ }
617
+ }
618
+ }
619
+ await followBoundary.waitForAdvance(cur, ac.signal); // rejects on abort → exits loop
620
+ }
621
+ }
622
+ catch (err) {
623
+ // Corruption must not be silent (honesty). An abort/drain-timeout stays silent.
624
+ if (err instanceof LogCorruptError)
625
+ console.error('slipstream reader: corruption in SSE follow', err);
626
+ }
627
+ finally {
628
+ if (heartbeat)
629
+ clearInterval(heartbeat);
630
+ if (cursor)
631
+ await cursor.close();
632
+ followers.delete(ac);
633
+ registry.removeFollower(id, ac);
634
+ if (!res.writableEnded)
635
+ res.destroy();
636
+ }
637
+ }
638
+ return {
639
+ url, port, token, descriptorPath,
640
+ close: async () => {
641
+ closing = true;
642
+ for (const ac of followers)
643
+ ac.abort();
644
+ await admission.close();
645
+ await Promise.all([clipService.close(), interfaceService.close()]);
646
+ await new Promise((resolve) => server.close(() => resolve()));
647
+ // Remove the descriptor this server published; a dead reader must not leave
648
+ // a stale pointer behind. ENOENT (already gone) is fine.
649
+ await unlink(descriptorPath).catch((err) => {
650
+ if (err.code !== 'ENOENT')
651
+ throw err;
652
+ });
653
+ },
654
+ };
655
+ }