@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.
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +49 -15
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +8 -2
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +51 -19
- package/src/mcpHttp.test.ts +3 -3
- package/src/mcpHttp.ts +10 -4
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -74
- package/dist/identity.js +0 -101
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -86
- package/src/identity.ts +0 -116
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/src/store/JsonlStore.ts
DELETED
|
@@ -1,1325 +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
|
-
|
|
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
|
-
// Write-once: first principal to open the session owns it.
|
|
487
|
-
createdBy: existing?.createdBy ?? meta.createdBy,
|
|
488
|
-
};
|
|
489
|
-
// Reset participants — we'll rebuild via dedup loop below
|
|
490
|
-
merged.participants = [];
|
|
491
|
-
// Build merged participants list
|
|
492
|
-
const seen = new Set<string>();
|
|
493
|
-
for (const p of existingParticipants) {
|
|
494
|
-
const key = `${p.projectId}::${p.buildId ?? ''}`;
|
|
495
|
-
if (!seen.has(key)) {
|
|
496
|
-
seen.add(key);
|
|
497
|
-
merged.participants.push(p);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
for (const p of incomingParticipants) {
|
|
501
|
-
const key = `${p.projectId}::${p.buildId ?? ''}`;
|
|
502
|
-
if (!seen.has(key)) {
|
|
503
|
-
seen.add(key);
|
|
504
|
-
merged.participants.push(p);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
writeJson(metaPath, merged);
|
|
509
|
-
this.sessionIndex.set(sessionId, merged);
|
|
510
|
-
return merged;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
closeSession(sessionId: string, endedAt?: number): void {
|
|
514
|
-
const metaPath = join(this.sessionDir(sessionId), 'meta.json');
|
|
515
|
-
const meta = readJson<SessionMeta>(metaPath);
|
|
516
|
-
if (!meta) return;
|
|
517
|
-
meta.endedAt = endedAt ?? Date.now();
|
|
518
|
-
writeJson(metaPath, meta);
|
|
519
|
-
// Update in-memory index
|
|
520
|
-
const cached = this.sessionIndex.get(sessionId);
|
|
521
|
-
if (cached) {
|
|
522
|
-
cached.endedAt = meta.endedAt;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
getSession(sessionId: string): SessionMeta | undefined {
|
|
527
|
-
// Check in-memory index first
|
|
528
|
-
const cached = this.sessionIndex.get(sessionId);
|
|
529
|
-
if (cached) return cached;
|
|
530
|
-
// Fall back to disk
|
|
531
|
-
const meta = readJson<SessionMeta>(join(this.sessionDir(sessionId), 'meta.json'));
|
|
532
|
-
if (meta) {
|
|
533
|
-
this.sessionIndex.set(sessionId, meta);
|
|
534
|
-
}
|
|
535
|
-
return meta ?? undefined;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
listSessions(opts: { tabId?: string; projectId?: string; buildId?: string; limit?: number } = {}): SessionMeta[] {
|
|
539
|
-
const { tabId, projectId, buildId, limit = 50 } = opts;
|
|
540
|
-
const sessionsDir = this.sessionsDir();
|
|
541
|
-
if (!existsSync(sessionsDir)) return [];
|
|
542
|
-
|
|
543
|
-
const sessions: SessionMeta[] = [];
|
|
544
|
-
try {
|
|
545
|
-
for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
|
|
546
|
-
if (!entry.isDirectory()) continue;
|
|
547
|
-
const meta = readJson<SessionMeta>(join(sessionsDir, String(entry.name), 'meta.json'));
|
|
548
|
-
if (!meta) continue;
|
|
549
|
-
// Update index
|
|
550
|
-
this.sessionIndex.set(meta.id, meta);
|
|
551
|
-
// Apply filters
|
|
552
|
-
if (tabId && meta.tabId !== tabId) continue;
|
|
553
|
-
if (projectId && !meta.participants.some((p) => p.projectId === projectId)) continue;
|
|
554
|
-
if (buildId && !meta.participants.some((p) => p.buildId === buildId)) continue;
|
|
555
|
-
sessions.push(meta);
|
|
556
|
-
}
|
|
557
|
-
} catch {
|
|
558
|
-
// ignore scan errors
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
return sessions.sort((a, b) => b.startedAt - a.startedAt).slice(0, limit);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// ── Write ─────────────────────────────────────────────────────────────
|
|
565
|
-
|
|
566
|
-
appendEvent(sessionId: string, event: StoreEvent): void {
|
|
567
|
-
if (!this.getSession(sessionId)) return;
|
|
568
|
-
const line = JSON.stringify(event);
|
|
569
|
-
if (Buffer.byteLength(line, 'utf-8') > MAX_EVENT_BYTES) {
|
|
570
|
-
process.stderr.write(
|
|
571
|
-
`[harness-fe] dropping oversized event (${Buffer.byteLength(line, 'utf-8')} bytes > ${MAX_EVENT_BYTES}) — type=${event.t}\n`,
|
|
572
|
-
);
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
this.writeQueue.enqueue(this.sessionTimeline(sessionId), sessionId, line);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
appendEventBatch(sessionId: string, events: StoreEvent[]): void {
|
|
579
|
-
if (!events.length) return;
|
|
580
|
-
if (!this.getSession(sessionId)) return;
|
|
581
|
-
for (const event of events) {
|
|
582
|
-
const line = JSON.stringify(event);
|
|
583
|
-
if (Buffer.byteLength(line, 'utf-8') > MAX_EVENT_BYTES) {
|
|
584
|
-
process.stderr.write(
|
|
585
|
-
`[harness-fe] dropping oversized event in batch (${Buffer.byteLength(line, 'utf-8')} bytes) — type=${event.t}\n`,
|
|
586
|
-
);
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
this.writeQueue.enqueue(this.sessionTimeline(sessionId), sessionId, line);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
appendRecording(sessionId: string, chunk: unknown): void {
|
|
594
|
-
if (!this.getSession(sessionId)) return;
|
|
595
|
-
const line = Array.isArray(chunk) ? { ts: Date.now(), events: chunk } : chunk;
|
|
596
|
-
const serialized = JSON.stringify(line);
|
|
597
|
-
if (Buffer.byteLength(serialized, 'utf-8') > MAX_RECORDING_CHUNK_BYTES) {
|
|
598
|
-
process.stderr.write(
|
|
599
|
-
`[harness-fe] dropping oversized rrweb chunk (${Buffer.byteLength(serialized, 'utf-8')} bytes > ${MAX_RECORDING_CHUNK_BYTES})\n`,
|
|
600
|
-
);
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
const target = this.sessionRecording(sessionId);
|
|
604
|
-
ensureDir(this.sessionDir(sessionId));
|
|
605
|
-
this.writeQueue.enqueue(target, sessionId, serialized);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
writeNote(projectId: string, key: string, value: string): void {
|
|
609
|
-
const projDir = this.projectDir(projectId);
|
|
610
|
-
ensureDir(projDir);
|
|
611
|
-
appendJsonl(join(projDir, 'notes.jsonl'), { ts: Date.now(), key, value });
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ── Project metadata ───────────────────────────────────────────────────
|
|
615
|
-
|
|
616
|
-
upsertProject(
|
|
617
|
-
projectId: string,
|
|
618
|
-
patch: Partial<Omit<ProjectMeta, 'id' | 'createdAt'>>,
|
|
619
|
-
): ProjectMeta {
|
|
620
|
-
const projDir = this.projectDir(projectId);
|
|
621
|
-
ensureDir(projDir);
|
|
622
|
-
const metaPath = join(projDir, 'meta.json');
|
|
623
|
-
const existing = readJson<ProjectMeta>(metaPath);
|
|
624
|
-
|
|
625
|
-
// Cycle detection
|
|
626
|
-
if (patch.parentProjectId !== undefined && patch.parentProjectId !== null) {
|
|
627
|
-
if (patch.parentProjectId === projectId) {
|
|
628
|
-
throw new Error(
|
|
629
|
-
`[harness-fe] refused to set parentProjectId=${projectId} on itself`,
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
|
-
const visited = new Set<string>();
|
|
633
|
-
let cursor: string | undefined = patch.parentProjectId;
|
|
634
|
-
while (cursor) {
|
|
635
|
-
if (cursor === projectId) {
|
|
636
|
-
throw new Error(
|
|
637
|
-
`[harness-fe] refused to create parent-project cycle: ${projectId} → … → ${projectId}`,
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
if (visited.has(cursor)) break;
|
|
641
|
-
visited.add(cursor);
|
|
642
|
-
const ancestor: ProjectMeta | undefined = readJson<ProjectMeta>(
|
|
643
|
-
join(this.projectDir(cursor), 'meta.json'),
|
|
644
|
-
) ?? undefined;
|
|
645
|
-
cursor = ancestor?.parentProjectId;
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
const merged: ProjectMeta = {
|
|
650
|
-
...existing,
|
|
651
|
-
...patch,
|
|
652
|
-
id: projectId,
|
|
653
|
-
createdAt: existing?.createdAt ?? Date.now(),
|
|
654
|
-
lastActiveAt: Date.now(),
|
|
655
|
-
// Write-once: the first principal to create the project owns it.
|
|
656
|
-
createdBy: existing?.createdBy ?? patch.createdBy,
|
|
657
|
-
};
|
|
658
|
-
enforceExtensionBudget(merged, `project ${projectId}`);
|
|
659
|
-
writeJson(metaPath, merged);
|
|
660
|
-
return merged;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
getProject(projectId: string): ProjectMeta | undefined {
|
|
664
|
-
return readJson<ProjectMeta>(join(this.projectDir(projectId), 'meta.json')) ?? undefined;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
listProjects(): ProjectMeta[] {
|
|
668
|
-
const projectsDir = this.projectsDir();
|
|
669
|
-
if (!existsSync(projectsDir)) return [];
|
|
670
|
-
const projects: ProjectMeta[] = [];
|
|
671
|
-
try {
|
|
672
|
-
for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
|
|
673
|
-
if (!entry.isDirectory()) continue;
|
|
674
|
-
const meta = readJson<ProjectMeta>(join(projectsDir, String(entry.name), 'meta.json'));
|
|
675
|
-
if (meta) projects.push(meta);
|
|
676
|
-
}
|
|
677
|
-
} catch {
|
|
678
|
-
// ignore
|
|
679
|
-
}
|
|
680
|
-
return projects.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// ── Visitor metadata (0.5+) ─────────────────────────────────────────────
|
|
684
|
-
|
|
685
|
-
upsertVisitor(
|
|
686
|
-
visitorId: string,
|
|
687
|
-
patch: {
|
|
688
|
-
userId?: string;
|
|
689
|
-
seenAt?: number;
|
|
690
|
-
incrementSession?: boolean;
|
|
691
|
-
addTabId?: string;
|
|
692
|
-
addProjectId?: string;
|
|
693
|
-
lastEnv?: VisitorEnv;
|
|
694
|
-
},
|
|
695
|
-
): VisitorMeta {
|
|
696
|
-
const dir = this.visitorDir(visitorId);
|
|
697
|
-
ensureDir(dir);
|
|
698
|
-
const metaPath = join(dir, 'meta.json');
|
|
699
|
-
const existing = readJson<VisitorMeta>(metaPath);
|
|
700
|
-
const now = patch.seenAt ?? Date.now();
|
|
701
|
-
|
|
702
|
-
const tabIds = lruAppend(existing?.tabIds, patch.addTabId, 50);
|
|
703
|
-
const projectIds = lruAppend(existing?.projectIds, patch.addProjectId, 50);
|
|
704
|
-
|
|
705
|
-
const merged: VisitorMeta = {
|
|
706
|
-
id: visitorId,
|
|
707
|
-
// userId: prefer fresh non-empty value; otherwise preserve existing
|
|
708
|
-
userId: patch.userId && patch.userId.length > 0 ? patch.userId : existing?.userId,
|
|
709
|
-
firstSeenAt: existing?.firstSeenAt ?? now,
|
|
710
|
-
lastSeenAt: now,
|
|
711
|
-
sessionCount: (existing?.sessionCount ?? 0) + (patch.incrementSession ? 1 : 0),
|
|
712
|
-
tabIds,
|
|
713
|
-
projectIds,
|
|
714
|
-
lastEnv: patch.lastEnv ?? existing?.lastEnv,
|
|
715
|
-
};
|
|
716
|
-
writeJson(metaPath, merged);
|
|
717
|
-
return merged;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
getVisitor(visitorId: string): VisitorMeta | undefined {
|
|
721
|
-
return readJson<VisitorMeta>(join(this.visitorDir(visitorId), 'meta.json')) ?? undefined;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
listVisitors(opts: { projectId?: string; limit?: number } = {}): VisitorMeta[] {
|
|
725
|
-
const dir = this.visitorsDir();
|
|
726
|
-
if (!existsSync(dir)) return [];
|
|
727
|
-
const out: VisitorMeta[] = [];
|
|
728
|
-
try {
|
|
729
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
730
|
-
if (!entry.isDirectory()) continue;
|
|
731
|
-
const meta = readJson<VisitorMeta>(join(dir, String(entry.name), 'meta.json'));
|
|
732
|
-
if (!meta) continue;
|
|
733
|
-
if (opts.projectId && !meta.projectIds.includes(opts.projectId)) continue;
|
|
734
|
-
out.push(meta);
|
|
735
|
-
}
|
|
736
|
-
} catch {
|
|
737
|
-
// ignore
|
|
738
|
-
}
|
|
739
|
-
out.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
740
|
-
return opts.limit ? out.slice(0, opts.limit) : out;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// ── Build metadata ─────────────────────────────────────────────────────
|
|
744
|
-
|
|
745
|
-
upsertBuild(
|
|
746
|
-
projectId: string,
|
|
747
|
-
buildId: string,
|
|
748
|
-
patch: Partial<Omit<BuildMeta, 'id' | 'projectId'>>,
|
|
749
|
-
): BuildMeta {
|
|
750
|
-
const dir = this.buildDir(projectId, buildId);
|
|
751
|
-
ensureDir(dir);
|
|
752
|
-
const metaPath = join(dir, 'meta.json');
|
|
753
|
-
const existing = readJson<BuildMeta>(metaPath);
|
|
754
|
-
const merged: BuildMeta = {
|
|
755
|
-
...existing,
|
|
756
|
-
...patch,
|
|
757
|
-
id: buildId,
|
|
758
|
-
projectId,
|
|
759
|
-
builtAt: existing?.builtAt ?? Date.now(),
|
|
760
|
-
};
|
|
761
|
-
enforceExtensionBudget(merged, `build ${projectId}/${buildId}`);
|
|
762
|
-
writeJson(metaPath, merged);
|
|
763
|
-
this.buildIndex.set(buildId, projectId);
|
|
764
|
-
return merged;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
getBuild(projectId: string, buildId: string): BuildMeta | undefined {
|
|
768
|
-
return readJson<BuildMeta>(join(this.buildDir(projectId, buildId), 'meta.json')) ?? undefined;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
listBuilds(projectId: string, limit = 50): BuildMeta[] {
|
|
772
|
-
const buildsDir = join(this.projectDir(projectId), 'builds');
|
|
773
|
-
if (!existsSync(buildsDir)) return [];
|
|
774
|
-
const builds: BuildMeta[] = [];
|
|
775
|
-
try {
|
|
776
|
-
for (const entry of readdirSync(buildsDir, { withFileTypes: true })) {
|
|
777
|
-
if (!entry.isDirectory()) continue;
|
|
778
|
-
const meta = readJson<BuildMeta>(join(buildsDir, String(entry.name), 'meta.json'));
|
|
779
|
-
if (meta) builds.push(meta);
|
|
780
|
-
}
|
|
781
|
-
} catch {
|
|
782
|
-
// ignore
|
|
783
|
-
}
|
|
784
|
-
return builds.sort((a, b) => b.builtAt - a.builtAt).slice(0, limit);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// ── Project tree ───────────────────────────────────────────────────────
|
|
788
|
-
|
|
789
|
-
getProjectTree(rootId?: string): ProjectTreeNode[] {
|
|
790
|
-
const all = this.listProjects();
|
|
791
|
-
const byParent = new Map<string, ProjectMeta[]>();
|
|
792
|
-
for (const p of all) {
|
|
793
|
-
const parent = p.parentProjectId;
|
|
794
|
-
if (!parent) continue;
|
|
795
|
-
const arr = byParent.get(parent) ?? [];
|
|
796
|
-
arr.push(p);
|
|
797
|
-
byParent.set(parent, arr);
|
|
798
|
-
}
|
|
799
|
-
const sortByLabel = (a: { displayName?: string; id: string }, b: { displayName?: string; id: string }) =>
|
|
800
|
-
(a.displayName ?? a.id).localeCompare(b.displayName ?? b.id);
|
|
801
|
-
|
|
802
|
-
const seedRoots = rootId
|
|
803
|
-
? all.filter((p) => p.id === rootId)
|
|
804
|
-
: all.filter((p) => !p.parentProjectId);
|
|
805
|
-
|
|
806
|
-
const nodeOf = new Map<string, ProjectTreeNode>();
|
|
807
|
-
const queue: ProjectMeta[] = [...seedRoots];
|
|
808
|
-
const visited = new Set<string>();
|
|
809
|
-
while (queue.length > 0) {
|
|
810
|
-
const p = queue.shift()!;
|
|
811
|
-
if (visited.has(p.id)) continue;
|
|
812
|
-
visited.add(p.id);
|
|
813
|
-
nodeOf.set(p.id, { id: p.id, displayName: p.displayName, tags: p.tags, children: [] });
|
|
814
|
-
const kids = (byParent.get(p.id) ?? []).slice().sort(sortByLabel);
|
|
815
|
-
for (const k of kids) queue.push(k);
|
|
816
|
-
}
|
|
817
|
-
for (const p of all) {
|
|
818
|
-
if (!nodeOf.has(p.id) || !p.parentProjectId) continue;
|
|
819
|
-
const parent = nodeOf.get(p.parentProjectId);
|
|
820
|
-
const me = nodeOf.get(p.id);
|
|
821
|
-
if (parent && me) parent.children.push(me);
|
|
822
|
-
}
|
|
823
|
-
return seedRoots
|
|
824
|
-
.slice()
|
|
825
|
-
.sort(sortByLabel)
|
|
826
|
-
.map((p) => nodeOf.get(p.id))
|
|
827
|
-
.filter((n): n is ProjectTreeNode => Boolean(n));
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// ── Read ──────────────────────────────────────────────────────────────
|
|
831
|
-
|
|
832
|
-
tail(sessionId: string, opts: TailOptions = {}): StoreEvent[] {
|
|
833
|
-
if (!this.getSession(sessionId)) return [];
|
|
834
|
-
|
|
835
|
-
const filePath = this.sessionTimeline(sessionId);
|
|
836
|
-
const n = opts.n ?? 50;
|
|
837
|
-
const multiplier = opts.type || opts.since || opts.until || opts.projectId ? 5 : 1;
|
|
838
|
-
const rawLines = readLastNLines(filePath, n * multiplier);
|
|
839
|
-
|
|
840
|
-
const events: StoreEvent[] = [];
|
|
841
|
-
for (const line of rawLines) {
|
|
842
|
-
const event = parseEvent(line);
|
|
843
|
-
if (!event) continue;
|
|
844
|
-
if (!matchesType(event, opts.type)) continue;
|
|
845
|
-
if (!matchesTimeRange(event, opts.since, opts.until)) continue;
|
|
846
|
-
if (opts.projectId && event.projectId !== opts.projectId) continue;
|
|
847
|
-
events.push(event);
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
return events.slice(-n);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
search(sessionId: string, query: string, opts: SearchOptions = {}): StoreEvent[] {
|
|
854
|
-
if (!this.getSession(sessionId)) return [];
|
|
855
|
-
|
|
856
|
-
const filePath = this.sessionTimeline(sessionId);
|
|
857
|
-
const limit = opts.limit ?? 50;
|
|
858
|
-
const lowerQuery = query.toLowerCase();
|
|
859
|
-
const results: StoreEvent[] = [];
|
|
860
|
-
|
|
861
|
-
for (const line of readAllLines(filePath)) {
|
|
862
|
-
if (!line.toLowerCase().includes(lowerQuery)) continue;
|
|
863
|
-
const event = parseEvent(line);
|
|
864
|
-
if (!event) continue;
|
|
865
|
-
if (!matchesType(event, opts.type)) continue;
|
|
866
|
-
results.push(event);
|
|
867
|
-
if (results.length >= limit) break;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
return results;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
listRecordings(sessionId: string): RecordingChunkSummary[] {
|
|
874
|
-
if (!this.getSession(sessionId)) return [];
|
|
875
|
-
|
|
876
|
-
const recPath = this.sessionRecording(sessionId);
|
|
877
|
-
const chunks: RecordingChunkSummary[] = [];
|
|
878
|
-
const sessionMeta = this.getSession(sessionId);
|
|
879
|
-
const tabId = sessionMeta?.tabId ?? '';
|
|
880
|
-
|
|
881
|
-
readAllLines(recPath).forEach((line, index) => {
|
|
882
|
-
const chunk = parseRecordingChunkLine(line, tabId, 0, index);
|
|
883
|
-
if (!chunk) return;
|
|
884
|
-
chunks.push({
|
|
885
|
-
chunkId: chunk.chunkId,
|
|
886
|
-
tabId: chunk.tabId,
|
|
887
|
-
startTs: chunk.startTs,
|
|
888
|
-
endTs: chunk.endTs,
|
|
889
|
-
eventCount: chunk.eventCount,
|
|
890
|
-
});
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
return chunks.sort((a, b) => a.startTs - b.startTs);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
sliceRecordings(sessionId: string, since: number, until: number): RecordingChunk[] {
|
|
897
|
-
if (!this.getSession(sessionId)) return [];
|
|
898
|
-
|
|
899
|
-
const recPath = this.sessionRecording(sessionId);
|
|
900
|
-
const sessionMeta = this.getSession(sessionId);
|
|
901
|
-
const tabId = sessionMeta?.tabId ?? '';
|
|
902
|
-
const chunks: RecordingChunk[] = [];
|
|
903
|
-
|
|
904
|
-
readAllLines(recPath).forEach((line, index) => {
|
|
905
|
-
const chunk = parseRecordingChunkLine(line, tabId, 0, index);
|
|
906
|
-
if (!chunk) return;
|
|
907
|
-
if (chunk.endTs < since || chunk.startTs > until) return;
|
|
908
|
-
chunks.push({
|
|
909
|
-
chunkId: chunk.chunkId,
|
|
910
|
-
tabId: chunk.tabId,
|
|
911
|
-
startTs: chunk.startTs,
|
|
912
|
-
endTs: chunk.endTs,
|
|
913
|
-
eventCount: chunk.eventCount,
|
|
914
|
-
events: chunk.events,
|
|
915
|
-
});
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
return chunks.sort((a, b) => a.startTs - b.startTs);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
writeExport(input: {
|
|
922
|
-
sessionId: string;
|
|
923
|
-
tabId?: string;
|
|
924
|
-
since: number;
|
|
925
|
-
until: number;
|
|
926
|
-
label?: string;
|
|
927
|
-
events: unknown[];
|
|
928
|
-
startTs: number;
|
|
929
|
-
endTs: number;
|
|
930
|
-
chunkCount: number;
|
|
931
|
-
}): ReplayExportMeta {
|
|
932
|
-
// Determine projectId from session participants
|
|
933
|
-
const session = this.getSession(input.sessionId);
|
|
934
|
-
const projectId = session?.participants[0]?.projectId ?? 'unknown';
|
|
935
|
-
|
|
936
|
-
const exportId = `exp_${randomUUID().slice(0, 12)}`;
|
|
937
|
-
const exportDir = this.exportsDir();
|
|
938
|
-
ensureDir(exportDir);
|
|
939
|
-
|
|
940
|
-
const eventsPath = this.exportEventsPath(exportId);
|
|
941
|
-
const payload = JSON.stringify(input.events);
|
|
942
|
-
writeFileSync(eventsPath, payload, 'utf-8');
|
|
943
|
-
|
|
944
|
-
const meta: ReplayExportMeta = {
|
|
945
|
-
exportId,
|
|
946
|
-
projectId,
|
|
947
|
-
sessionId: input.sessionId,
|
|
948
|
-
tabId: input.tabId,
|
|
949
|
-
label: input.label,
|
|
950
|
-
since: input.since,
|
|
951
|
-
until: input.until,
|
|
952
|
-
startTs: input.startTs,
|
|
953
|
-
endTs: input.endTs,
|
|
954
|
-
chunkCount: input.chunkCount,
|
|
955
|
-
eventCount: input.events.length,
|
|
956
|
-
bytes: Buffer.byteLength(payload, 'utf-8'),
|
|
957
|
-
createdAt: Date.now(),
|
|
958
|
-
};
|
|
959
|
-
appendJsonl(this.exportIndex(), meta);
|
|
960
|
-
return meta;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
getExport(exportId: string): ReplayExportMeta | undefined {
|
|
964
|
-
const indexPath = this.exportIndex();
|
|
965
|
-
if (!existsSync(indexPath)) return undefined;
|
|
966
|
-
let latest: ReplayExportMeta | undefined;
|
|
967
|
-
for (const line of readAllLines(indexPath)) {
|
|
968
|
-
try {
|
|
969
|
-
const meta = JSON.parse(line) as ReplayExportMeta;
|
|
970
|
-
if (meta?.exportId === exportId) latest = meta;
|
|
971
|
-
} catch {
|
|
972
|
-
/* swallow */
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return latest;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
readExportEvents(exportId: string): unknown[] | undefined {
|
|
979
|
-
const eventsPath = this.exportEventsPath(exportId);
|
|
980
|
-
if (!existsSync(eventsPath)) return undefined;
|
|
981
|
-
try {
|
|
982
|
-
const parsed = JSON.parse(readFileSync(eventsPath, 'utf-8'));
|
|
983
|
-
return Array.isArray(parsed) ? parsed : undefined;
|
|
984
|
-
} catch {
|
|
985
|
-
return undefined;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
listExports(projectId: string, limit?: number): ReplayExportMeta[] {
|
|
990
|
-
const indexPath = this.exportIndex();
|
|
991
|
-
if (!existsSync(indexPath)) return [];
|
|
992
|
-
const seen = new Map<string, ReplayExportMeta>();
|
|
993
|
-
for (const line of readAllLines(indexPath)) {
|
|
994
|
-
try {
|
|
995
|
-
const meta = JSON.parse(line) as ReplayExportMeta;
|
|
996
|
-
if (meta?.exportId && (projectId === 'all' || meta.projectId === projectId)) {
|
|
997
|
-
seen.set(meta.exportId, meta);
|
|
998
|
-
}
|
|
999
|
-
} catch {
|
|
1000
|
-
/* swallow */
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
const metas: ReplayExportMeta[] = [];
|
|
1004
|
-
for (const meta of seen.values()) metas.push(meta);
|
|
1005
|
-
metas.sort((a, b) => b.createdAt - a.createdAt);
|
|
1006
|
-
return typeof limit === 'number' ? metas.slice(0, limit) : metas;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
summary(sessionId: string): SessionSummary {
|
|
1010
|
-
const session = this.getSession(sessionId);
|
|
1011
|
-
|
|
1012
|
-
const counts: Partial<Record<string, number>> = {};
|
|
1013
|
-
let lastError: StoreEvent | undefined;
|
|
1014
|
-
let lastActivity: number | undefined;
|
|
1015
|
-
|
|
1016
|
-
const filePath = this.sessionTimeline(sessionId);
|
|
1017
|
-
for (const line of readAllLines(filePath)) {
|
|
1018
|
-
const event = parseEvent(line);
|
|
1019
|
-
if (!event) continue;
|
|
1020
|
-
counts[event.t] = (counts[event.t] ?? 0) + 1;
|
|
1021
|
-
if (event.t === 'err') lastError = event;
|
|
1022
|
-
if (!lastActivity || event.ts > lastActivity) lastActivity = event.ts;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
const tabs: string[] = session ? [session.tabId].filter(Boolean) : [];
|
|
1026
|
-
|
|
1027
|
-
const fallbackSession: SessionMeta = {
|
|
1028
|
-
id: sessionId,
|
|
1029
|
-
tabId: 'unknown',
|
|
1030
|
-
startedAt: 0,
|
|
1031
|
-
participants: [],
|
|
1032
|
-
};
|
|
1033
|
-
|
|
1034
|
-
return {
|
|
1035
|
-
session: session ?? fallbackSession,
|
|
1036
|
-
counts,
|
|
1037
|
-
lastError,
|
|
1038
|
-
lastActivity,
|
|
1039
|
-
tabs,
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
listNotes(projectId: string): Array<{ key: string; value: string; ts: number }> {
|
|
1044
|
-
const notesPath = join(this.projectDir(projectId), 'notes.jsonl');
|
|
1045
|
-
const notes: Array<{ key: string; value: string; ts: number }> = [];
|
|
1046
|
-
for (const line of readAllLines(notesPath)) {
|
|
1047
|
-
const parsed = parseEvent(line) as unknown as { key: string; value: string; ts: number } | undefined;
|
|
1048
|
-
if (parsed?.key) notes.push(parsed);
|
|
1049
|
-
}
|
|
1050
|
-
const latest = new Map<string, { key: string; value: string; ts: number }>();
|
|
1051
|
-
for (const note of notes) {
|
|
1052
|
-
const existing = latest.get(note.key);
|
|
1053
|
-
if (!existing || note.ts >= existing.ts) latest.set(note.key, note);
|
|
1054
|
-
}
|
|
1055
|
-
return [...latest.values()].sort((a, b) => b.ts - a.ts);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// ── Maintenance ───────────────────────────────────────────────────────
|
|
1059
|
-
|
|
1060
|
-
purge(policy: RetentionPolicy = {}): PurgeResult {
|
|
1061
|
-
// Normalize aliases
|
|
1062
|
-
const maxSessions = policy.maxSessions ?? policy.maxSessionsPerProject ?? DEFAULT_RETENTION.maxSessions;
|
|
1063
|
-
const maxAgeDays = policy.maxAgeDays ?? DEFAULT_RETENTION.maxAgeDays;
|
|
1064
|
-
const recordingRetentionDays = policy.recordingRetentionDays ?? DEFAULT_RETENTION.recordingRetentionDays;
|
|
1065
|
-
const maxChunks = policy.maxRecordingChunksPerSession ?? policy.maxRecordingChunksPerTab ?? DEFAULT_RETENTION.maxRecordingChunksPerSession;
|
|
1066
|
-
const maxBytes = policy.maxRecordingBytesPerSession ?? policy.maxRecordingBytesPerTab ?? DEFAULT_RETENTION.maxRecordingBytesPerSession;
|
|
1067
|
-
const preserveMarkedChunks = policy.preserveMarkedChunks ?? DEFAULT_RETENTION.preserveMarkedChunks;
|
|
1068
|
-
const maxExportsPerProject = policy.maxExportsPerProject ?? DEFAULT_RETENTION.maxExportsPerProject;
|
|
1069
|
-
const maxExportBytesPerProject = policy.maxExportBytesPerProject ?? DEFAULT_RETENTION.maxExportBytesPerProject;
|
|
1070
|
-
const maxBuildsPerProject = policy.maxBuildsPerProject ?? DEFAULT_RETENTION.maxBuildsPerProject;
|
|
1071
|
-
|
|
1072
|
-
const now = Date.now();
|
|
1073
|
-
const maxAge = maxAgeDays * 86400000;
|
|
1074
|
-
const recMaxAge = recordingRetentionDays * 86400000;
|
|
1075
|
-
|
|
1076
|
-
let sessionsDeleted = 0;
|
|
1077
|
-
let recordingsDeleted = 0;
|
|
1078
|
-
let exportsDeleted = 0;
|
|
1079
|
-
let bytesFreed = 0;
|
|
1080
|
-
let buildsDeleted = 0;
|
|
1081
|
-
|
|
1082
|
-
const sessionsDir = this.sessionsDir();
|
|
1083
|
-
if (existsSync(sessionsDir)) {
|
|
1084
|
-
const allSessions = this.listSessions({ limit: Number.MAX_SAFE_INTEGER });
|
|
1085
|
-
|
|
1086
|
-
// Delete sessions older than maxAge
|
|
1087
|
-
for (const sess of allSessions) {
|
|
1088
|
-
const age = now - sess.startedAt;
|
|
1089
|
-
if (age > maxAge) {
|
|
1090
|
-
const dir = this.sessionDir(sess.id);
|
|
1091
|
-
const size = dirSize(dir);
|
|
1092
|
-
rmrf(dir);
|
|
1093
|
-
this.sessionIndex.delete(sess.id);
|
|
1094
|
-
bytesFreed += size;
|
|
1095
|
-
sessionsDeleted++;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
// Keep only the most recent maxSessions
|
|
1100
|
-
const remaining = this.listSessions({ limit: Number.MAX_SAFE_INTEGER });
|
|
1101
|
-
if (remaining.length > maxSessions) {
|
|
1102
|
-
const toDelete = remaining.slice(maxSessions);
|
|
1103
|
-
for (const sess of toDelete) {
|
|
1104
|
-
const dir = this.sessionDir(sess.id);
|
|
1105
|
-
const size = dirSize(dir);
|
|
1106
|
-
rmrf(dir);
|
|
1107
|
-
this.sessionIndex.delete(sess.id);
|
|
1108
|
-
bytesFreed += size;
|
|
1109
|
-
sessionsDeleted++;
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// Trim recording data per session
|
|
1114
|
-
for (const sess of this.listSessions({ limit: Number.MAX_SAFE_INTEGER })) {
|
|
1115
|
-
const recPath = this.sessionRecording(sess.id);
|
|
1116
|
-
if (!existsSync(recPath)) continue;
|
|
1117
|
-
const timelinePath = this.sessionTimeline(sess.id);
|
|
1118
|
-
const result = this.pruneRecordingFile(
|
|
1119
|
-
recPath,
|
|
1120
|
-
timelinePath,
|
|
1121
|
-
now,
|
|
1122
|
-
recMaxAge,
|
|
1123
|
-
maxChunks,
|
|
1124
|
-
maxBytes,
|
|
1125
|
-
preserveMarkedChunks,
|
|
1126
|
-
);
|
|
1127
|
-
bytesFreed += result.bytesFreed;
|
|
1128
|
-
recordingsDeleted += result.chunksDeleted;
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Trim exports
|
|
1133
|
-
const exportResult = this.pruneExports(maxExportsPerProject, maxExportBytesPerProject);
|
|
1134
|
-
exportsDeleted += exportResult.exportsDeleted;
|
|
1135
|
-
bytesFreed += exportResult.bytesFreed;
|
|
1136
|
-
|
|
1137
|
-
// Trim builds per project
|
|
1138
|
-
for (const proj of this.listProjects()) {
|
|
1139
|
-
const allBuilds = this.listBuilds(proj.id, Number.MAX_SAFE_INTEGER);
|
|
1140
|
-
if (allBuilds.length > maxBuildsPerProject) {
|
|
1141
|
-
const stale = allBuilds.slice(maxBuildsPerProject);
|
|
1142
|
-
for (const b of stale) {
|
|
1143
|
-
const dir = this.buildDir(proj.id, b.id);
|
|
1144
|
-
const size = dirSize(dir);
|
|
1145
|
-
rmrf(dir);
|
|
1146
|
-
this.buildIndex.delete(b.id);
|
|
1147
|
-
bytesFreed += size;
|
|
1148
|
-
buildsDeleted++;
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
return { sessionsDeleted, recordingsDeleted, exportsDeleted, bytesFreed, buildsDeleted };
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
private pruneExports(
|
|
1157
|
-
maxExports: number,
|
|
1158
|
-
maxBytes: number,
|
|
1159
|
-
): { exportsDeleted: number; bytesFreed: number } {
|
|
1160
|
-
// Collect all exports across all projects
|
|
1161
|
-
const indexPath = this.exportIndex();
|
|
1162
|
-
if (!existsSync(indexPath)) return { exportsDeleted: 0, bytesFreed: 0 };
|
|
1163
|
-
|
|
1164
|
-
// Group by project
|
|
1165
|
-
const byProject = new Map<string, ReplayExportMeta[]>();
|
|
1166
|
-
for (const line of readAllLines(indexPath)) {
|
|
1167
|
-
try {
|
|
1168
|
-
const meta = JSON.parse(line) as ReplayExportMeta;
|
|
1169
|
-
if (!meta?.exportId) continue;
|
|
1170
|
-
const arr = byProject.get(meta.projectId) ?? [];
|
|
1171
|
-
arr.push(meta);
|
|
1172
|
-
byProject.set(meta.projectId, arr);
|
|
1173
|
-
} catch {
|
|
1174
|
-
/* swallow */
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
let totalDeleted = 0;
|
|
1179
|
-
let totalFreed = 0;
|
|
1180
|
-
const keepIds = new Set<string>();
|
|
1181
|
-
|
|
1182
|
-
for (const [, exports] of byProject) {
|
|
1183
|
-
exports.sort((a, b) => b.createdAt - a.createdAt);
|
|
1184
|
-
let runningBytes = 0;
|
|
1185
|
-
for (const meta of exports) {
|
|
1186
|
-
const fits = keepIds.size < maxExports && runningBytes + meta.bytes <= maxBytes;
|
|
1187
|
-
if (fits) {
|
|
1188
|
-
keepIds.add(meta.exportId);
|
|
1189
|
-
runningBytes += meta.bytes;
|
|
1190
|
-
} else {
|
|
1191
|
-
// Delete this export's events file
|
|
1192
|
-
const eventsPath = this.exportEventsPath(meta.exportId);
|
|
1193
|
-
if (existsSync(eventsPath)) {
|
|
1194
|
-
const size = statSync(eventsPath).size;
|
|
1195
|
-
try { unlinkSync(eventsPath); totalFreed += size; } catch { /* swallow */ }
|
|
1196
|
-
}
|
|
1197
|
-
totalDeleted++;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
if (totalDeleted > 0) {
|
|
1203
|
-
// Rewrite index keeping only surviving entries
|
|
1204
|
-
const allLines = readAllLines(indexPath);
|
|
1205
|
-
const kept = allLines.filter((line) => {
|
|
1206
|
-
try {
|
|
1207
|
-
const meta = JSON.parse(line) as ReplayExportMeta;
|
|
1208
|
-
return keepIds.has(meta.exportId);
|
|
1209
|
-
} catch {
|
|
1210
|
-
return false;
|
|
1211
|
-
}
|
|
1212
|
-
});
|
|
1213
|
-
if (kept.length === 0) {
|
|
1214
|
-
try { unlinkSync(indexPath); } catch { /* swallow */ }
|
|
1215
|
-
} else {
|
|
1216
|
-
writeFileSync(indexPath, kept.join('\n') + '\n', 'utf-8');
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
return { exportsDeleted: totalDeleted, bytesFreed: totalFreed };
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
/**
|
|
1224
|
-
* Flush all pending WriteQueue entries to disk. Used in tests.
|
|
1225
|
-
*/
|
|
1226
|
-
async flush(): Promise<void> {
|
|
1227
|
-
await this.writeQueue.drain();
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
async close(): Promise<void> {
|
|
1231
|
-
try {
|
|
1232
|
-
await this.writeQueue.drain();
|
|
1233
|
-
} catch (err) {
|
|
1234
|
-
console.error('[JsonlStore] close: drain failed:', err);
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
private pruneRecordingFile(
|
|
1239
|
-
recPath: string,
|
|
1240
|
-
timelinePath: string,
|
|
1241
|
-
now: number,
|
|
1242
|
-
recMaxAge: number,
|
|
1243
|
-
maxChunks: number,
|
|
1244
|
-
maxBytesLimit: number,
|
|
1245
|
-
preserveMarkedChunks: boolean,
|
|
1246
|
-
): { chunksDeleted: number; bytesFreed: number } {
|
|
1247
|
-
const lines = readAllLines(recPath);
|
|
1248
|
-
if (lines.length === 0) return { chunksDeleted: 0, bytesFreed: 0 };
|
|
1249
|
-
const fallbackAgeTs = statSync(recPath).mtimeMs;
|
|
1250
|
-
|
|
1251
|
-
const markerTimestamps = this.readMarkerTimestamps(timelinePath);
|
|
1252
|
-
const chunks: RecordingChunkRecord[] = [];
|
|
1253
|
-
|
|
1254
|
-
lines.forEach((line, index) => {
|
|
1255
|
-
const chunk = parseRecordingChunkLine(line, '', fallbackAgeTs, index);
|
|
1256
|
-
if (!chunk) return;
|
|
1257
|
-
chunk.marked = markerTimestamps.some((ts) => ts >= chunk.startTs && ts <= chunk.endTs);
|
|
1258
|
-
chunks.push(chunk);
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
if (chunks.length === 0) return { chunksDeleted: 0, bytesFreed: 0 };
|
|
1262
|
-
|
|
1263
|
-
const removed = new Set<string>();
|
|
1264
|
-
|
|
1265
|
-
for (const chunk of chunks) {
|
|
1266
|
-
if (now - chunk.ageTs > recMaxAge) removed.add(chunk.chunkId);
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
let kept = chunks.filter((chunk) => !removed.has(chunk.chunkId));
|
|
1270
|
-
|
|
1271
|
-
const chooseRemovalCandidate = (): RecordingChunkRecord | undefined => {
|
|
1272
|
-
if (kept.length === 0) return undefined;
|
|
1273
|
-
const sorted = [...kept].sort((a, b) => a.startTs - b.startTs);
|
|
1274
|
-
if (!preserveMarkedChunks) return sorted[0];
|
|
1275
|
-
return sorted.find((chunk) => !chunk.marked) ?? sorted[0];
|
|
1276
|
-
};
|
|
1277
|
-
|
|
1278
|
-
while (kept.length > maxChunks) {
|
|
1279
|
-
const candidate = chooseRemovalCandidate();
|
|
1280
|
-
if (!candidate) break;
|
|
1281
|
-
removed.add(candidate.chunkId);
|
|
1282
|
-
kept = kept.filter((chunk) => chunk.chunkId !== candidate.chunkId);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
let totalBytes = kept.reduce((sum, chunk) => sum + chunk.bytes, 0);
|
|
1286
|
-
while (totalBytes > maxBytesLimit) {
|
|
1287
|
-
const candidate = chooseRemovalCandidate();
|
|
1288
|
-
if (!candidate) break;
|
|
1289
|
-
removed.add(candidate.chunkId);
|
|
1290
|
-
kept = kept.filter((chunk) => chunk.chunkId !== candidate.chunkId);
|
|
1291
|
-
totalBytes = kept.reduce((sum, chunk) => sum + chunk.bytes, 0);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
if (removed.size === 0) return { chunksDeleted: 0, bytesFreed: 0 };
|
|
1295
|
-
|
|
1296
|
-
const bytesFreed = chunks
|
|
1297
|
-
.filter((chunk) => removed.has(chunk.chunkId))
|
|
1298
|
-
.reduce((sum, chunk) => sum + chunk.bytes, 0);
|
|
1299
|
-
|
|
1300
|
-
if (kept.length === 0) {
|
|
1301
|
-
unlinkSync(recPath);
|
|
1302
|
-
} else {
|
|
1303
|
-
writeFileSync(recPath, `${kept.map((chunk) => chunk.line).join('\n')}\n`, 'utf-8');
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
return { chunksDeleted: removed.size, bytesFreed };
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
private readMarkerTimestamps(timelinePath: string): number[] {
|
|
1310
|
-
const timestamps: number[] = [];
|
|
1311
|
-
for (const line of readAllLines(timelinePath)) {
|
|
1312
|
-
const event = parseEvent(line);
|
|
1313
|
-
if (!event || event.t !== 'rrweb:marker') continue;
|
|
1314
|
-
timestamps.push(event.ts);
|
|
1315
|
-
}
|
|
1316
|
-
return timestamps;
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
1321
|
-
|
|
1322
|
-
/** Sanitize a string for use as a directory name. */
|
|
1323
|
-
export function sanitizeId(id: string): string {
|
|
1324
|
-
return id.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
|
|
1325
|
-
}
|