@harness-fe/mcp-server 4.0.0-next.1 → 4.0.0-next.3

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