@tonyclaw/agent-inspector 2.1.2 → 2.1.4
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/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-BULPis8W.js → CompareDrawer-BihEPd3v.js} +1 -1
- package/.output/public/assets/ProxyViewerContainer-DYqXb6WW.js +117 -0
- package/.output/public/assets/{ReplayDialog-3ln-D7OS.js → ReplayDialog-DnX-6kY8.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-D8d1xV8O.js → RequestAnatomy-CIkHAxZO.js} +1 -1
- package/.output/public/assets/{ResponseView-CJijikn-.js → ResponseView-BFc8jjZM.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-C-RCuOTp.js → StreamingChunkSequence-p8qkpF6K.js} +1 -1
- package/.output/public/assets/_sessionId-Csx4T_y6.js +1 -0
- package/.output/public/assets/index-Bcq8bZoK.css +1 -0
- package/.output/public/assets/index-KMuh-31x.js +1 -0
- package/.output/public/assets/{main-D3pBFhb_.js → main-xlSdu7_I.js} +7 -7
- package/.output/server/_libs/lucide-react.mjs +204 -173
- package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -0
- package/.output/server/{_sessionId-BFMVfrw3.mjs → _sessionId-CwVQgxoK.mjs} +3 -3
- package/.output/server/_ssr/{CompareDrawer-CwNPZgc_.mjs → CompareDrawer-Br9Ec0ah.mjs} +3 -3
- package/.output/server/_ssr/{ProxyViewerContainer-HpLc7F_J.mjs → ProxyViewerContainer-Oe5Tr3C2.mjs} +982 -70
- package/.output/server/_ssr/{ReplayDialog-DBaFhSv6.mjs → ReplayDialog-C1_QsoA-.mjs} +4 -4
- package/.output/server/_ssr/{RequestAnatomy-D0E0e2DT.mjs → RequestAnatomy-cn08QSoz.mjs} +2 -2
- package/.output/server/_ssr/{ResponseView-D2FFSRQ8.mjs → ResponseView-CIqUCCTG.mjs} +3 -3
- package/.output/server/_ssr/{StreamingChunkSequence-Cdu36-rL.mjs → StreamingChunkSequence-0MhVWD2F.mjs} +3 -3
- package/.output/server/_ssr/{index-CtBfmJYM.mjs → index-jpnI5ghp.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-Dzx1sbAQ.mjs → router-SSOn7c09.mjs} +1971 -222
- package/.output/server/_tanstack-start-manifest_v-BCgy_9er.mjs +4 -0
- package/.output/server/index.mjs +62 -62
- package/package.json +2 -1
- package/src/assets/agent-inspector.ico +0 -0
- package/src/assets/favicon.svg +21 -31
- package/src/components/ProxyViewer.tsx +87 -1
- package/src/components/ProxyViewerContainer.tsx +26 -0
- package/src/components/alerts/AlertsDialog.tsx +610 -0
- package/src/components/proxy-viewer/LogEntry.tsx +324 -51
- package/src/components/ui/dialog.tsx +1 -0
- package/src/contracts/index.ts +15 -2
- package/src/contracts/log.ts +18 -0
- package/src/lib/alertContract.ts +79 -0
- package/src/lib/apiClient.ts +39 -0
- package/src/lib/export-logs.ts +47 -9
- package/src/lib/logImportContract.ts +12 -0
- package/src/lib/useAlerts.ts +82 -0
- package/src/lib/useGroupEvidence.ts +6 -2
- package/src/lib/useGroups.ts +6 -2
- package/src/proxy/alerts.ts +664 -0
- package/src/proxy/logBodyChunks.ts +91 -0
- package/src/proxy/logImporter.ts +491 -0
- package/src/proxy/logIndex.ts +63 -2
- package/src/proxy/sqliteLogIndex.ts +612 -0
- package/src/proxy/store.ts +32 -7
- package/src/routes/api/alerts.summary.ts +24 -0
- package/src/routes/api/alerts.ts +52 -0
- package/src/routes/api/logs.$id.body.ts +44 -0
- package/src/routes/api/logs.import.ts +50 -0
- package/src/services/alerts.ts +12 -0
- package/.output/public/assets/ProxyViewerContainer-YxtP1xBj.js +0 -117
- package/.output/public/assets/_sessionId-DGehOTux.js +0 -1
- package/.output/public/assets/index-B-MZ9lQM.css +0 -1
- package/.output/public/assets/index-DhsjXb_K.js +0 -1
- package/.output/server/_tanstack-start-manifest_v-VrssoWVE.mjs +0 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import type { CapturedLog, LogBodyChunk, LogBodyPart } from "../contracts";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_LOG_BODY_CHUNK_BYTES = 256 * 1024;
|
|
5
|
+
export const MAX_LOG_BODY_CHUNK_BYTES = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export function normalizeLogBodyChunkOffset(offset: number): number {
|
|
8
|
+
if (!Number.isInteger(offset) || offset < 0) return 0;
|
|
9
|
+
return offset;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeLogBodyChunkLimit(limit: number): number {
|
|
13
|
+
if (!Number.isInteger(limit) || limit < 1) return DEFAULT_LOG_BODY_CHUNK_BYTES;
|
|
14
|
+
return Math.min(limit, MAX_LOG_BODY_CHUNK_BYTES);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function bodyTextForPart(log: CapturedLog, part: LogBodyPart): string | null {
|
|
18
|
+
switch (part) {
|
|
19
|
+
case "request":
|
|
20
|
+
return log.rawRequestBody;
|
|
21
|
+
case "response":
|
|
22
|
+
return log.responseText;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isUtf8ContinuationByte(byte: number | undefined): boolean {
|
|
27
|
+
return byte !== undefined && (byte & 0b1100_0000) === 0b1000_0000;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expandToUtf8Boundary(buffer: Buffer, end: number): number {
|
|
31
|
+
if (end >= buffer.byteLength) return buffer.byteLength;
|
|
32
|
+
let safeEnd = end;
|
|
33
|
+
while (safeEnd < buffer.byteLength && isUtf8ContinuationByte(buffer[safeEnd])) {
|
|
34
|
+
safeEnd++;
|
|
35
|
+
}
|
|
36
|
+
return safeEnd;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createLogBodyChunk({
|
|
40
|
+
log,
|
|
41
|
+
part,
|
|
42
|
+
offset,
|
|
43
|
+
limit,
|
|
44
|
+
}: {
|
|
45
|
+
log: CapturedLog;
|
|
46
|
+
part: LogBodyPart;
|
|
47
|
+
offset: number;
|
|
48
|
+
limit: number;
|
|
49
|
+
}): LogBodyChunk {
|
|
50
|
+
const text = bodyTextForPart(log, part);
|
|
51
|
+
const normalizedLimit = normalizeLogBodyChunkLimit(limit);
|
|
52
|
+
if (text === null || text === "") {
|
|
53
|
+
return {
|
|
54
|
+
logId: log.id,
|
|
55
|
+
part,
|
|
56
|
+
text: "",
|
|
57
|
+
offset: 0,
|
|
58
|
+
limit: normalizedLimit,
|
|
59
|
+
totalBytes: 0,
|
|
60
|
+
textBytes: 0,
|
|
61
|
+
nextOffset: null,
|
|
62
|
+
hasMore: false,
|
|
63
|
+
contentMode: "empty",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const bodyBytes = Buffer.from(text, "utf8");
|
|
68
|
+
const totalBytes = bodyBytes.byteLength;
|
|
69
|
+
const normalizedOffset = Math.min(normalizeLogBodyChunkOffset(offset), totalBytes);
|
|
70
|
+
const end = expandToUtf8Boundary(
|
|
71
|
+
bodyBytes,
|
|
72
|
+
Math.min(normalizedOffset + normalizedLimit, totalBytes),
|
|
73
|
+
);
|
|
74
|
+
const chunkText = bodyBytes.toString("utf8", normalizedOffset, end);
|
|
75
|
+
const textBytes = end - normalizedOffset;
|
|
76
|
+
const hasMore = end < totalBytes;
|
|
77
|
+
const contentMode = hasMore || normalizedOffset > 0 ? "partial" : "full";
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
logId: log.id,
|
|
81
|
+
part,
|
|
82
|
+
text: chunkText,
|
|
83
|
+
offset: normalizedOffset,
|
|
84
|
+
limit: normalizedLimit,
|
|
85
|
+
totalBytes,
|
|
86
|
+
textBytes,
|
|
87
|
+
nextOffset: hasMore ? end : null,
|
|
88
|
+
hasMore,
|
|
89
|
+
contentMode,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import JSZip from "jszip";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
CapturedLogSchema,
|
|
6
|
+
StreamingChunkSchema,
|
|
7
|
+
type ApiFormat,
|
|
8
|
+
type CapturedLog,
|
|
9
|
+
type StreamingChunk,
|
|
10
|
+
} from "../contracts";
|
|
11
|
+
import packageJson from "../../package.json";
|
|
12
|
+
import { apiFormatForPath } from "./formats";
|
|
13
|
+
import { analyzeToolSchemaWarnings } from "./toolSchemaWarnings";
|
|
14
|
+
|
|
15
|
+
export type ImportedLogDraft = Omit<CapturedLog, "id">;
|
|
16
|
+
|
|
17
|
+
export type ParsedLogImport =
|
|
18
|
+
| {
|
|
19
|
+
ok: true;
|
|
20
|
+
logs: ImportedLogDraft[];
|
|
21
|
+
skipped: number;
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
ok: false;
|
|
26
|
+
message: string;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ManifestSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
version: z.string().optional(),
|
|
33
|
+
exportedAt: z.string().optional(),
|
|
34
|
+
mode: z.string().optional(),
|
|
35
|
+
redacted: z.boolean().optional(),
|
|
36
|
+
logIds: z.array(z.number().int().positive()).optional(),
|
|
37
|
+
})
|
|
38
|
+
.passthrough();
|
|
39
|
+
|
|
40
|
+
const LogsArraySchema = z.array(CapturedLogSchema);
|
|
41
|
+
const LogsEnvelopeSchema = z.object({ logs: z.array(CapturedLogSchema) }).passthrough();
|
|
42
|
+
const StreamingChunksImportSchema = z.object({
|
|
43
|
+
chunks: z.array(StreamingChunkSchema),
|
|
44
|
+
truncated: z.boolean().optional().default(false),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
type ParsedManifest = z.infer<typeof ManifestSchema>;
|
|
48
|
+
|
|
49
|
+
const SEMVER_MAJOR_PATTERN = /^([0-9]+)\.[0-9]+\.[0-9]+(?:[-+].*)?$/;
|
|
50
|
+
|
|
51
|
+
function readRecordProperty(value: unknown, name: string): unknown {
|
|
52
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
53
|
+
const desc = Object.getOwnPropertyDescriptor(value, name);
|
|
54
|
+
return desc === undefined ? undefined : desc.value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseJson(text: string | null): unknown {
|
|
58
|
+
if (text === null) return null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed: unknown = JSON.parse(text);
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseJsonLogs(text: string): CapturedLog[] | null {
|
|
68
|
+
const parsed = parseJson(text);
|
|
69
|
+
const direct = LogsArraySchema.safeParse(parsed);
|
|
70
|
+
if (direct.success) return direct.data;
|
|
71
|
+
|
|
72
|
+
const envelope = LogsEnvelopeSchema.safeParse(parsed);
|
|
73
|
+
if (envelope.success) return envelope.data.logs;
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseJsonlLogs(text: string): { logs: CapturedLog[]; skipped: number } {
|
|
79
|
+
const logs: CapturedLog[] = [];
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
for (const line of text.split(/\r?\n/)) {
|
|
82
|
+
if (line.trim() === "") continue;
|
|
83
|
+
const parsed = CapturedLogSchema.safeParse(parseJson(line));
|
|
84
|
+
if (parsed.success) {
|
|
85
|
+
logs.push(parsed.data);
|
|
86
|
+
} else {
|
|
87
|
+
skipped += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { logs, skipped };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readStringProperty(value: unknown, name: string): string | null {
|
|
94
|
+
const property = readRecordProperty(value, name);
|
|
95
|
+
return typeof property === "string" ? property : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasProperty(value: unknown, name: string): boolean {
|
|
99
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
100
|
+
return Object.getOwnPropertyDescriptor(value, name) !== undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function modelFromRequestText(requestText: string | null): string | null {
|
|
104
|
+
return readStringProperty(parseJson(requestText), "model");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function inferApiFormatFromBody(
|
|
108
|
+
path: string,
|
|
109
|
+
model: string | null,
|
|
110
|
+
requestText: string | null,
|
|
111
|
+
): ApiFormat {
|
|
112
|
+
const pathFormat = apiFormatForPath(path);
|
|
113
|
+
if (pathFormat !== "unknown") return pathFormat;
|
|
114
|
+
|
|
115
|
+
const normalizedModel = model?.toLowerCase() ?? "";
|
|
116
|
+
if (normalizedModel.startsWith("claude-")) return "anthropic";
|
|
117
|
+
if (
|
|
118
|
+
normalizedModel.startsWith("gpt-") ||
|
|
119
|
+
normalizedModel.startsWith("o1-") ||
|
|
120
|
+
normalizedModel.startsWith("o3-") ||
|
|
121
|
+
normalizedModel.startsWith("o4-") ||
|
|
122
|
+
normalizedModel.startsWith("deepseek-") ||
|
|
123
|
+
normalizedModel.startsWith("glm-") ||
|
|
124
|
+
normalizedModel.startsWith("qwen")
|
|
125
|
+
) {
|
|
126
|
+
return "openai";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parsedRequest = parseJson(requestText);
|
|
130
|
+
if (hasProperty(parsedRequest, "system") || hasProperty(parsedRequest, "max_tokens")) {
|
|
131
|
+
return "anthropic";
|
|
132
|
+
}
|
|
133
|
+
return "openai";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function defaultPathForApiFormat(apiFormat: ApiFormat): string {
|
|
137
|
+
switch (apiFormat) {
|
|
138
|
+
case "anthropic":
|
|
139
|
+
return "/v1/messages";
|
|
140
|
+
case "openai":
|
|
141
|
+
return "/v1/chat/completions";
|
|
142
|
+
case "unknown":
|
|
143
|
+
return "/v1/chat/completions";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeTimestamp(value: string, fallback: string): string {
|
|
148
|
+
return Number.isNaN(new Date(value).getTime()) ? fallback : value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function responseStatusFromText(responseText: string | null): number | null {
|
|
152
|
+
if (responseText === null) return null;
|
|
153
|
+
const parsed = parseJson(responseText);
|
|
154
|
+
return hasProperty(parsed, "error") ? 500 : 200;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mergeWarnings(
|
|
158
|
+
existing: readonly string[] | undefined,
|
|
159
|
+
generated: readonly string[],
|
|
160
|
+
): string[] | undefined {
|
|
161
|
+
const values: string[] = [];
|
|
162
|
+
for (const warning of existing ?? []) {
|
|
163
|
+
if (!values.includes(warning)) values.push(warning);
|
|
164
|
+
}
|
|
165
|
+
for (const warning of generated) {
|
|
166
|
+
if (!values.includes(warning)) values.push(warning);
|
|
167
|
+
}
|
|
168
|
+
return values.length === 0 ? undefined : values;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function withToolWarnings(log: ImportedLogDraft): ImportedLogDraft {
|
|
172
|
+
const warnings = analyzeToolSchemaWarnings({
|
|
173
|
+
rawRequestBody: log.rawRequestBody,
|
|
174
|
+
responseText: log.responseText,
|
|
175
|
+
apiFormat: log.apiFormat,
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
...log,
|
|
179
|
+
warnings: mergeWarnings(log.warnings, warnings),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function streamingChunksFromText(text: string | null):
|
|
184
|
+
| {
|
|
185
|
+
chunks: StreamingChunk[];
|
|
186
|
+
truncated: boolean;
|
|
187
|
+
}
|
|
188
|
+
| undefined {
|
|
189
|
+
const parsed = StreamingChunksImportSchema.safeParse(parseJson(text));
|
|
190
|
+
return parsed.success ? parsed.data : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function draftFromCapturedLog(
|
|
194
|
+
log: CapturedLog,
|
|
195
|
+
requestText: string | null,
|
|
196
|
+
responseText: string | null,
|
|
197
|
+
streamingChunks: { chunks: StreamingChunk[]; truncated: boolean } | undefined,
|
|
198
|
+
): ImportedLogDraft {
|
|
199
|
+
const rawRequestBody = requestText ?? log.rawRequestBody;
|
|
200
|
+
const response = responseText ?? log.responseText;
|
|
201
|
+
const model = log.model ?? modelFromRequestText(rawRequestBody);
|
|
202
|
+
const apiFormat =
|
|
203
|
+
log.apiFormat === "unknown"
|
|
204
|
+
? inferApiFormatFromBody(log.path, model, rawRequestBody)
|
|
205
|
+
: log.apiFormat;
|
|
206
|
+
const path = log.path.trim() === "" ? defaultPathForApiFormat(apiFormat) : log.path;
|
|
207
|
+
|
|
208
|
+
return withToolWarnings({
|
|
209
|
+
timestamp: normalizeTimestamp(log.timestamp, new Date().toISOString()),
|
|
210
|
+
method: log.method.trim() === "" ? "POST" : log.method,
|
|
211
|
+
path,
|
|
212
|
+
model,
|
|
213
|
+
sessionId: null,
|
|
214
|
+
rawRequestBody,
|
|
215
|
+
responseStatus: log.responseStatus ?? responseStatusFromText(response),
|
|
216
|
+
responseText: response,
|
|
217
|
+
inputTokens: log.inputTokens,
|
|
218
|
+
outputTokens: log.outputTokens,
|
|
219
|
+
cacheCreationInputTokens: log.cacheCreationInputTokens,
|
|
220
|
+
cacheReadInputTokens: log.cacheReadInputTokens,
|
|
221
|
+
elapsedMs: log.elapsedMs,
|
|
222
|
+
firstChunkMs: log.firstChunkMs ?? null,
|
|
223
|
+
totalStreamMs: log.totalStreamMs ?? null,
|
|
224
|
+
tokensPerSecond: log.tokensPerSecond ?? null,
|
|
225
|
+
streaming: log.streaming || streamingChunks !== undefined,
|
|
226
|
+
userAgent: log.userAgent,
|
|
227
|
+
origin: log.origin,
|
|
228
|
+
rawHeaders: log.rawHeaders,
|
|
229
|
+
headers: log.headers,
|
|
230
|
+
apiFormat,
|
|
231
|
+
isTest: false,
|
|
232
|
+
replayOfLogId: null,
|
|
233
|
+
providerName: log.providerName ?? null,
|
|
234
|
+
clientPort: log.clientPort ?? null,
|
|
235
|
+
clientPid: log.clientPid ?? null,
|
|
236
|
+
clientCwd: log.clientCwd ?? null,
|
|
237
|
+
clientProjectFolder: log.clientProjectFolder ?? null,
|
|
238
|
+
streamingChunks,
|
|
239
|
+
streamingChunksPath: null,
|
|
240
|
+
rawRequestBodyBytes: log.rawRequestBodyBytes,
|
|
241
|
+
responseTextBytes: log.responseTextBytes,
|
|
242
|
+
bodyContentMode: "full",
|
|
243
|
+
warnings: log.warnings,
|
|
244
|
+
error: log.error ?? null,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function legacyDraftFromParts(input: {
|
|
249
|
+
exportedAt: string;
|
|
250
|
+
requestText: string | null;
|
|
251
|
+
responseText: string | null;
|
|
252
|
+
streamingChunks: { chunks: StreamingChunk[]; truncated: boolean } | undefined;
|
|
253
|
+
}): ImportedLogDraft {
|
|
254
|
+
const model = modelFromRequestText(input.requestText);
|
|
255
|
+
const apiFormat = inferApiFormatFromBody("", model, input.requestText);
|
|
256
|
+
return withToolWarnings({
|
|
257
|
+
timestamp: input.exportedAt,
|
|
258
|
+
method: "POST",
|
|
259
|
+
path: defaultPathForApiFormat(apiFormat),
|
|
260
|
+
model,
|
|
261
|
+
sessionId: null,
|
|
262
|
+
rawRequestBody: input.requestText,
|
|
263
|
+
responseStatus: responseStatusFromText(input.responseText),
|
|
264
|
+
responseText: input.responseText,
|
|
265
|
+
inputTokens: null,
|
|
266
|
+
outputTokens: null,
|
|
267
|
+
cacheCreationInputTokens: null,
|
|
268
|
+
cacheReadInputTokens: null,
|
|
269
|
+
elapsedMs: null,
|
|
270
|
+
firstChunkMs: null,
|
|
271
|
+
totalStreamMs: null,
|
|
272
|
+
tokensPerSecond: null,
|
|
273
|
+
streaming: input.streamingChunks !== undefined,
|
|
274
|
+
userAgent: "agent-inspector-import",
|
|
275
|
+
origin: null,
|
|
276
|
+
apiFormat,
|
|
277
|
+
isTest: false,
|
|
278
|
+
replayOfLogId: null,
|
|
279
|
+
providerName: null,
|
|
280
|
+
clientPort: null,
|
|
281
|
+
clientPid: null,
|
|
282
|
+
clientCwd: null,
|
|
283
|
+
clientProjectFolder: null,
|
|
284
|
+
streamingChunks: input.streamingChunks,
|
|
285
|
+
streamingChunksPath: null,
|
|
286
|
+
bodyContentMode: "full",
|
|
287
|
+
error: null,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function readZipText(zip: JSZip, path: string): Promise<string | null> {
|
|
292
|
+
const file = zip.file(path);
|
|
293
|
+
return file === null ? null : await file.async("text");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function readManifest(zip: JSZip): Promise<ParsedManifest | null> {
|
|
297
|
+
const text = await readZipText(zip, "manifest.json");
|
|
298
|
+
const parsed = ManifestSchema.safeParse(parseJson(text));
|
|
299
|
+
return parsed.success ? parsed.data : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function versionMajor(version: string): number | null {
|
|
303
|
+
const match = SEMVER_MAJOR_PATTERN.exec(version);
|
|
304
|
+
if (match === null) return null;
|
|
305
|
+
const major = match[1];
|
|
306
|
+
return major === undefined ? null : Number.parseInt(major, 10);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validateManifestVersion(manifest: ParsedManifest | null):
|
|
310
|
+
| {
|
|
311
|
+
ok: true;
|
|
312
|
+
warning: string | null;
|
|
313
|
+
}
|
|
314
|
+
| {
|
|
315
|
+
ok: false;
|
|
316
|
+
message: string;
|
|
317
|
+
} {
|
|
318
|
+
const exportedVersion = manifest?.version;
|
|
319
|
+
if (exportedVersion === undefined) {
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
warning:
|
|
323
|
+
"Imported legacy export without version metadata. Compatibility could not be verified.",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const exportedMajor = versionMajor(exportedVersion);
|
|
328
|
+
if (exportedMajor === null) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
message: `Unsupported export version "${exportedVersion}".`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const currentMajor = versionMajor(packageJson.version);
|
|
336
|
+
if (currentMajor === null) {
|
|
337
|
+
return {
|
|
338
|
+
ok: true,
|
|
339
|
+
warning: `Current Agent Inspector version "${packageJson.version}" could not be verified against export version "${exportedVersion}".`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (exportedMajor !== currentMajor) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
message: `Export version mismatch: archive was created by Agent Inspector v${exportedVersion}, current version is v${packageJson.version}. Import with a matching major version.`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (exportedVersion !== packageJson.version) {
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
warning: `Imported export from Agent Inspector v${exportedVersion}; current version is v${packageJson.version}.`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { ok: true, warning: null };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function parseZipLogImport(data: Uint8Array): Promise<ParsedLogImport> {
|
|
361
|
+
const warnings: string[] = [];
|
|
362
|
+
const zip = await JSZip.loadAsync(data);
|
|
363
|
+
const manifest = await readManifest(zip);
|
|
364
|
+
const versionCheck = validateManifestVersion(manifest);
|
|
365
|
+
if (!versionCheck.ok) {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
message: versionCheck.message,
|
|
369
|
+
warnings,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (versionCheck.warning !== null) {
|
|
373
|
+
warnings.push(versionCheck.warning);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const exportedAt = normalizeTimestamp(manifest?.exportedAt ?? "", new Date().toISOString());
|
|
377
|
+
if (manifest?.redacted === true) {
|
|
378
|
+
warnings.push("Imported redacted export. Secrets and some replay details may be unavailable.");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const metadataText = await readZipText(zip, "logs.json");
|
|
382
|
+
const metadataLogs = metadataText === null ? null : parseJsonLogs(metadataText);
|
|
383
|
+
const logs: ImportedLogDraft[] = [];
|
|
384
|
+
let skipped = 0;
|
|
385
|
+
|
|
386
|
+
if (metadataLogs !== null) {
|
|
387
|
+
for (const log of metadataLogs) {
|
|
388
|
+
const requestText = await readZipText(zip, `#${String(log.id)}.Request.json`);
|
|
389
|
+
const responseText = await readZipText(zip, `#${String(log.id)}.Response.json`);
|
|
390
|
+
const streamingText = await readZipText(zip, `#${String(log.id)}.SSE.Response.json`);
|
|
391
|
+
logs.push(
|
|
392
|
+
draftFromCapturedLog(
|
|
393
|
+
log,
|
|
394
|
+
requestText,
|
|
395
|
+
responseText,
|
|
396
|
+
streamingChunksFromText(streamingText),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return { ok: true, logs, skipped, warnings };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const originalId of manifest?.logIds ?? []) {
|
|
404
|
+
const requestText = await readZipText(zip, `#${String(originalId)}.Request.json`);
|
|
405
|
+
const responseText = await readZipText(zip, `#${String(originalId)}.Response.json`);
|
|
406
|
+
const streamingText = await readZipText(zip, `#${String(originalId)}.SSE.Response.json`);
|
|
407
|
+
if (requestText === null && responseText === null && streamingText === null) {
|
|
408
|
+
skipped += 1;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
logs.push(
|
|
412
|
+
legacyDraftFromParts({
|
|
413
|
+
exportedAt,
|
|
414
|
+
requestText,
|
|
415
|
+
responseText,
|
|
416
|
+
streamingChunks: streamingChunksFromText(streamingText),
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (logs.length === 0) {
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
message: "No importable logs were found in the archive.",
|
|
425
|
+
warnings,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return { ok: true, logs, skipped, warnings };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function looksLikeZip(data: Uint8Array, fileName: string): boolean {
|
|
433
|
+
if (fileName.toLowerCase().endsWith(".zip")) return true;
|
|
434
|
+
return data[0] === 0x50 && data[1] === 0x4b;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function parseTextLogImport(text: string): ParsedLogImport {
|
|
438
|
+
const jsonLogs = parseJsonLogs(text);
|
|
439
|
+
if (jsonLogs !== null) {
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
logs: jsonLogs.map((log) => draftFromCapturedLog(log, null, null, undefined)),
|
|
443
|
+
skipped: 0,
|
|
444
|
+
warnings: [],
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const jsonl = parseJsonlLogs(text);
|
|
449
|
+
if (jsonl.logs.length > 0) {
|
|
450
|
+
return {
|
|
451
|
+
ok: true,
|
|
452
|
+
logs: jsonl.logs.map((log) => draftFromCapturedLog(log, null, null, undefined)),
|
|
453
|
+
skipped: jsonl.skipped,
|
|
454
|
+
warnings: [],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
message:
|
|
461
|
+
"Unsupported import file. Use an Agent Inspector export ZIP, logs.json, or JSONL file.",
|
|
462
|
+
warnings: [],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function parseImportedLogFile(input: {
|
|
467
|
+
fileName: string;
|
|
468
|
+
data: Uint8Array;
|
|
469
|
+
}): Promise<ParsedLogImport> {
|
|
470
|
+
try {
|
|
471
|
+
if (looksLikeZip(input.data, input.fileName)) {
|
|
472
|
+
return await parseZipLogImport(input.data);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return parseTextLogImport(new TextDecoder("utf-8").decode(input.data));
|
|
476
|
+
} catch (err) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
message: err instanceof Error ? err.message : "Failed to parse import file.",
|
|
480
|
+
warnings: [],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function sessionNameFromImportFileName(fileName: string): string {
|
|
486
|
+
const normalized = fileName.replaceAll("\\", "/");
|
|
487
|
+
const base = basename(normalized)
|
|
488
|
+
.replace(/\.(zip|json|jsonl)$/i, "")
|
|
489
|
+
.trim();
|
|
490
|
+
return base === "" ? `import-${new Date().toISOString().slice(0, 10)}` : base;
|
|
491
|
+
}
|
package/src/proxy/logIndex.ts
CHANGED
|
@@ -5,6 +5,14 @@ import { createInterface } from "node:readline";
|
|
|
5
5
|
import { Buffer } from "node:buffer";
|
|
6
6
|
import { resolveLogDir, logger } from "./logger";
|
|
7
7
|
import type { ApiFormat, CapturedLog } from "./schemas";
|
|
8
|
+
import {
|
|
9
|
+
findSqliteLogIndexEntry,
|
|
10
|
+
getSqliteLogIndexMaxId,
|
|
11
|
+
listSqliteLogIndexEntries,
|
|
12
|
+
replaceSqliteLogIndexEntries,
|
|
13
|
+
syncSqliteLogIndexEntries,
|
|
14
|
+
upsertSqliteLogIndexEntry,
|
|
15
|
+
} from "./sqliteLogIndex";
|
|
8
16
|
|
|
9
17
|
export type LogIndexSummary = {
|
|
10
18
|
id: number;
|
|
@@ -72,6 +80,7 @@ function getIndexPath(): string {
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
let cachedIndex: LogIndex | null = null;
|
|
83
|
+
let sqliteBackfillScheduledMaxId = 0;
|
|
75
84
|
|
|
76
85
|
function textByteLength(value: string | null): number | null {
|
|
77
86
|
return value === null ? null : Buffer.byteLength(value, "utf8");
|
|
@@ -191,13 +200,15 @@ export async function addToIndex(
|
|
|
191
200
|
metadata?: LogIndexEntryMetadata,
|
|
192
201
|
): Promise<void> {
|
|
193
202
|
const index = await loadIndex();
|
|
194
|
-
|
|
203
|
+
const entry: LogIndexEntry =
|
|
195
204
|
metadata === undefined
|
|
196
205
|
? { id, file, byteOffset, byteLength }
|
|
197
206
|
: { id, file, byteOffset, byteLength, ...metadata };
|
|
207
|
+
index.entries[id] = entry;
|
|
198
208
|
if (id > index.maxId) {
|
|
199
209
|
index.maxId = id;
|
|
200
210
|
}
|
|
211
|
+
void upsertSqliteLogIndexEntry(entry);
|
|
201
212
|
// Defer disk writes to reduce I/O - flush after a batch of updates
|
|
202
213
|
scheduleIndexFlush();
|
|
203
214
|
}
|
|
@@ -232,15 +243,62 @@ export async function flushIndex(): Promise<void> {
|
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
export async function findInIndex(id: number): Promise<LogIndexEntry | null> {
|
|
246
|
+
const sqliteEntry = await findSqliteLogIndexEntry(id);
|
|
247
|
+
if (sqliteEntry !== null) return sqliteEntry;
|
|
248
|
+
|
|
235
249
|
const index = await loadIndex();
|
|
236
250
|
return index.entries[id] ?? null;
|
|
237
251
|
}
|
|
238
252
|
|
|
239
253
|
export async function listIndexEntries(): Promise<LogIndexEntry[]> {
|
|
240
254
|
const index = await loadIndex();
|
|
255
|
+
const sqliteMaxId = await getSqliteLogIndexMaxId();
|
|
256
|
+
if (sqliteMaxId !== null && sqliteMaxId >= index.maxId) {
|
|
257
|
+
const sqliteEntries = await listSqliteLogIndexEntries();
|
|
258
|
+
if (sqliteEntries !== null) return sqliteEntries;
|
|
259
|
+
}
|
|
260
|
+
maybeBackfillSqliteIndex(index, sqliteMaxId);
|
|
241
261
|
return Object.values(index.entries).sort((left, right) => left.id - right.id);
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
export async function listFilteredIndexEntries({
|
|
265
|
+
sessionId,
|
|
266
|
+
model,
|
|
267
|
+
}: {
|
|
268
|
+
sessionId?: string;
|
|
269
|
+
model?: string;
|
|
270
|
+
}): Promise<LogIndexEntry[]> {
|
|
271
|
+
const index = await loadIndex();
|
|
272
|
+
const sqliteMaxId = await getSqliteLogIndexMaxId();
|
|
273
|
+
if (sqliteMaxId !== null && sqliteMaxId >= index.maxId) {
|
|
274
|
+
const sqliteEntries = await listSqliteLogIndexEntries({ sessionId, model });
|
|
275
|
+
if (sqliteEntries !== null) return sqliteEntries;
|
|
276
|
+
}
|
|
277
|
+
maybeBackfillSqliteIndex(index, sqliteMaxId);
|
|
278
|
+
|
|
279
|
+
return Object.values(index.entries)
|
|
280
|
+
.sort((left, right) => left.id - right.id)
|
|
281
|
+
.filter((entry) => {
|
|
282
|
+
if (sessionId !== undefined && entry.sessionId !== sessionId) return false;
|
|
283
|
+
if (model !== undefined && entry.model !== model) return false;
|
|
284
|
+
return true;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function maybeBackfillSqliteIndex(index: LogIndex, sqliteMaxId: number | null): void {
|
|
289
|
+
if (sqliteMaxId === null) return;
|
|
290
|
+
if (index.maxId <= sqliteMaxId) return;
|
|
291
|
+
if (index.maxId <= sqliteBackfillScheduledMaxId) return;
|
|
292
|
+
|
|
293
|
+
sqliteBackfillScheduledMaxId = index.maxId;
|
|
294
|
+
const entries = Object.values(index.entries);
|
|
295
|
+
void syncSqliteLogIndexEntries(entries).then((ok) => {
|
|
296
|
+
if (!ok && sqliteMaxId < sqliteBackfillScheduledMaxId) {
|
|
297
|
+
sqliteBackfillScheduledMaxId = sqliteMaxId;
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
244
302
|
type FileIndexResult = {
|
|
245
303
|
entries: Record<number, LogIndexEntry>;
|
|
246
304
|
maxId: number;
|
|
@@ -386,6 +444,7 @@ export async function rebuildIndex(): Promise<LogIndex> {
|
|
|
386
444
|
if (!existsSync(logDir)) {
|
|
387
445
|
cachedIndex = newIndex;
|
|
388
446
|
await saveIndex(newIndex);
|
|
447
|
+
await replaceSqliteLogIndexEntries([]);
|
|
389
448
|
return newIndex;
|
|
390
449
|
}
|
|
391
450
|
|
|
@@ -405,6 +464,7 @@ export async function rebuildIndex(): Promise<LogIndex> {
|
|
|
405
464
|
|
|
406
465
|
cachedIndex = newIndex;
|
|
407
466
|
await saveIndex(newIndex);
|
|
467
|
+
await replaceSqliteLogIndexEntries(Object.values(newIndex.entries));
|
|
408
468
|
return newIndex;
|
|
409
469
|
}
|
|
410
470
|
|
|
@@ -441,7 +501,8 @@ export async function getNextLogId(): Promise<number> {
|
|
|
441
501
|
await acquireLock();
|
|
442
502
|
try {
|
|
443
503
|
const index = await loadIndex();
|
|
444
|
-
const
|
|
504
|
+
const sqliteMaxId = await getSqliteLogIndexMaxId();
|
|
505
|
+
const nextId = Math.max(index.maxId, sqliteMaxId ?? 0) + 1;
|
|
445
506
|
index.maxId = nextId;
|
|
446
507
|
// Synchronously update the index in memory (disk write is deferred via batching)
|
|
447
508
|
cachedIndex = index;
|