@harness-fe/mcp-server 3.0.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
@@ -0,0 +1,1321 @@
1
+ /**
2
+ * JsonlStore — JSONL-based persistence layer (v0.4.0 layout).
3
+ *
4
+ * New layout (v0.4.0):
5
+ * {dataDir}/projects/{projectId}/meta.json
6
+ * {dataDir}/projects/{projectId}/notes.jsonl
7
+ * {dataDir}/projects/{projectId}/builds/{buildId}/meta.json
8
+ * {dataDir}/tabs/{tabId}/meta.json
9
+ * {dataDir}/sessions/{sessionId}/meta.json
10
+ * {dataDir}/sessions/{sessionId}/timeline.jsonl
11
+ * {dataDir}/sessions/{sessionId}/recording.jsonl
12
+ * {dataDir}/exports/index.jsonl
13
+ * {dataDir}/exports/{exportId}.rrweb.json
14
+ *
15
+ * Legacy layout (v0.3.x, read-only fallback):
16
+ * {dataDir}/{projectId}/sessions/{buildId}/tabs/{tabId}/...
17
+ * On startup, if legacy dirs are detected a warning is emitted pointing
18
+ * users to `rm -rf ~/.harness/data`.
19
+ */
20
+
21
+ import {
22
+ appendFileSync,
23
+ existsSync,
24
+ mkdirSync,
25
+ openSync,
26
+ readSync,
27
+ closeSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ rmdirSync,
31
+ statSync,
32
+ unlinkSync,
33
+ writeFileSync,
34
+ } from 'node:fs';
35
+ import { WriteQueue } from './WriteQueue.js';
36
+ import { join, resolve } from 'node:path';
37
+ import { randomUUID } from 'node:crypto';
38
+ import { homedir } from 'node:os';
39
+ import type {
40
+ BuildMeta,
41
+ IStore,
42
+ ProjectMeta,
43
+ ProjectTreeNode,
44
+ PurgeResult,
45
+ RecordingChunk,
46
+ RecordingChunkSummary,
47
+ ReplayExportMeta,
48
+ RetentionPolicy,
49
+ SearchOptions,
50
+ SessionMeta,
51
+ SessionSummary,
52
+ StoreEvent,
53
+ TabMeta,
54
+ TailOptions,
55
+ VisitorMeta,
56
+ } from './types.js';
57
+ import type { VisitorEnv } from '@harness-fe/protocol';
58
+
59
+ /**
60
+ * Append to a deduped LRU list capped at `max` entries. Pushing an existing
61
+ * value moves it to the tail (most-recent). Used for VisitorMeta.tabIds and
62
+ * VisitorMeta.projectIds so noisy demo sites don't grow these unboundedly.
63
+ */
64
+ function lruAppend(existing: string[] | undefined, value: string | undefined, max: number): string[] {
65
+ const list = existing ? [...existing] : [];
66
+ if (!value) return list;
67
+ const idx = list.indexOf(value);
68
+ if (idx >= 0) list.splice(idx, 1);
69
+ list.push(value);
70
+ while (list.length > max) list.shift();
71
+ return list;
72
+ }
73
+
74
+ const DEFAULT_DATA_DIR = join(homedir(), '.harness', 'data');
75
+ const DEFAULT_RETENTION = {
76
+ maxAgeDays: 7,
77
+ maxSessions: 200,
78
+ recordingRetentionDays: 3,
79
+ maxRecordingChunksPerSession: 500,
80
+ maxRecordingBytesPerSession: 250 * 1024 * 1024,
81
+ preserveMarkedChunks: true,
82
+ maxExportsPerProject: 50,
83
+ maxExportBytesPerProject: 200 * 1024 * 1024,
84
+ maxBuildsPerProject: 100,
85
+ };
86
+
87
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
88
+
89
+ function ensureDir(dir: string): void {
90
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
91
+ }
92
+
93
+ function writeJson(path: string, data: unknown): void {
94
+ writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8');
95
+ }
96
+
97
+ function readJson<T>(path: string): T | undefined {
98
+ try {
99
+ return JSON.parse(readFileSync(path, 'utf-8')) as T;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ function appendJsonl(path: string, obj: unknown): void {
106
+ appendFileSync(path, JSON.stringify(obj) + '\n', 'utf-8');
107
+ }
108
+
109
+ function readLastNLines(filePath: string, n: number): string[] {
110
+ if (!existsSync(filePath)) return [];
111
+ const CHUNK = 16 * 1024;
112
+ const { size } = statSync(filePath);
113
+ if (size === 0) return [];
114
+
115
+ if (size <= CHUNK * 2) {
116
+ const content = readFileSync(filePath, 'utf-8');
117
+ const lines = content.split('\n').filter((l) => l.trim());
118
+ return lines.slice(-n);
119
+ }
120
+
121
+ const fd = openSync(filePath, 'r');
122
+ try {
123
+ let pos = size;
124
+ let collected = '';
125
+ let lines: string[] = [];
126
+
127
+ while (pos > 0 && lines.length < n + 1) {
128
+ const readSize = Math.min(CHUNK, pos);
129
+ pos -= readSize;
130
+ const buf = Buffer.alloc(readSize);
131
+ readSync(fd, buf, 0, readSize, pos);
132
+ collected = buf.toString('utf-8') + collected;
133
+ lines = collected.split('\n').filter((l) => l.trim());
134
+ }
135
+
136
+ return lines.slice(-n);
137
+ } finally {
138
+ closeSync(fd);
139
+ }
140
+ }
141
+
142
+ function readAllLines(filePath: string): string[] {
143
+ if (!existsSync(filePath)) return [];
144
+ return readFileSync(filePath, 'utf-8')
145
+ .split('\n')
146
+ .filter((l) => l.trim());
147
+ }
148
+
149
+ function parseEvent(line: string): StoreEvent | undefined {
150
+ try {
151
+ return JSON.parse(line) as StoreEvent;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ function parseJsonLine<T>(line: string): T | undefined {
158
+ try {
159
+ return JSON.parse(line) as T;
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ interface RecordingChunkRecord extends RecordingChunk {
166
+ line: string;
167
+ bytes: number;
168
+ marked: boolean;
169
+ ageTs: number;
170
+ }
171
+
172
+ function parseRecordingChunkLine(
173
+ line: string,
174
+ tabId: string,
175
+ fallbackAgeTs: number,
176
+ index: number,
177
+ ): RecordingChunkRecord | undefined {
178
+ const parsed = parseJsonLine<Record<string, unknown>>(line);
179
+ if (!parsed || !Array.isArray(parsed.events)) return undefined;
180
+
181
+ if (typeof parsed.chunkId !== 'string') {
182
+ process.stderr.write(
183
+ `[harness-fe] recording chunk at index ${index} is missing chunkId — skipping (pre-0.4 data). ` +
184
+ `Run \`rm -rf ~/.harness/data\` to clear legacy data.\n`,
185
+ );
186
+ return undefined;
187
+ }
188
+ const chunkId = parsed.chunkId;
189
+ const startTs =
190
+ typeof parsed.startTs === 'number'
191
+ ? parsed.startTs
192
+ : typeof parsed.ts === 'number'
193
+ ? parsed.ts
194
+ : undefined;
195
+ const endTs =
196
+ typeof parsed.endTs === 'number'
197
+ ? parsed.endTs
198
+ : typeof parsed.ts === 'number'
199
+ ? parsed.ts
200
+ : undefined;
201
+ if (startTs === undefined || endTs === undefined) return undefined;
202
+
203
+ return {
204
+ chunkId,
205
+ tabId,
206
+ startTs,
207
+ endTs,
208
+ eventCount:
209
+ typeof parsed.eventCount === 'number'
210
+ ? parsed.eventCount
211
+ : parsed.events.length,
212
+ events: parsed.events,
213
+ line,
214
+ bytes: Buffer.byteLength(`${line}\n`, 'utf-8'),
215
+ marked: false,
216
+ ageTs:
217
+ typeof parsed.endTs === 'number'
218
+ ? parsed.endTs
219
+ : fallbackAgeTs,
220
+ };
221
+ }
222
+
223
+ const META_EXTENSION_LIMIT_BYTES = 16 * 1024;
224
+ const MAX_EVENT_BYTES = 256 * 1024;
225
+ const MAX_RECORDING_CHUNK_BYTES = 2 * 1024 * 1024;
226
+
227
+ function enforceExtensionBudget(meta: { tags?: unknown; metadata?: unknown }, label: string): void {
228
+ const open = JSON.stringify({ tags: meta.tags, metadata: meta.metadata });
229
+ const size = Buffer.byteLength(open, 'utf-8');
230
+ if (size > META_EXTENSION_LIMIT_BYTES) {
231
+ throw new Error(
232
+ `[harness-fe] refused to write ${label}: tags+metadata payload is ${size} bytes (limit ${META_EXTENSION_LIMIT_BYTES}).`,
233
+ );
234
+ }
235
+ }
236
+
237
+ function matchesType(event: StoreEvent, type: string | string[] | undefined): boolean {
238
+ if (!type) return true;
239
+ if (Array.isArray(type)) return type.includes(event.t);
240
+ return event.t === type;
241
+ }
242
+
243
+ function matchesTimeRange(event: StoreEvent, since?: number, until?: number): boolean {
244
+ if (since !== undefined && event.ts < since) return false;
245
+ if (until !== undefined && event.ts > until) return false;
246
+ return true;
247
+ }
248
+
249
+ function dirSize(dir: string): number {
250
+ if (!existsSync(dir)) return 0;
251
+ let total = 0;
252
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
253
+ const full = join(dir, entry.name);
254
+ if (entry.isDirectory()) total += dirSize(full);
255
+ else total += statSync(full).size;
256
+ }
257
+ return total;
258
+ }
259
+
260
+ function rmrf(dir: string): void {
261
+ if (!existsSync(dir)) return;
262
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
263
+ const full = join(dir, entry.name);
264
+ if (entry.isDirectory()) rmrf(full);
265
+ else unlinkSync(full);
266
+ }
267
+ rmdirSync(dir);
268
+ }
269
+
270
+ // ─── JsonlStore ───────────────────────────────────────────────────────────────
271
+
272
+ export class JsonlStore implements IStore {
273
+ private readonly dataDir: string;
274
+ private readonly writeQueue = new WriteQueue();
275
+
276
+ /**
277
+ * In-memory index: sessionId → SessionMeta (rebuilt on startup, kept in sync).
278
+ * Enables O(1) session lookup without disk reads.
279
+ */
280
+ private sessionIndex = new Map<string, SessionMeta>();
281
+
282
+ /**
283
+ * In-memory index: buildId → projectId (from openBuild / upsertBuild).
284
+ * Enables resolving project from buildId for legacy bridge compat.
285
+ */
286
+ private buildIndex = new Map<string, string>(); // buildId → projectId
287
+
288
+ constructor(dataDir?: string) {
289
+ const serverStartTimestamp = Date.now();
290
+ this.dataDir = resolve(dataDir ?? DEFAULT_DATA_DIR);
291
+ ensureDir(this.dataDir);
292
+ this._rebuildIndexes(serverStartTimestamp);
293
+ }
294
+
295
+ /** Scan disk to rebuild in-memory indexes. Mark orphaned sessions (no endedAt). */
296
+ private _rebuildIndexes(serverStartTimestamp: number): void {
297
+ const sessionsDir = join(this.dataDir, 'sessions');
298
+ if (!existsSync(sessionsDir)) return;
299
+
300
+ let entries: import('node:fs').Dirent[];
301
+ try {
302
+ entries = readdirSync(sessionsDir, { withFileTypes: true }) as import('node:fs').Dirent[];
303
+ } catch {
304
+ return;
305
+ }
306
+
307
+ for (const entry of entries) {
308
+ if (!entry.isDirectory()) continue;
309
+ const metaPath = join(sessionsDir, String(entry.name), 'meta.json');
310
+ const meta = readJson<SessionMeta>(metaPath);
311
+ if (!meta || !meta.id) continue;
312
+
313
+ // Mark orphaned sessions (crashed daemons)
314
+ if (meta.endedAt === undefined) {
315
+ meta.endedAt = serverStartTimestamp;
316
+ try {
317
+ writeJson(metaPath, meta);
318
+ } catch (err) {
319
+ console.error(
320
+ `[JsonlStore] startup recovery: failed to write endedAt for session ${meta.id}:`,
321
+ err,
322
+ );
323
+ }
324
+ }
325
+
326
+ this.sessionIndex.set(meta.id, meta);
327
+ }
328
+
329
+ // Rebuild buildIndex from projects/*/builds/*/meta.json
330
+ const projectsDir = join(this.dataDir, 'projects');
331
+ if (!existsSync(projectsDir)) return;
332
+ try {
333
+ for (const projEntry of readdirSync(projectsDir, { withFileTypes: true })) {
334
+ if (!projEntry.isDirectory()) continue;
335
+ const buildsDir = join(projectsDir, String(projEntry.name), 'builds');
336
+ if (!existsSync(buildsDir)) continue;
337
+ for (const buildEntry of readdirSync(buildsDir, { withFileTypes: true })) {
338
+ if (!buildEntry.isDirectory()) continue;
339
+ const buildMeta = readJson<BuildMeta>(join(buildsDir, String(buildEntry.name), 'meta.json'));
340
+ if (buildMeta?.id) {
341
+ this.buildIndex.set(buildMeta.id, String(projEntry.name));
342
+ }
343
+ }
344
+ }
345
+ } catch {
346
+ // ignore
347
+ }
348
+ }
349
+
350
+ // ── Path helpers ──────────────────────────────────────────────────────
351
+
352
+ private projectsDir(): string {
353
+ return join(this.dataDir, 'projects');
354
+ }
355
+
356
+ private projectDir(projectId: string): string {
357
+ return join(this.projectsDir(), sanitizeId(projectId));
358
+ }
359
+
360
+ private buildDir(projectId: string, buildId: string): string {
361
+ return join(this.projectDir(projectId), 'builds', sanitizeId(buildId));
362
+ }
363
+
364
+ private visitorsDir(): string {
365
+ return join(this.dataDir, 'visitors');
366
+ }
367
+
368
+ private visitorDir(visitorId: string): string {
369
+ return join(this.visitorsDir(), sanitizeId(visitorId));
370
+ }
371
+
372
+ private tabsDir(): string {
373
+ return join(this.dataDir, 'tabs');
374
+ }
375
+
376
+ private tabDir(tabId: string): string {
377
+ return join(this.tabsDir(), sanitizeId(tabId));
378
+ }
379
+
380
+ private sessionsDir(): string {
381
+ return join(this.dataDir, 'sessions');
382
+ }
383
+
384
+ private sessionDir(sessionId: string): string {
385
+ return join(this.sessionsDir(), sanitizeId(sessionId));
386
+ }
387
+
388
+ private sessionTimeline(sessionId: string): string {
389
+ return join(this.sessionDir(sessionId), 'timeline.jsonl');
390
+ }
391
+
392
+ private sessionRecording(sessionId: string): string {
393
+ return join(this.sessionDir(sessionId), 'recording.jsonl');
394
+ }
395
+
396
+ private exportsDir(): string {
397
+ return join(this.dataDir, 'exports');
398
+ }
399
+
400
+ private exportIndex(): string {
401
+ return join(this.exportsDir(), 'index.jsonl');
402
+ }
403
+
404
+ private exportEventsPath(exportId: string): string {
405
+ return join(this.exportsDir(), `${sanitizeId(exportId)}.rrweb.json`);
406
+ }
407
+
408
+ // ── Build lifecycle ───────────────────────────────────────────────────
409
+
410
+ openBuild(projectId: string, patch: Partial<Omit<BuildMeta, 'id' | 'projectId' | 'builtAt'>> = {}): string {
411
+ const buildId = randomUUID().slice(0, 8);
412
+ this.upsertBuild(projectId, buildId, patch);
413
+ // Also ensure project meta exists
414
+ const projMetaPath = join(this.projectDir(projectId), 'meta.json');
415
+ if (!existsSync(projMetaPath)) {
416
+ this.upsertProject(projectId, {});
417
+ } else {
418
+ // Touch lastActiveAt
419
+ const existing = readJson<ProjectMeta>(projMetaPath);
420
+ if (existing) {
421
+ existing.lastActiveAt = Date.now();
422
+ writeJson(projMetaPath, existing);
423
+ }
424
+ }
425
+ return buildId;
426
+ }
427
+
428
+ closeBuild(buildId: string, closedAt?: number): void {
429
+ const projectId = this.buildIndex.get(buildId);
430
+ if (!projectId) return;
431
+ const metaPath = join(this.buildDir(projectId, buildId), 'meta.json');
432
+ const meta = readJson<BuildMeta>(metaPath);
433
+ if (!meta) return;
434
+ meta.endedAt = closedAt ?? Date.now();
435
+ writeJson(metaPath, meta);
436
+ }
437
+
438
+ // ── Tab lifecycle ─────────────────────────────────────────────────────
439
+
440
+ upsertTab(tabId: string, patch: Partial<Omit<TabMeta, 'id'>>): TabMeta {
441
+ const dir = this.tabDir(tabId);
442
+ ensureDir(dir);
443
+ const metaPath = join(dir, 'meta.json');
444
+ const existing = readJson<TabMeta>(metaPath);
445
+ const merged: TabMeta = {
446
+ connectedAt: Date.now(),
447
+ ...existing,
448
+ ...patch,
449
+ id: tabId,
450
+ };
451
+ writeJson(metaPath, merged);
452
+ return merged;
453
+ }
454
+
455
+ getTab(tabId: string): TabMeta | undefined {
456
+ return readJson<TabMeta>(join(this.tabDir(tabId), 'meta.json')) ?? undefined;
457
+ }
458
+
459
+ closeTab(tabId: string, disconnectedAt?: number): void {
460
+ const metaPath = join(this.tabDir(tabId), 'meta.json');
461
+ const meta = readJson<TabMeta>(metaPath);
462
+ if (!meta) return;
463
+ meta.disconnectedAt = disconnectedAt ?? Date.now();
464
+ writeJson(metaPath, meta);
465
+ }
466
+
467
+ // ── Session lifecycle (pageload) ──────────────────────────────────────
468
+
469
+ upsertSession(
470
+ sessionId: string,
471
+ meta: Partial<Omit<SessionMeta, 'id'>> & { tabId: string; startedAt: number },
472
+ ): SessionMeta {
473
+ const dir = this.sessionDir(sessionId);
474
+ ensureDir(dir);
475
+ const metaPath = join(dir, 'meta.json');
476
+ const existing = readJson<SessionMeta>(metaPath);
477
+
478
+ // Merge participants: add new ones not already in the list
479
+ const existingParticipants = existing?.participants ?? [];
480
+ const incomingParticipants = meta.participants ?? [];
481
+ const merged: SessionMeta = {
482
+ participants: [],
483
+ ...existing,
484
+ ...meta,
485
+ id: sessionId,
486
+ };
487
+ // Reset participants — we'll rebuild via dedup loop below
488
+ merged.participants = [];
489
+ // Build merged participants list
490
+ const seen = new Set<string>();
491
+ for (const p of existingParticipants) {
492
+ const key = `${p.projectId}::${p.buildId ?? ''}`;
493
+ if (!seen.has(key)) {
494
+ seen.add(key);
495
+ merged.participants.push(p);
496
+ }
497
+ }
498
+ for (const p of incomingParticipants) {
499
+ const key = `${p.projectId}::${p.buildId ?? ''}`;
500
+ if (!seen.has(key)) {
501
+ seen.add(key);
502
+ merged.participants.push(p);
503
+ }
504
+ }
505
+
506
+ writeJson(metaPath, merged);
507
+ this.sessionIndex.set(sessionId, merged);
508
+ return merged;
509
+ }
510
+
511
+ closeSession(sessionId: string, endedAt?: number): void {
512
+ const metaPath = join(this.sessionDir(sessionId), 'meta.json');
513
+ const meta = readJson<SessionMeta>(metaPath);
514
+ if (!meta) return;
515
+ meta.endedAt = endedAt ?? Date.now();
516
+ writeJson(metaPath, meta);
517
+ // Update in-memory index
518
+ const cached = this.sessionIndex.get(sessionId);
519
+ if (cached) {
520
+ cached.endedAt = meta.endedAt;
521
+ }
522
+ }
523
+
524
+ getSession(sessionId: string): SessionMeta | undefined {
525
+ // Check in-memory index first
526
+ const cached = this.sessionIndex.get(sessionId);
527
+ if (cached) return cached;
528
+ // Fall back to disk
529
+ const meta = readJson<SessionMeta>(join(this.sessionDir(sessionId), 'meta.json'));
530
+ if (meta) {
531
+ this.sessionIndex.set(sessionId, meta);
532
+ }
533
+ return meta ?? undefined;
534
+ }
535
+
536
+ listSessions(opts: { tabId?: string; projectId?: string; buildId?: string; limit?: number } = {}): SessionMeta[] {
537
+ const { tabId, projectId, buildId, limit = 50 } = opts;
538
+ const sessionsDir = this.sessionsDir();
539
+ if (!existsSync(sessionsDir)) return [];
540
+
541
+ const sessions: SessionMeta[] = [];
542
+ try {
543
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
544
+ if (!entry.isDirectory()) continue;
545
+ const meta = readJson<SessionMeta>(join(sessionsDir, String(entry.name), 'meta.json'));
546
+ if (!meta) continue;
547
+ // Update index
548
+ this.sessionIndex.set(meta.id, meta);
549
+ // Apply filters
550
+ if (tabId && meta.tabId !== tabId) continue;
551
+ if (projectId && !meta.participants.some((p) => p.projectId === projectId)) continue;
552
+ if (buildId && !meta.participants.some((p) => p.buildId === buildId)) continue;
553
+ sessions.push(meta);
554
+ }
555
+ } catch {
556
+ // ignore scan errors
557
+ }
558
+
559
+ return sessions.sort((a, b) => b.startedAt - a.startedAt).slice(0, limit);
560
+ }
561
+
562
+ // ── Write ─────────────────────────────────────────────────────────────
563
+
564
+ appendEvent(sessionId: string, event: StoreEvent): void {
565
+ if (!this.getSession(sessionId)) return;
566
+ const line = JSON.stringify(event);
567
+ if (Buffer.byteLength(line, 'utf-8') > MAX_EVENT_BYTES) {
568
+ process.stderr.write(
569
+ `[harness-fe] dropping oversized event (${Buffer.byteLength(line, 'utf-8')} bytes > ${MAX_EVENT_BYTES}) — type=${event.t}\n`,
570
+ );
571
+ return;
572
+ }
573
+ this.writeQueue.enqueue(this.sessionTimeline(sessionId), sessionId, line);
574
+ }
575
+
576
+ appendEventBatch(sessionId: string, events: StoreEvent[]): void {
577
+ if (!events.length) return;
578
+ if (!this.getSession(sessionId)) return;
579
+ for (const event of events) {
580
+ const line = JSON.stringify(event);
581
+ if (Buffer.byteLength(line, 'utf-8') > MAX_EVENT_BYTES) {
582
+ process.stderr.write(
583
+ `[harness-fe] dropping oversized event in batch (${Buffer.byteLength(line, 'utf-8')} bytes) — type=${event.t}\n`,
584
+ );
585
+ continue;
586
+ }
587
+ this.writeQueue.enqueue(this.sessionTimeline(sessionId), sessionId, line);
588
+ }
589
+ }
590
+
591
+ appendRecording(sessionId: string, chunk: unknown): void {
592
+ if (!this.getSession(sessionId)) return;
593
+ const line = Array.isArray(chunk) ? { ts: Date.now(), events: chunk } : chunk;
594
+ const serialized = JSON.stringify(line);
595
+ if (Buffer.byteLength(serialized, 'utf-8') > MAX_RECORDING_CHUNK_BYTES) {
596
+ process.stderr.write(
597
+ `[harness-fe] dropping oversized rrweb chunk (${Buffer.byteLength(serialized, 'utf-8')} bytes > ${MAX_RECORDING_CHUNK_BYTES})\n`,
598
+ );
599
+ return;
600
+ }
601
+ const target = this.sessionRecording(sessionId);
602
+ ensureDir(this.sessionDir(sessionId));
603
+ this.writeQueue.enqueue(target, sessionId, serialized);
604
+ }
605
+
606
+ writeNote(projectId: string, key: string, value: string): void {
607
+ const projDir = this.projectDir(projectId);
608
+ ensureDir(projDir);
609
+ appendJsonl(join(projDir, 'notes.jsonl'), { ts: Date.now(), key, value });
610
+ }
611
+
612
+ // ── Project metadata ───────────────────────────────────────────────────
613
+
614
+ upsertProject(
615
+ projectId: string,
616
+ patch: Partial<Omit<ProjectMeta, 'id' | 'createdAt'>>,
617
+ ): ProjectMeta {
618
+ const projDir = this.projectDir(projectId);
619
+ ensureDir(projDir);
620
+ const metaPath = join(projDir, 'meta.json');
621
+ const existing = readJson<ProjectMeta>(metaPath);
622
+
623
+ // Cycle detection
624
+ if (patch.parentProjectId !== undefined && patch.parentProjectId !== null) {
625
+ if (patch.parentProjectId === projectId) {
626
+ throw new Error(
627
+ `[harness-fe] refused to set parentProjectId=${projectId} on itself`,
628
+ );
629
+ }
630
+ const visited = new Set<string>();
631
+ let cursor: string | undefined = patch.parentProjectId;
632
+ while (cursor) {
633
+ if (cursor === projectId) {
634
+ throw new Error(
635
+ `[harness-fe] refused to create parent-project cycle: ${projectId} → … → ${projectId}`,
636
+ );
637
+ }
638
+ if (visited.has(cursor)) break;
639
+ visited.add(cursor);
640
+ const ancestor: ProjectMeta | undefined = readJson<ProjectMeta>(
641
+ join(this.projectDir(cursor), 'meta.json'),
642
+ ) ?? undefined;
643
+ cursor = ancestor?.parentProjectId;
644
+ }
645
+ }
646
+
647
+ const merged: ProjectMeta = {
648
+ ...existing,
649
+ ...patch,
650
+ id: projectId,
651
+ createdAt: existing?.createdAt ?? Date.now(),
652
+ lastActiveAt: Date.now(),
653
+ };
654
+ enforceExtensionBudget(merged, `project ${projectId}`);
655
+ writeJson(metaPath, merged);
656
+ return merged;
657
+ }
658
+
659
+ getProject(projectId: string): ProjectMeta | undefined {
660
+ return readJson<ProjectMeta>(join(this.projectDir(projectId), 'meta.json')) ?? undefined;
661
+ }
662
+
663
+ listProjects(): ProjectMeta[] {
664
+ const projectsDir = this.projectsDir();
665
+ if (!existsSync(projectsDir)) return [];
666
+ const projects: ProjectMeta[] = [];
667
+ try {
668
+ for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
669
+ if (!entry.isDirectory()) continue;
670
+ const meta = readJson<ProjectMeta>(join(projectsDir, String(entry.name), 'meta.json'));
671
+ if (meta) projects.push(meta);
672
+ }
673
+ } catch {
674
+ // ignore
675
+ }
676
+ return projects.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
677
+ }
678
+
679
+ // ── Visitor metadata (0.5+) ─────────────────────────────────────────────
680
+
681
+ upsertVisitor(
682
+ visitorId: string,
683
+ patch: {
684
+ userId?: string;
685
+ seenAt?: number;
686
+ incrementSession?: boolean;
687
+ addTabId?: string;
688
+ addProjectId?: string;
689
+ lastEnv?: VisitorEnv;
690
+ },
691
+ ): VisitorMeta {
692
+ const dir = this.visitorDir(visitorId);
693
+ ensureDir(dir);
694
+ const metaPath = join(dir, 'meta.json');
695
+ const existing = readJson<VisitorMeta>(metaPath);
696
+ const now = patch.seenAt ?? Date.now();
697
+
698
+ const tabIds = lruAppend(existing?.tabIds, patch.addTabId, 50);
699
+ const projectIds = lruAppend(existing?.projectIds, patch.addProjectId, 50);
700
+
701
+ const merged: VisitorMeta = {
702
+ id: visitorId,
703
+ // userId: prefer fresh non-empty value; otherwise preserve existing
704
+ userId: patch.userId && patch.userId.length > 0 ? patch.userId : existing?.userId,
705
+ firstSeenAt: existing?.firstSeenAt ?? now,
706
+ lastSeenAt: now,
707
+ sessionCount: (existing?.sessionCount ?? 0) + (patch.incrementSession ? 1 : 0),
708
+ tabIds,
709
+ projectIds,
710
+ lastEnv: patch.lastEnv ?? existing?.lastEnv,
711
+ };
712
+ writeJson(metaPath, merged);
713
+ return merged;
714
+ }
715
+
716
+ getVisitor(visitorId: string): VisitorMeta | undefined {
717
+ return readJson<VisitorMeta>(join(this.visitorDir(visitorId), 'meta.json')) ?? undefined;
718
+ }
719
+
720
+ listVisitors(opts: { projectId?: string; limit?: number } = {}): VisitorMeta[] {
721
+ const dir = this.visitorsDir();
722
+ if (!existsSync(dir)) return [];
723
+ const out: VisitorMeta[] = [];
724
+ try {
725
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
726
+ if (!entry.isDirectory()) continue;
727
+ const meta = readJson<VisitorMeta>(join(dir, String(entry.name), 'meta.json'));
728
+ if (!meta) continue;
729
+ if (opts.projectId && !meta.projectIds.includes(opts.projectId)) continue;
730
+ out.push(meta);
731
+ }
732
+ } catch {
733
+ // ignore
734
+ }
735
+ out.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
736
+ return opts.limit ? out.slice(0, opts.limit) : out;
737
+ }
738
+
739
+ // ── Build metadata ─────────────────────────────────────────────────────
740
+
741
+ upsertBuild(
742
+ projectId: string,
743
+ buildId: string,
744
+ patch: Partial<Omit<BuildMeta, 'id' | 'projectId'>>,
745
+ ): BuildMeta {
746
+ const dir = this.buildDir(projectId, buildId);
747
+ ensureDir(dir);
748
+ const metaPath = join(dir, 'meta.json');
749
+ const existing = readJson<BuildMeta>(metaPath);
750
+ const merged: BuildMeta = {
751
+ ...existing,
752
+ ...patch,
753
+ id: buildId,
754
+ projectId,
755
+ builtAt: existing?.builtAt ?? Date.now(),
756
+ };
757
+ enforceExtensionBudget(merged, `build ${projectId}/${buildId}`);
758
+ writeJson(metaPath, merged);
759
+ this.buildIndex.set(buildId, projectId);
760
+ return merged;
761
+ }
762
+
763
+ getBuild(projectId: string, buildId: string): BuildMeta | undefined {
764
+ return readJson<BuildMeta>(join(this.buildDir(projectId, buildId), 'meta.json')) ?? undefined;
765
+ }
766
+
767
+ listBuilds(projectId: string, limit = 50): BuildMeta[] {
768
+ const buildsDir = join(this.projectDir(projectId), 'builds');
769
+ if (!existsSync(buildsDir)) return [];
770
+ const builds: BuildMeta[] = [];
771
+ try {
772
+ for (const entry of readdirSync(buildsDir, { withFileTypes: true })) {
773
+ if (!entry.isDirectory()) continue;
774
+ const meta = readJson<BuildMeta>(join(buildsDir, String(entry.name), 'meta.json'));
775
+ if (meta) builds.push(meta);
776
+ }
777
+ } catch {
778
+ // ignore
779
+ }
780
+ return builds.sort((a, b) => b.builtAt - a.builtAt).slice(0, limit);
781
+ }
782
+
783
+ // ── Project tree ───────────────────────────────────────────────────────
784
+
785
+ getProjectTree(rootId?: string): ProjectTreeNode[] {
786
+ const all = this.listProjects();
787
+ const byParent = new Map<string, ProjectMeta[]>();
788
+ for (const p of all) {
789
+ const parent = p.parentProjectId;
790
+ if (!parent) continue;
791
+ const arr = byParent.get(parent) ?? [];
792
+ arr.push(p);
793
+ byParent.set(parent, arr);
794
+ }
795
+ const sortByLabel = (a: { displayName?: string; id: string }, b: { displayName?: string; id: string }) =>
796
+ (a.displayName ?? a.id).localeCompare(b.displayName ?? b.id);
797
+
798
+ const seedRoots = rootId
799
+ ? all.filter((p) => p.id === rootId)
800
+ : all.filter((p) => !p.parentProjectId);
801
+
802
+ const nodeOf = new Map<string, ProjectTreeNode>();
803
+ const queue: ProjectMeta[] = [...seedRoots];
804
+ const visited = new Set<string>();
805
+ while (queue.length > 0) {
806
+ const p = queue.shift()!;
807
+ if (visited.has(p.id)) continue;
808
+ visited.add(p.id);
809
+ nodeOf.set(p.id, { id: p.id, displayName: p.displayName, tags: p.tags, children: [] });
810
+ const kids = (byParent.get(p.id) ?? []).slice().sort(sortByLabel);
811
+ for (const k of kids) queue.push(k);
812
+ }
813
+ for (const p of all) {
814
+ if (!nodeOf.has(p.id) || !p.parentProjectId) continue;
815
+ const parent = nodeOf.get(p.parentProjectId);
816
+ const me = nodeOf.get(p.id);
817
+ if (parent && me) parent.children.push(me);
818
+ }
819
+ return seedRoots
820
+ .slice()
821
+ .sort(sortByLabel)
822
+ .map((p) => nodeOf.get(p.id))
823
+ .filter((n): n is ProjectTreeNode => Boolean(n));
824
+ }
825
+
826
+ // ── Read ──────────────────────────────────────────────────────────────
827
+
828
+ tail(sessionId: string, opts: TailOptions = {}): StoreEvent[] {
829
+ if (!this.getSession(sessionId)) return [];
830
+
831
+ const filePath = this.sessionTimeline(sessionId);
832
+ const n = opts.n ?? 50;
833
+ const multiplier = opts.type || opts.since || opts.until || opts.projectId ? 5 : 1;
834
+ const rawLines = readLastNLines(filePath, n * multiplier);
835
+
836
+ const events: StoreEvent[] = [];
837
+ for (const line of rawLines) {
838
+ const event = parseEvent(line);
839
+ if (!event) continue;
840
+ if (!matchesType(event, opts.type)) continue;
841
+ if (!matchesTimeRange(event, opts.since, opts.until)) continue;
842
+ if (opts.projectId && event.projectId !== opts.projectId) continue;
843
+ events.push(event);
844
+ }
845
+
846
+ return events.slice(-n);
847
+ }
848
+
849
+ search(sessionId: string, query: string, opts: SearchOptions = {}): StoreEvent[] {
850
+ if (!this.getSession(sessionId)) return [];
851
+
852
+ const filePath = this.sessionTimeline(sessionId);
853
+ const limit = opts.limit ?? 50;
854
+ const lowerQuery = query.toLowerCase();
855
+ const results: StoreEvent[] = [];
856
+
857
+ for (const line of readAllLines(filePath)) {
858
+ if (!line.toLowerCase().includes(lowerQuery)) continue;
859
+ const event = parseEvent(line);
860
+ if (!event) continue;
861
+ if (!matchesType(event, opts.type)) continue;
862
+ results.push(event);
863
+ if (results.length >= limit) break;
864
+ }
865
+
866
+ return results;
867
+ }
868
+
869
+ listRecordings(sessionId: string): RecordingChunkSummary[] {
870
+ if (!this.getSession(sessionId)) return [];
871
+
872
+ const recPath = this.sessionRecording(sessionId);
873
+ const chunks: RecordingChunkSummary[] = [];
874
+ const sessionMeta = this.getSession(sessionId);
875
+ const tabId = sessionMeta?.tabId ?? '';
876
+
877
+ readAllLines(recPath).forEach((line, index) => {
878
+ const chunk = parseRecordingChunkLine(line, tabId, 0, index);
879
+ if (!chunk) return;
880
+ chunks.push({
881
+ chunkId: chunk.chunkId,
882
+ tabId: chunk.tabId,
883
+ startTs: chunk.startTs,
884
+ endTs: chunk.endTs,
885
+ eventCount: chunk.eventCount,
886
+ });
887
+ });
888
+
889
+ return chunks.sort((a, b) => a.startTs - b.startTs);
890
+ }
891
+
892
+ sliceRecordings(sessionId: string, since: number, until: number): RecordingChunk[] {
893
+ if (!this.getSession(sessionId)) return [];
894
+
895
+ const recPath = this.sessionRecording(sessionId);
896
+ const sessionMeta = this.getSession(sessionId);
897
+ const tabId = sessionMeta?.tabId ?? '';
898
+ const chunks: RecordingChunk[] = [];
899
+
900
+ readAllLines(recPath).forEach((line, index) => {
901
+ const chunk = parseRecordingChunkLine(line, tabId, 0, index);
902
+ if (!chunk) return;
903
+ if (chunk.endTs < since || chunk.startTs > until) return;
904
+ chunks.push({
905
+ chunkId: chunk.chunkId,
906
+ tabId: chunk.tabId,
907
+ startTs: chunk.startTs,
908
+ endTs: chunk.endTs,
909
+ eventCount: chunk.eventCount,
910
+ events: chunk.events,
911
+ });
912
+ });
913
+
914
+ return chunks.sort((a, b) => a.startTs - b.startTs);
915
+ }
916
+
917
+ writeExport(input: {
918
+ sessionId: string;
919
+ tabId?: string;
920
+ since: number;
921
+ until: number;
922
+ label?: string;
923
+ events: unknown[];
924
+ startTs: number;
925
+ endTs: number;
926
+ chunkCount: number;
927
+ }): ReplayExportMeta {
928
+ // Determine projectId from session participants
929
+ const session = this.getSession(input.sessionId);
930
+ const projectId = session?.participants[0]?.projectId ?? 'unknown';
931
+
932
+ const exportId = `exp_${randomUUID().slice(0, 12)}`;
933
+ const exportDir = this.exportsDir();
934
+ ensureDir(exportDir);
935
+
936
+ const eventsPath = this.exportEventsPath(exportId);
937
+ const payload = JSON.stringify(input.events);
938
+ writeFileSync(eventsPath, payload, 'utf-8');
939
+
940
+ const meta: ReplayExportMeta = {
941
+ exportId,
942
+ projectId,
943
+ sessionId: input.sessionId,
944
+ tabId: input.tabId,
945
+ label: input.label,
946
+ since: input.since,
947
+ until: input.until,
948
+ startTs: input.startTs,
949
+ endTs: input.endTs,
950
+ chunkCount: input.chunkCount,
951
+ eventCount: input.events.length,
952
+ bytes: Buffer.byteLength(payload, 'utf-8'),
953
+ createdAt: Date.now(),
954
+ };
955
+ appendJsonl(this.exportIndex(), meta);
956
+ return meta;
957
+ }
958
+
959
+ getExport(exportId: string): ReplayExportMeta | undefined {
960
+ const indexPath = this.exportIndex();
961
+ if (!existsSync(indexPath)) return undefined;
962
+ let latest: ReplayExportMeta | undefined;
963
+ for (const line of readAllLines(indexPath)) {
964
+ try {
965
+ const meta = JSON.parse(line) as ReplayExportMeta;
966
+ if (meta?.exportId === exportId) latest = meta;
967
+ } catch {
968
+ /* swallow */
969
+ }
970
+ }
971
+ return latest;
972
+ }
973
+
974
+ readExportEvents(exportId: string): unknown[] | undefined {
975
+ const eventsPath = this.exportEventsPath(exportId);
976
+ if (!existsSync(eventsPath)) return undefined;
977
+ try {
978
+ const parsed = JSON.parse(readFileSync(eventsPath, 'utf-8'));
979
+ return Array.isArray(parsed) ? parsed : undefined;
980
+ } catch {
981
+ return undefined;
982
+ }
983
+ }
984
+
985
+ listExports(projectId: string, limit?: number): ReplayExportMeta[] {
986
+ const indexPath = this.exportIndex();
987
+ if (!existsSync(indexPath)) return [];
988
+ const seen = new Map<string, ReplayExportMeta>();
989
+ for (const line of readAllLines(indexPath)) {
990
+ try {
991
+ const meta = JSON.parse(line) as ReplayExportMeta;
992
+ if (meta?.exportId && (projectId === 'all' || meta.projectId === projectId)) {
993
+ seen.set(meta.exportId, meta);
994
+ }
995
+ } catch {
996
+ /* swallow */
997
+ }
998
+ }
999
+ const metas: ReplayExportMeta[] = [];
1000
+ for (const meta of seen.values()) metas.push(meta);
1001
+ metas.sort((a, b) => b.createdAt - a.createdAt);
1002
+ return typeof limit === 'number' ? metas.slice(0, limit) : metas;
1003
+ }
1004
+
1005
+ summary(sessionId: string): SessionSummary {
1006
+ const session = this.getSession(sessionId);
1007
+
1008
+ const counts: Partial<Record<string, number>> = {};
1009
+ let lastError: StoreEvent | undefined;
1010
+ let lastActivity: number | undefined;
1011
+
1012
+ const filePath = this.sessionTimeline(sessionId);
1013
+ for (const line of readAllLines(filePath)) {
1014
+ const event = parseEvent(line);
1015
+ if (!event) continue;
1016
+ counts[event.t] = (counts[event.t] ?? 0) + 1;
1017
+ if (event.t === 'err') lastError = event;
1018
+ if (!lastActivity || event.ts > lastActivity) lastActivity = event.ts;
1019
+ }
1020
+
1021
+ const tabs: string[] = session ? [session.tabId].filter(Boolean) : [];
1022
+
1023
+ const fallbackSession: SessionMeta = {
1024
+ id: sessionId,
1025
+ tabId: 'unknown',
1026
+ startedAt: 0,
1027
+ participants: [],
1028
+ };
1029
+
1030
+ return {
1031
+ session: session ?? fallbackSession,
1032
+ counts,
1033
+ lastError,
1034
+ lastActivity,
1035
+ tabs,
1036
+ };
1037
+ }
1038
+
1039
+ listNotes(projectId: string): Array<{ key: string; value: string; ts: number }> {
1040
+ const notesPath = join(this.projectDir(projectId), 'notes.jsonl');
1041
+ const notes: Array<{ key: string; value: string; ts: number }> = [];
1042
+ for (const line of readAllLines(notesPath)) {
1043
+ const parsed = parseEvent(line) as unknown as { key: string; value: string; ts: number } | undefined;
1044
+ if (parsed?.key) notes.push(parsed);
1045
+ }
1046
+ const latest = new Map<string, { key: string; value: string; ts: number }>();
1047
+ for (const note of notes) {
1048
+ const existing = latest.get(note.key);
1049
+ if (!existing || note.ts >= existing.ts) latest.set(note.key, note);
1050
+ }
1051
+ return [...latest.values()].sort((a, b) => b.ts - a.ts);
1052
+ }
1053
+
1054
+ // ── Maintenance ───────────────────────────────────────────────────────
1055
+
1056
+ purge(policy: RetentionPolicy = {}): PurgeResult {
1057
+ // Normalize aliases
1058
+ const maxSessions = policy.maxSessions ?? policy.maxSessionsPerProject ?? DEFAULT_RETENTION.maxSessions;
1059
+ const maxAgeDays = policy.maxAgeDays ?? DEFAULT_RETENTION.maxAgeDays;
1060
+ const recordingRetentionDays = policy.recordingRetentionDays ?? DEFAULT_RETENTION.recordingRetentionDays;
1061
+ const maxChunks = policy.maxRecordingChunksPerSession ?? policy.maxRecordingChunksPerTab ?? DEFAULT_RETENTION.maxRecordingChunksPerSession;
1062
+ const maxBytes = policy.maxRecordingBytesPerSession ?? policy.maxRecordingBytesPerTab ?? DEFAULT_RETENTION.maxRecordingBytesPerSession;
1063
+ const preserveMarkedChunks = policy.preserveMarkedChunks ?? DEFAULT_RETENTION.preserveMarkedChunks;
1064
+ const maxExportsPerProject = policy.maxExportsPerProject ?? DEFAULT_RETENTION.maxExportsPerProject;
1065
+ const maxExportBytesPerProject = policy.maxExportBytesPerProject ?? DEFAULT_RETENTION.maxExportBytesPerProject;
1066
+ const maxBuildsPerProject = policy.maxBuildsPerProject ?? DEFAULT_RETENTION.maxBuildsPerProject;
1067
+
1068
+ const now = Date.now();
1069
+ const maxAge = maxAgeDays * 86400000;
1070
+ const recMaxAge = recordingRetentionDays * 86400000;
1071
+
1072
+ let sessionsDeleted = 0;
1073
+ let recordingsDeleted = 0;
1074
+ let exportsDeleted = 0;
1075
+ let bytesFreed = 0;
1076
+ let buildsDeleted = 0;
1077
+
1078
+ const sessionsDir = this.sessionsDir();
1079
+ if (existsSync(sessionsDir)) {
1080
+ const allSessions = this.listSessions({ limit: Number.MAX_SAFE_INTEGER });
1081
+
1082
+ // Delete sessions older than maxAge
1083
+ for (const sess of allSessions) {
1084
+ const age = now - sess.startedAt;
1085
+ if (age > maxAge) {
1086
+ const dir = this.sessionDir(sess.id);
1087
+ const size = dirSize(dir);
1088
+ rmrf(dir);
1089
+ this.sessionIndex.delete(sess.id);
1090
+ bytesFreed += size;
1091
+ sessionsDeleted++;
1092
+ }
1093
+ }
1094
+
1095
+ // Keep only the most recent maxSessions
1096
+ const remaining = this.listSessions({ limit: Number.MAX_SAFE_INTEGER });
1097
+ if (remaining.length > maxSessions) {
1098
+ const toDelete = remaining.slice(maxSessions);
1099
+ for (const sess of toDelete) {
1100
+ const dir = this.sessionDir(sess.id);
1101
+ const size = dirSize(dir);
1102
+ rmrf(dir);
1103
+ this.sessionIndex.delete(sess.id);
1104
+ bytesFreed += size;
1105
+ sessionsDeleted++;
1106
+ }
1107
+ }
1108
+
1109
+ // Trim recording data per session
1110
+ for (const sess of this.listSessions({ limit: Number.MAX_SAFE_INTEGER })) {
1111
+ const recPath = this.sessionRecording(sess.id);
1112
+ if (!existsSync(recPath)) continue;
1113
+ const timelinePath = this.sessionTimeline(sess.id);
1114
+ const result = this.pruneRecordingFile(
1115
+ recPath,
1116
+ timelinePath,
1117
+ now,
1118
+ recMaxAge,
1119
+ maxChunks,
1120
+ maxBytes,
1121
+ preserveMarkedChunks,
1122
+ );
1123
+ bytesFreed += result.bytesFreed;
1124
+ recordingsDeleted += result.chunksDeleted;
1125
+ }
1126
+ }
1127
+
1128
+ // Trim exports
1129
+ const exportResult = this.pruneExports(maxExportsPerProject, maxExportBytesPerProject);
1130
+ exportsDeleted += exportResult.exportsDeleted;
1131
+ bytesFreed += exportResult.bytesFreed;
1132
+
1133
+ // Trim builds per project
1134
+ for (const proj of this.listProjects()) {
1135
+ const allBuilds = this.listBuilds(proj.id, Number.MAX_SAFE_INTEGER);
1136
+ if (allBuilds.length > maxBuildsPerProject) {
1137
+ const stale = allBuilds.slice(maxBuildsPerProject);
1138
+ for (const b of stale) {
1139
+ const dir = this.buildDir(proj.id, b.id);
1140
+ const size = dirSize(dir);
1141
+ rmrf(dir);
1142
+ this.buildIndex.delete(b.id);
1143
+ bytesFreed += size;
1144
+ buildsDeleted++;
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ return { sessionsDeleted, recordingsDeleted, exportsDeleted, bytesFreed, buildsDeleted };
1150
+ }
1151
+
1152
+ private pruneExports(
1153
+ maxExports: number,
1154
+ maxBytes: number,
1155
+ ): { exportsDeleted: number; bytesFreed: number } {
1156
+ // Collect all exports across all projects
1157
+ const indexPath = this.exportIndex();
1158
+ if (!existsSync(indexPath)) return { exportsDeleted: 0, bytesFreed: 0 };
1159
+
1160
+ // Group by project
1161
+ const byProject = new Map<string, ReplayExportMeta[]>();
1162
+ for (const line of readAllLines(indexPath)) {
1163
+ try {
1164
+ const meta = JSON.parse(line) as ReplayExportMeta;
1165
+ if (!meta?.exportId) continue;
1166
+ const arr = byProject.get(meta.projectId) ?? [];
1167
+ arr.push(meta);
1168
+ byProject.set(meta.projectId, arr);
1169
+ } catch {
1170
+ /* swallow */
1171
+ }
1172
+ }
1173
+
1174
+ let totalDeleted = 0;
1175
+ let totalFreed = 0;
1176
+ const keepIds = new Set<string>();
1177
+
1178
+ for (const [, exports] of byProject) {
1179
+ exports.sort((a, b) => b.createdAt - a.createdAt);
1180
+ let runningBytes = 0;
1181
+ for (const meta of exports) {
1182
+ const fits = keepIds.size < maxExports && runningBytes + meta.bytes <= maxBytes;
1183
+ if (fits) {
1184
+ keepIds.add(meta.exportId);
1185
+ runningBytes += meta.bytes;
1186
+ } else {
1187
+ // Delete this export's events file
1188
+ const eventsPath = this.exportEventsPath(meta.exportId);
1189
+ if (existsSync(eventsPath)) {
1190
+ const size = statSync(eventsPath).size;
1191
+ try { unlinkSync(eventsPath); totalFreed += size; } catch { /* swallow */ }
1192
+ }
1193
+ totalDeleted++;
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ if (totalDeleted > 0) {
1199
+ // Rewrite index keeping only surviving entries
1200
+ const allLines = readAllLines(indexPath);
1201
+ const kept = allLines.filter((line) => {
1202
+ try {
1203
+ const meta = JSON.parse(line) as ReplayExportMeta;
1204
+ return keepIds.has(meta.exportId);
1205
+ } catch {
1206
+ return false;
1207
+ }
1208
+ });
1209
+ if (kept.length === 0) {
1210
+ try { unlinkSync(indexPath); } catch { /* swallow */ }
1211
+ } else {
1212
+ writeFileSync(indexPath, kept.join('\n') + '\n', 'utf-8');
1213
+ }
1214
+ }
1215
+
1216
+ return { exportsDeleted: totalDeleted, bytesFreed: totalFreed };
1217
+ }
1218
+
1219
+ /**
1220
+ * Flush all pending WriteQueue entries to disk. Used in tests.
1221
+ */
1222
+ async flush(): Promise<void> {
1223
+ await this.writeQueue.drain();
1224
+ }
1225
+
1226
+ async close(): Promise<void> {
1227
+ try {
1228
+ await this.writeQueue.drain();
1229
+ } catch (err) {
1230
+ console.error('[JsonlStore] close: drain failed:', err);
1231
+ }
1232
+ }
1233
+
1234
+ private pruneRecordingFile(
1235
+ recPath: string,
1236
+ timelinePath: string,
1237
+ now: number,
1238
+ recMaxAge: number,
1239
+ maxChunks: number,
1240
+ maxBytesLimit: number,
1241
+ preserveMarkedChunks: boolean,
1242
+ ): { chunksDeleted: number; bytesFreed: number } {
1243
+ const lines = readAllLines(recPath);
1244
+ if (lines.length === 0) return { chunksDeleted: 0, bytesFreed: 0 };
1245
+ const fallbackAgeTs = statSync(recPath).mtimeMs;
1246
+
1247
+ const markerTimestamps = this.readMarkerTimestamps(timelinePath);
1248
+ const chunks: RecordingChunkRecord[] = [];
1249
+
1250
+ lines.forEach((line, index) => {
1251
+ const chunk = parseRecordingChunkLine(line, '', fallbackAgeTs, index);
1252
+ if (!chunk) return;
1253
+ chunk.marked = markerTimestamps.some((ts) => ts >= chunk.startTs && ts <= chunk.endTs);
1254
+ chunks.push(chunk);
1255
+ });
1256
+
1257
+ if (chunks.length === 0) return { chunksDeleted: 0, bytesFreed: 0 };
1258
+
1259
+ const removed = new Set<string>();
1260
+
1261
+ for (const chunk of chunks) {
1262
+ if (now - chunk.ageTs > recMaxAge) removed.add(chunk.chunkId);
1263
+ }
1264
+
1265
+ let kept = chunks.filter((chunk) => !removed.has(chunk.chunkId));
1266
+
1267
+ const chooseRemovalCandidate = (): RecordingChunkRecord | undefined => {
1268
+ if (kept.length === 0) return undefined;
1269
+ const sorted = [...kept].sort((a, b) => a.startTs - b.startTs);
1270
+ if (!preserveMarkedChunks) return sorted[0];
1271
+ return sorted.find((chunk) => !chunk.marked) ?? sorted[0];
1272
+ };
1273
+
1274
+ while (kept.length > maxChunks) {
1275
+ const candidate = chooseRemovalCandidate();
1276
+ if (!candidate) break;
1277
+ removed.add(candidate.chunkId);
1278
+ kept = kept.filter((chunk) => chunk.chunkId !== candidate.chunkId);
1279
+ }
1280
+
1281
+ let totalBytes = kept.reduce((sum, chunk) => sum + chunk.bytes, 0);
1282
+ while (totalBytes > maxBytesLimit) {
1283
+ const candidate = chooseRemovalCandidate();
1284
+ if (!candidate) break;
1285
+ removed.add(candidate.chunkId);
1286
+ kept = kept.filter((chunk) => chunk.chunkId !== candidate.chunkId);
1287
+ totalBytes = kept.reduce((sum, chunk) => sum + chunk.bytes, 0);
1288
+ }
1289
+
1290
+ if (removed.size === 0) return { chunksDeleted: 0, bytesFreed: 0 };
1291
+
1292
+ const bytesFreed = chunks
1293
+ .filter((chunk) => removed.has(chunk.chunkId))
1294
+ .reduce((sum, chunk) => sum + chunk.bytes, 0);
1295
+
1296
+ if (kept.length === 0) {
1297
+ unlinkSync(recPath);
1298
+ } else {
1299
+ writeFileSync(recPath, `${kept.map((chunk) => chunk.line).join('\n')}\n`, 'utf-8');
1300
+ }
1301
+
1302
+ return { chunksDeleted: removed.size, bytesFreed };
1303
+ }
1304
+
1305
+ private readMarkerTimestamps(timelinePath: string): number[] {
1306
+ const timestamps: number[] = [];
1307
+ for (const line of readAllLines(timelinePath)) {
1308
+ const event = parseEvent(line);
1309
+ if (!event || event.t !== 'rrweb:marker') continue;
1310
+ timestamps.push(event.ts);
1311
+ }
1312
+ return timestamps;
1313
+ }
1314
+ }
1315
+
1316
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
1317
+
1318
+ /** Sanitize a string for use as a directory name. */
1319
+ export function sanitizeId(id: string): string {
1320
+ return id.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
1321
+ }