@yeison.restrepo.r/code-conductor 1.23.1 → 1.23.3

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.
@@ -0,0 +1,450 @@
1
+ // scripts/conductor-db.mjs
2
+ //
3
+ // Local task-state cache writer for Code Conductor (FEAT-005).
4
+ // Wraps Node's built-in node:sqlite (DatabaseSync) to own <repo-root>/.conductor/cache.db.
5
+ //
6
+ // REQUIRES Node >= 22.5 with node:sqlite available. On older Node the dynamic
7
+ // import('node:sqlite') fails and the script degrades gracefully (warn, exit 0).
8
+ // The caller (cc-implement Step 6 hook / test helper) passes --experimental-sqlite
9
+ // only when Node >= 22.5. engines.node stays >=20; this cache self-disables below 22.5.
10
+ //
11
+ // All failures are NON-FATAL: one stderr line prefixed "CONDUCTOR_DB:" then exit 0.
12
+ // The plan markdown remains the authoritative task-state record.
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import { mkdirSync, existsSync, renameSync, unlinkSync, statSync, readSync } from 'node:fs';
16
+ import { dirname, basename, join, resolve, relative, sep } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const PREFIX = 'CONDUCTOR_DB:';
20
+ const warn = (m) => process.stderr.write(`${PREFIX} ${m}\n`);
21
+
22
+ const VALID_STATES = new Set([' ', '>', 'X', '!']);
23
+ const MAX_KEY_LEN = 512;
24
+ const WALK_CAP = 40;
25
+ const USAGE = "usage: conductor-db.mjs record <plan_file> <task_id> <state> | init";
26
+ const SCHEMA_VERSION = 2;
27
+ const MAX_SNAP_BYTES = 10 * 1024 * 1024; // 10 MiB
28
+ const MAX_CONTENT_BYTES = 1024 * 1024; // 1 MiB
29
+ const STDIN_CHUNK = 65536;
30
+ const U_SESSION = 'usage: conductor-db.mjs session <session_id> <phase> <spec> <git_commit_hash>';
31
+ const U_GET_SESSION = 'usage: conductor-db.mjs get-session <session_id>';
32
+ const U_SNAPSHOT = 'usage: conductor-db.mjs snapshot <git_commit_hash>';
33
+ const U_GET_SNAPSHOT = 'usage: conductor-db.mjs get-snapshot <git_commit_hash>';
34
+ const U_HISTORY = 'usage: conductor-db.mjs history <session_id> <kind>';
35
+
36
+ function validateKey(name, value, usage = USAGE) {
37
+ const v = String(value ?? '').trim(); // 1. trim FIRST (whitespace never counts)
38
+ if (!v) { warn(`${name} is empty; ${usage}`); return null; } // 2. empty-check on trimmed
39
+ if (v.length > MAX_KEY_LEN) { warn(`${name} exceeds ${MAX_KEY_LEN} chars; rejected`); return null; } // 3. length-check on trimmed
40
+ return v;
41
+ }
42
+
43
+ // Optional metadata: empty allowed, NOT trimmed, capped at MAX_KEY_LEN. Returns
44
+ // the raw string (possibly '') on success, or null ONLY when over the cap. Callers
45
+ // must test `=== null` — '' is a valid accepted value, not a rejection.
46
+ function validateOptional(name, value) {
47
+ const v = String(value ?? '');
48
+ if (v.length > MAX_KEY_LEN) { warn(`${name} exceeds ${MAX_KEY_LEN} chars; rejected`); return null; }
49
+ return v;
50
+ }
51
+
52
+ function resolveRoot() {
53
+ // 1. Primary: git.
54
+ try {
55
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
56
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
57
+ }).trim();
58
+ if (out) return resolve(out);
59
+ } catch { /* fall through */ }
60
+ // 2. Fallback A: bounded walk up from cwd looking for a .git entry.
61
+ let dir = resolve(process.cwd());
62
+ for (let i = 0; i < WALK_CAP; i++) {
63
+ if (existsSync(join(dir, '.git'))) return dir;
64
+ const parent = dirname(dir);
65
+ if (parent === dir) break; // filesystem root
66
+ dir = parent;
67
+ }
68
+ // 3. Fallback B: this script lives at either <root>/scripts/conductor-db.mjs
69
+ // (dev checkout) or <root>/.claude/scripts/conductor-db.mjs (deployed project).
70
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
71
+ const parent = resolve(scriptsDir, '..');
72
+ return basename(parent) === '.claude' ? resolve(parent, '..') : parent;
73
+ }
74
+
75
+ function normalizePlanFile(planFile, root) {
76
+ // Repo-relative, then force POSIX separators so a Windows `\` key never
77
+ // duplicates the same plan's POSIX `/` key. `sep` is `\` on Windows, `/` on POSIX.
78
+ // `resolve`/`relative` already collapse redundant separators (`a//b` -> `a/b`);
79
+ // the trailing replace is a defensive guarantee that no `//` ever persists.
80
+ return relative(root, resolve(process.cwd(), planFile))
81
+ .split(sep).join('/')
82
+ .replace(/\/{2,}/g, '/');
83
+ }
84
+
85
+ function applySchema(db) {
86
+ // WAL is best-effort: on a network share / virtualized FS that lacks the
87
+ // shared-memory + POSIX locking WAL needs, this either silently returns a
88
+ // rollback journal (e.g. 'delete') or throws. Either way we proceed — the
89
+ // engine works correctly under the default rollback journal; WAL is only an
90
+ // optimization. A hard I/O failure surfaces later and degrades in withDb.
91
+ try { db.exec('PRAGMA journal_mode = WAL;'); } catch { /* WAL unsupported: use default journal */ }
92
+ db.exec('BEGIN IMMEDIATE;');
93
+ try {
94
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
95
+ db.exec(
96
+ "CREATE TABLE IF NOT EXISTS task_state (" +
97
+ "plan_file TEXT NOT NULL, task_id TEXT NOT NULL, " +
98
+ "state TEXT NOT NULL CHECK (state IN (' ', '>', 'X', '!')), " +
99
+ "updated_at TEXT NOT NULL, " +
100
+ "PRIMARY KEY (plan_file, task_id)) WITHOUT ROWID;"
101
+ );
102
+ db.exec(
103
+ "CREATE TABLE IF NOT EXISTS sessions (" +
104
+ "session_id TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, " +
105
+ "phase TEXT NOT NULL DEFAULT '', spec TEXT NOT NULL DEFAULT '', " +
106
+ "git_commit_hash TEXT NOT NULL DEFAULT '', " +
107
+ "PRIMARY KEY (session_id)) WITHOUT ROWID;"
108
+ );
109
+ db.exec(
110
+ "CREATE TABLE IF NOT EXISTS snapshots (" +
111
+ "id INTEGER PRIMARY KEY, git_commit_hash TEXT NOT NULL, " +
112
+ "created_at TEXT NOT NULL, snap_json TEXT NOT NULL);"
113
+ );
114
+ db.exec("CREATE INDEX IF NOT EXISTS idx_snapshots_hash ON snapshots (git_commit_hash);");
115
+ db.exec(
116
+ "CREATE TABLE IF NOT EXISTS raw_history (" +
117
+ "id INTEGER PRIMARY KEY, session_id TEXT NOT NULL DEFAULT '', " +
118
+ "created_at TEXT NOT NULL, kind TEXT NOT NULL DEFAULT '', content TEXT NOT NULL);"
119
+ );
120
+ db.exec("CREATE INDEX IF NOT EXISTS idx_raw_history_session ON raw_history (session_id);");
121
+ db.exec('COMMIT;');
122
+ } catch (e) {
123
+ try { db.exec('ROLLBACK;'); } catch { /* ignore */ }
124
+ throw e;
125
+ }
126
+ }
127
+
128
+ // True iff the task_state table is physically present, independent of the
129
+ // user_version header. Guards the case where an interrupted run left a version
130
+ // header without the table (or vice-versa): we re-apply the idempotent schema.
131
+ function tableExists(db) {
132
+ return !!db.prepare(
133
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'task_state'"
134
+ ).get();
135
+ }
136
+
137
+ // Open the db and immediately arm a busy timeout so rapid back-to-back hook
138
+ // invocations wait up to 2s for a transient lock instead of failing on the first
139
+ // contention. Every connection in this script is opened through here.
140
+ //
141
+ // The busy_timeout pragma is an optimization, not a correctness requirement: if
142
+ // it throws after a successful open, that is a NON-FATAL boundary — we log one
143
+ // CONDUCTOR_DB line and return the (still fully usable) handle rather than
144
+ // aborting or leaking it. Without the timeout the connection simply falls back
145
+ // to the SQLITE_BUSY graceful-degradation path on contention.
146
+ function openConn(DatabaseSync, dbPath) {
147
+ const db = new DatabaseSync(dbPath);
148
+ try { db.exec('PRAGMA busy_timeout = 2000;'); }
149
+ catch { warn('busy_timeout pragma failed, continuing without it'); }
150
+ return db;
151
+ }
152
+
153
+ // Bounded, memory-safe stdin reader. Loops readSync(0,...) into a fixed reusable
154
+ // chunk buffer and stops the instant the running total EXCEEDS `max` (returning an
155
+ // over-cap signal without draining a hostile stream), so peak memory is ~max+chunk.
156
+ // Never calls process.exit; a read error propagates to the caller's try/catch.
157
+ function readStdinCapped(max) {
158
+ const chunk = Buffer.allocUnsafe(STDIN_CHUNK);
159
+ const parts = [];
160
+ let total = 0;
161
+ for (;;) {
162
+ let n;
163
+ try { n = readSync(0, chunk, 0, STDIN_CHUNK, null); }
164
+ catch (e) {
165
+ if (e && e.code === 'EAGAIN') continue; // non-blocking stdin not ready: retry
166
+ if (e && e.code === 'EOF') break; // some platforms signal EOF as throw
167
+ throw e;
168
+ }
169
+ if (n === 0) break; // clean EOF
170
+ total += n;
171
+ if (total > max) return { buf: null, overCap: true };
172
+ parts.push(Buffer.from(chunk.subarray(0, n)));
173
+ }
174
+ return { buf: Buffer.concat(parts), overCap: false };
175
+ }
176
+
177
+ const compactStamp = () => new Date().toISOString().replace(/[-:.]/g, '');
178
+
179
+ // Best-effort removal of the WAL/SHM sidecars that belong to a db file we are
180
+ // replacing. A stale `-wal`/`-shm` whose header no longer matches the fresh db
181
+ // can panic the next connection open, so they must go with the main file.
182
+ function clearSidecars(dbPath) {
183
+ for (const suffix of ['-wal', '-shm']) {
184
+ // The common case is that the sidecar does not exist -> unlinkSync throws
185
+ // ENOENT; that is expected and swallowed. Any other error (EACCES, locked
186
+ // file) is likewise non-fatal here. The empty catch suppresses ALL of them.
187
+ try { unlinkSync(`${dbPath}${suffix}`); } catch { /* ENOENT (absent) or locked: best-effort, ignore */ }
188
+ }
189
+ }
190
+
191
+ // Move a corrupt/non-regular file aside; never recursively delete. Returns true
192
+ // if the path was cleared (renamed or removed) so a fresh db can be created.
193
+ function backupAside(path) {
194
+ const base = `${path}.corrupt.${compactStamp()}`;
195
+ let target = base;
196
+ for (let i = 1; i <= 100 && existsSync(target); i++) target = `${base}.${i}`;
197
+
198
+ const cleared = (() => {
199
+ // Preferred: rename aside — atomic and content-preserving for files AND dirs.
200
+ try { renameSync(path, target); return true; }
201
+ catch { /* rename failed (locked / cross-device / perms): type-aware fallback */ }
202
+
203
+ // Rename failed. The unlink fallback is valid ONLY for a regular file:
204
+ // - unlinkSync on a directory throws EISDIR (POSIX) / EPERM (Windows);
205
+ // - rmdirSync only removes an *empty* dir (ours may hold files) and a
206
+ // recursive delete is explicitly forbidden.
207
+ // So: regular file -> unlinkSync; directory -> give up (warn, no write),
208
+ // never unlink/rmdir/rm -r it. This is why we branch on statSync here rather
209
+ // than blindly calling unlinkSync.
210
+ let isDir = false;
211
+ try { isDir = statSync(path).isDirectory(); }
212
+ catch { return true; } // path vanished between attempts: treat as cleared
213
+ if (isDir) {
214
+ warn(`cannot move aside directory at ${path} (rename failed); skipping cache write`);
215
+ return false;
216
+ }
217
+ try { unlinkSync(path); return true; }
218
+ catch { warn(`cannot move aside file at ${path}; skipping cache write`); return false; }
219
+ })();
220
+
221
+ // Only after the main file is cleared: drop its now-orphaned sidecars so the
222
+ // fresh db opens against a clean slate (no mismatched journal headers).
223
+ if (cleared) clearSidecars(path);
224
+ return cleared;
225
+ }
226
+
227
+ function isCorruptionError(e) {
228
+ const s = `${(e && e.code) || ''} ${(e && e.message) || ''}`.toLowerCase();
229
+ return s.includes('not a database') || s.includes('malformed') ||
230
+ s.includes('file is encrypted') || s.includes('notadb');
231
+ }
232
+
233
+ function openReady(DatabaseSync, dbPath) {
234
+ // A directory or other non-regular file squatting the db path: move it aside
235
+ // (its contents travel with the rename) — never recursively delete it.
236
+ try {
237
+ if (!statSync(dbPath).isFile()) {
238
+ if (!backupAside(dbPath)) return null;
239
+ }
240
+ } catch { /* ENOENT: nothing there yet — normal first run */ }
241
+
242
+ let db;
243
+ try {
244
+ db = openConn(DatabaseSync, dbPath);
245
+ const ver = db.prepare('PRAGMA user_version').get().user_version; // first I/O
246
+ if (ver > SCHEMA_VERSION) {
247
+ warn(`db schema v${ver} newer than supported v${SCHEMA_VERSION}, skipping cache write`);
248
+ db.close();
249
+ return null;
250
+ }
251
+ if (ver < SCHEMA_VERSION || !tableExists(db)) applySchema(db);
252
+ return db;
253
+ } catch (e) {
254
+ // Close the (possibly half-usable) handle BEFORE any rethrow or return so
255
+ // no path — corruption OR an unexpected non-corruption error — leaks it.
256
+ try { if (db) db.close(); } catch { /* ignore */ }
257
+ if (!isCorruptionError(e)) throw e;
258
+ if (!backupAside(dbPath)) return null;
259
+ db = openConn(DatabaseSync, dbPath); // retry once on a fresh file
260
+ applySchema(db);
261
+ return db;
262
+ }
263
+ }
264
+
265
+ function upsert(db, planFile, taskId, state) {
266
+ db.prepare(
267
+ 'INSERT INTO task_state (plan_file, task_id, state, updated_at) VALUES (?, ?, ?, ?) ' +
268
+ 'ON CONFLICT (plan_file, task_id) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at'
269
+ ).run(planFile, taskId, state, new Date().toISOString());
270
+ }
271
+
272
+ function upsertSession(db, sessionId, phase, spec, gitHash) {
273
+ const now = new Date().toISOString();
274
+ db.prepare(
275
+ 'INSERT INTO sessions (session_id, started_at, updated_at, phase, spec, git_commit_hash) ' +
276
+ 'VALUES ($session_id, $started_at, $updated_at, $phase, $spec, $git_commit_hash) ' +
277
+ 'ON CONFLICT(session_id) DO UPDATE SET ' +
278
+ 'updated_at = excluded.updated_at, phase = excluded.phase, ' +
279
+ 'spec = excluded.spec, git_commit_hash = excluded.git_commit_hash'
280
+ ).run({ $session_id: sessionId, $started_at: now, $updated_at: now, $phase: phase, $spec: spec, $git_commit_hash: gitHash });
281
+ }
282
+
283
+ async function withDb(root, fn) {
284
+ let DatabaseSync;
285
+ try {
286
+ ({ DatabaseSync } = await import('node:sqlite'));
287
+ } catch {
288
+ warn('node:sqlite unavailable, skipping cache write');
289
+ return;
290
+ }
291
+ const dir = join(root, '.conductor');
292
+ const dbPath = join(dir, 'cache.db');
293
+ // If `.conductor` already exists but as a regular FILE (not a directory),
294
+ // mkdirSync would throw EEXIST/ENOTDIR. Move the squatting file aside with the
295
+ // same non-destructive rename used for a bad db, then create the directory.
296
+ try {
297
+ if (!statSync(dir).isDirectory()) { if (!backupAside(dir)) return; }
298
+ } catch { /* ENOENT: nothing at the path yet — mkdir will create it */ }
299
+ try {
300
+ mkdirSync(dir, { recursive: true });
301
+ } catch (e) {
302
+ warn(`cannot create ${dir}: ${(e && e.code) || (e && e.message)}, skipping cache write`);
303
+ return;
304
+ }
305
+ let db = null;
306
+ try {
307
+ db = openReady(DatabaseSync, dbPath);
308
+ if (!db) return; // skipped (unrecoverable or newer schema — Task 9)
309
+ return fn(db);
310
+ } catch (e) {
311
+ warn(`${(e && e.code) || 'error'} writing ${dbPath}: ${e && e.message}, skipping cache write`);
312
+ } finally {
313
+ if (db) {
314
+ // Force a full WAL flush + truncate before closing so the -wal/-shm
315
+ // sidecars are collapsed into the main db on every filesystem (some
316
+ // network/oddball FS skip the implicit close-time checkpoint). Best-effort:
317
+ // a checkpoint failure must never mask the operation's own outcome.
318
+ try { db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); } catch { /* best-effort flush */ }
319
+ try { db.close(); } catch { /* ignore */ }
320
+ }
321
+ }
322
+ }
323
+
324
+ async function cmdRecord(args) {
325
+ if (args.length !== 3) { warn(USAGE); return; }
326
+ const [rawPlan, rawTask, rawState] = args;
327
+ const root = resolveRoot();
328
+ const planFile = validateKey('plan_file', normalizePlanFile(String(rawPlan ?? ''), root));
329
+ if (planFile === null) return;
330
+ const taskId = validateKey('task_id', rawTask);
331
+ if (taskId === null) return;
332
+ const state = String(rawState ?? '');
333
+ if (!VALID_STATES.has(state)) { warn(`invalid state ${JSON.stringify(state)}; must be one of ' ' '>' 'X' '!'`); return; }
334
+ await withDb(root, (db) => upsert(db, planFile, taskId, state));
335
+ }
336
+
337
+ async function cmdInit(args) {
338
+ if (args.length !== 0) { warn(USAGE); return; }
339
+ const root = resolveRoot();
340
+ await withDb(root, () => { /* create/verify only */ });
341
+ }
342
+
343
+ async function cmdSession(args) {
344
+ if (args.length !== 4) { warn(U_SESSION); return; }
345
+ const [rawId, rawPhase, rawSpec, rawHash] = args;
346
+ const sessionId = validateKey('session_id', rawId, U_SESSION);
347
+ if (sessionId === null) return;
348
+ const phase = validateOptional('phase', rawPhase);
349
+ if (phase === null) return;
350
+ const spec = validateOptional('spec', rawSpec);
351
+ if (spec === null) return;
352
+ const gitHash = validateKey('git_commit_hash', rawHash, U_SESSION);
353
+ if (gitHash === null) return;
354
+ const root = resolveRoot();
355
+ await withDb(root, (db) => upsertSession(db, sessionId, phase, spec, gitHash));
356
+ }
357
+
358
+ async function cmdGetSession(args) {
359
+ if (args.length !== 1) { warn(U_GET_SESSION); return; }
360
+ const sessionId = validateKey('session_id', args[0], U_GET_SESSION);
361
+ if (sessionId === null) return;
362
+ const root = resolveRoot();
363
+ const row = await withDb(root, (db) =>
364
+ db.prepare(
365
+ 'SELECT session_id, started_at, updated_at, phase, spec, git_commit_hash FROM sessions WHERE session_id = $id'
366
+ ).get({ $id: sessionId })
367
+ );
368
+ if (row) {
369
+ const out = {
370
+ session_id: row.session_id, started_at: row.started_at, updated_at: row.updated_at,
371
+ phase: row.phase, spec: row.spec, git_commit_hash: row.git_commit_hash,
372
+ };
373
+ process.stdout.write(JSON.stringify(out) + '\n');
374
+ }
375
+ }
376
+
377
+ async function cmdSnapshot(args) {
378
+ if (args.length !== 1) { warn(U_SNAPSHOT); return; }
379
+ const gitHash = validateKey('git_commit_hash', args[0], U_SNAPSHOT);
380
+ if (gitHash === null) return;
381
+ if (process.stdin.isTTY) { warn(`snap_json is empty; ${U_SNAPSHOT}`); return; } // no-hang guard
382
+ let buf, overCap;
383
+ try { ({ buf, overCap } = readStdinCapped(MAX_SNAP_BYTES)); }
384
+ catch (e) { warn(`error reading stdin: ${(e && e.code) || (e && e.message)}, skipping cache write`); return; }
385
+ if (overCap) { warn(`snap_json exceeds ${MAX_SNAP_BYTES} bytes (10 MiB); rejected`); return; }
386
+ if (buf.length === 0) { warn(`snap_json is empty; ${U_SNAPSHOT}`); return; }
387
+ let snapJson;
388
+ try { snapJson = new TextDecoder('utf-8', { fatal: true }).decode(buf); }
389
+ catch { warn('snap_json is not valid UTF-8; rejected'); return; }
390
+ const root = resolveRoot();
391
+ await withDb(root, (db) => {
392
+ db.prepare('INSERT INTO snapshots (git_commit_hash, created_at, snap_json) VALUES ($h, $c, $j)')
393
+ .run({ $h: gitHash, $c: new Date().toISOString(), $j: snapJson });
394
+ });
395
+ }
396
+
397
+ async function cmdGetSnapshot(args) {
398
+ if (args.length !== 1) { warn(U_GET_SNAPSHOT); return; }
399
+ const gitHash = validateKey('git_commit_hash', args[0], U_GET_SNAPSHOT);
400
+ if (gitHash === null) return;
401
+ const root = resolveRoot();
402
+ const row = await withDb(root, (db) =>
403
+ db.prepare('SELECT snap_json FROM snapshots WHERE git_commit_hash = $h ORDER BY id DESC LIMIT 1').get({ $h: gitHash })
404
+ );
405
+ if (row) { process.stdout.write(row.snap_json); process.stdout.write('\n'); }
406
+ }
407
+
408
+ async function cmdHistory(args) {
409
+ if (args.length !== 2) { warn(U_HISTORY); return; }
410
+ const sessionId = validateKey('session_id', args[0], U_HISTORY);
411
+ if (sessionId === null) return;
412
+ const kind = validateOptional('kind', args[1]);
413
+ if (kind === null) return;
414
+ if (process.stdin.isTTY) { warn(`content is empty; ${U_HISTORY}`); return; } // no-hang guard
415
+ let buf, overCap;
416
+ try { ({ buf, overCap } = readStdinCapped(MAX_CONTENT_BYTES)); }
417
+ catch (e) { warn(`error reading stdin: ${(e && e.code) || (e && e.message)}, skipping cache write`); return; }
418
+ if (overCap) { warn(`content exceeds ${MAX_CONTENT_BYTES} bytes (1 MiB); rejected`); return; }
419
+ if (buf.length === 0) { warn(`content is empty; ${U_HISTORY}`); return; }
420
+ let content;
421
+ try { content = new TextDecoder('utf-8', { fatal: true }).decode(buf); }
422
+ catch { warn('content is not valid UTF-8; rejected'); return; }
423
+ const root = resolveRoot();
424
+ await withDb(root, (db) => {
425
+ db.prepare('INSERT INTO raw_history (session_id, created_at, kind, content) VALUES ($s, $c, $k, $ct)')
426
+ .run({ $s: sessionId, $c: new Date().toISOString(), $k: kind, $ct: content });
427
+ });
428
+ }
429
+
430
+ async function main() {
431
+ const [sub, ...rest] = process.argv.slice(2);
432
+ if (sub === 'record') return cmdRecord(rest);
433
+ if (sub === 'init') return cmdInit(rest);
434
+ if (sub === 'session') return cmdSession(rest);
435
+ if (sub === 'get-session') return cmdGetSession(rest);
436
+ if (sub === 'snapshot') return cmdSnapshot(rest);
437
+ if (sub === 'get-snapshot') return cmdGetSnapshot(rest);
438
+ if (sub === 'history') return cmdHistory(rest);
439
+ warn(`unknown subcommand ${JSON.stringify(sub ?? '')}; ${USAGE}`);
440
+ }
441
+
442
+ // Set exitCode rather than calling process.exit(0): a large query payload
443
+ // (> the ~64 KiB pipe buffer) is written asynchronously, and a synchronous
444
+ // process.exit would truncate the unflushed tail. The db is already closed in
445
+ // withDb's finally and no timers/handles linger, so the event loop drains
446
+ // stdout and exits 0 on its own.
447
+ main().then(() => { process.exitCode = 0; }).catch((e) => {
448
+ warn(`unexpected: ${e && e.message}`);
449
+ process.exitCode = 0;
450
+ });