@tonyclaw/agent-inspector 2.0.17 → 2.0.19
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/.output/cli.js +149 -40
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-DDg7Cs4N.js → CompareDrawer-D-90PIPw.js} +1 -1
- package/.output/public/assets/ProxyViewerContainer-CxRDWacg.js +114 -0
- package/.output/public/assets/ReplayDialog-DzlLYoTO.js +1 -0
- package/.output/public/assets/{RequestAnatomy-DR8OICOJ.js → RequestAnatomy-0hbyf6oa.js} +1 -1
- package/.output/public/assets/ResponseView-CKe0tNqr.js +1 -0
- package/.output/public/assets/StreamingChunkSequence-enhfdeNh.js +1 -0
- package/.output/public/assets/_sessionId-CJ-SusES.js +1 -0
- package/.output/public/assets/index-BbFsmQRC.js +1 -0
- package/.output/public/assets/index-BfBgrkgU.css +1 -0
- package/.output/public/assets/{main-bwZlEXw2.js → main-o9-hg8g1.js} +2 -2
- package/.output/server/_libs/lucide-react.mjs +141 -118
- package/.output/server/_libs/tanstack__react-virtual.mjs +13 -2
- package/.output/server/_libs/tanstack__virtual-core.mjs +24 -1
- package/.output/server/{_sessionId-BEuJJhqT.mjs → _sessionId-F8lm6PIb.mjs} +3 -2
- package/.output/server/_ssr/{CompareDrawer-yTO93GMz.mjs → CompareDrawer-Cf1yfwfd.mjs} +4 -3
- package/.output/server/_ssr/{ProxyViewerContainer-C8qCkHGB.mjs → ProxyViewerContainer-Bmu9867L.mjs} +349 -74
- package/.output/server/_ssr/{ReplayDialog-DRF9PG7Z.mjs → ReplayDialog-DxZfLBOe.mjs} +199 -17
- package/.output/server/_ssr/{RequestAnatomy-DBcuV_jV.mjs → RequestAnatomy-CbdERGZd.mjs} +4 -3
- package/.output/server/_ssr/{ResponseView-JJm78HZT.mjs → ResponseView-CvLd6qxI.mjs} +4 -3
- package/.output/server/_ssr/{StreamingChunkSequence-DyGKjBYx.mjs → StreamingChunkSequence-B_lkRB6o.mjs} +4 -3
- package/.output/server/_ssr/{index-T7JG28t6.mjs → index-ejrSItfz.mjs} +3 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-BJob8RN3.mjs → router-SbCnbDc2.mjs} +896 -240
- package/.output/server/{_tanstack-start-manifest_v-CuiFi3XC.mjs → _tanstack-start-manifest_v-yx8ftqWz.mjs} +1 -1
- package/.output/server/index.mjs +60 -60
- package/README.md +26 -109
- package/package.json +2 -1
- package/src/cli/doctor.ts +160 -8
- package/src/cli/templates/skill-onboard.ts +2 -20
- package/src/components/OnboardingBanner.tsx +13 -13
- package/src/components/ProxyViewer.tsx +55 -38
- package/src/components/ProxyViewerContainer.tsx +5 -0
- package/src/components/providers/SettingsDialog.tsx +113 -2
- package/src/components/proxy-viewer/ConversationGroup.tsx +20 -3
- package/src/components/proxy-viewer/ConversationGroupList.tsx +144 -0
- package/src/components/proxy-viewer/ConversationHeader.tsx +0 -2
- package/src/components/proxy-viewer/ReplayDialog.tsx +200 -13
- package/src/components/proxy-viewer/index.ts +1 -1
- package/src/knowledge/openclawClient.ts +0 -8
- package/src/knowledge/types.ts +0 -2
- package/src/lib/export-logs.ts +72 -5
- package/src/lib/providerTestContract.ts +0 -1
- package/src/lib/useProviders.ts +1 -1
- package/src/mcp/server.ts +14 -6
- package/src/mcp/toolHandlers.ts +21 -4
- package/src/proxy/chunkStorage.ts +2 -4
- package/src/proxy/dataDir.ts +37 -15
- package/src/proxy/formats/anthropic/index.ts +0 -1
- package/src/proxy/formats/anthropic/schemas.ts +0 -2
- package/src/proxy/handler.ts +41 -29
- package/src/proxy/logFinalizer.ts +93 -11
- package/src/proxy/logger.ts +35 -10
- package/src/proxy/providers.ts +12 -5
- package/src/proxy/rawStreamCapture.ts +85 -0
- package/src/proxy/schemas.ts +1 -51
- package/src/proxy/sessionRuntime.ts +0 -9
- package/src/proxy/socketTracker.ts +15 -8
- package/src/proxy/store.ts +414 -13
- package/src/routes/api/logs.$id.replay.ts +66 -1
- package/src/routes/api/logs.stream.ts +11 -3
- package/src/routes/api/logs.ts +51 -17
- package/src/routes/api/providers.$providerId.ts +10 -3
- package/src/routes/api/providers.ts +6 -3
- package/.output/public/assets/ProxyViewerContainer-DbbK1y7N.js +0 -114
- package/.output/public/assets/ReplayDialog-C1zWNkoz.js +0 -1
- package/.output/public/assets/ResponseView-Dh9iSj6h.js +0 -1
- package/.output/public/assets/StreamingChunkSequence-B_xElyH3.js +0 -1
- package/.output/public/assets/_sessionId-gv1a_NNu.js +0 -1
- package/.output/public/assets/index-D_nZj9Vt.css +0 -1
- package/.output/public/assets/index-u-LwDaeH.js +0 -1
- package/src/components/ui/json-expansion-button.tsx +0 -56
package/src/proxy/schemas.ts
CHANGED
|
@@ -8,8 +8,6 @@ export type { JsonValue };
|
|
|
8
8
|
|
|
9
9
|
export type RequestFormat = "anthropic" | "openai" | "unknown";
|
|
10
10
|
|
|
11
|
-
import { safeGetOwnProperty } from "../lib/objectUtils";
|
|
12
|
-
|
|
13
11
|
/**
|
|
14
12
|
* A single SSE event from a streaming response.
|
|
15
13
|
*/
|
|
@@ -50,6 +48,7 @@ export const CapturedLogSchema = z.object({
|
|
|
50
48
|
headers: z.record(z.string(), z.string()).optional(),
|
|
51
49
|
apiFormat: z.enum(["anthropic", "openai", "unknown"]).default("unknown"),
|
|
52
50
|
isTest: z.boolean().optional().default(false),
|
|
51
|
+
replayOfLogId: z.number().nullable().optional(),
|
|
53
52
|
providerName: z.string().nullable().optional(),
|
|
54
53
|
clientPort: z.number().nullable().optional(),
|
|
55
54
|
clientPid: z.number().nullable().optional(),
|
|
@@ -74,9 +73,6 @@ export type TokenUsage = {
|
|
|
74
73
|
// Import types and schemas from formats
|
|
75
74
|
// ============================================================
|
|
76
75
|
|
|
77
|
-
// Import types and schemas from formats for internal use
|
|
78
|
-
import { InspectorRequestSchema, type InspectorRequest } from "./formats/anthropic/schemas";
|
|
79
|
-
|
|
80
76
|
// Re-export types and schemas from formats for backward compatibility
|
|
81
77
|
export {
|
|
82
78
|
InspectorRequestSchema,
|
|
@@ -84,7 +80,6 @@ export {
|
|
|
84
80
|
SseEventSchema,
|
|
85
81
|
} from "./formats/anthropic/schemas";
|
|
86
82
|
export type {
|
|
87
|
-
InspectorRequest,
|
|
88
83
|
InspectorResponse,
|
|
89
84
|
ResponseContentBlockType,
|
|
90
85
|
} from "./formats/anthropic/schemas";
|
|
@@ -98,35 +93,6 @@ export {
|
|
|
98
93
|
parseOpenAIResponse,
|
|
99
94
|
} from "./formats/openai/schemas";
|
|
100
95
|
|
|
101
|
-
// ============================================================
|
|
102
|
-
// Utility functions
|
|
103
|
-
// ============================================================
|
|
104
|
-
|
|
105
|
-
function detectFormat(rawBody: string | null): RequestFormat {
|
|
106
|
-
if (rawBody === null) return "unknown";
|
|
107
|
-
try {
|
|
108
|
-
const json: unknown = JSON.parse(rawBody);
|
|
109
|
-
if (typeof json === "object" && json !== null && !Array.isArray(json)) {
|
|
110
|
-
const hasModel = safeGetOwnProperty(json, "model") !== undefined;
|
|
111
|
-
const hasMessages = safeGetOwnProperty(json, "messages") !== undefined;
|
|
112
|
-
if (hasModel && hasMessages) {
|
|
113
|
-
// Anthropic requests put `system` as a top-level key; OpenAI does not.
|
|
114
|
-
// Both formats can have `tools`, so we check `system` as the discriminator.
|
|
115
|
-
if (safeGetOwnProperty(json, "system") !== undefined) {
|
|
116
|
-
return "anthropic";
|
|
117
|
-
}
|
|
118
|
-
const msgVal = safeGetOwnProperty(json, "messages");
|
|
119
|
-
if (Array.isArray(msgVal)) {
|
|
120
|
-
return "openai";
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return "unknown";
|
|
125
|
-
} catch {
|
|
126
|
-
return "unknown";
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
96
|
// Schema for model name in request body (used for routing)
|
|
131
97
|
const RequestModelSchema = z.object({
|
|
132
98
|
model: z.string(),
|
|
@@ -191,19 +157,3 @@ export function extractRequestMetadata(body: string | null, headers: Headers): R
|
|
|
191
157
|
}
|
|
192
158
|
return { model: null, sessionId: headerSessionId };
|
|
193
159
|
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Parse a raw request body into an InspectorRequest.
|
|
197
|
-
* @deprecated Use FormatHandler.parseRequest() instead
|
|
198
|
-
*/
|
|
199
|
-
export function parseRequest(rawBody: string | null): InspectorRequest | null {
|
|
200
|
-
if (rawBody === null) return null;
|
|
201
|
-
try {
|
|
202
|
-
const json: unknown = JSON.parse(rawBody);
|
|
203
|
-
const result = InspectorRequestSchema.safeParse(json);
|
|
204
|
-
if (result.success) return result.data;
|
|
205
|
-
return null;
|
|
206
|
-
} catch {
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { logger } from "./logger";
|
|
2
2
|
import { executeFinalizeLogJob, type FinalizeLogJob } from "./logFinalizer";
|
|
3
|
-
import type { CapturedLog } from "./schemas";
|
|
4
3
|
import {
|
|
5
4
|
getLogSessionId,
|
|
6
5
|
markSessionTaskFinished,
|
|
@@ -70,14 +69,6 @@ export function enqueueSessionTask<T>(
|
|
|
70
69
|
return current;
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
export function enqueueLogTask<T>(
|
|
74
|
-
log: CapturedLog,
|
|
75
|
-
taskName: SessionTaskName,
|
|
76
|
-
task: SessionTask<T>,
|
|
77
|
-
): Promise<T> {
|
|
78
|
-
return enqueueSessionTask(getLogSessionId(log), taskName, task);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
72
|
export function enqueueFinalizeLogJob(job: FinalizeLogJob): Promise<void> {
|
|
82
73
|
return enqueueSessionTask(getLogSessionId(job.log), "finalize-log", () =>
|
|
83
74
|
executeFinalizeLogJob(job),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec, execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { logger } from "./logger";
|
|
4
5
|
|
|
5
6
|
const execAsync = promisify(exec);
|
|
@@ -20,6 +21,12 @@ type CacheEntry = ClientInfo & {
|
|
|
20
21
|
const cache = new Map<number, CacheEntry>();
|
|
21
22
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
22
23
|
const MAX_CACHE_SIZE = 200;
|
|
24
|
+
const RemotePortSchema = z.number().int().min(1).max(65_535);
|
|
25
|
+
const SocketSchema = z
|
|
26
|
+
.object({
|
|
27
|
+
remotePort: RemotePortSchema.nullish(),
|
|
28
|
+
})
|
|
29
|
+
.passthrough();
|
|
23
30
|
|
|
24
31
|
// Deduplicate concurrent lookups for the same port
|
|
25
32
|
const inflight = new Map<number, Promise<ClientInfo>>();
|
|
@@ -50,14 +57,14 @@ function setCache(port: number, info: ClientInfo): void {
|
|
|
50
57
|
* Get the remote port from a Request's underlying socket.
|
|
51
58
|
* Works with Bun, Node.js, and Nitro's event.node.req.
|
|
52
59
|
*/
|
|
53
|
-
function extractRemotePort(request: Request): number | null {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (remotePort
|
|
60
|
-
return
|
|
60
|
+
export function extractRemotePort(request: Request): number | null {
|
|
61
|
+
const descriptor = Object.getOwnPropertyDescriptor(request, "socket");
|
|
62
|
+
if (descriptor === undefined || descriptor.get !== undefined) return null;
|
|
63
|
+
const parsed = SocketSchema.safeParse(descriptor.value);
|
|
64
|
+
if (!parsed.success) return null;
|
|
65
|
+
const remotePort = parsed.data.remotePort;
|
|
66
|
+
if (remotePort === undefined || remotePort === null) return null;
|
|
67
|
+
return remotePort;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/**
|
package/src/proxy/store.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
import { readFileSync, existsSync, createReadStream } from "node:fs";
|
|
2
|
-
import { open } from "node:fs/promises";
|
|
1
|
+
import { readFileSync, existsSync, createReadStream, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { open, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { readdir as readdirCallback } from "node:fs/promises";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { Buffer } from "node:buffer";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
appendLogEntry,
|
|
9
|
+
resolveLogDir,
|
|
10
|
+
logger,
|
|
11
|
+
runWithFlushedLogWriteLock,
|
|
12
|
+
runWithLogWriteLock,
|
|
13
|
+
} from "./logger";
|
|
14
|
+
import { getChunksDir } from "./chunkStorage";
|
|
15
|
+
import {
|
|
16
|
+
addToIndex,
|
|
17
|
+
findInIndex,
|
|
18
|
+
flushIndex,
|
|
19
|
+
getNextLogId,
|
|
20
|
+
getCurrentLogFile,
|
|
21
|
+
rebuildIndex,
|
|
22
|
+
} from "./logIndex";
|
|
9
23
|
import { writeChunks } from "./chunkStorage";
|
|
10
24
|
import type { CapturedLog } from "./schemas";
|
|
11
25
|
import { CapturedLogSchema } from "./schemas";
|
|
@@ -27,6 +41,42 @@ export { getLogSessionId, getSessionSnapshots } from "./sessionSupervisor";
|
|
|
27
41
|
|
|
28
42
|
const MAX_MEMORY_CACHE = 100;
|
|
29
43
|
|
|
44
|
+
export type LogStorageStats = {
|
|
45
|
+
memoryCount: number;
|
|
46
|
+
logDir: string;
|
|
47
|
+
logFileCount: number;
|
|
48
|
+
logBytes: number;
|
|
49
|
+
chunkDir: string;
|
|
50
|
+
chunkFileCount: number;
|
|
51
|
+
chunkBytes: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ClearPersistedLogStorageResult = {
|
|
55
|
+
cleared: number;
|
|
56
|
+
logFilesDeleted: number;
|
|
57
|
+
chunkFilesDeleted: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ClearPersistedLogsByIdsResult = {
|
|
61
|
+
cleared: number;
|
|
62
|
+
logFilesRewritten: number;
|
|
63
|
+
chunkFilesDeleted: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type ListLogsPageOptions = {
|
|
67
|
+
sessionId?: string;
|
|
68
|
+
model?: string;
|
|
69
|
+
offset: number;
|
|
70
|
+
limit: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ListLogsPageResult = {
|
|
74
|
+
logs: CapturedLog[];
|
|
75
|
+
total: number;
|
|
76
|
+
offset: number;
|
|
77
|
+
limit: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
30
80
|
// Memory cache: id -> CapturedLog (recent logs only, FIFO eviction)
|
|
31
81
|
const memoryCache: Map<number, CapturedLog> = new Map();
|
|
32
82
|
|
|
@@ -88,9 +138,11 @@ export async function addTestLogEntry(entry: Omit<CapturedLog, "id">): Promise<C
|
|
|
88
138
|
streamingChunksPath,
|
|
89
139
|
};
|
|
90
140
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
await runWithLogWriteLock(async () => {
|
|
142
|
+
const logFile = getCurrentLogFile();
|
|
143
|
+
appendLogEntry(log);
|
|
144
|
+
await addToIndex(id, logFile, -1, -1);
|
|
145
|
+
});
|
|
94
146
|
|
|
95
147
|
addToCache(log);
|
|
96
148
|
observeSessionLog(log, session.source);
|
|
@@ -154,9 +206,11 @@ export async function createLog(
|
|
|
154
206
|
};
|
|
155
207
|
|
|
156
208
|
// Write to disk and update index
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
209
|
+
await runWithLogWriteLock(async () => {
|
|
210
|
+
const logFile = getCurrentLogFile();
|
|
211
|
+
appendLogEntry(log);
|
|
212
|
+
await addToIndex(id, logFile, -1, -1); // line numbers not tracked precisely
|
|
213
|
+
});
|
|
160
214
|
|
|
161
215
|
// Add to memory cache
|
|
162
216
|
addToCache(log);
|
|
@@ -262,12 +316,105 @@ export function getFilteredLogs(sessionId?: string, model?: string): CapturedLog
|
|
|
262
316
|
// Cache maintains insertion order (sorted by ID since logs are added in ID order)
|
|
263
317
|
// Use spread instead of Array.from for slightly better performance
|
|
264
318
|
return [...memoryCache.values()].filter((l) => {
|
|
265
|
-
|
|
266
|
-
if (model !== undefined && l.model !== model) return false;
|
|
267
|
-
return true;
|
|
319
|
+
return matchesLogFilters(l, sessionId, model);
|
|
268
320
|
});
|
|
269
321
|
}
|
|
270
322
|
|
|
323
|
+
function matchesLogFilters(log: CapturedLog, sessionId?: string, model?: string): boolean {
|
|
324
|
+
if (sessionId !== undefined && getLogSessionId(log) !== sessionId) return false;
|
|
325
|
+
if (model !== undefined && log.model !== model) return false;
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function requestedWindowFitsMemory(
|
|
330
|
+
filteredMemoryCount: number,
|
|
331
|
+
offset: number,
|
|
332
|
+
limit: number,
|
|
333
|
+
): boolean {
|
|
334
|
+
return offset + limit <= filteredMemoryCount;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function paginateLogs(logs: CapturedLog[], offset: number, limit: number): CapturedLog[] {
|
|
338
|
+
return logs.slice(offset, offset + limit);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function readPersistedLogsById(): Promise<Map<number, CapturedLog>> {
|
|
342
|
+
const byId = new Map<number, CapturedLog>();
|
|
343
|
+
const logDir = resolveLogDir();
|
|
344
|
+
if (!existsSync(logDir)) return byId;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const entries = await readdirCallback(logDir);
|
|
348
|
+
const files = entries.filter((file) => file.endsWith(".jsonl")).sort();
|
|
349
|
+
for (const file of files) {
|
|
350
|
+
await readPersistedLogFile(join(logDir, file), byId);
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
logger.error("[store] Failed to list persisted logs:", String(err));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const log of memoryCache.values()) {
|
|
357
|
+
byId.set(log.id, log);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return byId;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function readPersistedLogFile(
|
|
364
|
+
filePath: string,
|
|
365
|
+
byId: Map<number, CapturedLog>,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
if (!existsSync(filePath)) return;
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const fileStream = createInterface({
|
|
371
|
+
input: createReadStream(filePath),
|
|
372
|
+
crlfDelay: Infinity,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
for await (const line of fileStream) {
|
|
376
|
+
if (line.trim() === "") continue;
|
|
377
|
+
try {
|
|
378
|
+
const parsed: unknown = JSON.parse(line);
|
|
379
|
+
const result = CapturedLogSchema.safeParse(parsed);
|
|
380
|
+
if (result.success) {
|
|
381
|
+
const log = normalizeLogSession(result.data);
|
|
382
|
+
byId.set(log.id, log);
|
|
383
|
+
observeSessionLog(log);
|
|
384
|
+
}
|
|
385
|
+
} catch {
|
|
386
|
+
// Skip malformed lines.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
logger.error("[store] Failed to read persisted log file:", filePath, String(err));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function listLogsPage(options: ListLogsPageOptions): Promise<ListLogsPageResult> {
|
|
395
|
+
const memoryLogs = getFilteredLogs(options.sessionId, options.model);
|
|
396
|
+
if (requestedWindowFitsMemory(memoryLogs.length, options.offset, options.limit)) {
|
|
397
|
+
return {
|
|
398
|
+
logs: paginateLogs(memoryLogs, options.offset, options.limit),
|
|
399
|
+
total: memoryLogs.length,
|
|
400
|
+
offset: options.offset,
|
|
401
|
+
limit: options.limit,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const persistedById = await readPersistedLogsById();
|
|
406
|
+
const persistedLogs = [...persistedById.values()]
|
|
407
|
+
.filter((log) => matchesLogFilters(log, options.sessionId, options.model))
|
|
408
|
+
.sort((left, right) => left.id - right.id);
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
logs: paginateLogs(persistedLogs, options.offset, options.limit),
|
|
412
|
+
total: persistedLogs.length,
|
|
413
|
+
offset: options.offset,
|
|
414
|
+
limit: options.limit,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
271
418
|
export function getSessions(): string[] {
|
|
272
419
|
return getSessionIds();
|
|
273
420
|
}
|
|
@@ -280,6 +427,60 @@ export function getModels(): string[] {
|
|
|
280
427
|
return [...set];
|
|
281
428
|
}
|
|
282
429
|
|
|
430
|
+
export function getReplayLogsForSource(sourceLogId: number): CapturedLog[] {
|
|
431
|
+
return [...memoryCache.values()].filter((log) => log.replayOfLogId === sourceLogId);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function collectDirectoryStats(
|
|
435
|
+
dir: string,
|
|
436
|
+
shouldCount: (fileName: string) => boolean,
|
|
437
|
+
): { fileCount: number; bytes: number } {
|
|
438
|
+
if (!existsSync(dir)) return { fileCount: 0, bytes: 0 };
|
|
439
|
+
let fileCount = 0;
|
|
440
|
+
let bytes = 0;
|
|
441
|
+
const visit = (currentDir: string, depth: number): void => {
|
|
442
|
+
if (depth > 8) return;
|
|
443
|
+
let entries: string[];
|
|
444
|
+
try {
|
|
445
|
+
entries = readdirSync(currentDir);
|
|
446
|
+
} catch {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
for (const entry of entries) {
|
|
450
|
+
const path = join(currentDir, entry);
|
|
451
|
+
try {
|
|
452
|
+
const stats = statSync(path);
|
|
453
|
+
if (stats.isDirectory()) {
|
|
454
|
+
visit(path, depth + 1);
|
|
455
|
+
} else if (shouldCount(entry)) {
|
|
456
|
+
fileCount += 1;
|
|
457
|
+
bytes += stats.size;
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
// Ignore files that disappear while stats are being collected.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
visit(dir, 0);
|
|
465
|
+
return { fileCount, bytes };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function getLogStorageStats(): LogStorageStats {
|
|
469
|
+
const logDir = resolveLogDir();
|
|
470
|
+
const chunkDir = getChunksDir();
|
|
471
|
+
const logStats = collectDirectoryStats(logDir, (fileName) => fileName.endsWith(".jsonl"));
|
|
472
|
+
const chunkStats = collectDirectoryStats(chunkDir, (fileName) => fileName.endsWith(".json"));
|
|
473
|
+
return {
|
|
474
|
+
memoryCount: memoryCache.size,
|
|
475
|
+
logDir,
|
|
476
|
+
logFileCount: logStats.fileCount,
|
|
477
|
+
logBytes: logStats.bytes,
|
|
478
|
+
chunkDir,
|
|
479
|
+
chunkFileCount: chunkStats.fileCount,
|
|
480
|
+
chunkBytes: chunkStats.bytes,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
283
484
|
/**
|
|
284
485
|
* Load recent completed log entries from all log files into the memory cache.
|
|
285
486
|
* Called on server startup so the frontend shows existing logs.
|
|
@@ -359,6 +560,206 @@ export function clearAllLogs(): { cleared: number } {
|
|
|
359
560
|
return { cleared: count };
|
|
360
561
|
}
|
|
361
562
|
|
|
563
|
+
async function deleteMatchingFiles(
|
|
564
|
+
dir: string,
|
|
565
|
+
shouldDelete: (fileName: string) => boolean,
|
|
566
|
+
): Promise<number> {
|
|
567
|
+
if (!existsSync(dir)) return 0;
|
|
568
|
+
|
|
569
|
+
let deleted = 0;
|
|
570
|
+
let entries: string[];
|
|
571
|
+
try {
|
|
572
|
+
entries = await readdirCallback(dir);
|
|
573
|
+
} catch (err) {
|
|
574
|
+
logger.warn("[store] Failed to list log storage directory:", String(err));
|
|
575
|
+
return deleted;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
for (const entry of entries) {
|
|
579
|
+
if (!shouldDelete(entry)) continue;
|
|
580
|
+
try {
|
|
581
|
+
await unlink(join(dir, entry));
|
|
582
|
+
deleted += 1;
|
|
583
|
+
} catch (err) {
|
|
584
|
+
logger.warn("[store] Failed to delete log storage file:", entry, String(err));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return deleted;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export async function clearPersistedLogStorage(): Promise<ClearPersistedLogStorageResult> {
|
|
591
|
+
return await runWithFlushedLogWriteLock(async () => {
|
|
592
|
+
await flushIndex();
|
|
593
|
+
|
|
594
|
+
const result = clearAllLogs();
|
|
595
|
+
const [logFilesDeleted, chunkFilesDeleted] = await Promise.all([
|
|
596
|
+
deleteMatchingFiles(
|
|
597
|
+
resolveLogDir(),
|
|
598
|
+
(fileName) => fileName.endsWith(".jsonl") || fileName === "logs.idx",
|
|
599
|
+
),
|
|
600
|
+
deleteMatchingFiles(
|
|
601
|
+
getChunksDir(),
|
|
602
|
+
(fileName) =>
|
|
603
|
+
fileName.endsWith(".json") || (fileName.startsWith(".") && fileName.endsWith(".tmp")),
|
|
604
|
+
),
|
|
605
|
+
]);
|
|
606
|
+
|
|
607
|
+
await rebuildIndex();
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
cleared: result.cleared,
|
|
611
|
+
logFilesDeleted,
|
|
612
|
+
chunkFilesDeleted,
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function readLogIdFromLine(line: string): number | null {
|
|
618
|
+
if (line.trim() === "") return null;
|
|
619
|
+
try {
|
|
620
|
+
const parsed: unknown = JSON.parse(line);
|
|
621
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
622
|
+
const idDesc = Object.getOwnPropertyDescriptor(parsed, "id");
|
|
623
|
+
if (idDesc === undefined || typeof idDesc.value !== "number") return null;
|
|
624
|
+
return idDesc.value;
|
|
625
|
+
} catch {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
type RewriteLogFileResult = {
|
|
631
|
+
rewritten: boolean;
|
|
632
|
+
removedIds: Set<number>;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
async function rewriteLogFileWithoutIds(
|
|
636
|
+
filePath: string,
|
|
637
|
+
ids: ReadonlySet<number>,
|
|
638
|
+
): Promise<RewriteLogFileResult> {
|
|
639
|
+
let content: string;
|
|
640
|
+
try {
|
|
641
|
+
content = await readFile(filePath, "utf-8");
|
|
642
|
+
} catch (err) {
|
|
643
|
+
logger.warn("[store] Failed to read log file for deletion:", filePath, String(err));
|
|
644
|
+
return { rewritten: false, removedIds: new Set() };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const lines = content.split("\n");
|
|
648
|
+
const keptLines: string[] = [];
|
|
649
|
+
const removedIds = new Set<number>();
|
|
650
|
+
for (const line of lines) {
|
|
651
|
+
if (line === "") continue;
|
|
652
|
+
const id = readLogIdFromLine(line);
|
|
653
|
+
if (id !== null && ids.has(id)) {
|
|
654
|
+
removedIds.add(id);
|
|
655
|
+
} else {
|
|
656
|
+
keptLines.push(line);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (removedIds.size === 0) return { rewritten: false, removedIds };
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
if (keptLines.length === 0) {
|
|
664
|
+
await unlink(filePath);
|
|
665
|
+
} else {
|
|
666
|
+
await writeFile(filePath, `${keptLines.join("\n")}\n`, "utf-8");
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
logger.warn("[store] Failed to rewrite log file after deletion:", filePath, String(err));
|
|
670
|
+
return { rewritten: false, removedIds: new Set() };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return { rewritten: true, removedIds };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
type RewriteLogFilesResult = {
|
|
677
|
+
logFilesRewritten: number;
|
|
678
|
+
removedIds: Set<number>;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
async function rewriteLogFilesWithoutIds(ids: ReadonlySet<number>): Promise<RewriteLogFilesResult> {
|
|
682
|
+
const logDir = resolveLogDir();
|
|
683
|
+
if (!existsSync(logDir)) return { logFilesRewritten: 0, removedIds: new Set() };
|
|
684
|
+
|
|
685
|
+
let entries: string[];
|
|
686
|
+
try {
|
|
687
|
+
entries = await readdirCallback(logDir);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
logger.warn("[store] Failed to list log directory for deletion:", String(err));
|
|
690
|
+
return { logFilesRewritten: 0, removedIds: new Set() };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
let logFilesRewritten = 0;
|
|
694
|
+
const removedIds = new Set<number>();
|
|
695
|
+
for (const entry of entries) {
|
|
696
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
697
|
+
const result = await rewriteLogFileWithoutIds(join(logDir, entry), ids);
|
|
698
|
+
if (result.rewritten) logFilesRewritten += 1;
|
|
699
|
+
for (const removedId of result.removedIds) {
|
|
700
|
+
removedIds.add(removedId);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return { logFilesRewritten, removedIds };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function deleteChunkFilesByIds(ids: ReadonlySet<number>): Promise<number> {
|
|
707
|
+
const chunkDir = getChunksDir();
|
|
708
|
+
if (!existsSync(chunkDir)) return 0;
|
|
709
|
+
|
|
710
|
+
let deleted = 0;
|
|
711
|
+
for (const id of ids) {
|
|
712
|
+
const candidates = [`${String(id)}.json`, `.${String(id)}.tmp`];
|
|
713
|
+
for (const candidate of candidates) {
|
|
714
|
+
try {
|
|
715
|
+
await unlink(join(chunkDir, candidate));
|
|
716
|
+
deleted += 1;
|
|
717
|
+
} catch {
|
|
718
|
+
// Missing chunk files are expected for non-streaming logs.
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return deleted;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export async function clearPersistedLogsByIds(
|
|
726
|
+
ids: readonly number[],
|
|
727
|
+
): Promise<ClearPersistedLogsByIdsResult> {
|
|
728
|
+
const uniqueIds = new Set(ids);
|
|
729
|
+
if (uniqueIds.size === 0) {
|
|
730
|
+
return { cleared: 0, logFilesRewritten: 0, chunkFilesDeleted: 0 };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return await runWithFlushedLogWriteLock(async () => {
|
|
734
|
+
await flushIndex();
|
|
735
|
+
|
|
736
|
+
const memoryRemovedIds = new Set<number>();
|
|
737
|
+
for (const id of uniqueIds) {
|
|
738
|
+
if (memoryCache.has(id)) memoryRemovedIds.add(id);
|
|
739
|
+
}
|
|
740
|
+
const result = clearLogsByIds([...uniqueIds]);
|
|
741
|
+
const [rewriteResult, chunkFilesDeleted] = await Promise.all([
|
|
742
|
+
rewriteLogFilesWithoutIds(uniqueIds),
|
|
743
|
+
deleteChunkFilesByIds(uniqueIds),
|
|
744
|
+
]);
|
|
745
|
+
|
|
746
|
+
await rebuildIndex();
|
|
747
|
+
|
|
748
|
+
const clearedIds = new Set<number>(rewriteResult.removedIds);
|
|
749
|
+
if (result.cleared > 0) {
|
|
750
|
+
for (const id of memoryRemovedIds) {
|
|
751
|
+
clearedIds.add(id);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
cleared: clearedIds.size,
|
|
757
|
+
logFilesRewritten: rewriteResult.logFilesRewritten,
|
|
758
|
+
chunkFilesDeleted,
|
|
759
|
+
};
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
362
763
|
/**
|
|
363
764
|
* Remove a specific set of log IDs from the in-memory cache. Returns the
|
|
364
765
|
* number of logs actually removed. IDs not present in the cache are ignored.
|