@opencode-trace/viewer 0.0.3 → 0.0.5

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.
@@ -19,8 +19,8 @@
19
19
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
20
20
  }
21
21
  </style>
22
- <script type="module" crossorigin src="/assets/index-DoL5ISmB.js"></script>
23
- <link rel="stylesheet" crossorigin href="/assets/index-idry_N9R.css">
22
+ <script type="module" crossorigin src="/assets/index-DgiS5drt.js"></script>
23
+ <link rel="stylesheet" crossorigin href="/assets/index-BAtN_fgH.css">
24
24
  </head>
25
25
 
26
26
  <body>
package/dist/server.d.ts CHANGED
@@ -1,12 +1,17 @@
1
+ import { type FastifyInstance } from "fastify";
1
2
  export interface ViewerOptions {
2
3
  port?: number;
3
4
  traceDir?: string;
5
+ globalDir?: string;
6
+ localDir?: string;
4
7
  open?: boolean;
5
8
  corsOrigin?: string | string[] | RegExp | boolean;
9
+ noListen?: boolean;
6
10
  }
7
11
  export interface ViewerInstance {
8
12
  url: string;
9
13
  close: () => Promise<void>;
14
+ app?: FastifyInstance;
10
15
  }
11
16
  export declare function createViewer(options?: ViewerOptions): Promise<ViewerInstance>;
12
17
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAuCA,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,MAAM,GAAG,OAAO,CAAC;CACnD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B;AAED,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAqWnF"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAGA,OAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,SAAS,CAAC;AAqDxD,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,MAAM,GAAG,OAAO,CAAC;IAClD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,GAAG,CAAC,EAAE,eAAe,CAAC;CACvB;AAOD,wBAAsB,YAAY,CAChC,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,cAAc,CAAC,CAsxBzB"}
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync } from "node:fs";
1
+ import { readFileSync, readdirSync, existsSync, writeFileSync, promises as fs } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import Fastify from "fastify";
@@ -6,10 +6,14 @@ import cors from "@fastify/cors";
6
6
  import rateLimit from "@fastify/rate-limit";
7
7
  import multipart from "@fastify/multipart";
8
8
  import fastifyStatic from "@fastify/static";
9
- import { store, parse, query, transform, record } from "@opencode-trace/core";
9
+ import chokidar from "chokidar";
10
+ import { store, parse, query, transform, record, getTraceDir, } from "@opencode-trace/core";
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  function validateSessionId(sessionId) {
12
- return typeof sessionId === "string" && sessionId.length > 0 && sessionId.length <= 256 && /^[a-zA-Z0-9_-]+$/.test(sessionId);
13
+ return (typeof sessionId === "string" &&
14
+ sessionId.length > 0 &&
15
+ sessionId.length <= 256 &&
16
+ /^[a-zA-Z0-9_-]+$/.test(sessionId));
13
17
  }
14
18
  function validateRecordId(recordId) {
15
19
  const num = parseInt(recordId, 10);
@@ -34,8 +38,68 @@ function validateParams(reply, sessionId, recordId) {
34
38
  }
35
39
  export async function createViewer(options) {
36
40
  const port = options?.port ?? 3210;
37
- const traceDir = options?.traceDir;
38
- const storeOpts = traceDir ? { traceDir } : undefined;
41
+ const globalDir = options?.globalDir ?? options?.traceDir ?? getTraceDir();
42
+ const localDir = options?.localDir ?? options?.traceDir;
43
+ const bothDirsOpts = { globalDir, localDir };
44
+ const sseClients = new Set();
45
+ // SSE keep-alive heartbeat — prevents proxies from closing idle connections
46
+ const sseKeepAlive = setInterval(() => {
47
+ for (const client of sseClients) {
48
+ try {
49
+ client.reply.raw.write(": heartbeat\n\n");
50
+ }
51
+ catch {
52
+ sseClients.delete(client);
53
+ }
54
+ }
55
+ }, 15000);
56
+ let clientIdCounter = 0;
57
+ function broadcastSSE(event, data) {
58
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
59
+ for (const client of sseClients) {
60
+ try {
61
+ client.reply.raw.write(payload);
62
+ }
63
+ catch {
64
+ sseClients.delete(client);
65
+ }
66
+ }
67
+ }
68
+ function findSessionTraceDir(sessionId) {
69
+ if (localDir) {
70
+ const localMeta = store.readSessionMetadata(sessionId, localDir);
71
+ if (localMeta)
72
+ return localDir;
73
+ const localRecords = store.getSessionRecords(sessionId, {
74
+ traceDir: localDir,
75
+ });
76
+ if (localRecords.length > 0)
77
+ return localDir;
78
+ }
79
+ const globalMeta = store.readSessionMetadata(sessionId, globalDir);
80
+ if (globalMeta)
81
+ return globalDir;
82
+ const globalRecords = store.getSessionRecords(sessionId, {
83
+ traceDir: globalDir,
84
+ });
85
+ if (globalRecords.length > 0)
86
+ return globalDir;
87
+ return null;
88
+ }
89
+ function validateSessionAndFindDir(reply, sessionId) {
90
+ if (!validateSessionId(sessionId)) {
91
+ reply.code(400);
92
+ reply.send({ error: "Invalid session ID format" });
93
+ return null;
94
+ }
95
+ const sessionTraceDir = findSessionTraceDir(sessionId);
96
+ if (!sessionTraceDir) {
97
+ reply.code(404);
98
+ reply.send({ error: "Session not found" });
99
+ return null;
100
+ }
101
+ return sessionTraceDir;
102
+ }
39
103
  const app = Fastify({ logger: false });
40
104
  await app.register(cors, {
41
105
  origin: options?.corsOrigin ?? [/^https?:\/\/localhost(:\d+)?$/],
@@ -56,18 +120,20 @@ export async function createViewer(options) {
56
120
  app.setNotFoundHandler(async (_req, reply) => {
57
121
  const indexPath = join(publicDir, "index.html");
58
122
  if (existsSync(indexPath)) {
59
- reply.type("text/html; charset=utf-8").send(readFileSync(indexPath, "utf-8"));
123
+ reply
124
+ .type("text/html; charset=utf-8")
125
+ .send(readFileSync(indexPath, "utf-8"));
60
126
  }
61
127
  else {
62
128
  reply.code(404).send({ error: "Not found" });
63
129
  }
64
130
  });
65
131
  app.get("/api/sessions", async (_req, reply) => {
66
- const sessions = store.listSessions(storeOpts);
132
+ const sessions = store.listSessionsFromBothDirs(bothDirsOpts);
67
133
  return sessions;
68
134
  });
69
135
  app.get("/api/sessions/tree", async (_req, reply) => {
70
- const tree = store.listSessionsTree(storeOpts);
136
+ const tree = store.listSessionsTreeFromBothDirs(bothDirsOpts);
71
137
  return tree;
72
138
  });
73
139
  app.get("/api/sessions/:sessionId/timeline", async (req, reply) => {
@@ -76,7 +142,52 @@ export async function createViewer(options) {
76
142
  reply.code(400);
77
143
  return { error: "Invalid session ID format" };
78
144
  }
79
- const records = store.getSessionRecords(sessionId, storeOpts);
145
+ const sessionTraceDir = findSessionTraceDir(sessionId);
146
+ if (!sessionTraceDir) {
147
+ reply.code(404);
148
+ return { error: "Session not found" };
149
+ }
150
+ const timelineEntries = store.readTimelineIndex(sessionId, {
151
+ traceDir: sessionTraceDir,
152
+ });
153
+ if (timelineEntries.length > 0) {
154
+ const timelineRecords = [];
155
+ for (const entry of timelineEntries) {
156
+ const cached = store.getCachedParsed(sessionId, entry.seq, {
157
+ traceDir: sessionTraceDir,
158
+ });
159
+ if (cached) {
160
+ timelineRecords.push({
161
+ id: entry.seq,
162
+ requestAt: entry.requestAt,
163
+ parsed: cached,
164
+ });
165
+ }
166
+ else {
167
+ const rec = store.getRecord(sessionId, entry.seq, {
168
+ traceDir: sessionTraceDir,
169
+ });
170
+ if (rec) {
171
+ const parsed = parse.detectAndParse(rec);
172
+ timelineRecords.push({
173
+ id: rec.id,
174
+ requestAt: rec.requestAt,
175
+ parsed,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ const timeline = query.buildSessionTimeline(sessionId, timelineRecords);
181
+ const recordMeta = timelineRecords.map((r) => ({
182
+ id: r.id,
183
+ model: r.parsed.model,
184
+ provider: r.parsed.provider,
185
+ }));
186
+ return { ...timeline, recordMeta };
187
+ }
188
+ const records = store.getSessionRecords(sessionId, {
189
+ traceDir: sessionTraceDir,
190
+ });
80
191
  const parsedRecords = records
81
192
  .map((rec) => {
82
193
  const parsed = parse.detectAndParse(rec);
@@ -102,6 +213,41 @@ export async function createViewer(options) {
102
213
  };
103
214
  })
104
215
  .filter((c) => c.parsed.provider !== "unknown" || c.parsed.msgs.length > 0);
216
+ // Fire-and-forget rebuild timeline.ndjson from full-parse results
217
+ // so subsequent requests use the fast ndjson path
218
+ const ndjsonSessionDir = join(sessionTraceDir, sessionId);
219
+ const ndjsonLines = [];
220
+ for (const rec of records) {
221
+ try {
222
+ ndjsonLines.push(JSON.stringify({
223
+ seq: rec.id,
224
+ url: rec.request.url,
225
+ method: rec.request.method,
226
+ purpose: rec.purpose,
227
+ requestAt: rec.requestAt,
228
+ responseAt: rec.responseAt,
229
+ status: rec.response?.status ?? 0,
230
+ provider: parse.detectProvider(rec.request.url, rec.request.body),
231
+ model: null,
232
+ inputTokens: null,
233
+ outputTokens: null,
234
+ totalDurationMs: null,
235
+ }));
236
+ }
237
+ catch {
238
+ // skip records that can't be serialized
239
+ }
240
+ }
241
+ setImmediate(async () => {
242
+ try {
243
+ if (ndjsonLines.length > 0) {
244
+ await fs.writeFile(join(ndjsonSessionDir, "timeline.ndjson"), ndjsonLines.join("\n") + "\n");
245
+ }
246
+ }
247
+ catch {
248
+ // rebuild is optional
249
+ }
250
+ });
105
251
  const timeline = query.buildSessionTimeline(sessionId, parsedRecords);
106
252
  const recordMeta = parsedRecords.map((r) => ({
107
253
  id: r.id,
@@ -116,23 +262,60 @@ export async function createViewer(options) {
116
262
  reply.code(400);
117
263
  return { error: "Invalid session ID format" };
118
264
  }
119
- const records = store.getSessionRecords(sessionId, storeOpts);
120
- const parsedRecords = records
121
- .map((rec) => ({
122
- id: rec.id,
123
- record: rec,
124
- parsed: parse.detectAndParse(rec),
125
- }))
126
- .filter((c) => c.parsed.provider !== "unknown" || c.parsed.msgs.length > 0);
127
- const sessions = store.listSessions(storeOpts);
265
+ const sessionTraceDir = findSessionTraceDir(sessionId);
266
+ if (!sessionTraceDir) {
267
+ reply.code(404);
268
+ return { error: "Session not found" };
269
+ }
270
+ // Read timeline.ndjson for record list + basic timing data
271
+ const timelineEntries = store.readTimelineIndex(sessionId, {
272
+ traceDir: sessionTraceDir,
273
+ });
274
+ const metaRecords = [];
275
+ if (timelineEntries.length > 0) {
276
+ // Use ndjson + parsed cache — no full JSON scan + parse
277
+ for (const entry of timelineEntries) {
278
+ const cached = store.getCachedParsed(sessionId, entry.seq, {
279
+ traceDir: sessionTraceDir,
280
+ });
281
+ if (cached) {
282
+ metaRecords.push({
283
+ id: entry.seq,
284
+ record: undefined,
285
+ parsed: cached,
286
+ });
287
+ }
288
+ else {
289
+ const rec = store.getRecord(sessionId, entry.seq, {
290
+ traceDir: sessionTraceDir,
291
+ });
292
+ if (rec) {
293
+ const parsed = parse.detectAndParse(rec);
294
+ metaRecords.push({ id: entry.seq, record: rec, parsed });
295
+ }
296
+ }
297
+ }
298
+ }
299
+ else {
300
+ // No ndjson — full fallback (legacy sessions before this upgrade)
301
+ const records = store.getSessionRecords(sessionId, {
302
+ traceDir: sessionTraceDir,
303
+ });
304
+ for (const rec of records) {
305
+ const parsed = parse.detectAndParse(rec);
306
+ metaRecords.push({ id: rec.id, record: rec, parsed });
307
+ }
308
+ }
309
+ const filtered = metaRecords.filter((r) => r.parsed.provider !== "unknown" || r.parsed.msgs.length > 0);
310
+ const sessions = store.listSessionsFromBothDirs(bothDirsOpts);
128
311
  const sessionMeta = sessions.find((s) => s.id === sessionId);
129
- const tree = store.listSessionsTree(storeOpts);
312
+ const tree = store.listSessionsTreeFromBothDirs(bothDirsOpts);
130
313
  const node = tree.find((n) => n.id === sessionId);
131
- const metadata = query.buildSessionMetadata(sessionId, parsedRecords, sessionMeta?.folderPath);
314
+ const metadata = query.buildSessionMetadata(sessionId, filtered, sessionMeta?.folderPath);
132
315
  if (sessionMeta) {
133
316
  metadata.createdAt = sessionMeta.createdAt;
134
317
  metadata.updatedAt = sessionMeta.updatedAt;
135
- metadata.subSessions = node?.children?.map(c => c.id) ?? [];
318
+ metadata.subSessions = node?.children?.map((c) => c.id) ?? [];
136
319
  metadata.parentSession = sessionMeta.parentID ?? null;
137
320
  }
138
321
  return metadata;
@@ -142,7 +325,17 @@ export async function createViewer(options) {
142
325
  const rid = validateParams(reply, sessionId, recordId);
143
326
  if (rid === null)
144
327
  return;
145
- const rec = store.getRecord(sessionId, rid, storeOpts);
328
+ const sessionTraceDir = validateSessionAndFindDir(reply, sessionId);
329
+ if (sessionTraceDir === null)
330
+ return;
331
+ const cached = store.getCachedParsed(sessionId, rid, {
332
+ traceDir: sessionTraceDir,
333
+ });
334
+ if (cached)
335
+ return cached;
336
+ const rec = store.getRecord(sessionId, rid, {
337
+ traceDir: sessionTraceDir,
338
+ });
146
339
  if (!rec) {
147
340
  reply.code(404);
148
341
  return { error: "Record not found" };
@@ -155,7 +348,12 @@ export async function createViewer(options) {
155
348
  const rid = validateParams(reply, sessionId, recordId);
156
349
  if (rid === null)
157
350
  return;
158
- const rec = store.getRecord(sessionId, rid, storeOpts);
351
+ const sessionTraceDir = validateSessionAndFindDir(reply, sessionId);
352
+ if (sessionTraceDir === null)
353
+ return;
354
+ const rec = store.getRecord(sessionId, rid, {
355
+ traceDir: sessionTraceDir,
356
+ });
159
357
  if (!rec) {
160
358
  reply.code(404);
161
359
  return { error: "Record not found" };
@@ -168,7 +366,12 @@ export async function createViewer(options) {
168
366
  const rid = validateParams(reply, sessionId, recordId);
169
367
  if (rid === null)
170
368
  return;
171
- const rec = store.getRecord(sessionId, rid, storeOpts);
369
+ const sessionTraceDir = validateSessionAndFindDir(reply, sessionId);
370
+ if (sessionTraceDir === null)
371
+ return;
372
+ const rec = store.getRecord(sessionId, rid, {
373
+ traceDir: sessionTraceDir,
374
+ });
172
375
  if (!rec) {
173
376
  reply.code(404);
174
377
  return { error: "Record not found" };
@@ -181,12 +384,19 @@ export async function createViewer(options) {
181
384
  const rid = validateParams(reply, sessionId, recordId);
182
385
  if (rid === null)
183
386
  return;
184
- const sseData = store.getSSEStream(sessionId, rid, storeOpts);
387
+ const sessionTraceDir = validateSessionAndFindDir(reply, sessionId);
388
+ if (sessionTraceDir === null)
389
+ return;
390
+ const sseData = store.getSSEStream(sessionId, rid, {
391
+ traceDir: sessionTraceDir,
392
+ });
185
393
  if (!sseData) {
186
394
  reply.code(404);
187
395
  return { error: "No SSE data found" };
188
396
  }
189
- const rec = store.getRecord(sessionId, rid, storeOpts);
397
+ const rec = store.getRecord(sessionId, rid, {
398
+ traceDir: sessionTraceDir,
399
+ });
190
400
  const provider = rec?.request
191
401
  ? parse.detectProvider(rec.request.url, rec.request.body)
192
402
  : null;
@@ -207,7 +417,12 @@ export async function createViewer(options) {
207
417
  const rid = validateParams(reply, sessionId, recordId);
208
418
  if (rid === null)
209
419
  return;
210
- const rec = store.getRecord(sessionId, rid, storeOpts);
420
+ const sessionTraceDir = validateSessionAndFindDir(reply, sessionId);
421
+ if (sessionTraceDir === null)
422
+ return;
423
+ const rec = store.getRecord(sessionId, rid, {
424
+ traceDir: sessionTraceDir,
425
+ });
211
426
  if (!rec) {
212
427
  reply.code(404);
213
428
  return { error: "Record not found" };
@@ -220,9 +435,12 @@ export async function createViewer(options) {
220
435
  reply.code(400);
221
436
  return { error: "Invalid session ID format" };
222
437
  }
223
- const sessions = store.listSessions(storeOpts);
438
+ const sessionTraceDir = findSessionTraceDir(sessionId);
439
+ const sessions = store.listSessionsFromBothDirs(bothDirsOpts);
224
440
  const sessionMeta = sessions.find((s) => s.id === sessionId);
225
- const records = store.getSessionRecords(sessionId, storeOpts);
441
+ const records = sessionTraceDir
442
+ ? store.getSessionRecords(sessionId, { traceDir: sessionTraceDir })
443
+ : [];
226
444
  const enriched = records.map((rec) => ({
227
445
  ...rec,
228
446
  provider: rec?.request
@@ -241,19 +459,34 @@ export async function createViewer(options) {
241
459
  };
242
460
  });
243
461
  app.get("/api/trace/status", async (_req, reply) => {
244
- const enabled = record.getGlobalTraceEnabled(traceDir);
462
+ const enabled = record.getGlobalTraceEnabled(globalDir);
245
463
  return { globalEnabled: enabled };
246
464
  });
247
465
  app.get("/api/trace/enable", async (_req, reply) => {
248
- record.setGlobalTraceEnabled(true, traceDir);
466
+ record.setGlobalTraceEnabled(true, globalDir);
249
467
  return { success: true, globalEnabled: true };
250
468
  });
251
469
  app.get("/api/trace/disable", async (_req, reply) => {
252
- record.setGlobalTraceEnabled(false, traceDir);
470
+ record.setGlobalTraceEnabled(false, globalDir);
253
471
  return { success: true, globalEnabled: false };
254
472
  });
255
473
  app.get("/api/trace-dir", async (_req, reply) => {
256
- return { traceDir: store.getTraceDir(storeOpts) };
474
+ return { traceDir: globalDir, localDir };
475
+ });
476
+ app.get("/api/events", async (req, reply) => {
477
+ const clientId = String(++clientIdCounter);
478
+ const client = { id: clientId, reply };
479
+ reply.raw.writeHead(200, {
480
+ "Content-Type": "text/event-stream",
481
+ "Cache-Control": "no-cache",
482
+ Connection: "keep-alive",
483
+ "X-Accel-Buffering": "no",
484
+ });
485
+ reply.raw.write(`event: connected\ndata: {"clientId":"${clientId}"}\n\n`);
486
+ sseClients.add(client);
487
+ req.raw.on("close", () => {
488
+ sseClients.delete(client);
489
+ });
257
490
  });
258
491
  app.post("/api/sessions/:sessionId/export", async (req, reply) => {
259
492
  try {
@@ -262,11 +495,18 @@ export async function createViewer(options) {
262
495
  reply.code(400);
263
496
  return { error: "Invalid session ID format" };
264
497
  }
265
- const stream = await store.exportSessionZip(sessionId, storeOpts);
498
+ const sessionTraceDir = findSessionTraceDir(sessionId);
499
+ if (!sessionTraceDir) {
500
+ reply.code(404);
501
+ return { error: "Session not found" };
502
+ }
503
+ const buffer = await store.exportSessionZip(sessionId, {
504
+ traceDir: sessionTraceDir,
505
+ });
266
506
  reply
267
507
  .type("application/zip")
268
508
  .header("Content-Disposition", `attachment; filename="session-${sessionId}.zip"`)
269
- .send(stream);
509
+ .send(buffer);
270
510
  }
271
511
  catch (e) {
272
512
  const err = e;
@@ -290,10 +530,12 @@ export async function createViewer(options) {
290
530
  const validStrategies = ["prompt", "rename", "skip", "overwrite"];
291
531
  if (!validStrategies.includes(conflictStrategy)) {
292
532
  reply.code(400);
293
- return { error: `Invalid conflict strategy: ${conflictStrategy}. Valid: ${validStrategies.join(", ")}` };
533
+ return {
534
+ error: `Invalid conflict strategy: ${conflictStrategy}. Valid: ${validStrategies.join(", ")}`,
535
+ };
294
536
  }
295
537
  const result = await store.importSessionZip(fileBuffer, {
296
- ...storeOpts,
538
+ traceDir: globalDir,
297
539
  conflictStrategy: conflictStrategy,
298
540
  });
299
541
  return result;
@@ -315,21 +557,147 @@ export async function createViewer(options) {
315
557
  reply.code(400);
316
558
  return { error: "Invalid session ID format" };
317
559
  }
318
- await store.deleteSession(sessionId, storeOpts);
560
+ const sessionTraceDir = findSessionTraceDir(sessionId);
561
+ if (!sessionTraceDir) {
562
+ reply.code(404);
563
+ return { error: "Session not found" };
564
+ }
565
+ await store.deleteSession(sessionId, { traceDir: sessionTraceDir });
319
566
  return { success: true, sessionId };
320
567
  }
321
568
  catch (e) {
322
569
  const err = e;
323
- if (err.message === "Session not found") {
324
- reply.code(404);
325
- return { error: "Session not found" };
326
- }
327
570
  reply.code(500);
328
571
  return { error: "Delete failed: " + err.message };
329
572
  }
330
573
  });
331
- await app.listen({ port, host: "0.0.0.0" });
332
- const addr = `http://localhost:${port}`;
574
+ app.post("/api/sessions/batch-delete", async (req, reply) => {
575
+ try {
576
+ const body = req.body;
577
+ if (!body ||
578
+ !Array.isArray(body.sessionIds) ||
579
+ body.sessionIds.length === 0) {
580
+ reply.code(400);
581
+ return { error: "sessionIds must be a non-empty array" };
582
+ }
583
+ const deleted = [];
584
+ const errors = [];
585
+ for (const sessionId of body.sessionIds) {
586
+ try {
587
+ const sessionTraceDir = findSessionTraceDir(sessionId);
588
+ if (sessionTraceDir) {
589
+ await store.deleteSession(sessionId, { traceDir: sessionTraceDir });
590
+ deleted.push(sessionId);
591
+ }
592
+ else {
593
+ errors.push({ sessionId, error: "Session not found" });
594
+ }
595
+ }
596
+ catch (e) {
597
+ errors.push({ sessionId, error: e.message });
598
+ }
599
+ }
600
+ return { success: true, deleted, errors };
601
+ }
602
+ catch (e) {
603
+ reply.code(500);
604
+ return { error: "Batch delete failed: " + e.message };
605
+ }
606
+ });
607
+ const watchDirs = [globalDir];
608
+ if (localDir && localDir !== globalDir) {
609
+ watchDirs.push(localDir);
610
+ }
611
+ const watcher = chokidar.watch(watchDirs, {
612
+ ignored: /(\.tmp$|\.parsed$)/,
613
+ persistent: true,
614
+ ignoreInitial: true,
615
+ depth: 2,
616
+ });
617
+ watcher.on("add", (filePath) => {
618
+ const match = filePath.match(/\/([^/]+)\/(\d+)\.json$/);
619
+ if (match) {
620
+ const sessionId = match[1];
621
+ const seq = parseInt(match[2], 10);
622
+ broadcastSSE("record:added", { sessionId, seq });
623
+ }
624
+ });
625
+ watcher.on("change", (filePath) => {
626
+ const match = filePath.match(/\/([^/]+)\/(\d+)\.json$/);
627
+ if (match) {
628
+ const sessionId = match[1];
629
+ const seq = parseInt(match[2], 10);
630
+ broadcastSSE("record:updated", { sessionId, seq });
631
+ }
632
+ });
633
+ watcher.on("unlink", (filePath) => {
634
+ const match = filePath.match(/\/([^/]+)\/(\d+)\.json$/);
635
+ if (match) {
636
+ const sessionId = match[1];
637
+ const seq = parseInt(match[2], 10);
638
+ // Clean up the ndjinx entry so the timeline fast path doesn't show ghost
639
+ // records pointing to deleted JSON files.
640
+ const sessionDir = dirname(filePath);
641
+ const ndjinxPath = join(sessionDir, "timeline.ndjinx");
642
+ try {
643
+ if (existsSync(ndjinxPath)) {
644
+ const raw = readFileSync(ndjinxPath, "utf-8");
645
+ const lines = raw
646
+ .split("\n")
647
+ .filter((l) => {
648
+ if (!l.trim())
649
+ return false;
650
+ try {
651
+ return JSON.parse(l).seq !== seq;
652
+ }
653
+ catch {
654
+ return false;
655
+ }
656
+ });
657
+ writeFileSync(ndjinxPath, lines.join("\n") + "\n");
658
+ }
659
+ }
660
+ catch {
661
+ // ndjinx cleanup is best-effort
662
+ }
663
+ broadcastSSE("record:deleted", { sessionId, seq });
664
+ }
665
+ });
666
+ watcher.on("addDir", (dirPath) => {
667
+ const match = dirPath.match(/\/([^/]+)$/);
668
+ if (match) {
669
+ const sessionId = match[1];
670
+ // Validate it's a real session dir (has at least one JSON file or metadata)
671
+ if (/^\d+\.json$/.test(sessionId))
672
+ return; // not a session dir (it's a record number match)
673
+ try {
674
+ const files = readdirSync(dirPath);
675
+ const hasRecord = files.some((f) => /^\d+\.json$/.test(f));
676
+ const hasMeta = files.some((f) => f === "metadata.json");
677
+ if (hasRecord || hasMeta) {
678
+ broadcastSSE("session:created", { sessionId });
679
+ }
680
+ }
681
+ catch {
682
+ // best-effort validation
683
+ }
684
+ }
685
+ });
686
+ watcher.on("unlinkDir", (dirPath) => {
687
+ const match = dirPath.match(/\/([^/]+)$/);
688
+ if (match) {
689
+ const sessionId = match[1];
690
+ broadcastSSE("session:deleted", { sessionId });
691
+ }
692
+ });
693
+ let addr;
694
+ if (options?.noListen) {
695
+ addr = "http://localhost:0";
696
+ }
697
+ else {
698
+ await app.listen({ port, host: "0.0.0.0" });
699
+ addr = `http://localhost:${port}`;
700
+ }
333
701
  if (options?.open) {
334
702
  import("node:child_process").then(({ exec }) => {
335
703
  const cmd = process.platform === "darwin"
@@ -342,7 +710,12 @@ export async function createViewer(options) {
342
710
  }
343
711
  return {
344
712
  url: addr,
345
- close: () => app.close(),
713
+ app,
714
+ close: async () => {
715
+ clearInterval(sseKeepAlive);
716
+ await watcher.close();
717
+ await app.close();
718
+ },
346
719
  };
347
720
  }
348
721
  //# sourceMappingURL=server.js.map