@druumen/sessions-db 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 (50) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +10 -0
  4. package/README.md +250 -0
  5. package/cli/_write-helpers.mjs +99 -0
  6. package/cli/alias.mjs +115 -0
  7. package/cli/argparse.mjs +296 -0
  8. package/cli/close.mjs +116 -0
  9. package/cli/find.mjs +185 -0
  10. package/cli/format.mjs +277 -0
  11. package/cli/link-parent.mjs +133 -0
  12. package/cli/link.mjs +132 -0
  13. package/cli/rebuild.mjs +98 -0
  14. package/cli/sessions-db-session-start-main.mjs +454 -0
  15. package/cli/sessions-db-session-start.mjs +56 -0
  16. package/cli/sessions-db.mjs +119 -0
  17. package/cli/sweep.mjs +171 -0
  18. package/cli/tree.mjs +127 -0
  19. package/lib/git-context.mjs +479 -0
  20. package/lib/identity.mjs +616 -0
  21. package/lib/index.mjs +145 -0
  22. package/lib/init.mjs +185 -0
  23. package/lib/lock.mjs +86 -0
  24. package/lib/operations.mjs +490 -0
  25. package/lib/paths.mjs +199 -0
  26. package/lib/projection.mjs +496 -0
  27. package/lib/sanitize.mjs +131 -0
  28. package/lib/storage.mjs +759 -0
  29. package/lib/sweep.mjs +209 -0
  30. package/lib/transcript.mjs +230 -0
  31. package/lib/types.mjs +276 -0
  32. package/lib/uuid.mjs +116 -0
  33. package/lib/watch.mjs +217 -0
  34. package/package.json +53 -0
  35. package/types/git-context.d.mts +98 -0
  36. package/types/identity.d.mts +658 -0
  37. package/types/index.d.mts +10 -0
  38. package/types/index.d.ts +127 -0
  39. package/types/init.d.mts +53 -0
  40. package/types/lock.d.mts +18 -0
  41. package/types/operations.d.mts +204 -0
  42. package/types/paths.d.mts +54 -0
  43. package/types/projection.d.mts +79 -0
  44. package/types/sanitize.d.mts +39 -0
  45. package/types/storage.d.mts +276 -0
  46. package/types/sweep.d.mts +58 -0
  47. package/types/transcript.d.mts +59 -0
  48. package/types/types.d.mts +255 -0
  49. package/types/uuid.d.mts +17 -0
  50. package/types/watch.d.mts +33 -0
@@ -0,0 +1,759 @@
1
+ /**
2
+ * IO orchestration for sessions-db.
3
+ *
4
+ * Responsibilities:
5
+ * - Build canonical event objects (`newEvent`)
6
+ * - Append events to `events.jsonl` via single-syscall O_APPEND
7
+ * (`fs.appendFileSync` with `{ flag: 'a' }`) — race-safe across
8
+ * processes (POSIX guarantees `O_APPEND + write(2)` is atomic up to
9
+ * PIPE_BUF for regular files; we enforce a hard MAX_EVENT_BYTES guard
10
+ * so payloads never approach that bound)
11
+ * - Atomically rewrite the projection cache under a file lock
12
+ * (write `.tmp.<pid>` → fsync → rename → release lock)
13
+ * - Rebuild the projection from events.jsonl when needed (with explicit
14
+ * tail-partial vs middle-line corruption diagnostics)
15
+ * - Best-effort incremental update for hook callers (`tryUpdateProjection`)
16
+ * that holds the projection lock across the full load → apply → save
17
+ * cycle so concurrent hooks cannot clobber each other's derived state
18
+ *
19
+ * Zero new npm deps: `node:fs`, `node:fs/promises`, `node:path`,
20
+ * `node:crypto` (event_id), and the in-tree `lock.mjs` + `projection.mjs`.
21
+ */
22
+
23
+ import {
24
+ appendFileSync,
25
+ closeSync,
26
+ existsSync,
27
+ fsyncSync,
28
+ mkdirSync,
29
+ openSync,
30
+ readFileSync,
31
+ renameSync,
32
+ unlinkSync,
33
+ writeSync,
34
+ } from 'node:fs';
35
+ import { dirname, isAbsolute, resolve } from 'node:path';
36
+
37
+ import { acquireLock } from './lock.mjs';
38
+ import { resolveIdentity } from './identity.mjs';
39
+ import { resolveStoragePaths } from './paths.mjs';
40
+ import { applyEvent, emptyProjection, rebuildFromEvents } from './projection.mjs';
41
+ import { generateSessionId } from './uuid.mjs';
42
+
43
+ const REPO_ROOT_DEFAULT = process.cwd();
44
+
45
+ /**
46
+ * Hard cap on a single event's serialized size (line bytes including the
47
+ * trailing newline). Set to 4 KiB — the conservative POSIX `PIPE_BUF` lower
48
+ * bound that guarantees `O_APPEND + write(2)` is atomic on regular files
49
+ * across concurrent writers. Larger payloads risk write interleave on some
50
+ * filesystems even with O_APPEND, so we reject them up front and force the
51
+ * caller to chunk or trim instead of corrupting events.jsonl.
52
+ *
53
+ * Exported so callers (sanitize layer, transcript reader, hook composers)
54
+ * can pre-check before constructing events.
55
+ */
56
+ export const MAX_EVENT_BYTES = 4096;
57
+
58
+ /**
59
+ * Default on-disk paths. Resolved against `process.cwd()` so callers that
60
+ * run from the workspace root see the canonical layout. Tests pass an
61
+ * `opts.paths` override to write to a tmpdir.
62
+ */
63
+ export const PATHS = Object.freeze({
64
+ eventsJsonl: 'tickets/_logs/sessions-db-events.jsonl',
65
+ projectionJson: 'tickets/_logs/sessions-db.json',
66
+ lockFile: 'tickets/_logs/sessions-db.lock',
67
+ });
68
+
69
+ /**
70
+ * Build a canonical event. Auto-fills `ts` (ISO ms) and `event_id`
71
+ * (UUIDv7 with `evt_` prefix — same generator as session IDs but with a
72
+ * different prefix so naive lookups can distinguish them).
73
+ *
74
+ * @param {{ op: string, stable_id: string, payload?: object,
75
+ * ts?: string, event_id?: string }} input
76
+ */
77
+ export function newEvent({ op, stable_id, payload, ts, event_id }) {
78
+ if (typeof op !== 'string' || op.length === 0) {
79
+ throw new TypeError('newEvent: op required');
80
+ }
81
+ if (typeof stable_id !== 'string' || stable_id.length === 0) {
82
+ throw new TypeError('newEvent: stable_id required');
83
+ }
84
+ return {
85
+ ts: ts ?? new Date().toISOString(),
86
+ // generateSessionId returns `sess_<uuidv7>` — re-prefix to `evt_` so
87
+ // event ids and stable ids are visually distinct in jsonl tails.
88
+ event_id: event_id ?? `evt_${generateSessionId().slice('sess_'.length)}`,
89
+ op,
90
+ stable_id,
91
+ payload: payload ?? {},
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Append an event to events.jsonl. **No lock** — relies on POSIX O_APPEND
97
+ * atomicity for concurrent multi-process append safety, which is only
98
+ * guaranteed for writes ≤ PIPE_BUF (4 KiB). We enforce that bound via
99
+ * MAX_EVENT_BYTES and reject oversized events instead of silently risking
100
+ * interleave.
101
+ *
102
+ * @param {object} event
103
+ * @param {{ paths?: typeof PATHS, root?: string }} [opts]
104
+ * @throws {Error} when the serialized line exceeds MAX_EVENT_BYTES — caller
105
+ * must reduce payload size or split into multiple smaller events.
106
+ */
107
+ export async function appendEvent(event, opts = {}) {
108
+ const { eventsPath } = resolvePaths(opts);
109
+ ensureParentDir(eventsPath);
110
+ const line = JSON.stringify(event) + '\n';
111
+ // Enforce PIPE_BUF safety up front — exceeding this risks O_APPEND
112
+ // interleave with concurrent writers (which then corrupts events.jsonl
113
+ // in ways rebuild can only flag, not recover). Surface the error so the
114
+ // caller can chunk or trim before persistence.
115
+ const bytes = Buffer.byteLength(line, 'utf8');
116
+ if (bytes > MAX_EVENT_BYTES) {
117
+ throw new Error(
118
+ `appendEvent: event payload too large (${bytes} bytes, max ${MAX_EVENT_BYTES}). ` +
119
+ `Reduce payload size (sanitize transcript previews / fingerprints) or ` +
120
+ `split into multiple events.`,
121
+ );
122
+ }
123
+ // `flag: 'a'` => O_WRONLY | O_CREAT | O_APPEND. Linux + macOS guarantee
124
+ // single-write atomicity for writes ≤ PIPE_BUF (4 KiB); the guard above
125
+ // ensures `line` always fits.
126
+ appendFileSync(eventsPath, line, { flag: 'a' });
127
+ }
128
+
129
+ /**
130
+ * Read events.jsonl into structured `{ events, corruptions }` output.
131
+ *
132
+ * Distinguishes two corruption modes:
133
+ * - tail_partial: malformed line is the last non-empty line of the file —
134
+ * almost always a write-in-progress (writer crashed or we read mid-write).
135
+ * Tolerated; surfaced in `corruptions` for diagnostics but does not block
136
+ * rebuild.
137
+ * - middle_corruption: malformed line has at least one valid line after it
138
+ * in the file. This implies real data damage (filesystem error, partial
139
+ * overwrite, manual edit). Surfaced as a fatal corruption that callers
140
+ * (rebuildProjection) escalate to an exception.
141
+ *
142
+ * @param {{ paths?: typeof PATHS, root?: string }} [opts]
143
+ * @returns {{ events: Array<object>, corruptions: Array<{
144
+ * lineNumber: number, kind: 'tail_partial'|'middle_corruption',
145
+ * tolerated: boolean, excerpt: string, error: string }> }}
146
+ */
147
+ export function readAllEvents(opts = {}) {
148
+ const { eventsPath } = resolvePaths(opts);
149
+ if (!existsSync(eventsPath)) return { events: [], corruptions: [] };
150
+ const raw = readFileSync(eventsPath, 'utf8');
151
+ // Use the raw string split — we want to preserve every separator so we can
152
+ // tell whether a malformed line is at the tail (no trailing newline / last
153
+ // non-empty line) or buried in the middle (has a valid line after it).
154
+ const splitLines = raw.split('\n');
155
+
156
+ // Build a list of {index, content} for non-empty lines, preserving original
157
+ // file line numbers (1-based) for diagnostics.
158
+ const nonEmpty = [];
159
+ for (let i = 0; i < splitLines.length; i++) {
160
+ if (splitLines[i].length > 0) {
161
+ nonEmpty.push({ lineNumber: i + 1, content: splitLines[i] });
162
+ }
163
+ }
164
+ // Also note whether the file ends with a newline — affects whether the
165
+ // last non-empty line is considered "complete" for tail-partial detection.
166
+ const endsWithNewline = raw.length > 0 && raw.endsWith('\n');
167
+
168
+ const events = [];
169
+ const corruptions = [];
170
+ for (let idx = 0; idx < nonEmpty.length; idx++) {
171
+ const { lineNumber, content } = nonEmpty[idx];
172
+ try {
173
+ events.push(JSON.parse(content));
174
+ } catch (err) {
175
+ // Tail = the very last non-empty line AND that line is not newline-
176
+ // terminated (i.e. the writer was interrupted mid-line). A malformed
177
+ // line that IS newline-terminated indicates corruption rather than an
178
+ // in-progress write.
179
+ const isLastNonEmpty = idx === nonEmpty.length - 1;
180
+ const isTailPartial = isLastNonEmpty && !endsWithNewline;
181
+ corruptions.push({
182
+ lineNumber,
183
+ kind: isTailPartial ? 'tail_partial' : 'middle_corruption',
184
+ tolerated: isTailPartial,
185
+ excerpt: content.slice(0, 80),
186
+ error: String(err),
187
+ });
188
+ }
189
+ }
190
+ return { events, corruptions };
191
+ }
192
+
193
+ /**
194
+ * Load the projection cache from disk. On missing/corrupt file, falls back
195
+ * to a full rebuild from events.jsonl.
196
+ *
197
+ * @param {{ paths?: typeof PATHS, root?: string }} [opts]
198
+ * @returns {Promise<object>}
199
+ */
200
+ export async function loadProjection(opts = {}) {
201
+ const { projectionPath } = resolvePaths(opts);
202
+ if (!existsSync(projectionPath)) {
203
+ return rebuildProjectionInMemory(opts);
204
+ }
205
+ let raw;
206
+ try {
207
+ raw = readFileSync(projectionPath, 'utf8');
208
+ } catch {
209
+ return rebuildProjectionInMemory(opts);
210
+ }
211
+ try {
212
+ const parsed = JSON.parse(raw);
213
+ // Sanity check: must be an object with a `sessions` map and `_meta`.
214
+ // Anything else => fall back to rebuild.
215
+ if (
216
+ !parsed ||
217
+ typeof parsed !== 'object' ||
218
+ !parsed.sessions ||
219
+ typeof parsed.sessions !== 'object' ||
220
+ !parsed._meta ||
221
+ typeof parsed._meta !== 'object'
222
+ ) {
223
+ return rebuildProjectionInMemory(opts);
224
+ }
225
+ return parsed;
226
+ } catch {
227
+ // JSON.parse failed => corrupted projection.
228
+ return rebuildProjectionInMemory(opts);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Atomically write the projection cache.
234
+ *
235
+ * Default behavior acquires the file lock around the entire write. Callers
236
+ * that already hold the lock (e.g. `tryUpdateProjection`'s read-modify-write
237
+ * cycle) pass `withLock: false` to avoid double-acquire deadlock.
238
+ *
239
+ * Steps (with lock):
240
+ * 1. Acquire the file lock
241
+ * 2. Write to `<projection>.tmp.<pid>`
242
+ * 3. fsync the tmp file
243
+ * 4. rename tmp → real (atomic on POSIX)
244
+ * 5. Release the lock
245
+ *
246
+ * On any error, attempts to clean up the tmp file before propagating.
247
+ *
248
+ * @param {object} projection
249
+ * @param {{ paths?: typeof PATHS, root?: string,
250
+ * lockTimeoutMs?: number, lockRetryMs?: number,
251
+ * withLock?: boolean }} [opts]
252
+ */
253
+ export async function saveProjection(projection, opts = {}) {
254
+ const { projectionPath, lockPath } = resolvePaths(opts);
255
+ ensureParentDir(projectionPath);
256
+ ensureParentDir(lockPath);
257
+
258
+ const withLock = opts.withLock !== false;
259
+ const lock = withLock
260
+ ? await acquireLock(lockPath, {
261
+ timeoutMs: opts.lockTimeoutMs,
262
+ retryMs: opts.lockRetryMs,
263
+ })
264
+ : null;
265
+
266
+ try {
267
+ saveProjectionUnlocked(projection, projectionPath);
268
+ } finally {
269
+ if (lock) lock.release();
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Internal: write projection to disk without lock acquisition. Caller is
275
+ * responsible for serializing concurrent writes (held lock or single-writer
276
+ * invariant). Used by both the public locked `saveProjection` and the
277
+ * `tryUpdateProjection` read-modify-write under-lock fast path.
278
+ */
279
+ function saveProjectionUnlocked(projection, projectionPath) {
280
+ const tmpPath = `${projectionPath}.tmp.${process.pid}`;
281
+ try {
282
+ // Bump _meta.updated to now so consumers can detect freshness without
283
+ // touching event_count (which represents derived input volume).
284
+ if (projection && projection._meta) {
285
+ projection._meta.updated = new Date().toISOString();
286
+ }
287
+ const body = JSON.stringify(projection, null, 2);
288
+
289
+ // Write + fsync via fd to guarantee data hits disk before rename.
290
+ const fd = openSync(tmpPath, 'w');
291
+ try {
292
+ writeSync(fd, body);
293
+ fsyncSync(fd);
294
+ } finally {
295
+ closeSync(fd);
296
+ }
297
+
298
+ renameSync(tmpPath, projectionPath);
299
+ } catch (err) {
300
+ // Clean up partial tmp file so subsequent runs do not see stale debris.
301
+ try {
302
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
303
+ } catch {
304
+ // Best-effort cleanup — original error is more important.
305
+ }
306
+ throw err;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Full rebuild: scan events.jsonl, fold into a fresh projection, persist.
312
+ *
313
+ * Returns `toleratedCorruptions` so callers can surface diagnostics
314
+ * (tail-partial lines from interrupted writes are common during heavy
315
+ * concurrent load and worth observing). Middle-line corruption escalates
316
+ * to a thrown error from `readAllEventsOrThrow` and never reaches here.
317
+ *
318
+ * @param {{ paths?: typeof PATHS, root?: string,
319
+ * lockTimeoutMs?: number, lockRetryMs?: number }} [opts]
320
+ * @returns {Promise<{ sessionCount: number, eventCount: number,
321
+ * toleratedCorruptions: number }>}
322
+ */
323
+ export async function rebuildProjection(opts = {}) {
324
+ const { projection, toleratedCorruptions } = rebuildProjectionInMemoryDetailed(opts);
325
+ await saveProjection(projection, opts);
326
+ return {
327
+ sessionCount: Object.keys(projection.sessions).length,
328
+ eventCount: projection._meta.event_count,
329
+ toleratedCorruptions,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Best-effort incremental update for hook callers.
335
+ *
336
+ * Pattern (from Phase 1 ticket §"Hook caller pattern"):
337
+ * 1. Build event with newEvent()
338
+ * 2. Append it to events.jsonl FIRST via O_APPEND (race-safe SSoT)
339
+ * 3. Acquire the projection lock and run the full read-modify-write under
340
+ * the lock so concurrent hooks cannot read the same baseline projection
341
+ * and clobber each other's derived state.
342
+ * 4. If anything fails, return `{ ok: false, error }` — the SSoT is
343
+ * already consistent, so the next rebuild reconciles the projection.
344
+ *
345
+ * @param {object} event
346
+ * @param {{ paths?: typeof PATHS, root?: string,
347
+ * lockTimeoutMs?: number, lockRetryMs?: number }} [opts]
348
+ * @returns {Promise<{ ok: boolean, error?: string }>}
349
+ */
350
+ export async function tryUpdateProjection(event, opts = {}) {
351
+ // (1) Append to SSoT first. O_APPEND is race-safe so multiple concurrent
352
+ // hooks all land their events without coordination. If the SSoT append
353
+ // fails (oversized payload, disk full), we never proceed to projection —
354
+ // there's no derived state to update.
355
+ try {
356
+ await appendEvent(event, opts);
357
+ } catch (err) {
358
+ return { ok: false, error: `append: ${err && err.message ? err.message : String(err)}` };
359
+ }
360
+
361
+ // (2) Acquire the projection lock for the full read-modify-write cycle.
362
+ // The lock MUST span loadProjection → applyEvent → saveProjection,
363
+ // otherwise two concurrent hooks both load N, each applies their own
364
+ // event, and the loser's apply is overwritten on save (lost-update bug).
365
+ // The events.jsonl SSoT still has both events — next rebuild fixes the
366
+ // projection — but live readers see a stale state in the meantime.
367
+ const { lockPath } = resolvePaths(opts);
368
+ ensureParentDir(lockPath);
369
+ let lock;
370
+ try {
371
+ lock = await acquireLock(lockPath, {
372
+ timeoutMs: opts.lockTimeoutMs,
373
+ retryMs: opts.lockRetryMs,
374
+ });
375
+ } catch (err) {
376
+ return { ok: false, error: err && err.message ? err.message : String(err) };
377
+ }
378
+
379
+ try {
380
+ const projection = await loadProjection(opts);
381
+ applyEvent(projection, event);
382
+ // Skip the lock in saveProjection — we already hold it. Reacquiring
383
+ // would be a guaranteed deadlock (the lock is exclusive create-or-fail).
384
+ await saveProjection(projection, { ...opts, withLock: false });
385
+ return { ok: true };
386
+ } catch (err) {
387
+ return { ok: false, error: err && err.message ? err.message : String(err) };
388
+ } finally {
389
+ lock.release();
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Atomic transaction for `session_seen` events.
395
+ *
396
+ * Why a dedicated entry point instead of "lookup → mint → tryUpdateProjection"?
397
+ *
398
+ * The hook's old flow ran `loadProjection` outside the lock to look up an
399
+ * existing stable_id by claude_session_id, minted a fresh one on miss, built
400
+ * the event with that stable_id, then handed the event to
401
+ * `tryUpdateProjection`. With two concurrent hooks for the SAME
402
+ * claude_session_id, both would observe an empty projection during the
403
+ * unlocked lookup, both mint different stable_ids, both append events under
404
+ * different stable_ids, and the projection would split into two records
405
+ * for the same logical session.
406
+ *
407
+ * P3 (this phase) extends the resolution from "claude_session_id only" to
408
+ * the full 3-priority chain implemented in `identity.mjs`:
409
+ *
410
+ * 1. claude_session_id_index (exact) — baseline P2 behavior
411
+ * 2. transcript_lineage (high) — covers fork/resume
412
+ * 3. fingerprint_corroborator (low) — soft cross-session match
413
+ *
414
+ * On any miss → mint via uuidv7. Fingerprint matches without enough
415
+ * corroborators are surfaced as `parent_candidate_ids[]` (hub-spoke hints —
416
+ * NOT auto-promoted to parent_session_id).
417
+ *
418
+ * Critical-section flow (held under projection lock end-to-end):
419
+ *
420
+ * 1. Acquire projection lock
421
+ * 2. Load projection inside lock
422
+ * 3. Run resolveIdentity() against the baseline projection (P1→P2→P3→mint)
423
+ * 4. Call `payloadBuilder(stableId, identityResolution)` for the payload
424
+ * 5. Auto-inject `identity_resolution` + merged `parent_candidate_ids`
425
+ * into the payload so the audit trail is always present
426
+ * 6. Build canonical event via `newEvent`
427
+ * 7. Append event to events.jsonl
428
+ * 8. Apply event to in-memory projection
429
+ * 9. Save projection (under same lock — pass `withLock: false`)
430
+ * 10. Release lock
431
+ *
432
+ * The `payloadBuilder` callback receives both `stableId` and the full
433
+ * `identityResolution` object; callers may inspect/override the audit
434
+ * fields, but if they leave `payload.identity_resolution` undefined we
435
+ * inject it ourselves. `parent_candidate_ids` is merged additively so a
436
+ * caller-supplied list (rare; mostly the projection's own derivation) is
437
+ * preserved.
438
+ *
439
+ * Privacy: pass `opts.storeFirstPrompt: false` to clear the
440
+ * `first_prompt_preview` field on the persisted payload (whatever the
441
+ * payloadBuilder returned is overwritten with `null`). Default `true`
442
+ * preserves the pre-0.1.0 behavior. Sanitization, fingerprints, and
443
+ * transcript_files meta are NOT affected — only the human-readable preview
444
+ * is stripped, so identity reconciliation still works for opt-out users.
445
+ *
446
+ * @param {{
447
+ * claudeSessionId: string,
448
+ * payloadBuilder: (stableId: string, identityResolution?: object) => object,
449
+ * transcriptMeta?: object|null,
450
+ * gitContext?: object|null,
451
+ * cwd?: string|null,
452
+ * fingerprints?: { first_human_prompt_v1?: string|null, lineage_prefix_v1?: string|null }|null,
453
+ * now?: number,
454
+ * timeWindowHours?: number,
455
+ * minCorroborators?: number,
456
+ * storeFirstPrompt?: boolean,
457
+ * paths?: typeof PATHS,
458
+ * root?: string,
459
+ * lockTimeoutMs?: number,
460
+ * lockRetryMs?: number,
461
+ * }} opts
462
+ * @returns {Promise<{ ok: boolean, stableId?: string, eventId?: string,
463
+ * minted?: boolean, identityResolution?: object, error?: string }>}
464
+ */
465
+ export async function recordSessionSeen(opts) {
466
+ if (!opts || typeof opts !== 'object') {
467
+ return { ok: false, error: 'recordSessionSeen: opts required' };
468
+ }
469
+ const { claudeSessionId, payloadBuilder } = opts;
470
+ if (typeof claudeSessionId !== 'string' || claudeSessionId.length === 0) {
471
+ return { ok: false, error: 'recordSessionSeen: claudeSessionId required' };
472
+ }
473
+ if (typeof payloadBuilder !== 'function') {
474
+ return { ok: false, error: 'recordSessionSeen: payloadBuilder required' };
475
+ }
476
+
477
+ const { lockPath } = resolvePaths(opts);
478
+ ensureParentDir(lockPath);
479
+
480
+ let lock;
481
+ try {
482
+ lock = await acquireLock(lockPath, {
483
+ timeoutMs: opts.lockTimeoutMs,
484
+ retryMs: opts.lockRetryMs,
485
+ });
486
+ } catch (err) {
487
+ return { ok: false, error: `lock: ${err && err.message ? err.message : String(err)}` };
488
+ }
489
+
490
+ try {
491
+ // (1) Load projection INSIDE the lock so the resolution observes the
492
+ // same baseline we're about to mutate.
493
+ const projection = await loadProjection(opts);
494
+
495
+ // (2) Run the 3-priority identity chain. resolveIdentity is pure — all
496
+ // IO already happened (load) and the result is fully determined by the
497
+ // projection snapshot + input signals.
498
+ const identityResolution = resolveIdentity({
499
+ projection,
500
+ claudeSessionId,
501
+ transcriptMeta: opts.transcriptMeta ?? null,
502
+ gitContext: opts.gitContext ?? null,
503
+ cwd: opts.cwd ?? null,
504
+ fingerprints: opts.fingerprints ?? null,
505
+ now: opts.now,
506
+ timeWindowHours: opts.timeWindowHours,
507
+ minCorroborators: opts.minCorroborators,
508
+ mintStableId: generateSessionId,
509
+ });
510
+ const stableId = identityResolution.stableId;
511
+ const minted = identityResolution.source === 'minted';
512
+
513
+ // (3) Build payload via caller-supplied closure. The closure receives
514
+ // both the stable_id AND the resolution result so callers may include
515
+ // identity-derived fields (e.g. surface a human-readable description of
516
+ // why this session was matched) without recomputing.
517
+ let payload;
518
+ try {
519
+ payload = payloadBuilder(stableId, identityResolution);
520
+ } catch (err) {
521
+ return {
522
+ ok: false,
523
+ error: `payloadBuilder: ${err && err.message ? err.message : String(err)}`,
524
+ };
525
+ }
526
+ if (!payload || typeof payload !== 'object') {
527
+ payload = {};
528
+ }
529
+ // Guarantee claude_session_id is present in the payload — the projection
530
+ // reducer keys off it for the `claude_session_ids[]` dedup.
531
+ if (typeof payload.claude_session_id !== 'string' ||
532
+ payload.claude_session_id.length === 0) {
533
+ payload = { ...payload, claude_session_id: claudeSessionId };
534
+ }
535
+
536
+ // (3b) Privacy opt-out: when storeFirstPrompt === false the caller is
537
+ // saying "do not persist the human-readable first-prompt excerpt".
538
+ // We strip the `first_prompt_preview` field from the outbound payload
539
+ // (set to null so projection consumers can distinguish "never had a
540
+ // preview" from "field missing"). Fingerprints + transcript_files meta
541
+ // are intentionally untouched — identity reconciliation still works
542
+ // because the hashes derive from the in-memory transcriptMeta inside
543
+ // the hook before recordSessionSeen is even called.
544
+ //
545
+ // Default `true` (i.e. anything other than literal `false`) preserves
546
+ // the 0.1.0-dev behavior — backward-compat guarantee for callers that
547
+ // never pass the opt.
548
+ if (opts.storeFirstPrompt === false) {
549
+ payload.first_prompt_preview = null;
550
+ }
551
+
552
+ // (4) Inject audit trail. The contract: every session_seen event MUST
553
+ // carry the resolution so any future rebuild can show how the stable_id
554
+ // was derived. If the caller already set it, we honor that. Otherwise
555
+ // we inject the canonical shape.
556
+ if (payload.identity_resolution === undefined) {
557
+ payload.identity_resolution = {
558
+ source: identityResolution.source,
559
+ confidence: identityResolution.confidence,
560
+ matched: identityResolution.matched,
561
+ };
562
+ }
563
+
564
+ // (5) Merge parent candidates additively. resolveIdentity surfaces them
565
+ // as `{ stable_id, source, confidence, reason }` records; the reducer
566
+ // dedups by stable_id when applying. Already capped at
567
+ // identity.MAX_PARENT_CANDIDATES so the merged payload stays under
568
+ // MAX_EVENT_BYTES even when many fingerprint candidates exist.
569
+ if (Array.isArray(identityResolution.parentCandidates) &&
570
+ identityResolution.parentCandidates.length > 0) {
571
+ const existing = Array.isArray(payload.parent_candidate_ids)
572
+ ? payload.parent_candidate_ids
573
+ : [];
574
+ payload.parent_candidate_ids = [
575
+ ...existing,
576
+ ...identityResolution.parentCandidates,
577
+ ];
578
+ }
579
+
580
+ // (5b) Surface omitted-count when the cap fired. Stored alongside the
581
+ // candidates so CLI / audit can render "+ N more". A value of 0 (or
582
+ // missing) means the surface is complete. Reducer treats missing as 0
583
+ // for backward compat with pre-cap events.
584
+ if (typeof identityResolution.parentCandidatesOmittedCount === 'number'
585
+ && identityResolution.parentCandidatesOmittedCount > 0
586
+ && payload.parent_candidates_omitted_count === undefined) {
587
+ payload.parent_candidates_omitted_count =
588
+ identityResolution.parentCandidatesOmittedCount;
589
+ }
590
+
591
+ // (6) Build the canonical event.
592
+ const event = newEvent({
593
+ op: 'session_seen',
594
+ stable_id: stableId,
595
+ payload,
596
+ });
597
+
598
+ // (7) Append to SSoT first (events.jsonl). Even if the projection write
599
+ // later fails, the event is durable and a future rebuild reconstructs
600
+ // the projection state.
601
+ try {
602
+ await appendEvent(event, opts);
603
+ } catch (err) {
604
+ return {
605
+ ok: false,
606
+ error: `append: ${err && err.message ? err.message : String(err)}`,
607
+ };
608
+ }
609
+
610
+ // (8) Apply to projection in memory + persist (skip lock — already held).
611
+ try {
612
+ applyEvent(projection, event);
613
+ await saveProjection(projection, { ...opts, withLock: false });
614
+ } catch (err) {
615
+ return {
616
+ ok: false,
617
+ error: `projection: ${err && err.message ? err.message : String(err)}`,
618
+ };
619
+ }
620
+
621
+ return {
622
+ ok: true,
623
+ stableId,
624
+ eventId: event.event_id,
625
+ minted,
626
+ identityResolution,
627
+ };
628
+ } finally {
629
+ lock.release();
630
+ }
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // Internal helpers
635
+ // ---------------------------------------------------------------------------
636
+
637
+ /**
638
+ * Resolve the on-disk file triple for the current operation.
639
+ *
640
+ * Three input shapes are supported (priority order, first hit wins) so all
641
+ * three storage-call patterns from Days 1-3 keep working unmodified:
642
+ *
643
+ * 1. `opts.paths` — fully-formed override (legacy form used by storage
644
+ * tests). Each field may be absolute (tests pin a tmpdir explicitly) or
645
+ * relative; relative paths anchor on `opts.root` (or cwd).
646
+ *
647
+ * 2. `opts.rootPath` — Day 4 single-arg form. Delegates to
648
+ * `resolveStoragePaths` so the canonical filenames + ascend chain apply
649
+ * uniformly. Library consumers (cockpit, init wizard) pass this.
650
+ *
651
+ * 3. `opts.root` — operations.mjs / wrapper form. Treated as a root
652
+ * override that combines with the canonical PATHS layout (relative
653
+ * segments) so existing operations callsites keep their behavior.
654
+ *
655
+ * 4. (default) — no override → `resolveStoragePaths()` with no args runs
656
+ * the full env > existing-storage > default chain anchored on cwd.
657
+ *
658
+ * Why preserve the legacy `opts.paths` shape verbatim instead of routing
659
+ * everything through `resolveStoragePaths`? Existing storage unit tests
660
+ * pass `paths.eventsJsonl = join(tmpdir, 'events.jsonl')` (NOT
661
+ * `sessions-db-events.jsonl`) — switching the resolver would force every
662
+ * test to know the canonical filename. The legacy shape stays a 1-line
663
+ * passthrough so 350+ existing tests keep working.
664
+ *
665
+ * @param {{ paths?: { eventsJsonl: string, projectionJson: string, lockFile: string },
666
+ * rootPath?: string, root?: string, cwd?: string }} opts
667
+ * @returns {{ eventsPath: string, projectionPath: string, lockPath: string }}
668
+ */
669
+ function resolvePaths(opts) {
670
+ // Shape 1: explicit `opts.paths` override (legacy storage-test form).
671
+ // Anchor relative entries on `opts.root` (or cwd) just like Day 1.
672
+ if (opts && opts.paths) {
673
+ const root = opts.root ?? REPO_ROOT_DEFAULT;
674
+ const abs = (p) => (isAbsolute(p) ? p : resolve(root, p));
675
+ return {
676
+ eventsPath: abs(opts.paths.eventsJsonl),
677
+ projectionPath: abs(opts.paths.projectionJson),
678
+ lockPath: abs(opts.paths.lockFile),
679
+ };
680
+ }
681
+ // Shape 2: explicit `opts.rootPath` — Day 4 form, delegates entirely.
682
+ if (opts && typeof opts.rootPath === 'string' && opts.rootPath.length > 0) {
683
+ const r = resolveStoragePaths({ rootPath: opts.rootPath });
684
+ return { eventsPath: r.eventsJsonl, projectionPath: r.projectionJson, lockPath: r.lockFile };
685
+ }
686
+ // Shape 3: legacy `opts.root` (operations / CLI --root / rebuild-test form).
687
+ // PRESERVES the pre-Day-4 layout exactly: PATHS (which embeds the
688
+ // `tickets/_logs/` prefix) is anchored at `opts.root`. We do NOT delegate
689
+ // to the ascend chain here — many existing tests plant ONLY events.jsonl
690
+ // (no projection file) at `<root>/tickets/_logs/` and call with `--root
691
+ // <root>`; ascend's existence check would miss and fall through to
692
+ // `<root>/.dru-code/`, breaking the test contract. The Day 4 ascend is
693
+ // intended for callers that never supply a root, not for callers that
694
+ // already pinned one.
695
+ if (opts && typeof opts.root === 'string' && opts.root.length > 0) {
696
+ const root = opts.root;
697
+ const abs = (p) => (isAbsolute(p) ? p : resolve(root, p));
698
+ return {
699
+ eventsPath: abs(PATHS.eventsJsonl),
700
+ projectionPath: abs(PATHS.projectionJson),
701
+ lockPath: abs(PATHS.lockFile),
702
+ };
703
+ }
704
+ // Shape 4: full default chain (env → ascend → cwd/.dru-code).
705
+ const r = resolveStoragePaths({ cwd: opts && opts.cwd });
706
+ return { eventsPath: r.eventsJsonl, projectionPath: r.projectionJson, lockPath: r.lockFile };
707
+ }
708
+
709
+ function ensureParentDir(filePath) {
710
+ const dir = dirname(filePath);
711
+ mkdirSync(dir, { recursive: true });
712
+ }
713
+
714
+ /**
715
+ * Read all events, escalating any middle-line corruption to a thrown error.
716
+ * Tail-partial corruptions are returned alongside the events as a
717
+ * diagnostic count so callers can observe them.
718
+ *
719
+ * @returns {{ events: Array<object>, toleratedCorruptions: number }}
720
+ */
721
+ function readAllEventsOrThrow(opts) {
722
+ const { events, corruptions } = readAllEvents(opts);
723
+ const fatal = corruptions.filter((c) => !c.tolerated);
724
+ if (fatal.length > 0) {
725
+ const summary = fatal
726
+ .map((c) => `line ${c.lineNumber}: ${c.error}`)
727
+ .slice(0, 5)
728
+ .join('; ');
729
+ const err = new Error(
730
+ `events.jsonl middle-line corruption (${fatal.length} line${fatal.length === 1 ? '' : 's'}): ${summary}`,
731
+ );
732
+ err.corruptions = fatal;
733
+ throw err;
734
+ }
735
+ return { events, toleratedCorruptions: corruptions.length };
736
+ }
737
+
738
+ /**
739
+ * Build a fresh projection in memory (without persisting). Used as the
740
+ * backing op for both rebuildProjection and the loadProjection fallback.
741
+ * Does NOT surface corruption diagnostics — used only when caller doesn't
742
+ * need them (loadProjection fallback discards info anyway).
743
+ */
744
+ function rebuildProjectionInMemory(opts) {
745
+ const { events } = readAllEventsOrThrow(opts);
746
+ if (events.length === 0) return emptyProjection();
747
+ return rebuildFromEvents(events);
748
+ }
749
+
750
+ /**
751
+ * Same as `rebuildProjectionInMemory` but returns the tolerated corruption
752
+ * count alongside the projection so `rebuildProjection` can include it in
753
+ * its diagnostics output.
754
+ */
755
+ function rebuildProjectionInMemoryDetailed(opts) {
756
+ const { events, toleratedCorruptions } = readAllEventsOrThrow(opts);
757
+ const projection = events.length === 0 ? emptyProjection() : rebuildFromEvents(events);
758
+ return { projection, toleratedCorruptions };
759
+ }