@tonyclaw/agent-inspector 3.0.27 → 3.0.29

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.
Files changed (43) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BVCAAbbu.js → CompareDrawer-Cb5ccPeX.js} +1 -1
  3. package/.output/public/assets/{ProxyViewerContainer-CYOXB81n.js → ProxyViewerContainer-COBe5GTT.js} +198 -198
  4. package/.output/public/assets/{ReplayDialog-DMX23O5G.js → ReplayDialog-CctnITbt.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-S17pYDRc.js → RequestAnatomy-BrN0RBbL.js} +1 -1
  6. package/.output/public/assets/{ResponseView-Cf1XrawD.js → ResponseView-Cqq87lX_.js} +2 -2
  7. package/.output/public/assets/{StreamingChunkSequence-BdOrVlCi.js → StreamingChunkSequence-CnTa9mCr.js} +1 -1
  8. package/.output/public/assets/{_sessionId-BnKGmJrR.js → _sessionId-DKM91udC.js} +1 -1
  9. package/.output/public/assets/{index-gY3I-7-D.js → index-Bq-nqolG.js} +1 -1
  10. package/.output/public/assets/{index-9fMLCU4d.js → index-DPiESBo2.js} +1 -1
  11. package/.output/public/assets/{index-B1atk19i.js → index-DTLNVcgc.js} +7 -7
  12. package/.output/public/assets/index-DliqmwUw.css +1 -0
  13. package/.output/public/assets/{json-viewer-CpyYpPjx.js → json-viewer-CfaDJwx8.js} +1 -1
  14. package/.output/server/_libs/lucide-react.mjs +39 -39
  15. package/.output/server/{_sessionId-eA_h2_s6.mjs → _sessionId-CwGXPD4C.mjs} +2 -2
  16. package/.output/server/_ssr/{CompareDrawer-K2k9G7r1.mjs → CompareDrawer-R_P1-VNr.mjs} +3 -3
  17. package/.output/server/_ssr/{ProxyViewerContainer-CbyBpCAj.mjs → ProxyViewerContainer-DUgxRNbj.mjs} +344 -191
  18. package/.output/server/_ssr/{ReplayDialog-DacI2Iv0.mjs → ReplayDialog-De9wKKzS.mjs} +4 -4
  19. package/.output/server/_ssr/{RequestAnatomy-gh-pAdkO.mjs → RequestAnatomy-Db4MxUYo.mjs} +2 -2
  20. package/.output/server/_ssr/{ResponseView-BRJKN50-.mjs → ResponseView-8rDXOiNB.mjs} +3 -3
  21. package/.output/server/_ssr/{StreamingChunkSequence-BQLVEuWY.mjs → StreamingChunkSequence-N9teZHkA.mjs} +2 -2
  22. package/.output/server/_ssr/{index-DuSWrjbH.mjs → index-D4DvByuW.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-DNs1ZjKI.mjs → json-viewer-BjoHRbjS.mjs} +3 -3
  25. package/.output/server/_ssr/{router-Dgx6-ot3.mjs → router-BYlePDj8.mjs} +138 -162
  26. package/.output/server/{_tanstack-start-manifest_v-CoGjnkQD.mjs → _tanstack-start-manifest_v-DMCA8KK3.mjs} +1 -1
  27. package/.output/server/index.mjs +68 -68
  28. package/package.json +1 -1
  29. package/src/components/ProxyViewer.tsx +57 -1
  30. package/src/components/clients/ClientLogo.tsx +51 -9
  31. package/src/components/inspector-pet/InspectorPet.tsx +0 -7
  32. package/src/components/proxy-viewer/ConversationGroup.tsx +1 -1
  33. package/src/components/proxy-viewer/ConversationGroupList.tsx +251 -155
  34. package/src/components/proxy-viewer/ConversationHeader.tsx +43 -23
  35. package/src/lib/jsonFile.ts +46 -0
  36. package/src/lib/utils.ts +5 -1
  37. package/src/proxy/evidenceAnalysis.ts +116 -117
  38. package/src/proxy/groupEvidenceExporter.ts +4 -8
  39. package/src/proxy/groupStore.ts +3 -10
  40. package/src/proxy/providerSecretStore.ts +4 -8
  41. package/src/proxy/runFailures.ts +4 -10
  42. package/src/proxy/runStore.ts +4 -11
  43. package/.output/public/assets/index-CmeF5XXS.css +0 -1
@@ -22,7 +22,23 @@ import { ProviderLogoStack } from "./ProviderLogoStack";
22
22
  import { resolveLogFormat } from "./log-formats";
23
23
 
24
24
  const HEADER_ACTION_BUTTON_CLASS =
25
- "bg-white/[0.045] hover:bg-white/[0.075] inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground";
25
+ "inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[var(--inspector-surface-low)] text-muted-foreground transition-colors hover:bg-[var(--inspector-surface-medium)] hover:text-foreground";
26
+
27
+ export function isSyntheticSessionId(conversationId: string): boolean {
28
+ return (
29
+ conversationId.startsWith("PID:") ||
30
+ conversationId.startsWith("process:") ||
31
+ conversationId.startsWith("connection:") ||
32
+ conversationId.includes("|")
33
+ );
34
+ }
35
+
36
+ export function compactSessionLabel(conversationId: string): string {
37
+ if (isSyntheticSessionId(conversationId)) return "No session ID";
38
+ return conversationId.length > 24
39
+ ? `${conversationId.slice(0, 12)}...${conversationId.slice(-12)}`
40
+ : conversationId;
41
+ }
26
42
 
27
43
  function HeaderMetric({
28
44
  icon,
@@ -38,7 +54,7 @@ function HeaderMetric({
38
54
  return (
39
55
  <span
40
56
  className={cn(
41
- "bg-white/[0.045] inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md px-2 text-xs text-muted-foreground",
57
+ "inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md bg-[var(--inspector-surface-low)] px-2 text-xs text-muted-foreground",
42
58
  className,
43
59
  )}
44
60
  title={title}
@@ -52,7 +68,7 @@ function HeaderMetric({
52
68
  function PendingTurnBadge(): JSX.Element {
53
69
  return (
54
70
  <span
55
- className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md bg-amber-300/[0.10] px-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-amber-100/90 ring-1 ring-amber-200/10"
71
+ className="inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md bg-[var(--inspector-surface-medium)] px-1.5 font-mono text-[10px] uppercase text-[var(--inspector-status-watch)] ring-1 ring-border/50"
56
72
  title="This session has a turn that has not finished yet"
57
73
  >
58
74
  <Loader2 className="size-3 animate-spin" />
@@ -145,11 +161,8 @@ export function ConversationHeader({
145
161
  const clientDisplayLabel = detectedClientLabel ?? userAgent ?? null;
146
162
  const clientTooltip =
147
163
  detectedClientLabel !== null && hasUserAgent ? `${detectedClientLabel} - ${userAgent}` : null;
148
- const isFallbackConversation = conversationId.startsWith("PID:") || conversationId.includes("|");
149
- const compactSessionLabel =
150
- conversationId.length > 24
151
- ? conversationId.slice(0, 12) + "..." + conversationId.slice(-12)
152
- : conversationId;
164
+ const isFallbackConversation = isSyntheticSessionId(conversationId);
165
+ const sessionLabel = compactSessionLabel(conversationId);
153
166
 
154
167
  const timeLabel = formatTimestampRange(startTime, endTime, timeDisplayFormat);
155
168
  const callsLabel = `${totalCalls} call${totalCalls !== 1 ? "s" : ""}`;
@@ -161,11 +174,11 @@ export function ConversationHeader({
161
174
  data-nav-id={`conv-${conversationId}`}
162
175
  data-nav-action={expanded ? "collapse" : "expand"}
163
176
  className={cn(
164
- "bg-card/80 shadow-sm relative flex cursor-pointer items-start gap-3 overflow-hidden rounded-[8px] px-3 py-2.5 transition-colors",
165
- "hover:bg-card",
177
+ "relative flex cursor-pointer items-start gap-2.5 overflow-hidden rounded-md bg-[var(--inspector-surface-low)] px-3 py-2.5 transition-colors",
178
+ "hover:bg-[var(--inspector-surface-medium)]",
166
179
  "select-none",
167
180
  "sticky top-0 z-10 mb-2",
168
- "focus-visible:ring-1 focus-visible:ring-cyan-300/35 focus-visible:outline-none",
181
+ "focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none",
169
182
  )}
170
183
  onClick={onToggle}
171
184
  onKeyDown={(e) => {
@@ -185,17 +198,20 @@ export function ConversationHeader({
185
198
 
186
199
  <span className="min-w-0 flex-1">
187
200
  <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
201
+ <span className="shrink-0 rounded bg-[var(--inspector-surface-medium)] px-1.5 py-0.5 font-mono text-[9px] uppercase text-muted-foreground">
202
+ Session
203
+ </span>
188
204
  {isFallbackConversation && showProcessMetadata ? (
189
205
  clientPid !== null && clientPid !== undefined ? (
190
206
  <span
191
- className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
207
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-foreground"
192
208
  title={`PID ${clientPid}`}
193
209
  >
194
210
  PID {clientPid}
195
211
  </span>
196
212
  ) : (
197
213
  <span
198
- className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
214
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-foreground"
199
215
  title={conversationId}
200
216
  >
201
217
  {conversationId}
@@ -203,10 +219,10 @@ export function ConversationHeader({
203
219
  )
204
220
  ) : (
205
221
  <span
206
- className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-violet-200"
222
+ className="min-w-0 max-w-full truncate font-mono text-xs font-semibold text-foreground"
207
223
  title={conversationId}
208
224
  >
209
- {isFallbackConversation ? "sessionless" : compactSessionLabel}
225
+ {sessionLabel}
210
226
  </span>
211
227
  )}
212
228
 
@@ -215,7 +231,7 @@ export function ConversationHeader({
215
231
  clientPid !== null &&
216
232
  clientPid !== undefined && (
217
233
  <span
218
- className="bg-white/[0.045] shrink-0 rounded px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground"
234
+ className="shrink-0 rounded bg-[var(--inspector-surface-medium)] px-1.5 py-0.5 font-mono text-xs tabular-nums text-muted-foreground"
219
235
  title={`Client process ID ${clientPid}`}
220
236
  >
221
237
  PID {clientPid}
@@ -237,10 +253,10 @@ export function ConversationHeader({
237
253
 
238
254
  {showClientIdentity && clientDisplayLabel !== null && clientDisplayLabel !== "" && (
239
255
  <span
240
- className="bg-white/[0.045] inline-flex min-h-6 shrink-0 flex-wrap items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground"
256
+ className="inline-flex min-h-6 shrink-0 flex-wrap items-center gap-1 rounded-md bg-[var(--inspector-surface-medium)] px-1.5 py-0.5 text-xs text-muted-foreground"
241
257
  title={clientTooltip ?? clientDisplayLabel}
242
258
  >
243
- <ClientLogo client={clientApp} className="size-4 shrink-0" />
259
+ <ClientLogo client={clientApp} className="size-4 shrink-0" decorative={true} />
244
260
  <span className="font-mono leading-snug tabular-nums">{clientDisplayLabel}</span>
245
261
  </span>
246
262
  )}
@@ -256,9 +272,13 @@ export function ConversationHeader({
256
272
  {callsLabel}
257
273
  </HeaderMetric>
258
274
  <HeaderMetric icon={<Zap className="size-3 shrink-0" />} className="max-w-full">
259
- <span className="text-blue-400">{formatTokens(totalInputTokens)}</span>
275
+ <span className="text-[var(--inspector-status-info)]">
276
+ {formatTokens(totalInputTokens)}
277
+ </span>
260
278
  {" / "}
261
- <span className="text-amber-400">{formatTokens(totalOutputTokens)}</span>
279
+ <span className="text-[var(--inspector-status-watch)]">
280
+ {formatTokens(totalOutputTokens)}
281
+ </span>
262
282
  </HeaderMetric>
263
283
  </span>
264
284
  </span>
@@ -295,7 +315,7 @@ export function ConversationHeader({
295
315
 
296
316
  {/* Expand chevron - shows spinner when collapsed and group has pending logs */}
297
317
  <span
298
- className="bg-white/[0.045] hover:bg-white/[0.075] inline-flex size-8 shrink-0 items-center justify-center rounded-md text-slate-200"
318
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-[var(--inspector-surface-low)] text-muted-foreground transition-colors hover:bg-[var(--inspector-surface-medium)] hover:text-foreground"
299
319
  aria-hidden="true"
300
320
  >
301
321
  {expanded ? (
@@ -310,10 +330,10 @@ export function ConversationHeader({
310
330
 
311
331
  {isLoading && (
312
332
  <span
313
- className="pointer-events-none absolute inset-x-3 bottom-0 h-px overflow-hidden rounded-full bg-white/[0.06]"
333
+ className="pointer-events-none absolute inset-x-3 bottom-0 h-px overflow-hidden rounded-full bg-[var(--inspector-surface-medium)]"
314
334
  aria-hidden="true"
315
335
  >
316
- <span className="absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full bg-amber-200/65 shadow-[0_0_14px_rgba(252,211,77,0.32)]" />
336
+ <span className="absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full bg-[var(--inspector-status-watch)] opacity-70" />
317
337
  </span>
318
338
  )}
319
339
 
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { readProperty } from "./unknown";
4
+
5
+ /**
6
+ * Reads a UTF-8 JSON file from disk and validates it against a Zod schema in
7
+ * one call. Used by config/secret/evidence files that store small records
8
+ * atomically rewritten on update — *not* for streaming log files (those go
9
+ * through line-by-line parsing in store.ts).
10
+ *
11
+ * On any failure returns `{ ok: false, reason }` rather than throwing. The
12
+ * caller decides between returning a fallback, logging, or surfacing an
13
+ * error response. To log on failure, wrap the call site with the project's
14
+ * `logger.warn` (the helper stays a pure parser to avoid a `lib → proxy`
15
+ * dependency direction).
16
+ */
17
+ export type ReadJsonFileFailureReason = "missing" | "io-error" | "parse-error" | "schema-mismatch";
18
+
19
+ export type ReadJsonFileResult<T> =
20
+ | { ok: true; data: T }
21
+ | { ok: false; reason: ReadJsonFileFailureReason };
22
+
23
+ export function readJsonFile<T>(path: string, schema: z.ZodType<T>): ReadJsonFileResult<T> {
24
+ let raw: string;
25
+ try {
26
+ raw = readFileSync(path, "utf8");
27
+ } catch (error) {
28
+ if (isEnoent(error)) return { ok: false, reason: "missing" };
29
+ return { ok: false, reason: "io-error" };
30
+ }
31
+
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ } catch {
36
+ return { ok: false, reason: "parse-error" };
37
+ }
38
+
39
+ const result = schema.safeParse(parsed);
40
+ if (!result.success) return { ok: false, reason: "schema-mismatch" };
41
+ return { ok: true, data: result.data };
42
+ }
43
+
44
+ function isEnoent(error: unknown): boolean {
45
+ return readProperty(error, "code") === "ENOENT";
46
+ }
package/src/lib/utils.ts CHANGED
@@ -6,7 +6,11 @@ export function cn(...inputs: ClassValue[]): string {
6
6
  }
7
7
 
8
8
  export function formatTokens(count: number): string {
9
- if (count >= 1_000_000) return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
9
+ const absoluteCount = Math.abs(count);
10
+ if (absoluteCount < 1000) return String(count);
11
+ if (absoluteCount >= 1_000_000) {
12
+ return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
13
+ }
10
14
  return (count / 1000).toFixed(1).replace(/\.0$/, "") + "K";
11
15
  }
12
16
 
@@ -96,86 +96,113 @@ function hasStreamingMismatch(session: SessionInfo): boolean {
96
96
  return false;
97
97
  }
98
98
 
99
- function categorySummary(category: FailureCategory): string {
100
- switch (category) {
101
- case "none":
102
- return "Session completed without captured errors.";
103
- case "active":
104
- return "Session still has active requests or running tasks.";
105
- case "provider-timeout":
106
- return "Provider call appears to have timed out.";
107
- case "provider-http-error":
108
- return "Provider returned an HTTP error.";
109
- case "provider-auth-error":
110
- return "Provider authentication or API key configuration appears invalid.";
111
- case "provider-rate-limit":
112
- return "Provider rejected the request because of rate limit or quota pressure.";
113
- case "network-or-container":
114
- return "Network connectivity looks suspicious; check container boundaries and localhost use.";
115
- case "streaming-mismatch":
116
- return "Streaming and non-streaming behavior differ for the same provider/model.";
117
- case "context-pressure":
118
- return "The failure looks related to context window or token pressure.";
119
- case "runtime-task-error":
120
- return "Inspector session runtime reported a task error.";
121
- case "model-output-error":
122
- return "The model output or upstream payload shape appears invalid.";
123
- case "unknown-failure":
124
- return "Inspector captured errors, but the root cause is not yet classified.";
125
- case "no-session-data":
126
- return "No Inspector session data was available for this run.";
127
- }
128
- }
129
-
130
- function categoryHints(category: FailureCategory): string[] {
131
- switch (category) {
132
- case "none":
133
- return ["Keep the evidence pack as the success proof for CI or release notes."];
134
- case "active":
135
- return ["Wait for the session to settle, then export evidence again."];
136
- case "provider-timeout":
137
- return [
138
- "Increase the provider test timeout for slow models.",
139
- "Compare streaming and non-streaming probes for the same model.",
140
- ];
141
- case "provider-http-error":
142
- return [
143
- "Open the cited log ids and inspect upstream status, response body, and provider URL.",
144
- ];
145
- case "provider-auth-error":
146
- return ["Verify the provider API key, auth header mode, and configured base URL."];
147
- case "provider-rate-limit":
148
- return [
149
- "Retry after provider quota recovers or switch to a less constrained provider/model.",
150
- ];
151
- case "network-or-container":
152
- return [
153
- "If the coding agent and Inspector are in different containers, replace localhost with a reachable host.",
154
- "Check DNS, proxy, firewall, and container network routes.",
155
- ];
156
- case "streaming-mismatch":
157
- return [
158
- "Compare the streaming and non-streaming captured logs side by side.",
159
- "Check whether the provider's OpenAI-compatible endpoint handles stream=false differently.",
160
- ];
161
- case "context-pressure":
162
- return [
163
- "Reduce prompt/tool schema/history size or configure the model context window metadata.",
164
- ];
165
- case "runtime-task-error":
166
- return ["Inspect the session runtime error and retry after clearing stuck session tasks."];
167
- case "model-output-error":
168
- return ["Inspect the raw response and schema parsing details for the cited log ids."];
169
- case "unknown-failure":
170
- return [
171
- "Open the cited logs and inspect error/status/latency fields to refine the diagnosis.",
172
- ];
173
- case "no-session-data":
174
- return [
175
- "Confirm the run's sessionId matches captured traffic and that history scanning is enabled when needed.",
176
- ];
99
+ /**
100
+ * Per-category summary, hints, severity, and outcome. Keyed by every
101
+ * `FailureCategory` so TypeScript's `Record` enforces exhaustiveness —
102
+ * adding a new category is a single-place compile error instead of four
103
+ * parallel switch statements missing a case.
104
+ */
105
+ const FAILURE_CATEGORY_INFO: Record<
106
+ FailureCategory,
107
+ {
108
+ summary: string;
109
+ hints: string[];
110
+ severity: FailureClassification["severity"];
111
+ outcome: FailureClassification["outcome"];
177
112
  }
178
- }
113
+ > = {
114
+ none: {
115
+ summary: "Session completed without captured errors.",
116
+ hints: ["Keep the evidence pack as the success proof for CI or release notes."],
117
+ severity: "none",
118
+ outcome: "success",
119
+ },
120
+ active: {
121
+ summary: "Session still has active requests or running tasks.",
122
+ hints: ["Wait for the session to settle, then export evidence again."],
123
+ severity: "low",
124
+ outcome: "active",
125
+ },
126
+ "provider-timeout": {
127
+ summary: "Provider call appears to have timed out.",
128
+ hints: [
129
+ "Increase the provider test timeout for slow models.",
130
+ "Compare streaming and non-streaming probes for the same model.",
131
+ ],
132
+ severity: "high",
133
+ outcome: "failure",
134
+ },
135
+ "provider-http-error": {
136
+ summary: "Provider returned an HTTP error.",
137
+ hints: ["Open the cited log ids and inspect upstream status, response body, and provider URL."],
138
+ severity: "high",
139
+ outcome: "failure",
140
+ },
141
+ "provider-auth-error": {
142
+ summary: "Provider authentication or API key configuration appears invalid.",
143
+ hints: ["Verify the provider API key, auth header mode, and configured base URL."],
144
+ severity: "high",
145
+ outcome: "failure",
146
+ },
147
+ "provider-rate-limit": {
148
+ summary: "Provider rejected the request because of rate limit or quota pressure.",
149
+ hints: ["Retry after provider quota recovers or switch to a less constrained provider/model."],
150
+ severity: "high",
151
+ outcome: "failure",
152
+ },
153
+ "network-or-container": {
154
+ summary: "Network connectivity looks suspicious; check container boundaries and localhost use.",
155
+ hints: [
156
+ "If the coding agent and Inspector are in different containers, replace localhost with a reachable host.",
157
+ "Check DNS, proxy, firewall, and container network routes.",
158
+ ],
159
+ severity: "high",
160
+ outcome: "failure",
161
+ },
162
+ "streaming-mismatch": {
163
+ summary: "Streaming and non-streaming behavior differ for the same provider/model.",
164
+ hints: [
165
+ "Compare the streaming and non-streaming captured logs side by side.",
166
+ "Check whether the provider's OpenAI-compatible endpoint handles stream=false differently.",
167
+ ],
168
+ severity: "high",
169
+ outcome: "failure",
170
+ },
171
+ "context-pressure": {
172
+ summary: "The failure looks related to context window or token pressure.",
173
+ hints: [
174
+ "Reduce prompt/tool schema/history size or configure the model context window metadata.",
175
+ ],
176
+ severity: "medium",
177
+ outcome: "failure",
178
+ },
179
+ "runtime-task-error": {
180
+ summary: "Inspector session runtime reported a task error.",
181
+ hints: ["Inspect the session runtime error and retry after clearing stuck session tasks."],
182
+ severity: "high",
183
+ outcome: "failure",
184
+ },
185
+ "model-output-error": {
186
+ summary: "The model output or upstream payload shape appears invalid.",
187
+ hints: ["Inspect the raw response and schema parsing details for the cited log ids."],
188
+ severity: "medium",
189
+ outcome: "failure",
190
+ },
191
+ "unknown-failure": {
192
+ summary: "Inspector captured errors, but the root cause is not yet classified.",
193
+ hints: ["Open the cited logs and inspect error/status/latency fields to refine the diagnosis."],
194
+ severity: "medium",
195
+ outcome: "failure",
196
+ },
197
+ "no-session-data": {
198
+ summary: "No Inspector session data was available for this run.",
199
+ hints: [
200
+ "Confirm the run's sessionId matches captured traffic and that history scanning is enabled when needed.",
201
+ ],
202
+ severity: "medium",
203
+ outcome: "unknown",
204
+ },
205
+ };
179
206
 
180
207
  function classifyCategory(
181
208
  session: SessionInfo,
@@ -221,47 +248,19 @@ function classifyCategory(
221
248
  }
222
249
 
223
250
  function severityFor(category: FailureCategory): FailureClassification["severity"] {
224
- switch (category) {
225
- case "none":
226
- return "none";
227
- case "active":
228
- return "low";
229
- case "provider-timeout":
230
- case "provider-http-error":
231
- case "provider-auth-error":
232
- case "provider-rate-limit":
233
- case "network-or-container":
234
- case "streaming-mismatch":
235
- case "runtime-task-error":
236
- return "high";
237
- case "context-pressure":
238
- case "model-output-error":
239
- case "unknown-failure":
240
- case "no-session-data":
241
- return "medium";
242
- }
251
+ return FAILURE_CATEGORY_INFO[category].severity;
243
252
  }
244
253
 
245
254
  function outcomeFor(category: FailureCategory): FailureClassification["outcome"] {
246
- switch (category) {
247
- case "none":
248
- return "success";
249
- case "active":
250
- return "active";
251
- case "no-session-data":
252
- return "unknown";
253
- case "provider-timeout":
254
- case "provider-http-error":
255
- case "provider-auth-error":
256
- case "provider-rate-limit":
257
- case "network-or-container":
258
- case "streaming-mismatch":
259
- case "context-pressure":
260
- case "runtime-task-error":
261
- case "model-output-error":
262
- case "unknown-failure":
263
- return "failure";
264
- }
255
+ return FAILURE_CATEGORY_INFO[category].outcome;
256
+ }
257
+
258
+ function categorySummary(category: FailureCategory): string {
259
+ return FAILURE_CATEGORY_INFO[category].summary;
260
+ }
261
+
262
+ function categoryHints(category: FailureCategory): string[] {
263
+ return FAILURE_CATEGORY_INFO[category].hints;
265
264
  }
266
265
 
267
266
  export function classifyRunFailure(
@@ -15,6 +15,7 @@ import { getDataDir } from "./dataDir";
15
15
  import { getRun } from "./runStore";
16
16
  import { getSessionInfo } from "./store";
17
17
  import { updateGroupEvidence } from "./groupStore";
18
+ import { readJsonFile } from "../lib/jsonFile";
18
19
 
19
20
  type GroupEvidenceDocument = GroupEvidenceExportResult;
20
21
 
@@ -343,12 +344,7 @@ export function readGroupEvidenceMarkdown(group: InspectorGroup): string | null
343
344
 
344
345
  export function readGroupEvidenceDocument(group: InspectorGroup): GroupEvidenceExportResult | null {
345
346
  const path = group.evidence?.jsonPath;
346
- if (path === undefined || !existsSync(path)) return null;
347
- try {
348
- const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
349
- const parsed = GroupEvidenceExportResultSchema.safeParse(raw);
350
- return parsed.success ? parsed.data : null;
351
- } catch {
352
- return null;
353
- }
347
+ if (path === undefined) return null;
348
+ const result = readJsonFile(path, GroupEvidenceExportResultSchema);
349
+ return result.ok ? result.data : null;
354
350
  }
@@ -19,6 +19,7 @@ import {
19
19
  type UpdateInspectorGroupInput,
20
20
  } from "../lib/groupContract";
21
21
  import { getDataDir } from "./dataDir";
22
+ import { readJsonFile } from "../lib/jsonFile";
22
23
 
23
24
  const GroupsFileSchema = z.object({
24
25
  groups: z.array(InspectorGroupSchema),
@@ -70,16 +71,8 @@ function nullableText(value: string | null | undefined): string | null {
70
71
  }
71
72
 
72
73
  function readGroups(): InspectorGroup[] {
73
- const filePath = groupsFilePath();
74
- if (!existsSync(filePath)) return [];
75
-
76
- try {
77
- const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
78
- const result = GroupsFileSchema.safeParse(parsed);
79
- return result.success ? result.data.groups : [];
80
- } catch {
81
- return [];
82
- }
74
+ const result = readJsonFile(groupsFilePath(), GroupsFileSchema);
75
+ return result.ok ? result.data.groups : [];
83
76
  }
84
77
 
85
78
  function writeGroups(groups: readonly InspectorGroup[]): void {
@@ -11,6 +11,7 @@ import {
11
11
  } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { z } from "zod";
14
+ import { readJsonFile } from "../lib/jsonFile";
14
15
  import { logger } from "./logger";
15
16
 
16
17
  const SECRET_REFERENCE_PREFIX = "secret:provider:";
@@ -39,14 +40,9 @@ function emptySecretFile(): SecretFile {
39
40
 
40
41
  function readSecretFile(path: string): SecretFile {
41
42
  if (!existsSync(path)) return emptySecretFile();
42
- try {
43
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
44
- const result = SecretFileSchema.safeParse(parsed);
45
- if (result.success) return result.data;
46
- logger.warn(`[provider-secrets] Ignoring invalid secret file ${path}`);
47
- } catch (error) {
48
- logger.warn(`[provider-secrets] Could not read ${path}: ${String(error)}`);
49
- }
43
+ const result = readJsonFile(path, SecretFileSchema);
44
+ if (result.ok) return result.data;
45
+ logger.warn(`[provider-secrets] Ignoring invalid secret file ${path} (${result.reason})`);
50
46
  return emptySecretFile();
51
47
  }
52
48
 
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import {
3
2
  EvidenceExportResultSchema,
4
3
  type EvidenceExportResult,
@@ -9,6 +8,7 @@ import {
9
8
  type RecentFailure,
10
9
  type RecentFailuresResponse,
11
10
  } from "../lib/runContract";
11
+ import { readJsonFile } from "../lib/jsonFile";
12
12
  import { listRuns } from "./runStore";
13
13
 
14
14
  export const RECENT_FAILURES_DEFAULT_LIMIT = 5;
@@ -21,15 +21,9 @@ export function clampRecentFailuresLimit(value: number): number {
21
21
 
22
22
  function readEvidenceDocument(run: InspectorRun): EvidenceExportResult | null {
23
23
  const path = run.evidence?.jsonPath;
24
- if (path === undefined || !existsSync(path)) return null;
25
-
26
- try {
27
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
28
- const result = EvidenceExportResultSchema.safeParse(parsed);
29
- return result.success ? result.data : null;
30
- } catch {
31
- return null;
32
- }
24
+ if (path === undefined) return null;
25
+ const result = readJsonFile(path, EvidenceExportResultSchema);
26
+ return result.ok ? result.data : null;
33
27
  }
34
28
 
35
29
  function syntheticFailedClassification(run: InspectorRun): FailureClassification {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { dirname, join } from "node:path";
4
4
  import { z } from "zod";
@@ -13,6 +13,7 @@ import {
13
13
  } from "../lib/runContract";
14
14
  import { getDataDir } from "./dataDir";
15
15
  import { addGroupSession, getGroup } from "./groupStore";
16
+ import { readJsonFile } from "../lib/jsonFile";
16
17
 
17
18
  const RunsFileSchema = z.object({
18
19
  runs: z.array(InspectorRunSchema),
@@ -95,16 +96,8 @@ function syncRunGroupMember(run: InspectorRun, createIfMissing: boolean): void {
95
96
  }
96
97
 
97
98
  function readRuns(): InspectorRun[] {
98
- const filePath = runsFilePath();
99
- if (!existsSync(filePath)) return [];
100
-
101
- try {
102
- const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
103
- const result = RunsFileSchema.safeParse(parsed);
104
- return result.success ? result.data.runs : [];
105
- } catch {
106
- return [];
107
- }
99
+ const result = readJsonFile(runsFilePath(), RunsFileSchema);
100
+ return result.ok ? result.data.runs : [];
108
101
  }
109
102
 
110
103
  function writeRuns(runs: readonly InspectorRun[]): void {