@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.
- package/README.md +17 -2
- package/dist/cli.js +20 -6
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +110 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +21 -0
- package/dist/index.test.js.map +1 -0
- package/dist/public/assets/index-BAtN_fgH.css +1 -0
- package/dist/public/assets/index-DgiS5drt.js +91 -0
- package/dist/public/index.html +2 -2
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +418 -45
- package/dist/server.js.map +1 -1
- package/dist/server.test.js +1275 -81
- package/dist/server.test.js.map +1 -1
- package/package.json +5 -3
- package/dist/public/assets/index-DoL5ISmB.js +0 -91
- package/dist/public/assets/index-idry_N9R.css +0 -1
package/dist/public/index.html
CHANGED
|
@@ -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-
|
|
23
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"
|
|
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
|
|
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" &&
|
|
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
|
|
38
|
-
const
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
120
|
-
|
|
121
|
-
.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
312
|
+
const tree = store.listSessionsTreeFromBothDirs(bothDirsOpts);
|
|
130
313
|
const node = tree.find((n) => n.id === sessionId);
|
|
131
|
-
const metadata = query.buildSessionMetadata(sessionId,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
438
|
+
const sessionTraceDir = findSessionTraceDir(sessionId);
|
|
439
|
+
const sessions = store.listSessionsFromBothDirs(bothDirsOpts);
|
|
224
440
|
const sessionMeta = sessions.find((s) => s.id === sessionId);
|
|
225
|
-
const records =
|
|
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(
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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(
|
|
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 {
|
|
533
|
+
return {
|
|
534
|
+
error: `Invalid conflict strategy: ${conflictStrategy}. Valid: ${validStrategies.join(", ")}`,
|
|
535
|
+
};
|
|
294
536
|
}
|
|
295
537
|
const result = await store.importSessionZip(fileBuffer, {
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|