@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.
- package/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- package/types/watch.d.mts +33 -0
package/lib/storage.mjs
ADDED
|
@@ -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
|
+
}
|