@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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- package/schemas/slipstream.task.started.v1.json +72 -0
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
import { createServer, connect } from 'node:net';
|
|
2
|
+
import { chmod, lstat, unlink, realpath, open } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { startCapture, InvalidTitleError } from "./session.js";
|
|
7
|
+
import { QuestionError } from "./questions.js";
|
|
8
|
+
import { verifyClaudeRootTranscript } from "./claude-root-transcript.js";
|
|
9
|
+
import { StorageError, mkdirpDurable, assertOwnerOnly } from "./storage.js";
|
|
10
|
+
import { acquireSessionLock, SessionOwnedError } from "./lock.js";
|
|
11
|
+
import { startReaderServer } from "./http-reader.js";
|
|
12
|
+
import { isValidSessionId, listSessions } from "./store-reader.js";
|
|
13
|
+
import { publishTombstone, removeSessionHistory, sessionExists, reclaimUnreferencedBlobs, } from "./maintenance.js";
|
|
14
|
+
import { createBoundaryRegistry } from "./boundary-registry.js";
|
|
15
|
+
import { liveBoundary } from "./reader-runtime.js";
|
|
16
|
+
import { createLineDecoder, encodeMessage, } from "./control-protocol.js";
|
|
17
|
+
const HARNESSES = ['claude-code', 'codex'];
|
|
18
|
+
const SUPPORTED_CODEX_VERSIONS = new Set(['0.154.0', '0.155.1']);
|
|
19
|
+
const MAX_CODEX_META_BYTES = 64 * 1024;
|
|
20
|
+
class RootIdentityError extends Error {
|
|
21
|
+
}
|
|
22
|
+
/** Verify the selected transcript's own first record once at attach. It binds
|
|
23
|
+
* the reported session and worktree without treating either as authorship. */
|
|
24
|
+
async function verifyCodexRootTranscript(path, sessionId, worktree) {
|
|
25
|
+
try {
|
|
26
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
27
|
+
let bytes;
|
|
28
|
+
try {
|
|
29
|
+
if (!(await handle.stat()).isFile())
|
|
30
|
+
throw new Error('transcript is not a file');
|
|
31
|
+
const buffer = Buffer.alloc(MAX_CODEX_META_BYTES + 1);
|
|
32
|
+
let total = 0;
|
|
33
|
+
let end = -1;
|
|
34
|
+
while (total < buffer.length && end < 0) {
|
|
35
|
+
const { bytesRead } = await handle.read(buffer, total, buffer.length - total, total);
|
|
36
|
+
if (bytesRead === 0)
|
|
37
|
+
break;
|
|
38
|
+
end = buffer.subarray(total, total + bytesRead).indexOf(0x0a);
|
|
39
|
+
if (end >= 0)
|
|
40
|
+
end += total;
|
|
41
|
+
total += bytesRead;
|
|
42
|
+
}
|
|
43
|
+
if (end < 0 || end > MAX_CODEX_META_BYTES)
|
|
44
|
+
throw new Error('transcript metadata exceeds limit');
|
|
45
|
+
bytes = buffer.subarray(0, end);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await handle.close();
|
|
49
|
+
}
|
|
50
|
+
const record = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
51
|
+
const payload = record.payload;
|
|
52
|
+
if (record.type !== 'session_meta' || !payload
|
|
53
|
+
|| payload.originator !== 'codex_sdk_ts'
|
|
54
|
+
|| (payload.source !== 'exec' && payload.source !== 'vscode')
|
|
55
|
+
|| !SUPPORTED_CODEX_VERSIONS.has(String(payload.cli_version))
|
|
56
|
+
|| payload.session_id !== sessionId || typeof payload.cwd !== 'string'
|
|
57
|
+
|| await realpath(payload.cwd) !== worktree)
|
|
58
|
+
throw new Error('transcript metadata does not match selected root');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new RootIdentityError('root Codex transcript is missing, mismatched, or from an unsupported runtime');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Build the per-session transcript runtime from resolved config: only harnesses
|
|
65
|
+
* the operator declared `configured` are read. */
|
|
66
|
+
function transcriptRuntimeFrom(config) {
|
|
67
|
+
if (!config)
|
|
68
|
+
return undefined;
|
|
69
|
+
const harnesses = HARNESSES.filter((h) => config.policy.sources[h] === 'configured');
|
|
70
|
+
if (harnesses.length === 0)
|
|
71
|
+
return undefined;
|
|
72
|
+
return {
|
|
73
|
+
harnesses,
|
|
74
|
+
homes: config.transcript.homes,
|
|
75
|
+
codexScanLimit: config.transcript.codexScanLimit,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export class DaemonAlreadyRunningError extends Error {
|
|
79
|
+
constructor(socketPath) {
|
|
80
|
+
super(`a slipstream daemon is already running at ${socketPath}`);
|
|
81
|
+
this.name = 'DaemonAlreadyRunningError';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export const PROBE_TIMEOUT_MS = 1000;
|
|
85
|
+
/** A connection must deliver one complete control request within this window;
|
|
86
|
+
* otherwise it is dropped so it can never wedge shutdown. */
|
|
87
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
88
|
+
function nonEmptyString(v) {
|
|
89
|
+
return typeof v === 'string' && v.length > 0;
|
|
90
|
+
}
|
|
91
|
+
/** Classify who, if anyone, holds a control socket that failed to bind. Only a
|
|
92
|
+
* refused connection or a missing path proves the socket is stale and reclaimable;
|
|
93
|
+
* every other error (EACCES, EMFILE, ...) is ambiguous and must fail closed, since
|
|
94
|
+
* it does not prove the owner is dead (locked design, decision 4). */
|
|
95
|
+
export function probeSocket(socketPath, timeoutMs) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const sock = connect(socketPath);
|
|
98
|
+
const cleanup = () => { clearTimeout(timer); sock.removeAllListeners(); sock.destroy(); };
|
|
99
|
+
const timer = setTimeout(() => { cleanup(); resolve('ambiguous'); }, timeoutMs);
|
|
100
|
+
sock.on('connect', () => { cleanup(); resolve('live'); });
|
|
101
|
+
sock.on('error', (err) => {
|
|
102
|
+
cleanup();
|
|
103
|
+
const code = err.code;
|
|
104
|
+
resolve(code === 'ECONNREFUSED' || code === 'ENOENT' ? 'stale' : 'ambiguous');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
async function bindControl(server, socketPath) {
|
|
109
|
+
const listenOnce = () => new Promise((resolve, reject) => {
|
|
110
|
+
const onError = (err) => { server.off('listening', onListening); reject(err); };
|
|
111
|
+
const onListening = () => { server.off('error', onError); resolve(); };
|
|
112
|
+
server.once('error', onError);
|
|
113
|
+
server.once('listening', onListening);
|
|
114
|
+
server.listen(socketPath);
|
|
115
|
+
});
|
|
116
|
+
const closeServer = () => new Promise((resolve) => server.close(() => resolve()));
|
|
117
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
118
|
+
try {
|
|
119
|
+
await listenOnce();
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err.code !== 'EADDRINUSE' || attempt === 1)
|
|
123
|
+
throw err;
|
|
124
|
+
const verdict = await probeSocket(socketPath, PROBE_TIMEOUT_MS);
|
|
125
|
+
if (verdict === 'live')
|
|
126
|
+
throw new DaemonAlreadyRunningError(socketPath);
|
|
127
|
+
if (verdict === 'ambiguous') {
|
|
128
|
+
throw new Error(`control socket ${socketPath} is unresponsive; refusing to start (a paused daemon may still own it). Remove it manually only if you are sure no daemon is running.`);
|
|
129
|
+
}
|
|
130
|
+
// Stale: reclaim ONLY an actual socket file, never a regular file or directory
|
|
131
|
+
// squatting the path — deleting an arbitrary object would be a data-loss bug.
|
|
132
|
+
const st = await lstat(socketPath);
|
|
133
|
+
if (!st.isSocket()) {
|
|
134
|
+
throw new Error(`refusing to remove non-socket object at control path ${socketPath}`);
|
|
135
|
+
}
|
|
136
|
+
await unlink(socketPath);
|
|
137
|
+
continue; // retry listen on the freed path
|
|
138
|
+
}
|
|
139
|
+
// Listening succeeded. Lock down permissions; if that fails, do not leak the
|
|
140
|
+
// listener — close it before surfacing the error.
|
|
141
|
+
try {
|
|
142
|
+
await chmod(socketPath, 0o600); // owner-only; the parent dir was already asserted 0700
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
await closeServer();
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export async function startDaemon(opts) {
|
|
152
|
+
const storeDir = opts.storeDir;
|
|
153
|
+
const identityRealpath = opts.identityRealpath ?? realpath;
|
|
154
|
+
const socketPath = join(storeDir, 'control.sock');
|
|
155
|
+
await mkdirpDurable(storeDir);
|
|
156
|
+
await assertOwnerOnly(storeDir, 'dir');
|
|
157
|
+
// Guard the store lock's stale-reclaim against a paused-but-live owner: a lock
|
|
158
|
+
// unrefreshed for STALE_MS is reclaimable even if its owner is only paused, and
|
|
159
|
+
// reclaiming it makes that owner shut down when it resumes. The control socket
|
|
160
|
+
// distinguishes a paused owner (still answers via the listen backlog) from a
|
|
161
|
+
// dead one (connection refused). Probe before we can reclaim; abort without
|
|
162
|
+
// touching the lock if a daemon still answers.
|
|
163
|
+
const startupVerdict = await probeSocket(socketPath, PROBE_TIMEOUT_MS);
|
|
164
|
+
if (startupVerdict === 'live')
|
|
165
|
+
throw new DaemonAlreadyRunningError(socketPath);
|
|
166
|
+
if (startupVerdict === 'ambiguous') {
|
|
167
|
+
throw new Error(`control socket ${socketPath} is unresponsive; refusing to start (a paused daemon may still own it). Remove it manually only if you are sure no daemon is running.`);
|
|
168
|
+
}
|
|
169
|
+
// 'stale' (connection refused / socket absent): no live daemon; the store lock's
|
|
170
|
+
// own stale logic and bindControl may proceed.
|
|
171
|
+
// Everything teardown touches is declared before the store lock is acquired, so
|
|
172
|
+
// an onCompromised callback that fires mid-startup never hits a temporal dead
|
|
173
|
+
// zone (it may run with `reader` still undefined — teardown null-checks it).
|
|
174
|
+
const registry = createBoundaryRegistry();
|
|
175
|
+
let state = 'detached';
|
|
176
|
+
let current;
|
|
177
|
+
const inflightTasks = new Set();
|
|
178
|
+
const connections = new Set();
|
|
179
|
+
let attachInFlight;
|
|
180
|
+
// At most one detached-only maintenance op (delete_session / gc) runs at a time.
|
|
181
|
+
// The slot is claimed synchronously in runMaintenance before its first await, and
|
|
182
|
+
// attach refuses while it is held — so a single-threaded turn makes maintenance
|
|
183
|
+
// and attach mutually exclusive without a lock.
|
|
184
|
+
let maintenanceInFlight;
|
|
185
|
+
let reader;
|
|
186
|
+
let torn = false;
|
|
187
|
+
let compromised = false; // store lock lost
|
|
188
|
+
let sessionCompromised = false; // current session lock lost mid-attach
|
|
189
|
+
let abortStopError; // a failed stop of a session aborted mid-attach
|
|
190
|
+
// Gates DISPATCH, not acceptance: connections are tracked and given a deadline
|
|
191
|
+
// from the moment they arrive (so bind/chmod-window sockets can never orphan or
|
|
192
|
+
// hang shutdown), but no verb runs until startup is fully ready — which is also
|
|
193
|
+
// when `readerRef` is initialized, so an early request can never hit its TDZ.
|
|
194
|
+
let ready = false;
|
|
195
|
+
const server = createServer((sock) => handleConnection(sock));
|
|
196
|
+
const closeServer = () => new Promise((resolve) => server.close(() => resolve()));
|
|
197
|
+
/** Remove OUR control socket. Only ever called on the normal teardown path, while
|
|
198
|
+
* the store lock is still held, so no successor can have bound the path; the
|
|
199
|
+
* isSocket() guard is a belt-and-braces check against an unrelated file. On the
|
|
200
|
+
* compromise path the caller skips this entirely (ownership is already lost). */
|
|
201
|
+
async function unlinkOwnSocket() {
|
|
202
|
+
try {
|
|
203
|
+
const st = await lstat(socketPath);
|
|
204
|
+
if (st.isSocket())
|
|
205
|
+
await unlink(socketPath);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// best-effort cleanup; the path is gone or unreadable, nothing more to do
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Memoized so repeated stop() calls (and a compromise firing mid-shutdown) await
|
|
212
|
+
// the one in-flight teardown instead of returning early while it is still running.
|
|
213
|
+
let teardownPromise;
|
|
214
|
+
function teardown() {
|
|
215
|
+
return (teardownPromise ??= doTeardown());
|
|
216
|
+
}
|
|
217
|
+
async function doTeardown() {
|
|
218
|
+
torn = true;
|
|
219
|
+
// Drop live control connections so server.close() cannot block on an idle or
|
|
220
|
+
// half-sent request.
|
|
221
|
+
for (const sock of connections)
|
|
222
|
+
sock.destroy();
|
|
223
|
+
await closeServer();
|
|
224
|
+
// An attach whose capture is still starting must not outlive shutdown: wait for
|
|
225
|
+
// it to settle (it self-stops once it observes `torn`), then stop whatever it or
|
|
226
|
+
// an active session left installed.
|
|
227
|
+
if (attachInFlight)
|
|
228
|
+
await attachInFlight.catch(() => { });
|
|
229
|
+
// A maintenance op in flight has already seen `torn` (gc aborts its sweep); wait
|
|
230
|
+
// for it to settle so a durable tombstone or blob unlink is not racing shutdown.
|
|
231
|
+
if (maintenanceInFlight)
|
|
232
|
+
await maintenanceInFlight.catch(() => { });
|
|
233
|
+
// An attach aborted mid-startup may have failed to stop its half-built session
|
|
234
|
+
// (it runs in startAndActivate, which sets abortStopError). Surface that too so
|
|
235
|
+
// shutdown never reports success over a capture that could not be confirmed
|
|
236
|
+
// stopped.
|
|
237
|
+
let stopErr = abortStopError;
|
|
238
|
+
if (current) {
|
|
239
|
+
// Drain in-flight task declarations before stopping, exactly like detach:
|
|
240
|
+
// `torn` is already set, so beginTask admits no new work, and the append of
|
|
241
|
+
// any task that slipped in before shutdown must not race the session log
|
|
242
|
+
// closing under session.stop().
|
|
243
|
+
await Promise.allSettled([...inflightTasks]);
|
|
244
|
+
// Do NOT swallow a failed stop: if capture could not be confirmed stopped,
|
|
245
|
+
// shutdown must not report success. session.stop() still releases the session
|
|
246
|
+
// lock and halts the engine even when it rethrows, so releasing the store
|
|
247
|
+
// lock below is safe; we surface the failure once cleanup is complete.
|
|
248
|
+
try {
|
|
249
|
+
await current.session.stop();
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
stopErr = err;
|
|
253
|
+
}
|
|
254
|
+
current = undefined;
|
|
255
|
+
}
|
|
256
|
+
await reader?.close().catch(() => { });
|
|
257
|
+
// Unlink the socket only while we still hold the store lock. On the compromise
|
|
258
|
+
// path the lock is already lost, so a successor may have bound the path — an
|
|
259
|
+
// isSocket() check proves type, not ownership, so deleting it could remove the
|
|
260
|
+
// successor's socket. Leave it for the new owner instead.
|
|
261
|
+
if (!compromised)
|
|
262
|
+
await unlinkOwnSocket();
|
|
263
|
+
await lock.release();
|
|
264
|
+
if (stopErr)
|
|
265
|
+
throw stopErr;
|
|
266
|
+
}
|
|
267
|
+
let lock;
|
|
268
|
+
try {
|
|
269
|
+
lock = await acquireSessionLock(storeDir, {
|
|
270
|
+
onCompromised: (reason) => {
|
|
271
|
+
compromised = true;
|
|
272
|
+
console.error(`slipstream daemon: store lock lost: ${reason}`);
|
|
273
|
+
void teardown().catch((teardownErr) => {
|
|
274
|
+
console.error('slipstream daemon: teardown after store-lock loss failed:', teardownErr);
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (err instanceof SessionOwnedError)
|
|
281
|
+
throw new DaemonAlreadyRunningError(socketPath);
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
// Bring the reader up before accepting control traffic, so a verb can never be
|
|
285
|
+
// dispatched against a reader that is not yet serving the public view.
|
|
286
|
+
try {
|
|
287
|
+
reader = await startReaderServer({ storeDir, registry });
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
await lock.release();
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
// A store-lock compromise during startReaderServer() already ran teardown while
|
|
294
|
+
// `reader` was still undefined, so teardown could not close this server. Close it
|
|
295
|
+
// here and abort before binding the control listener, so no listener leaks.
|
|
296
|
+
if (torn || compromised) {
|
|
297
|
+
await reader.close().catch(() => { });
|
|
298
|
+
await lock.release().catch(() => { });
|
|
299
|
+
throw new Error('slipstream daemon lost its store lock during startup');
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
await bindControl(server, socketPath);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
await reader.close().catch(() => { });
|
|
306
|
+
await lock.release();
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
// A compromise that landed during startup already triggered teardown; honor it
|
|
310
|
+
// rather than handing back a daemon that is shutting down. bindControl may have
|
|
311
|
+
// (re)bound the listener AFTER teardown ran — e.g. a compromise during its
|
|
312
|
+
// stale-socket probe, when the server was not yet listening for teardown to
|
|
313
|
+
// close — so close the listener explicitly here before awaiting the memoized
|
|
314
|
+
// teardown, which would otherwise no-op and leak it.
|
|
315
|
+
if (torn || compromised) {
|
|
316
|
+
// Destroy tracked connections before the explicit close: one accepted after
|
|
317
|
+
// teardown's own destruction sweep (e.g. during this retry's chmod window)
|
|
318
|
+
// could otherwise keep closeServer()'s promise from resolving if its peer
|
|
319
|
+
// holds the send side open, hanging startup rejection indefinitely.
|
|
320
|
+
for (const sock of connections)
|
|
321
|
+
sock.destroy();
|
|
322
|
+
await closeServer();
|
|
323
|
+
await teardown();
|
|
324
|
+
throw new Error('slipstream daemon lost its store lock during startup');
|
|
325
|
+
}
|
|
326
|
+
const readerRef = reader;
|
|
327
|
+
function statusFields() {
|
|
328
|
+
const fields = { state, reader_url: readerRef.url };
|
|
329
|
+
if (current) {
|
|
330
|
+
// session_id is capture SCOPE, never authorship. The declared identity is the
|
|
331
|
+
// exact context the caller bound; semantic verification is the P4 forwarder's.
|
|
332
|
+
fields.session_id = current.id;
|
|
333
|
+
fields.worktree = current.worktree;
|
|
334
|
+
fields.harness = current.harness;
|
|
335
|
+
fields.harness_session_id = current.harnessSessionId;
|
|
336
|
+
if (current.rootTranscript)
|
|
337
|
+
fields.root_transcript = current.rootTranscript;
|
|
338
|
+
fields.durable_seq = current.session.health.snapshot().durable_seq;
|
|
339
|
+
}
|
|
340
|
+
return fields;
|
|
341
|
+
}
|
|
342
|
+
/** The active session lost its own lock. Stop presenting healthy, preserve the
|
|
343
|
+
* boundary at the last durable seq, and refuse further mutations (decision 9).
|
|
344
|
+
* `current` is retained so teardown can still stop the wedged session. */
|
|
345
|
+
function handleSessionCompromise(id, reason) {
|
|
346
|
+
console.error(`slipstream daemon: session ${id} lost its lock: ${reason}`);
|
|
347
|
+
if (current?.id === id && state === 'active') {
|
|
348
|
+
registry.freeze(id, BigInt(current.session.health.snapshot().durable_seq));
|
|
349
|
+
state = 'wedged';
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (state === 'attaching')
|
|
353
|
+
sessionCompromised = true; // startup aborts below
|
|
354
|
+
}
|
|
355
|
+
async function startAndActivate(id, worktree, harness, harnessSessionId, rootTranscript) {
|
|
356
|
+
let session;
|
|
357
|
+
let resolvedWorktree;
|
|
358
|
+
let resolvedTranscript;
|
|
359
|
+
try {
|
|
360
|
+
// Canonicalize before binding: capture resolves the root with realpath, so a
|
|
361
|
+
// symlinked or relative declared path must report the same durable root in
|
|
362
|
+
// status rather than the caller's raw string (locked design, decision 5).
|
|
363
|
+
resolvedWorktree = await realpath(worktree);
|
|
364
|
+
if (rootTranscript !== undefined) {
|
|
365
|
+
if (harness !== 'codex' && harness !== 'claude-code') {
|
|
366
|
+
throw new RootIdentityError('root_transcript requires a supported harness');
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
resolvedTranscript = await realpath(rootTranscript);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
throw new RootIdentityError(harness === 'claude-code'
|
|
373
|
+
? 'root Claude transcript is unavailable; check its path or retry attach after the first root tool call'
|
|
374
|
+
: 'root Codex transcript cannot be resolved');
|
|
375
|
+
}
|
|
376
|
+
if (harness === 'codex') {
|
|
377
|
+
await verifyCodexRootTranscript(resolvedTranscript, harnessSessionId, resolvedWorktree);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
const verified = await verifyClaudeRootTranscript(resolvedTranscript, harnessSessionId, resolvedWorktree);
|
|
381
|
+
if (!verified.ok) {
|
|
382
|
+
throw new RootIdentityError(verified.reason === 'not-yet'
|
|
383
|
+
? 'root Claude transcript head is not complete yet; retry attach after the first root tool call'
|
|
384
|
+
: verified.reason === 'gap'
|
|
385
|
+
? 'root Claude transcript head exceeds the verification bounds; select a new root early in its session'
|
|
386
|
+
: `root Claude transcript identity is ${verified.reason}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const transcript = transcriptRuntimeFrom(opts.config);
|
|
391
|
+
session = await startCapture({
|
|
392
|
+
root: resolvedWorktree,
|
|
393
|
+
storeDir,
|
|
394
|
+
sessionId: id,
|
|
395
|
+
onCompromised: (reason) => handleSessionCompromise(id, reason),
|
|
396
|
+
...(opts.config ? { enrichmentPolicy: opts.config.policy } : {}),
|
|
397
|
+
...(transcript ? { transcript } : {}),
|
|
398
|
+
}, opts.captureDependencies);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
// Freeze at boundary 0 (capture never activated); never delete the entry,
|
|
402
|
+
// which would let the reader fall back to disk for a half-written session.
|
|
403
|
+
registry.freeze(id, 0n);
|
|
404
|
+
if (state === 'attaching')
|
|
405
|
+
state = 'detached';
|
|
406
|
+
if (err instanceof StorageError)
|
|
407
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
408
|
+
if (err instanceof RootIdentityError)
|
|
409
|
+
return errFields('IDENTITY_UNRESOLVED', err.message);
|
|
410
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
411
|
+
}
|
|
412
|
+
if (torn || compromised || sessionCompromised || state !== 'attaching') {
|
|
413
|
+
// Shutdown, store-lock loss, or a session compromise arrived while capture was
|
|
414
|
+
// starting: do not publish this session. Stop it and hold the boundary. A
|
|
415
|
+
// failed stop here is recorded (not swallowed) so teardown can surface it.
|
|
416
|
+
try {
|
|
417
|
+
await session.stop();
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
abortStopError ??= err;
|
|
421
|
+
}
|
|
422
|
+
registry.freeze(id, BigInt(session.health.snapshot().durable_seq));
|
|
423
|
+
if (state === 'attaching')
|
|
424
|
+
state = 'detached';
|
|
425
|
+
return errFields('STORAGE_UNAVAILABLE', 'daemon could not complete the attach');
|
|
426
|
+
}
|
|
427
|
+
registry.activate(id, liveBoundary(session.health));
|
|
428
|
+
current = { id, session, worktree: resolvedWorktree, harness, harnessSessionId, rootTranscript: resolvedTranscript };
|
|
429
|
+
state = 'active';
|
|
430
|
+
return { session_id: id };
|
|
431
|
+
}
|
|
432
|
+
async function attach(req) {
|
|
433
|
+
if (!nonEmptyString(req.worktree) || !nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id)) {
|
|
434
|
+
// Structural fail-closed identity gate (honesty): an ambiguous or partial
|
|
435
|
+
// identity refuses attachment rather than guessing. Semantic verification
|
|
436
|
+
// that the declared harness is a live real process is the P4 forwarder's job.
|
|
437
|
+
return errFields('IDENTITY_UNRESOLVED', 'attach requires non-empty worktree, harness, and harness_session_id');
|
|
438
|
+
}
|
|
439
|
+
if (req.root_transcript !== undefined && !nonEmptyString(req.root_transcript)) {
|
|
440
|
+
return errFields('IDENTITY_UNRESOLVED', 'root_transcript must be a non-empty existing path');
|
|
441
|
+
}
|
|
442
|
+
if (state !== 'detached') {
|
|
443
|
+
if (state === 'active')
|
|
444
|
+
return errFields('SESSION_ACTIVE', 'a session is already attached; detach it first');
|
|
445
|
+
if (state === 'wedged')
|
|
446
|
+
return errFields('STORAGE_UNAVAILABLE', 'daemon is wedged; restart it');
|
|
447
|
+
return errFields('CAPTURE_NOT_READY', `cannot attach while ${state}`);
|
|
448
|
+
}
|
|
449
|
+
// Refuse to attach over an in-flight maintenance op. This check and the
|
|
450
|
+
// `state === 'detached'` guard in runMaintenance are the two halves of the same
|
|
451
|
+
// mutual exclusion: both are synchronous, so whichever claims its slot first in
|
|
452
|
+
// this turn locks the other out. (Kept before `state = 'attaching'` so a refused
|
|
453
|
+
// attach leaves the state machine untouched.)
|
|
454
|
+
if (maintenanceInFlight)
|
|
455
|
+
return errFields('CAPTURE_NOT_READY', 'a maintenance operation is running; retry shortly');
|
|
456
|
+
// Set attaching BEFORE the first await so a concurrent attach is refused.
|
|
457
|
+
state = 'attaching';
|
|
458
|
+
sessionCompromised = false;
|
|
459
|
+
const id = randomUUID();
|
|
460
|
+
// Reserve the registry entry (boundary 0) before startCapture creates the log
|
|
461
|
+
// dir on disk, so the reader never over-publishes an uncommitted record from a
|
|
462
|
+
// session that momentarily appears on disk mid-startup.
|
|
463
|
+
registry.reserve(id);
|
|
464
|
+
const p = startAndActivate(id, req.worktree, req.harness, req.harness_session_id, req.root_transcript);
|
|
465
|
+
attachInFlight = p;
|
|
466
|
+
try {
|
|
467
|
+
return await p;
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
if (attachInFlight === p)
|
|
471
|
+
attachInFlight = undefined;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async function detach() {
|
|
475
|
+
if (state !== 'active' || !current) {
|
|
476
|
+
return errFields('SESSION_NOT_SELECTED', 'no active session to detach');
|
|
477
|
+
}
|
|
478
|
+
const { id, session } = current;
|
|
479
|
+
state = 'detaching'; // close admission synchronously; in-flight tasks still settle
|
|
480
|
+
await Promise.allSettled([...inflightTasks]);
|
|
481
|
+
let stopErr;
|
|
482
|
+
try {
|
|
483
|
+
await session.stop();
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
stopErr = err;
|
|
487
|
+
}
|
|
488
|
+
// Pin the reader at the final durable seq the session reached (honest even if
|
|
489
|
+
// stop() failed — health still holds the last durable value).
|
|
490
|
+
registry.freeze(id, BigInt(session.health.snapshot().durable_seq));
|
|
491
|
+
if (stopErr) {
|
|
492
|
+
// Fail closed: never claim a clean detach or admit new capture. Retain
|
|
493
|
+
// `current` so teardown can still attempt to release the session's resources.
|
|
494
|
+
state = 'wedged';
|
|
495
|
+
return errFields('STORAGE_UNAVAILABLE', `detach did not stop cleanly: ${stopErr.message}`);
|
|
496
|
+
}
|
|
497
|
+
current = undefined;
|
|
498
|
+
state = 'detached';
|
|
499
|
+
return { session_id: id };
|
|
500
|
+
}
|
|
501
|
+
async function beginTask(req) {
|
|
502
|
+
if (state === 'wedged')
|
|
503
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture is wedged; its storage is no longer trustworthy');
|
|
504
|
+
if (state !== 'active' || !current) {
|
|
505
|
+
if (state === 'attaching')
|
|
506
|
+
return errFields('CAPTURE_NOT_READY', 'capture is still starting');
|
|
507
|
+
if (state === 'detaching')
|
|
508
|
+
return errFields('CAPTURE_NOT_READY', 'capture is detaching');
|
|
509
|
+
return errFields('SESSION_NOT_SELECTED', 'no active session');
|
|
510
|
+
}
|
|
511
|
+
if (req.session_id !== undefined && req.session_id !== current.id) {
|
|
512
|
+
// The optional target guards a retry after detach+attach-B from misrouting to
|
|
513
|
+
// a different session than the caller declared.
|
|
514
|
+
return errFields('SESSION_NOT_SELECTED', `session_id ${String(req.session_id)} is not the selected session`);
|
|
515
|
+
}
|
|
516
|
+
// Selection guard (locked design, decision X): the caller ships its verified
|
|
517
|
+
// identity triple and only the selected session's exact triple may declare.
|
|
518
|
+
// The P4 forwarder always sends the triple; a missing one is a contract
|
|
519
|
+
// violation, so fail closed rather than declare into whatever is selected.
|
|
520
|
+
if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
|
|
521
|
+
return errFields('IDENTITY_UNRESOLVED', 'begin_task requires harness, harness_session_id, and worktree');
|
|
522
|
+
}
|
|
523
|
+
if (!nonEmptyString(req.title))
|
|
524
|
+
return errFields('INVALID_TITLE', 'begin_task requires a non-empty title');
|
|
525
|
+
if (!nonEmptyString(req.request_id))
|
|
526
|
+
return errFields('CAPTURE_NOT_READY', 'begin_task requires a non-empty request_id');
|
|
527
|
+
// Canonicalize the declared worktree with the SAME realpath policy as attach
|
|
528
|
+
// (startAndActivate), so a symlinked or relative declared path matches the
|
|
529
|
+
// durable root capture actually watches.
|
|
530
|
+
let declaredWorktree;
|
|
531
|
+
try {
|
|
532
|
+
declaredWorktree = await realpath(req.worktree);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
declaredWorktree = req.worktree; // an unresolvable path cannot match the canonical root
|
|
536
|
+
}
|
|
537
|
+
// Re-verify selection AFTER the realpath await: a concurrent detach, teardown,
|
|
538
|
+
// or store-lock loss may have changed state or swapped the session while we
|
|
539
|
+
// canonicalized. From here through inflightTasks.add there is no await, so
|
|
540
|
+
// neither a detach nor a teardown can slip between these checks and the append
|
|
541
|
+
// (they either observe the task in flight and drain it, or are observed here).
|
|
542
|
+
// Teardown fails closed without touching `state`, so check `torn`/`compromised`
|
|
543
|
+
// explicitly — otherwise a request could append after the daemon has already
|
|
544
|
+
// begun shutting down or lost its store lock.
|
|
545
|
+
if (compromised || torn) {
|
|
546
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
547
|
+
}
|
|
548
|
+
if (state !== 'active' || !current) {
|
|
549
|
+
return errFields('SESSION_NOT_SELECTED', 'the selected session changed before the declaration committed');
|
|
550
|
+
}
|
|
551
|
+
// Re-apply the optional session_id target guard after the await, exactly like
|
|
552
|
+
// the pre-await check: a concurrent detach+attach may have swapped in a session
|
|
553
|
+
// sharing the triple, and a request explicitly addressed to the prior session
|
|
554
|
+
// must not commit into its replacement.
|
|
555
|
+
if (req.session_id !== undefined && req.session_id !== current.id) {
|
|
556
|
+
return errFields('SESSION_NOT_SELECTED', `session_id ${String(req.session_id)} is not the selected session`);
|
|
557
|
+
}
|
|
558
|
+
if (req.harness !== current.harness ||
|
|
559
|
+
req.harness_session_id !== current.harnessSessionId ||
|
|
560
|
+
declaredWorktree !== current.worktree) {
|
|
561
|
+
return errFields('SESSION_NOT_SELECTED', 'the declared identity is not the selected session');
|
|
562
|
+
}
|
|
563
|
+
const { session } = current;
|
|
564
|
+
const promise = session.beginTask({ title: req.title, requestId: req.request_id });
|
|
565
|
+
inflightTasks.add(promise);
|
|
566
|
+
try {
|
|
567
|
+
const result = await promise;
|
|
568
|
+
return {
|
|
569
|
+
session_id: result.session_id,
|
|
570
|
+
task_id: result.task_id,
|
|
571
|
+
event_id: result.event_id,
|
|
572
|
+
seq: result.seq,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
if (err instanceof InvalidTitleError)
|
|
577
|
+
return errFields('INVALID_TITLE', err.message);
|
|
578
|
+
if (err instanceof StorageError)
|
|
579
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
580
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
inflightTasks.delete(promise);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** Queue a question on the selected capture. `askQuestion` installs its own
|
|
587
|
+
* request reservation synchronously; install the daemon-level lifetime promise
|
|
588
|
+
* in the same turn so detach and shutdown drain source reads and appends. */
|
|
589
|
+
async function ask(req) {
|
|
590
|
+
if (compromised || torn)
|
|
591
|
+
return errFields('STORAGE_UNAVAILABLE', 'daemon storage ownership is unavailable');
|
|
592
|
+
if (state === 'wedged')
|
|
593
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture is wedged; its storage is no longer trustworthy');
|
|
594
|
+
if (state !== 'active' || !current) {
|
|
595
|
+
if (state === 'attaching' || state === 'detaching')
|
|
596
|
+
return errFields('CAPTURE_NOT_READY', 'capture is not ready to accept questions');
|
|
597
|
+
return errFields('SESSION_NOT_SELECTED', 'no active session');
|
|
598
|
+
}
|
|
599
|
+
if (current.harness !== 'claude-code' && current.harness !== 'codex') {
|
|
600
|
+
return errFields('SESSION_NOT_SELECTED', 'the selected capture has no supported question target');
|
|
601
|
+
}
|
|
602
|
+
// No await may appear before this call and registration: detach/shutdown must
|
|
603
|
+
// see any source verification admitted before they change state.
|
|
604
|
+
const binding = current;
|
|
605
|
+
const { session, harness, harnessSessionId, worktree } = binding;
|
|
606
|
+
const promise = session.askQuestion(req, {
|
|
607
|
+
harness: harness,
|
|
608
|
+
harness_session_id: harnessSessionId,
|
|
609
|
+
worktree,
|
|
610
|
+
}, () => {
|
|
611
|
+
if (compromised || sessionCompromised || current !== binding) {
|
|
612
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
inflightTasks.add(promise);
|
|
616
|
+
try {
|
|
617
|
+
const result = await promise;
|
|
618
|
+
return { ...result };
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
if (err instanceof QuestionError)
|
|
622
|
+
return errFields(err.code, err.message);
|
|
623
|
+
if (err instanceof StorageError)
|
|
624
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
625
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
626
|
+
}
|
|
627
|
+
finally {
|
|
628
|
+
inflightTasks.delete(promise);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async function claimQuestion(req) {
|
|
632
|
+
if (compromised || torn || state === 'wedged')
|
|
633
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
634
|
+
if (state !== 'active' || !current)
|
|
635
|
+
return errFields('SESSION_NOT_SELECTED', 'no active capture');
|
|
636
|
+
if ((current.harness !== 'codex' && current.harness !== 'claude-code')
|
|
637
|
+
|| !current.rootTranscript || req.harness !== current.harness
|
|
638
|
+
|| !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)
|
|
639
|
+
|| !nonEmptyString(req.transcript_path)
|
|
640
|
+
|| Object.hasOwn(req, 'agent_id') || Object.hasOwn(req, 'agent_type')) {
|
|
641
|
+
return errFields('IDENTITY_UNRESOLVED', 'unsupported or incomplete root identity');
|
|
642
|
+
}
|
|
643
|
+
const binding = current;
|
|
644
|
+
let worktree;
|
|
645
|
+
let transcript;
|
|
646
|
+
try {
|
|
647
|
+
[worktree, transcript] = await Promise.all([identityRealpath(req.worktree), identityRealpath(req.transcript_path)]);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
return errFields('IDENTITY_UNRESOLVED', 'identity path cannot be resolved');
|
|
651
|
+
}
|
|
652
|
+
if (compromised || torn || sessionCompromised || state !== 'active' || current !== binding
|
|
653
|
+
|| req.harness_session_id !== binding.harnessSessionId
|
|
654
|
+
|| worktree !== binding.worktree || transcript !== binding.rootTranscript) {
|
|
655
|
+
return errFields('SESSION_NOT_SELECTED', 'the callback is not the selected root session');
|
|
656
|
+
}
|
|
657
|
+
const promise = binding.session.claimQuestion({ harness: binding.harness, harness_session_id: binding.harnessSessionId,
|
|
658
|
+
worktree: binding.worktree }, () => {
|
|
659
|
+
if (compromised || sessionCompromised || current !== binding) {
|
|
660
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
inflightTasks.add(promise);
|
|
664
|
+
try {
|
|
665
|
+
const result = await promise;
|
|
666
|
+
return result === null ? { question: null } : { question: result };
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
if (err instanceof QuestionError)
|
|
670
|
+
return errFields(err.code, err.message);
|
|
671
|
+
if (err instanceof StorageError)
|
|
672
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
673
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
inflightTasks.delete(promise);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** Record an answer returned through the selected harness session's MCP tool.
|
|
680
|
+
* Like begin_task, the caller's triple must equal the binding after the realpath
|
|
681
|
+
* await; with no transcript or agent id this proves the call came through the
|
|
682
|
+
* selected harness session, not which agent in it wrote the answer. */
|
|
683
|
+
async function answerQuestion(req) {
|
|
684
|
+
if (!nonEmptyString(req.harness) || !nonEmptyString(req.harness_session_id) || !nonEmptyString(req.worktree)) {
|
|
685
|
+
return errFields('IDENTITY_UNRESOLVED', 'answer_question requires harness, harness_session_id, and worktree');
|
|
686
|
+
}
|
|
687
|
+
if (compromised || torn || state === 'wedged')
|
|
688
|
+
return errFields('STORAGE_UNAVAILABLE', 'capture storage is unavailable');
|
|
689
|
+
if (state !== 'active' || !current)
|
|
690
|
+
return errFields('SESSION_NOT_SELECTED', 'no active capture');
|
|
691
|
+
const binding = current;
|
|
692
|
+
let worktree;
|
|
693
|
+
try {
|
|
694
|
+
worktree = await identityRealpath(req.worktree);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
worktree = req.worktree;
|
|
698
|
+
}
|
|
699
|
+
if (compromised || torn || sessionCompromised || state !== 'active' || current !== binding
|
|
700
|
+
|| req.harness !== binding.harness || req.harness_session_id !== binding.harnessSessionId || worktree !== binding.worktree) {
|
|
701
|
+
return errFields('SESSION_NOT_SELECTED', 'the caller is not the selected harness session');
|
|
702
|
+
}
|
|
703
|
+
const promise = binding.session.answerQuestion({ question_id: req.question_id, text: req.text }, { harness: binding.harness, harness_session_id: binding.harnessSessionId, worktree: binding.worktree }, () => {
|
|
704
|
+
if (compromised || sessionCompromised || current !== binding) {
|
|
705
|
+
throw new QuestionError('STORAGE_UNAVAILABLE', 'the selected capture lost storage ownership');
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
inflightTasks.add(promise);
|
|
709
|
+
try {
|
|
710
|
+
return { ...await promise };
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
if (err instanceof QuestionError)
|
|
714
|
+
return errFields(err.code, err.message);
|
|
715
|
+
if (err instanceof StorageError)
|
|
716
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
717
|
+
return errFields('CAPTURE_NOT_READY', err.message);
|
|
718
|
+
}
|
|
719
|
+
finally {
|
|
720
|
+
inflightTasks.delete(promise);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** Admit and run a detached-only maintenance op. Admission is synchronous through
|
|
724
|
+
* claiming `maintenanceInFlight`, so it cannot interleave with attach (which sets
|
|
725
|
+
* `state` synchronously and refuses while the slot is held). The op runs only in
|
|
726
|
+
* `detached`; anything else — an active/attaching/detaching session, a wedged or
|
|
727
|
+
* compromised store, an op already running — is refused without touching disk. */
|
|
728
|
+
async function runMaintenance(op) {
|
|
729
|
+
if (compromised || torn)
|
|
730
|
+
return errFields('STORAGE_UNAVAILABLE', 'the daemon is shutting down or its store lock was lost');
|
|
731
|
+
if (state !== 'detached') {
|
|
732
|
+
// A capture in any stage — attaching, active, or detaching — is SESSION_ACTIVE;
|
|
733
|
+
// CAPTURE_NOT_READY is reserved for the maintenance-slot contention below.
|
|
734
|
+
if (state === 'wedged')
|
|
735
|
+
return errFields('STORAGE_UNAVAILABLE', 'daemon is wedged; restart it');
|
|
736
|
+
return errFields('SESSION_ACTIVE', `a capture session is ${state}; detach it before deleting or collecting`);
|
|
737
|
+
}
|
|
738
|
+
if (maintenanceInFlight)
|
|
739
|
+
return errFields('CAPTURE_NOT_READY', 'another maintenance operation is running; retry shortly');
|
|
740
|
+
let release;
|
|
741
|
+
const slot = new Promise((resolve) => { release = resolve; });
|
|
742
|
+
maintenanceInFlight = slot;
|
|
743
|
+
try {
|
|
744
|
+
return await op();
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
release();
|
|
748
|
+
if (maintenanceInFlight === slot)
|
|
749
|
+
maintenanceInFlight = undefined;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function deleteSession(req) {
|
|
753
|
+
// Validate the id shape before admission: it names an on-disk path, so a
|
|
754
|
+
// malformed or traversing id is a protocol fault, never a lookup miss.
|
|
755
|
+
if (!nonEmptyString(req.session_id) || !isValidSessionId(req.session_id)) {
|
|
756
|
+
return errFields('PROTOCOL', 'delete_session requires a valid session_id');
|
|
757
|
+
}
|
|
758
|
+
const id = req.session_id;
|
|
759
|
+
return runMaintenance(async () => {
|
|
760
|
+
let present;
|
|
761
|
+
try {
|
|
762
|
+
present = await sessionExists(storeDir, id);
|
|
763
|
+
}
|
|
764
|
+
catch (err) {
|
|
765
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
766
|
+
}
|
|
767
|
+
if (!present)
|
|
768
|
+
return errFields('SESSION_NOT_FOUND', `no session ${id} to delete`);
|
|
769
|
+
// Durable-tombstone-first: publish the marker (fsynced) BEFORE removing any
|
|
770
|
+
// history, so a crash in between leaves a session the reader already serves as
|
|
771
|
+
// gone and a later gc finishes the cleanup. Nothing is committed yet, so a
|
|
772
|
+
// failure here is a plain STORAGE_UNAVAILABLE — no rollback needed.
|
|
773
|
+
try {
|
|
774
|
+
await publishTombstone(storeDir, id);
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
return errFields('STORAGE_UNAVAILABLE', `could not durably record deletion of ${id}: ${err.message}`);
|
|
778
|
+
}
|
|
779
|
+
// Now that the tombstone is durable, abort any in-flight follower of this
|
|
780
|
+
// session so it re-resolves, re-reads the tombstone, and gets 410 rather than
|
|
781
|
+
// streaming a log we are about to delete. The reader's installIfAbsent gives a
|
|
782
|
+
// followed retained session a registry entry this freeze can reach.
|
|
783
|
+
registry.freeze(id, 0n);
|
|
784
|
+
// The tombstone is durable → the session is logically removed and the marker is
|
|
785
|
+
// never rolled back. If we have since lost the store lock, or history cleanup
|
|
786
|
+
// fails, report that removal committed and cleanup is retryable via gc.
|
|
787
|
+
const committed = `session ${id} is logically removed (tombstone durable); history cleanup is retryable via gc`;
|
|
788
|
+
if (compromised || torn)
|
|
789
|
+
return errFields('STORAGE_UNAVAILABLE', committed);
|
|
790
|
+
try {
|
|
791
|
+
await removeSessionHistory(storeDir, id);
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
return errFields('STORAGE_UNAVAILABLE', `${committed}: ${err.message}`);
|
|
795
|
+
}
|
|
796
|
+
return { session_id: id };
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
async function gc() {
|
|
800
|
+
return runMaintenance(async () => {
|
|
801
|
+
try {
|
|
802
|
+
// Finish any interrupted deletion first: a session tombstoned by a
|
|
803
|
+
// delete_session that crashed before cleanup still carries residual history.
|
|
804
|
+
// Re-establish the tombstone's durability, invalidate any follower a partial
|
|
805
|
+
// delete left pinned, then complete the removal — the same publish -> freeze
|
|
806
|
+
// -> remove sequence delete_session runs. Stop before any mutation if we lose
|
|
807
|
+
// the store lock: a successor now owns this store's files.
|
|
808
|
+
for (const s of await listSessions(storeDir)) {
|
|
809
|
+
if (!s.removed)
|
|
810
|
+
continue;
|
|
811
|
+
if (compromised || torn) {
|
|
812
|
+
throw new StorageError('gc-aborted', new Error('store ownership lost mid-cleanup'));
|
|
813
|
+
}
|
|
814
|
+
await publishTombstone(storeDir, s.id);
|
|
815
|
+
registry.freeze(s.id, 0n);
|
|
816
|
+
await removeSessionHistory(storeDir, s.id);
|
|
817
|
+
}
|
|
818
|
+
// Abort mid-sweep if we lose the store lock: a successor may then own the
|
|
819
|
+
// shared blobs, and deleting one it still references would be data loss.
|
|
820
|
+
const removed = await reclaimUnreferencedBlobs(storeDir, () => torn || compromised);
|
|
821
|
+
return { removed };
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
// A corrupt/missing retained log or an aborted sweep deletes nothing; report
|
|
825
|
+
// the store as unavailable rather than a partial success.
|
|
826
|
+
return errFields('STORAGE_UNAVAILABLE', err.message);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
async function dispatch(req) {
|
|
831
|
+
if (compromised)
|
|
832
|
+
return { v: 1, ok: false, code: 'STORAGE_UNAVAILABLE', message: 'daemon lost its store lock' };
|
|
833
|
+
let outcome;
|
|
834
|
+
switch (req.verb) {
|
|
835
|
+
case 'status':
|
|
836
|
+
outcome = statusFields();
|
|
837
|
+
break;
|
|
838
|
+
case 'attach':
|
|
839
|
+
outcome = await attach(req);
|
|
840
|
+
break;
|
|
841
|
+
case 'detach':
|
|
842
|
+
outcome = await detach();
|
|
843
|
+
break;
|
|
844
|
+
case 'begin_task':
|
|
845
|
+
outcome = await beginTask(req);
|
|
846
|
+
break;
|
|
847
|
+
case 'ask':
|
|
848
|
+
outcome = await ask(req);
|
|
849
|
+
break;
|
|
850
|
+
case 'claim_question':
|
|
851
|
+
outcome = await claimQuestion(req);
|
|
852
|
+
break;
|
|
853
|
+
case 'answer_question':
|
|
854
|
+
outcome = await answerQuestion(req);
|
|
855
|
+
break;
|
|
856
|
+
case 'delete_session':
|
|
857
|
+
outcome = await deleteSession(req);
|
|
858
|
+
break;
|
|
859
|
+
case 'gc':
|
|
860
|
+
outcome = await gc();
|
|
861
|
+
break;
|
|
862
|
+
default: return { v: 1, ok: false, code: 'PROTOCOL', message: `unknown verb: ${String(req.verb)}` };
|
|
863
|
+
}
|
|
864
|
+
if (isErrorFields(outcome))
|
|
865
|
+
return { v: 1, ok: false, code: outcome.code, message: outcome.message };
|
|
866
|
+
return { v: 1, ok: true, ...outcome };
|
|
867
|
+
}
|
|
868
|
+
function handleConnection(sock) {
|
|
869
|
+
if (torn) {
|
|
870
|
+
// Shutting down: never take on a new connection that could linger and block
|
|
871
|
+
// server.close(); drop it so the client sees a closed connection and retries.
|
|
872
|
+
sock.destroy();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
connections.add(sock);
|
|
876
|
+
const decoder = createLineDecoder();
|
|
877
|
+
let handled = false;
|
|
878
|
+
const answer = (res) => {
|
|
879
|
+
handled = true;
|
|
880
|
+
if (!sock.destroyed)
|
|
881
|
+
sock.end(encodeMessage(res));
|
|
882
|
+
};
|
|
883
|
+
const deadline = setTimeout(() => { if (!handled)
|
|
884
|
+
sock.destroy(); }, REQUEST_TIMEOUT_MS);
|
|
885
|
+
sock.on('data', (chunk) => {
|
|
886
|
+
if (handled)
|
|
887
|
+
return; // one request per connection: stop feeding the decoder
|
|
888
|
+
let messages;
|
|
889
|
+
try {
|
|
890
|
+
messages = decoder.push(chunk);
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
answer({ v: 1, ok: false, code: 'PROTOCOL', message: err.message });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (messages.length === 0)
|
|
897
|
+
return;
|
|
898
|
+
handled = true; // one request per connection; ignore anything further
|
|
899
|
+
const req = messages[0];
|
|
900
|
+
if (typeof req !== 'object' || req === null || typeof req.verb !== 'string') {
|
|
901
|
+
answer({ v: 1, ok: false, code: 'PROTOCOL', message: 'malformed control request' });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (req.v !== 1) {
|
|
905
|
+
// Reject an unknown protocol version before any mutation runs; a v:999 or a
|
|
906
|
+
// missing version must never execute and return a v1 success.
|
|
907
|
+
answer({ v: 1, ok: false, code: 'PROTOCOL', message: `unsupported control protocol version: ${String(req.v)}` });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (!ready) {
|
|
911
|
+
// A request that arrived during the bind/chmod window, before startup
|
|
912
|
+
// finished. Do not dispatch (the reader reference is not yet bound); answer
|
|
913
|
+
// a retryable error rather than orphaning the connection.
|
|
914
|
+
answer({ v: 1, ok: false, code: 'CAPTURE_NOT_READY', message: 'daemon is still starting' });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
void dispatch(req).then(answer).catch((err) => {
|
|
918
|
+
answer({ v: 1, ok: false, code: 'STORAGE_UNAVAILABLE', message: err.message });
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
sock.on('end', () => {
|
|
922
|
+
if (handled)
|
|
923
|
+
return;
|
|
924
|
+
// Peer finished sending: an unterminated final frame is a fault, not a
|
|
925
|
+
// silent drop.
|
|
926
|
+
try {
|
|
927
|
+
decoder.end();
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
answer({ v: 1, ok: false, code: 'PROTOCOL', message: err.message });
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
sock.on('error', () => sock.destroy());
|
|
934
|
+
sock.on('close', () => { clearTimeout(deadline); connections.delete(sock); });
|
|
935
|
+
}
|
|
936
|
+
// Startup is fully ready: reader running, lock held, not torn, readerRef bound.
|
|
937
|
+
// Only now let the connection handler dispatch verbs (see the `ready` gate).
|
|
938
|
+
ready = true;
|
|
939
|
+
return {
|
|
940
|
+
socketPath,
|
|
941
|
+
readerUrl: reader.url,
|
|
942
|
+
readerToken: reader.token,
|
|
943
|
+
stop: teardown,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function errFields(code, message) {
|
|
947
|
+
return { __error: true, code, message };
|
|
948
|
+
}
|
|
949
|
+
function isErrorFields(v) {
|
|
950
|
+
return v.__error === true;
|
|
951
|
+
}
|