@tonyclaw/agent-inspector 3.0.27 → 3.0.28

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 (42) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BVCAAbbu.js → CompareDrawer-TghZr5t1.js} +1 -1
  3. package/.output/public/assets/{ProxyViewerContainer-CYOXB81n.js → ProxyViewerContainer-fShBAZjj.js} +198 -198
  4. package/.output/public/assets/{ReplayDialog-DMX23O5G.js → ReplayDialog-CZMApaF4.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-S17pYDRc.js → RequestAnatomy-DNWccR7O.js} +1 -1
  6. package/.output/public/assets/{ResponseView-Cf1XrawD.js → ResponseView-d25MdFhY.js} +2 -2
  7. package/.output/public/assets/{StreamingChunkSequence-BdOrVlCi.js → StreamingChunkSequence-DIFZEnWe.js} +1 -1
  8. package/.output/public/assets/{_sessionId-BnKGmJrR.js → _sessionId-DRfvuE2k.js} +1 -1
  9. package/.output/public/assets/{index-9fMLCU4d.js → index-B3aAuqWq.js} +1 -1
  10. package/.output/public/assets/index-BsxAGqMA.css +1 -0
  11. package/.output/public/assets/{index-gY3I-7-D.js → index-D4HkS7Ct.js} +1 -1
  12. package/.output/public/assets/{index-B1atk19i.js → index-DzARL2Fi.js} +3 -3
  13. package/.output/public/assets/{json-viewer-CpyYpPjx.js → json-viewer-wuJSKaed.js} +1 -1
  14. package/.output/server/_libs/lucide-react.mjs +39 -39
  15. package/.output/server/{_sessionId-eA_h2_s6.mjs → _sessionId-BJTNiP_O.mjs} +2 -2
  16. package/.output/server/_ssr/{CompareDrawer-K2k9G7r1.mjs → CompareDrawer-BMRH3N97.mjs} +3 -3
  17. package/.output/server/_ssr/{ProxyViewerContainer-CbyBpCAj.mjs → ProxyViewerContainer-AFwcHAIR.mjs} +312 -172
  18. package/.output/server/_ssr/{ReplayDialog-DacI2Iv0.mjs → ReplayDialog-CbWvRHiN.mjs} +4 -4
  19. package/.output/server/_ssr/{RequestAnatomy-gh-pAdkO.mjs → RequestAnatomy-C4kcPyqN.mjs} +2 -2
  20. package/.output/server/_ssr/{ResponseView-BRJKN50-.mjs → ResponseView-D8VYt-gZ.mjs} +3 -3
  21. package/.output/server/_ssr/{StreamingChunkSequence-BQLVEuWY.mjs → StreamingChunkSequence-BUUo6vU0.mjs} +2 -2
  22. package/.output/server/_ssr/{index-DuSWrjbH.mjs → index-DTwMEEXO.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-DNs1ZjKI.mjs → json-viewer-BqF1pSQD.mjs} +3 -3
  25. package/.output/server/_ssr/{router-Dgx6-ot3.mjs → router-C8BOns7y.mjs} +138 -162
  26. package/.output/server/{_tanstack-start-manifest_v-CoGjnkQD.mjs → _tanstack-start-manifest_v-BBf2nRhb.mjs} +1 -1
  27. package/.output/server/index.mjs +74 -74
  28. package/package.json +1 -1
  29. package/src/components/ProxyViewer.tsx +57 -1
  30. package/src/components/clients/ClientLogo.tsx +43 -4
  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 +233 -152
  34. package/src/components/proxy-viewer/ConversationHeader.tsx +29 -13
  35. package/src/lib/jsonFile.ts +46 -0
  36. package/src/proxy/evidenceAnalysis.ts +116 -117
  37. package/src/proxy/groupEvidenceExporter.ts +4 -8
  38. package/src/proxy/groupStore.ts +3 -10
  39. package/src/proxy/providerSecretStore.ts +4 -8
  40. package/src/proxy/runFailures.ts +4 -10
  41. package/src/proxy/runStore.ts +4 -11
  42. package/.output/public/assets/index-CmeF5XXS.css +0 -1
@@ -24,6 +24,22 @@ import { resolveLogFormat } from "./log-formats";
24
24
  const HEADER_ACTION_BUTTON_CLASS =
25
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";
26
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 "Sessionless";
38
+ return conversationId.length > 24
39
+ ? `${conversationId.slice(0, 12)}...${conversationId.slice(-12)}`
40
+ : conversationId;
41
+ }
42
+
27
43
  function HeaderMetric({
28
44
  icon,
29
45
  children,
@@ -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-amber-300/[0.10] px-1.5 font-mono text-[10px] uppercase text-amber-100/90 ring-1 ring-amber-200/10"
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,8 +174,8 @@ 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
+ "bg-black/[0.10] relative flex cursor-pointer items-start gap-2.5 overflow-hidden rounded-[8px] px-3 py-2.5 transition-colors",
178
+ "hover:bg-black/[0.16]",
166
179
  "select-none",
167
180
  "sticky top-0 z-10 mb-2",
168
181
  "focus-visible:ring-1 focus-visible:ring-cyan-300/35 focus-visible:outline-none",
@@ -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-white/[0.04] px-1.5 py-0.5 font-mono text-[9px] uppercase text-muted-foreground/70">
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-slate-100"
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-slate-100"
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-slate-100"
207
223
  title={conversationId}
208
224
  >
209
- {isFallbackConversation ? "sessionless" : compactSessionLabel}
225
+ {sessionLabel}
210
226
  </span>
211
227
  )}
212
228
 
@@ -240,7 +256,7 @@ export function ConversationHeader({
240
256
  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"
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
  )}
@@ -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
+ }
@@ -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 {