@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,866 @@
1
+ import { access, readdir, realpath } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { isAbsolute, join, relative, sep } from 'node:path';
4
+ import { createCas } from "./cas.js";
5
+ import { createReader, DEFAULT_MAX_BYTES } from "./reader.js";
6
+ import { createLog } from "./log.js";
7
+ import { createEngine } from "./engine.js";
8
+ import { createHealth } from "./health.js";
9
+ import { acquireSessionLock } from "./lock.js";
10
+ import { recoverSession } from "./recovery.js";
11
+ import { StorageError, assertOwnerOnly, mkdirpDurable } from "./storage.js";
12
+ import { snapshotsEqual } from "./snapshot.js";
13
+ import { isValidSessionId } from "./store-reader.js";
14
+ import { createPlatform } from "./platform.js";
15
+ import { createAttributionProducer, DEFAULT_ENRICHMENT_POLICY, } from "./attribution-producer.js";
16
+ import { normalizeAsk, questionBody, questionResult, readQuestionContext, QuestionError, QUESTION_TTL_MS, QUESTION_LIMIT, assertAnswerText, answerResult } from "./questions.js";
17
+ import { createCoverageRunner } from "./transcript/runner.js";
18
+ import { nodeDiscoveryIO, nodeTranscriptFileIO } from "./transcript/fs-io.js";
19
+ /** How often the transcript watcher re-runs discovery and reads new records. */
20
+ export const DEFAULT_TRANSCRIPT_POLL_MS = 2000;
21
+ /** A `request_id` was reused with a different title — a caller mistake, not a
22
+ * retry, so it never commits a second declaration. */
23
+ export class InvalidTitleError extends Error {
24
+ code = 'INVALID_TITLE';
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = 'InvalidTitleError';
28
+ }
29
+ }
30
+ const defaultDependencies = { createLog, platform: createPlatform(), enumerate, readQuestionContext };
31
+ /** A relative path escapes its base only via a leading `..` segment (or when it
32
+ * comes back absolute); a filename that merely starts with `..`, like
33
+ * `..notes.ts`, stays inside. */
34
+ function escapesBase(rel) {
35
+ return isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`);
36
+ }
37
+ function isUnder(child, parent) {
38
+ return !escapesBase(relative(parent, child));
39
+ }
40
+ export async function startCapture(opts, dependencies = {}) {
41
+ const deps = { ...defaultDependencies, ...dependencies };
42
+ if (opts.sessionId !== undefined) {
43
+ if (opts.resumeSessionId !== undefined) {
44
+ throw new Error('sessionId and resumeSessionId are mutually exclusive');
45
+ }
46
+ if (!isValidSessionId(opts.sessionId)) {
47
+ throw new Error(`sessionId is not a valid session UUID: ${opts.sessionId}`);
48
+ }
49
+ }
50
+ // The native watcher reports realpaths; resolve symlinks in the root (e.g.
51
+ // macOS /var -> /private/var) so relative-path math against events matches.
52
+ const root = await realpath(opts.root);
53
+ await mkdirpDurable(opts.storeDir);
54
+ const storeDir = await realpath(opts.storeDir);
55
+ await assertOwnerOnly(storeDir, 'dir');
56
+ if (isUnder(root, storeDir)) {
57
+ throw new Error('store directory must not equal or contain the watched root');
58
+ }
59
+ const resuming = opts.resumeSessionId !== undefined;
60
+ const sessionId = opts.resumeSessionId ?? opts.sessionId ?? randomUUID();
61
+ const blobsDir = join(storeDir, 'blobs');
62
+ const sessionDir = join(storeDir, 'sessions', sessionId);
63
+ await mkdirpDurable(blobsDir);
64
+ await mkdirpDurable(sessionDir);
65
+ await assertOwnerOnly(sessionDir, 'dir'); // blobsDir perms are checked by createCas
66
+ const logPath = join(sessionDir, 'events.jsonl');
67
+ const cas = await createCas(blobsDir);
68
+ // Durable task boundaries (Stage 3). `currentTaskId` is the latest committed
69
+ // declaration in shared append order — the grouping hint stamped onto changes.
70
+ // The two maps are the idempotency index keyed by `request_id`: `committedTasks`
71
+ // replays a settled result, `inflightTasks` coalesces concurrent duplicates.
72
+ // Both the pointer and the committed index are rebuilt from the log on recovery.
73
+ const now = opts.now ?? Date.now;
74
+ const committedQuestions = new Map();
75
+ const inflightQuestions = new Map();
76
+ const reservedQuestions = new Set();
77
+ // Questions not yet attempted. Removed once dispatch is attempted (durably
78
+ // consumed either way) so eligibility counting and claim lookup scan only
79
+ // outstanding questions, not every question ever queued by a long-lived capture.
80
+ const unattemptedQuestions = new Map();
81
+ // Kept for the capture's lifetime: a question stays answerable after it
82
+ // leaves the unattempted queue or passes its claim TTL.
83
+ const questionsById = new Map();
84
+ const attemptSeqs = new Map();
85
+ const committedAnswers = new Map();
86
+ const inflightAnswers = new Map();
87
+ let currentTaskId;
88
+ const committedTasks = new Map();
89
+ const inflightTasks = new Map();
90
+ // The attribution producer, constructed once the append path exists below. Its
91
+ // `noteCommitted` is fed from the log's single in-order commit hook, so every
92
+ // durably-committed change/evidence/policy reaches it exactly once, in order.
93
+ let producer;
94
+ const seedTaskState = (rec) => {
95
+ committedQuestions.clear();
96
+ unattemptedQuestions.clear();
97
+ for (const event of rec.questions) {
98
+ committedQuestions.set(event.data.request_id, { body: questionBody(event.data), result: questionResult(event) });
99
+ unattemptedQuestions.set(event.data.question_id, event);
100
+ }
101
+ for (const event of rec.questionAttempts)
102
+ unattemptedQuestions.delete(event.data.question_id);
103
+ questionsById.clear();
104
+ attemptSeqs.clear();
105
+ committedAnswers.clear();
106
+ for (const event of rec.questions)
107
+ questionsById.set(event.data.question_id, event.data);
108
+ for (const event of rec.questionAttempts)
109
+ attemptSeqs.set(event.data.question_id, event.seq);
110
+ for (const event of rec.questionAnswers)
111
+ committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
112
+ currentTaskId = rec.currentTaskId;
113
+ committedTasks.clear();
114
+ for (const [requestId, decl] of rec.taskDeclarations) {
115
+ committedTasks.set(requestId, {
116
+ title: decl.title,
117
+ result: Object.freeze({ session_id: sessionId, task_id: decl.taskId, event_id: decl.seq, seq: decl.seq }),
118
+ });
119
+ }
120
+ };
121
+ // Stamp grouping metadata inside the log's serialized sequencing section, the
122
+ // single place with a total order over appends: a change is stamped with the
123
+ // task committed before it, and the pointer advances only after a declaration
124
+ // is durable. A newly emitted change carries no attribution — "no result yet"
125
+ // means PENDING, and the producer appends a revisable result asynchronously
126
+ // once the change is durable (never a fabricated inline `unknown` seed).
127
+ const taskSequencer = {
128
+ enrich: (input) => {
129
+ if (input.type !== 'slipstream.file.changed.v1')
130
+ return input;
131
+ return {
132
+ ...input,
133
+ data: {
134
+ ...input.data,
135
+ ...(currentTaskId !== undefined ? { task_hint_id: currentTaskId } : {}),
136
+ },
137
+ };
138
+ },
139
+ onCommitted: (event) => {
140
+ if (event.type === 'slipstream.task.started.v1')
141
+ currentTaskId = event.data.task_id;
142
+ // Route every durable commit to attribution in the same total order.
143
+ if (event.type === 'slipstream.question.queued.v1') {
144
+ unattemptedQuestions.set(event.data.question_id, event);
145
+ questionsById.set(event.data.question_id, event.data);
146
+ }
147
+ if (event.type === 'slipstream.question.dispatch_attempted.v1') {
148
+ unattemptedQuestions.delete(event.data.question_id);
149
+ attemptSeqs.set(event.data.question_id, event.seq);
150
+ }
151
+ if (event.type === 'slipstream.question.answered.v1') {
152
+ committedAnswers.set(event.data.question_id, { text: event.data.text, result: answerResult(event) });
153
+ }
154
+ if (event.type !== 'slipstream.question.queued.v1' && event.type !== 'slipstream.question.dispatch_attempted.v1'
155
+ && event.type !== 'slipstream.question.answered.v1')
156
+ producer?.noteCommitted(event);
157
+ },
158
+ };
159
+ // Set if the lock heartbeat finds another process has taken ownership: capture
160
+ // must stop acknowledging and must never reopen the log (the new owner has it).
161
+ // Declared before the lock is acquired so a compromise during the startup
162
+ // recovery/open window still flips it; `healthRef` is filled in once health
163
+ // exists, so the failing state is disclosed if the loss happens later.
164
+ let surrendered = false;
165
+ let healthRef;
166
+ const lock = await acquireSessionLock(sessionDir, {
167
+ onCompromised: (reason) => {
168
+ if (surrendered)
169
+ return;
170
+ surrendered = true;
171
+ console.error(`slipstream: session ownership lost: ${reason}`);
172
+ healthRef?.markFailing({ code: 'ELOCKLOST', operation: 'lock', detected_at_ms: Date.now() });
173
+ opts.onCompromised?.(reason);
174
+ },
175
+ });
176
+ if (!resuming && opts.sessionId !== undefined) {
177
+ // A fresh caller-supplied id must name a session that does not exist yet:
178
+ // opening an existing log in append mode restarts sequencing at zero and
179
+ // duplicates seqs. Resuming existing history requires resumeSessionId. The
180
+ // check runs while we hold the session lock so a concurrent caller cannot
181
+ // create then release the same id between the check and log creation.
182
+ const logExists = await access(logPath).then(() => true, () => false);
183
+ if (logExists) {
184
+ await lock.release();
185
+ throw new Error(`cannot start a fresh session ${sessionId}: a log already exists at ${logPath}; use resumeSessionId to continue it`);
186
+ }
187
+ }
188
+ let recovered;
189
+ if (resuming) {
190
+ try {
191
+ await assertOwnerOnly(logPath, 'file').catch((err) => {
192
+ if (err.code !== 'ENOENT')
193
+ throw err;
194
+ });
195
+ recovered = await recoverSession(logPath, sessionId, cas);
196
+ if (recovered.root === undefined) {
197
+ throw new Error(`cannot resume session ${sessionId}: no existing session.started record`);
198
+ }
199
+ if (recovered.root !== root) {
200
+ throw new Error(`session ${sessionId} is bound to ${recovered.root}, not ${root}`);
201
+ }
202
+ seedTaskState(recovered); // rebuild the dedup index + current task before appends
203
+ }
204
+ catch (err) {
205
+ await lock.release(); // a failed resume must not leave the session locked
206
+ throw err;
207
+ }
208
+ }
209
+ const maxBytes = recovered?.maxBytes ?? opts.maxBytes ?? DEFAULT_MAX_BYTES;
210
+ const enrichmentPolicy = opts.enrichmentPolicy ?? DEFAULT_ENRICHMENT_POLICY;
211
+ const health = createHealth(recovered?.recoveredThroughSeq ?? 0n);
212
+ healthRef = health; // the compromise callback can now disclose a lost lock
213
+ let underlying;
214
+ try {
215
+ underlying = await deps.createLog({
216
+ filePath: logPath,
217
+ sessionId,
218
+ startSeq: recovered?.recoveredThroughSeq,
219
+ sequencer: taskSequencer,
220
+ });
221
+ }
222
+ catch (err) {
223
+ await lock.release();
224
+ throw err;
225
+ }
226
+ let stopped = false;
227
+ let supervising = false;
228
+ // The active recovery-supervisor loop, so shutdown can await an in-flight
229
+ // recovery before releasing the lock (never append after another process could
230
+ // take ownership). Resolved when no recovery is running.
231
+ let supervisorLoop = Promise.resolve();
232
+ // One outage episode id, minted when acknowledgment first stops and reused
233
+ // across recovery attempts so the disclosing storage gap is written once (Q6).
234
+ let outageEpisodeId;
235
+ // Suspend capture on a storage fault: mark health failing (preserving the
236
+ // first failure of the outage) and kick the recovery supervisor. Used by both
237
+ // the append path and the reader — a CAS publish failure must suspend capture,
238
+ // never be laundered into a fabricated `unavailable` snapshot.
239
+ const enterFailing = (failure) => {
240
+ if (surrendered)
241
+ return; // ownership lost: do not fight the new owner
242
+ outageEpisodeId ??= randomUUID();
243
+ const alreadySupervising = supervising;
244
+ health.markFailing(failure);
245
+ // Capture the loop's promise only when starting a fresh one, so `stop()` can
246
+ // await the recovery in progress. runSupervisor sets `supervising` in its
247
+ // synchronous prefix, so a concurrent kick sees it and no-ops.
248
+ if (!alreadySupervising)
249
+ supervisorLoop = runSupervisor();
250
+ };
251
+ async function appendEvent(input) {
252
+ if (surrendered) {
253
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
254
+ }
255
+ try {
256
+ const event = await underlying.append(input);
257
+ health.setDurableSeq(underlying.durableSeq());
258
+ return event;
259
+ }
260
+ catch (err) {
261
+ if (err instanceof StorageError) {
262
+ enterFailing({ code: err.code, operation: err.operation, detected_at_ms: Date.now() });
263
+ }
264
+ throw err;
265
+ }
266
+ }
267
+ ;
268
+ // Wrap the reader so a CAS publish failure (a storage fault, not an unreadable
269
+ // source file) suspends capture and is disclosed, rather than recorded as a
270
+ // change whose bytes we could not durably store.
271
+ const rawReader = createReader({ root, cas, maxBytes });
272
+ const reader = {
273
+ read: async (rel) => {
274
+ try {
275
+ return await rawReader.read(rel);
276
+ }
277
+ catch (err) {
278
+ if (err instanceof StorageError) {
279
+ enterFailing({ code: err.code, operation: err.operation, detected_at_ms: Date.now() });
280
+ }
281
+ throw err;
282
+ }
283
+ },
284
+ };
285
+ const engine = createEngine({ reader, log: { append: appendEvent } });
286
+ // Attribution runs off the same append path. Timers are unref'd so a pending
287
+ // grace window never keeps the process alive; the clock is real wall time.
288
+ producer = createAttributionProducer({
289
+ appendEvent,
290
+ now: Date.now,
291
+ setTimer: (delayMs, fn) => {
292
+ const t = setTimeout(fn, delayMs);
293
+ t.unref?.();
294
+ return t;
295
+ },
296
+ clearTimer: (h) => clearTimeout(h),
297
+ });
298
+ // Never capture the store or version-control metadata. If the store lives
299
+ // inside the watched root, excluding it is what stops the watcher from
300
+ // observing its own output.
301
+ const excluded = [storeDir, join(root, '.git')];
302
+ const isExcluded = (abs) => excluded.some((e) => isUnder(abs, e));
303
+ let live = false;
304
+ const buffer = [];
305
+ const onObservation = (abs, observedAtMs) => {
306
+ if (isExcluded(abs))
307
+ return;
308
+ const rel = relative(root, abs);
309
+ if (rel === '' || escapesBase(rel))
310
+ return;
311
+ if (live)
312
+ engine.notify(rel, observedAtMs);
313
+ else
314
+ buffer.push([rel, observedAtMs]);
315
+ };
316
+ // A native-watcher error means delivery may have lapsed; disclose an honest
317
+ // session-wide coverage gap instead of letting the miss vanish silently.
318
+ const onError = (err) => {
319
+ console.error(`slipstream: watcher error: ${err.message}`);
320
+ void appendEvent({
321
+ type: 'slipstream.capture.gap.v1',
322
+ occurred_at_ms: Date.now(),
323
+ data: { scope: { kind: 'session' }, reason: 'watcher-error' },
324
+ }).catch(() => { });
325
+ };
326
+ const priorFor = (path, unknownDirs) => {
327
+ for (const prefix of unknownDirs) {
328
+ if (prefix === '' || path === prefix || path.startsWith(prefix + sep)) {
329
+ return { kind: 'unavailable', reason: 'baseline-unknown' };
330
+ }
331
+ }
332
+ return { kind: 'absent' };
333
+ };
334
+ // Re-observe the whole worktree against the last committed snapshots and emit a
335
+ // reconciliation change for every differing endpoint. Enumeration omission is
336
+ // NOT deletion — a known path missing from the scan is actually re-read, so a
337
+ // real delete is distinguished from an unreadable subtree.
338
+ const reconcile = async (state, gapSeq, append) => {
339
+ const current = new Map();
340
+ const newUnknownDirs = new Set();
341
+ await deps.enumerate(root, root, isExcluded, {
342
+ onFile: async (rel) => { current.set(rel, await reader.read(rel)); },
343
+ onDirError: async (relDir) => { newUnknownDirs.add(relDir); },
344
+ });
345
+ const unknownDirs = new Set([...state.baselineUnknownDirs, ...newUnknownDirs]);
346
+ const union = new Set([...state.committed.keys(), ...current.keys()]);
347
+ for (const path of union) {
348
+ const after = current.get(path) ?? (await reader.read(path));
349
+ const before = state.committed.get(path) ?? priorFor(path, unknownDirs);
350
+ if (!snapshotsEqual(before, after)) {
351
+ await append({
352
+ type: 'slipstream.file.changed.v1',
353
+ occurred_at_ms: Date.now(),
354
+ data: {
355
+ path,
356
+ before,
357
+ after,
358
+ observation: 'reconciliation',
359
+ gap_ref: gapSeq,
360
+ // A reconciliation endpoint is a state diff discovered on restart, not a
361
+ // watched transition, so it cannot bound a real interval. Disclose that
362
+ // explicitly rather than fabricate one; attribution reads it as unknown.
363
+ observed_interval_ms: { unavailable: true, reason: 'reconciliation' },
364
+ },
365
+ });
366
+ }
367
+ engine.setBaseline(path, snapshotsEqual(before, after) ? before : after);
368
+ }
369
+ // Install every unknown directory — recovered as well as newly-failed — so a
370
+ // later live observation of a never-baselined path reports baseline-unknown
371
+ // rather than a fabricated absent prior state.
372
+ for (const relDir of unknownDirs)
373
+ engine.markBaselineUnknown(relDir);
374
+ };
375
+ const goLive = () => {
376
+ // A recovery attempt that finishes during stop() must not re-enable the engine
377
+ // or flush the buffer: stop() has already unsubscribed and is draining toward
378
+ // lock release, and no observation may be appended past that point.
379
+ if (stopped)
380
+ return;
381
+ live = true;
382
+ for (const [rel, ts] of buffer)
383
+ engine.notify(rel, ts);
384
+ buffer.length = 0;
385
+ };
386
+ // Bounded-backoff recovery of a suspended session: reopen and repair the log,
387
+ // disclose ONE storage gap, reconcile, and restore health. Appends here go
388
+ // straight to the reopened log so a repeat fault aborts the attempt cleanly
389
+ // (rather than recursing back into the supervisor).
390
+ const runSupervisor = async () => {
391
+ if (supervising)
392
+ return;
393
+ supervising = true;
394
+ let delay = 20;
395
+ try {
396
+ while (!stopped && !surrendered && health.snapshot().state === 'failing') {
397
+ await new Promise((r) => { const t = setTimeout(r, delay); t.unref?.(); });
398
+ delay = Math.min(delay * 2, 500);
399
+ if (stopped)
400
+ break;
401
+ if (await tryStorageRecovery())
402
+ break;
403
+ }
404
+ }
405
+ finally {
406
+ supervising = false;
407
+ }
408
+ };
409
+ const tryStorageRecovery = async () => {
410
+ live = false; // buffer live events while we repair and reconcile
411
+ try {
412
+ // Let in-flight engine tasks settle before swapping the log out from under
413
+ // them; otherwise one could append with a stale committed `before`.
414
+ await engine.drain();
415
+ engine.resetNotifications();
416
+ // Fence the attribution generation before the log is swapped: a stale
417
+ // evaluation from the pre-recovery generation must never append to the
418
+ // reopened log. A fresh generation is armed after the log reopens.
419
+ await producer?.stop();
420
+ if (stopped || surrendered)
421
+ return false; // shutting down / dispossessed: don't reopen
422
+ await underlying.close().catch(() => { });
423
+ const rec = await recoverSession(logPath, sessionId, cas);
424
+ // Rebuild the dedup index + current task from the durable log before the log
425
+ // reopens: an in-process recovery must not leave a duplicate-commit window,
426
+ // and reconciliation changes below must stamp the recovered current task.
427
+ seedTaskState(rec);
428
+ underlying = await deps.createLog({
429
+ filePath: logPath,
430
+ sessionId,
431
+ startSeq: rec.recoveredThroughSeq,
432
+ sequencer: taskSequencer,
433
+ });
434
+ health.setDurableSeq(rec.recoveredThroughSeq);
435
+ health.markRecovering();
436
+ // Replay barrier for the new generation: rebuild projections and outstanding
437
+ // work from the recovered durable log (seeding prior results so nothing is
438
+ // re-attributed), and re-assert the effective policy, before the storage gap
439
+ // and reconciliation changes below route through the producer.
440
+ producer?.start(rec.attributionEvents);
441
+ await producer?.ensurePolicy(enrichmentPolicy);
442
+ const rawAppend = async (input) => {
443
+ // If the lock was lost mid-recovery, stop writing: another process now
444
+ // owns the log. This bounds — it cannot fully prevent — the residual, as
445
+ // an append already awaiting its fsync when surrender flips still lands.
446
+ if (surrendered) {
447
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
448
+ }
449
+ const event = await underlying.append(input);
450
+ health.setDurableSeq(underlying.durableSeq());
451
+ return event;
452
+ };
453
+ // Disclose exactly one storage gap per outage: if a prior attempt's gap
454
+ // survived on disk, reuse its seq instead of appending a duplicate (Q6).
455
+ const episode = (outageEpisodeId ??= randomUUID());
456
+ let gapSeq = rec.storageGapSeqByEpisode.get(episode);
457
+ if (gapSeq === undefined) {
458
+ const gap = await rawAppend({
459
+ type: 'slipstream.capture.gap.v1',
460
+ occurred_at_ms: Date.now(),
461
+ data: { scope: { kind: 'session' }, reason: 'storage', episode_id: episode },
462
+ });
463
+ gapSeq = gap.seq;
464
+ }
465
+ await reconcile({ committed: rec.committed, baselineUnknownDirs: rec.baselineUnknownDirs }, gapSeq, rawAppend);
466
+ if (surrendered)
467
+ return false; // dispossessed during recovery: stay failing, don't mask it
468
+ goLive();
469
+ health.markHealthy();
470
+ outageEpisodeId = undefined; // outage resolved; a later fault opens a new episode
471
+ return true;
472
+ }
473
+ catch {
474
+ // Recovery failed. Restore the failing state (the original failure is
475
+ // preserved) so the supervisor keeps retrying. Stay NOT live: the engine's
476
+ // in-memory baselines may now disagree with the reopened log (a partial
477
+ // reconcile, or a reopen that read a durably-newer state), so replaying
478
+ // buffered events here would append a change against a stale `before` and
479
+ // poison the log. Buffering holds them until a *successful* recovery resets
480
+ // the baselines via reconcile and then goes live.
481
+ health.markFailing({ code: undefined, operation: 'recovery', detected_at_ms: Date.now() });
482
+ return false;
483
+ }
484
+ };
485
+ // Install the watcher BEFORE enumerating so nothing that happens during the
486
+ // scan is missed; buffered events reconcile against the baseline afterward.
487
+ let subscription;
488
+ try {
489
+ subscription = await deps.platform.watch({ root, ignore: excluded, onObservation, onError });
490
+ if (resuming) {
491
+ // Replay barrier: rebuild the producer's projections and reconstruct
492
+ // outstanding work from the durable log, seeding prior results so a
493
+ // reproduced attribution is not re-appended, BEFORE any new commit routes
494
+ // through it. The reconciliation changes below then attribute live.
495
+ producer.start(recovered.attributionEvents);
496
+ await appendEvent({
497
+ type: 'slipstream.session.resumed.v1',
498
+ occurred_at_ms: Date.now(),
499
+ data: {
500
+ recovered_through_seq: recovered.recoveredThroughSeq.toString(),
501
+ discarded_tail_bytes: recovered.discardedTailBytes,
502
+ },
503
+ });
504
+ const restartGap = await appendEvent({
505
+ type: 'slipstream.capture.gap.v1',
506
+ occurred_at_ms: Date.now(),
507
+ data: { scope: { kind: 'session' }, reason: 'restart' },
508
+ });
509
+ await producer.ensurePolicy(enrichmentPolicy);
510
+ await reconcile({
511
+ committed: recovered.committed,
512
+ baselineUnknownDirs: recovered.baselineUnknownDirs,
513
+ }, restartGap.seq, appendEvent);
514
+ }
515
+ else {
516
+ await appendEvent({
517
+ type: 'slipstream.session.started.v1',
518
+ occurred_at_ms: Date.now(),
519
+ data: { root, max_bytes: maxBytes },
520
+ });
521
+ const unknownScopes = [];
522
+ await deps.enumerate(root, root, isExcluded, {
523
+ onFile: async (rel) => {
524
+ const snapshot = await reader.read(rel);
525
+ await appendEvent({
526
+ type: 'slipstream.file.baselined.v1',
527
+ occurred_at_ms: Date.now(),
528
+ data: { path: rel, snapshot },
529
+ });
530
+ engine.setBaseline(rel, snapshot);
531
+ },
532
+ onDirError: async (relDir) => {
533
+ engine.markBaselineUnknown(relDir);
534
+ unknownScopes.push(relDir);
535
+ await appendEvent({
536
+ type: 'slipstream.capture.gap.v1',
537
+ occurred_at_ms: Date.now(),
538
+ data: { scope: { kind: 'directory', path: relDir }, reason: 'baseline-unreadable' },
539
+ });
540
+ },
541
+ });
542
+ await appendEvent({
543
+ type: 'slipstream.capture.baseline.completed.v1',
544
+ occurred_at_ms: Date.now(),
545
+ data: { unknown_scopes: unknownScopes },
546
+ });
547
+ // Arm attribution and publish the effective policy before going live, so
548
+ // the first live change binds to a committed policy.
549
+ producer.start([]);
550
+ await producer.ensurePolicy(enrichmentPolicy);
551
+ }
552
+ }
553
+ catch (err) {
554
+ // A failed startup may already have kicked the recovery supervisor (an append
555
+ // fault calls enterFailing). Stop it and await the in-flight attempt before
556
+ // releasing the lock, or its next reopen could write to a log a second owner
557
+ // has since acquired — the same ordering `stop()` relies on.
558
+ stopped = true;
559
+ await subscription?.close().catch(() => { });
560
+ await supervisorLoop.catch(() => { });
561
+ await producer.stop().catch(() => { });
562
+ await engine.drain().catch(() => { });
563
+ await underlying.close().catch(() => { });
564
+ await lock.release();
565
+ throw err;
566
+ }
567
+ goLive();
568
+ health.markHealthy();
569
+ const ingestEvidence = (evidence) => producer.ingestEvidence(evidence);
570
+ // The transcript coverage watcher (A2): reads configured harnesses' transcripts
571
+ // for this worktree as revisable evidence. It never creates a change and its
572
+ // evidence flows through the same deduped, append-only producer path, so a
573
+ // re-read (including a late transcript after a restart) revises without
574
+ // duplicating. Runs only when a harness is actually configured.
575
+ let coverageRunner;
576
+ const tc = opts.transcript;
577
+ if (tc && tc.harnesses.length > 0) {
578
+ coverageRunner = createCoverageRunner({
579
+ harnesses: tc.harnesses,
580
+ homes: tc.homes,
581
+ codexScanLimit: tc.codexScanLimit,
582
+ root,
583
+ sink: { ingest: ingestEvidence },
584
+ publish: async (data) => {
585
+ await appendEvent({ type: 'slipstream.enrichment.coverage.v1', occurred_at_ms: Date.now(), data });
586
+ },
587
+ discoveryIO: tc.discoveryIO ?? nodeDiscoveryIO,
588
+ fileIO: tc.fileIO ?? nodeTranscriptFileIO,
589
+ intervalMs: tc.pollIntervalMs ?? DEFAULT_TRANSCRIPT_POLL_MS,
590
+ onError: (err) => console.error(`slipstream: transcript watcher error: ${err.message}`),
591
+ });
592
+ coverageRunner.start();
593
+ }
594
+ const beginTask = async ({ title, requestId }) => {
595
+ // Readiness: only a live, healthy, still-owned session may declare a task.
596
+ // Domain error codes (CAPTURE_NOT_READY, STORAGE_UNAVAILABLE, ...) are a later
597
+ // PR's forwarder concern; in-process this is a plain refusal.
598
+ if (stopped || surrendered || health.snapshot().state !== 'healthy') {
599
+ throw new Error('capture session is not ready to accept task declarations');
600
+ }
601
+ if (typeof requestId !== 'string' || requestId.length === 0) {
602
+ throw new Error('beginTask requires a non-empty request_id');
603
+ }
604
+ if (typeof title !== 'string' || title.length === 0) {
605
+ throw new Error('beginTask requires a non-empty title');
606
+ }
607
+ // Idempotency keyed by (capture session, request_id); this session is the key's
608
+ // session component, so request_id alone indexes within it. A settled result
609
+ // replays as-is; a same-key call still in flight coalesces onto it; a reused
610
+ // key with a different title is a caller mistake, not a retry.
611
+ const settled = committedTasks.get(requestId);
612
+ if (settled !== undefined) {
613
+ if (settled.title !== title) {
614
+ throw new InvalidTitleError(`request_id ${requestId} was already used for a different title`);
615
+ }
616
+ return settled.result;
617
+ }
618
+ const pending = inflightTasks.get(requestId);
619
+ if (pending !== undefined) {
620
+ if (pending.title !== title) {
621
+ throw new InvalidTitleError(`request_id ${requestId} is in flight for a different title`);
622
+ }
623
+ return pending.promise;
624
+ }
625
+ // The registration below is synchronous (no await precedes it), so a
626
+ // concurrent duplicate observes the in-flight entry and never starts a second
627
+ // append. `task_id` is minted here; `request_id` is the caller's.
628
+ const taskId = randomUUID();
629
+ const promise = (async () => {
630
+ const event = await appendEvent({
631
+ type: 'slipstream.task.started.v1',
632
+ occurred_at_ms: Date.now(),
633
+ data: { task_id: taskId, request_id: requestId, title },
634
+ });
635
+ // The record may have landed durably while ownership was lost mid-fsync (an
636
+ // accepted residual the new owner reconciles). A surrendered session must
637
+ // not ACKNOWLEDGE the commit or cache it as a settled result — acknowledging
638
+ // after surrender is exactly what the lost lock forbids.
639
+ if (surrendered) {
640
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
641
+ }
642
+ // Frozen so a caller cannot mutate the object the dedup index hands back to
643
+ // every future retry (the result is an immutable identity record).
644
+ const result = Object.freeze({
645
+ session_id: sessionId,
646
+ task_id: taskId,
647
+ event_id: event.id, // equals seq by construction
648
+ seq: event.seq,
649
+ });
650
+ committedTasks.set(requestId, { title, result });
651
+ return result;
652
+ })();
653
+ inflightTasks.set(requestId, { title, promise });
654
+ try {
655
+ return await promise;
656
+ }
657
+ finally {
658
+ inflightTasks.delete(requestId);
659
+ }
660
+ };
661
+ const questionReady = () => {
662
+ if (stopped)
663
+ throw new QuestionError('CAPTURE_NOT_READY', 'capture is stopping');
664
+ if (surrendered || health.snapshot().state === 'failing' || health.snapshot().state === 'recovering') {
665
+ throw new QuestionError('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
666
+ }
667
+ if (health.snapshot().state !== 'healthy')
668
+ throw new QuestionError('CAPTURE_NOT_READY', 'capture is not ready');
669
+ };
670
+ const askQuestion = async (input, target, assertOwnership = () => { }) => {
671
+ const req = normalizeAsk(input);
672
+ if (req.session_id !== sessionId)
673
+ throw new QuestionError('SESSION_NOT_SELECTED', 'question addresses a different capture');
674
+ assertOwnership();
675
+ questionReady();
676
+ const body = questionBody(req);
677
+ const settled = committedQuestions.get(req.request_id);
678
+ const pending = inflightQuestions.get(req.request_id);
679
+ const existing = settled ?? pending;
680
+ if (existing && existing.body !== body)
681
+ throw new QuestionError('REQUEST_CONFLICT', 'request_id was used for a different question or context');
682
+ if (settled)
683
+ return { ...settled.result, duplicate: true };
684
+ if (pending)
685
+ return { ...await pending.promise, duplicate: true };
686
+ if (req.reply_to_question_id !== undefined) {
687
+ const parent = questionsById.get(req.reply_to_question_id)?.context;
688
+ const c = req.context;
689
+ if (!parent || parent.change_seq !== c.change_seq || parent.path !== c.path || parent.snapshot_sha256 !== c.snapshot_sha256
690
+ || parent.line_start !== c.line_start || parent.line_end !== c.line_end) {
691
+ throw new QuestionError('INVALID_CONTEXT', 'a follow-up must reply to a question in this capture about the same source');
692
+ }
693
+ }
694
+ // A reservation covers source reads as well as append, bounding concurrent scans.
695
+ // An expired-but-never-attempted entry can never become eligible again, so it
696
+ // is dropped here rather than kept forever awaiting a claim that will not come.
697
+ let eligible = 0;
698
+ for (const [id, q] of unattemptedQuestions) {
699
+ if (now() < q.data.expires_at_ms)
700
+ eligible += 1;
701
+ else
702
+ unattemptedQuestions.delete(id);
703
+ }
704
+ const reserved = [...inflightQuestions.keys()].filter(id => !committedQuestions.has(id)).length;
705
+ if (eligible + reserved >= QUESTION_LIMIT)
706
+ throw new QuestionError('QUESTION_LIMIT', 'at most 16 unexpired questions may be queued');
707
+ // Defer starting I/O until the in-flight reservation is installed synchronously.
708
+ const promise = Promise.resolve().then(async () => {
709
+ const selected_text = await deps.readQuestionContext({ storeDir, logPath, sessionId,
710
+ boundary: BigInt(health.snapshot().durable_seq), context: req.context });
711
+ assertOwnership();
712
+ questionReady();
713
+ const queued_at_ms = now();
714
+ const event = await appendEvent({ type: 'slipstream.question.queued.v1', occurred_at_ms: queued_at_ms,
715
+ data: { question_id: randomUUID(), request_id: req.request_id, target, text: req.text,
716
+ context: { ...req.context, selected_text }, queued_at_ms, expires_at_ms: queued_at_ms + QUESTION_TTL_MS,
717
+ ...(req.reply_to_question_id !== undefined ? { reply_to_question_id: req.reply_to_question_id } : {}) } });
718
+ if (surrendered)
719
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
720
+ assertOwnership();
721
+ const result = questionResult(event);
722
+ committedQuestions.set(req.request_id, { body, result });
723
+ return result;
724
+ });
725
+ inflightQuestions.set(req.request_id, { body, promise });
726
+ try {
727
+ return await promise;
728
+ }
729
+ finally {
730
+ inflightQuestions.delete(req.request_id);
731
+ }
732
+ };
733
+ const claimQuestion = async (target, assertOwnership = () => { }) => {
734
+ assertOwnership();
735
+ questionReady();
736
+ const queued = [...unattemptedQuestions.values()].find(q => q.data.target.harness === target.harness
737
+ && q.data.target.harness_session_id === target.harness_session_id
738
+ && q.data.target.worktree === target.worktree
739
+ && now() < q.data.expires_at_ms
740
+ && !reservedQuestions.has(q.data.question_id));
741
+ if (!queued)
742
+ return null;
743
+ const id = queued.data.question_id;
744
+ reservedQuestions.add(id);
745
+ try {
746
+ const attempted_at_ms = now();
747
+ await appendEvent({ type: 'slipstream.question.dispatch_attempted.v1', occurred_at_ms: attempted_at_ms,
748
+ data: { question_id: id, queued_seq: queued.seq, attempted_at_ms } });
749
+ if (surrendered)
750
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
751
+ assertOwnership();
752
+ return { ...queued.data, queued_seq: queued.seq };
753
+ }
754
+ finally {
755
+ reservedQuestions.delete(id);
756
+ }
757
+ };
758
+ // Order: INVALID_ANSWER, then QUESTION_NOT_FOUND (which also hides questions
759
+ // dispatched to another harness session), then duplicate or ANSWER_CONFLICT.
760
+ const answerQuestion = async (input, target, assertOwnership = () => { }) => {
761
+ const { question_id: id, text } = input;
762
+ assertAnswerText(text);
763
+ assertOwnership();
764
+ questionReady();
765
+ const owner = typeof id === 'string' ? questionsById.get(id)?.target : undefined;
766
+ const attemptSeq = typeof id === 'string' ? attemptSeqs.get(id) : undefined;
767
+ if (typeof id !== 'string' || !owner || attemptSeq === undefined || owner.harness !== target.harness
768
+ || owner.harness_session_id !== target.harness_session_id || owner.worktree !== target.worktree) {
769
+ throw new QuestionError('QUESTION_NOT_FOUND', 'no question with that id was dispatched to this harness session');
770
+ }
771
+ const settled = committedAnswers.get(id);
772
+ const pending = inflightAnswers.get(id);
773
+ const existing = settled ?? pending;
774
+ if (existing && existing.text !== text)
775
+ throw new QuestionError('ANSWER_CONFLICT', 'this question already has a different answer');
776
+ if (settled)
777
+ return { ...settled.result, duplicate: true };
778
+ if (pending)
779
+ return { ...await pending.promise, duplicate: true };
780
+ const promise = Promise.resolve().then(async () => {
781
+ const answered_at_ms = now();
782
+ const event = await appendEvent({ type: 'slipstream.question.answered.v1', occurred_at_ms: answered_at_ms,
783
+ data: { question_id: id, attempt_seq: attemptSeq, text, answered_at_ms } });
784
+ if (surrendered)
785
+ throw new StorageError('lock', Object.assign(new Error('session ownership lost'), { code: 'ELOCKLOST' }));
786
+ assertOwnership();
787
+ return answerResult(event);
788
+ });
789
+ inflightAnswers.set(id, { text, promise });
790
+ try {
791
+ return await promise;
792
+ }
793
+ finally {
794
+ inflightAnswers.delete(id);
795
+ }
796
+ };
797
+ const doStop = async () => {
798
+ stopped = true;
799
+ // Stop feeding the engine immediately: if the watcher unsubscribe below
800
+ // rejects, late observations buffer instead of appending after the lock is
801
+ // released. Every step is then best-effort so a single failure never skips
802
+ // closing the log or releasing the lock; the first error is rethrown.
803
+ live = false;
804
+ let firstError;
805
+ const record = (err) => {
806
+ if (firstError === undefined)
807
+ firstError = err;
808
+ };
809
+ // Stop the coverage watcher before the producer and log close: it appends
810
+ // evidence and coverage through both, and an append after close would throw.
811
+ await coverageRunner?.stop().catch(record);
812
+ await subscription?.close().catch(record);
813
+ // Await any in-flight recovery: it may be mid-reopen, and appending after
814
+ // we release the lock would let a second owner's writes interleave. The
815
+ // loop stops starting new attempts once `stopped` is set.
816
+ await supervisorLoop.catch(() => { });
817
+ await engine.drain().catch(record);
818
+ // Fence the attribution generation and flush its in-flight appends before the
819
+ // log closes: a stale evaluation must never append after the log is gone.
820
+ await producer?.stop().catch(record);
821
+ await Promise.allSettled([...inflightQuestions.values(), ...inflightAnswers.values()].map(q => q.promise));
822
+ await underlying.close().catch(record);
823
+ await lock.release().catch(record);
824
+ if (firstError !== undefined)
825
+ throw firstError;
826
+ };
827
+ // Memoized so an overlapping detach + daemon shutdown (both hold the same
828
+ // handle) run teardown once. Two concurrent releases could otherwise both read
829
+ // the current nonce and the loser could unlink a successor's lock.
830
+ let stopPromise;
831
+ return {
832
+ sessionId,
833
+ logPath,
834
+ blobsDir,
835
+ health,
836
+ beginTask,
837
+ askQuestion,
838
+ claimQuestion,
839
+ answerQuestion,
840
+ ingestEvidence,
841
+ stop: () => (stopPromise ??= doStop()),
842
+ };
843
+ }
844
+ async function enumerate(root, dir, isExcluded, handlers) {
845
+ let entries;
846
+ try {
847
+ entries = await readdir(dir, { withFileTypes: true });
848
+ }
849
+ catch {
850
+ await handlers.onDirError(relative(root, dir));
851
+ return;
852
+ }
853
+ for (const entry of entries) {
854
+ const abs = join(dir, entry.name);
855
+ if (isExcluded(abs))
856
+ continue;
857
+ if (entry.isSymbolicLink())
858
+ continue; // excluded, never followed
859
+ if (entry.isDirectory()) {
860
+ await enumerate(root, abs, isExcluded, handlers);
861
+ }
862
+ else if (entry.isFile()) {
863
+ await handlers.onFile(relative(root, abs));
864
+ }
865
+ }
866
+ }