@tonyclaw/agent-inspector 2.0.26 → 2.0.27

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 (40) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-DBTFzxG5.js → CompareDrawer-B_p6vmMQ.js} +1 -1
  3. package/.output/public/assets/{ProxyViewerContainer-ivZk8MgE.js → ProxyViewerContainer-DItqh1EO.js} +4 -4
  4. package/.output/public/assets/{ReplayDialog-syW2hDNE.js → ReplayDialog-BmprJ6D3.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-BkuRtY9o.js → RequestAnatomy-DqO0_SVJ.js} +1 -1
  6. package/.output/public/assets/{ResponseView-BwyvEvoE.js → ResponseView-FZbPNdHa.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-BY4MbPKg.js → StreamingChunkSequence-BZPzfJWj.js} +1 -1
  8. package/.output/public/assets/_sessionId-y2ATIp51.js +1 -0
  9. package/.output/public/assets/index-CQT4higx.js +1 -0
  10. package/.output/public/assets/{main-DKRDRBdd.js → main-CzItFZlM.js} +2 -2
  11. package/.output/server/_libs/modelcontextprotocol__server.mjs +228 -0
  12. package/.output/server/{_sessionId-VDd4N_1B.mjs → _sessionId-DZH8SrEZ.mjs} +3 -3
  13. package/.output/server/_ssr/{CompareDrawer-BQZlxsOC.mjs → CompareDrawer-L0aE1UV1.mjs} +2 -2
  14. package/.output/server/_ssr/{ProxyViewerContainer-njY2oQCc.mjs → ProxyViewerContainer-B62RB9ER.mjs} +7 -7
  15. package/.output/server/_ssr/{ReplayDialog-B8lkdAIh.mjs → ReplayDialog-FVWnpx2C.mjs} +3 -3
  16. package/.output/server/_ssr/{RequestAnatomy-CwOTfdwS.mjs → RequestAnatomy-DIyqW-Ny.mjs} +2 -2
  17. package/.output/server/_ssr/{ResponseView-3Iz_mZpd.mjs → ResponseView-BX4mxEZ5.mjs} +2 -2
  18. package/.output/server/_ssr/{StreamingChunkSequence-hmJcQNW5.mjs → StreamingChunkSequence-Cs3nzOor.mjs} +2 -2
  19. package/.output/server/_ssr/{index-UuTKM4aC.mjs → index-Dik2Mc3h.mjs} +2 -2
  20. package/.output/server/_ssr/index.mjs +2 -2
  21. package/.output/server/_ssr/{router-C7InHrxE.mjs → router-DCPg8ykx.mjs} +1816 -151
  22. package/.output/server/_tanstack-start-manifest_v-BaoL3JCh.mjs +4 -0
  23. package/.output/server/index.mjs +57 -57
  24. package/README.md +55 -1
  25. package/package.json +1 -1
  26. package/src/lib/runContract.ts +162 -0
  27. package/src/mcp/server.ts +554 -2
  28. package/src/mcp/toolHandlers.ts +154 -0
  29. package/src/proxy/evidenceAnalysis.ts +522 -0
  30. package/src/proxy/evidenceExporter.ts +215 -0
  31. package/src/proxy/logSearch.ts +118 -0
  32. package/src/proxy/runFailures.ts +100 -0
  33. package/src/proxy/runStore.ts +159 -0
  34. package/src/routes/api/logs.ts +16 -0
  35. package/src/routes/api/runs.$runId.evidence.ts +62 -0
  36. package/src/routes/api/runs.$runId.ts +50 -0
  37. package/src/routes/api/runs.ts +58 -0
  38. package/.output/public/assets/_sessionId-XlPUgIIb.js +0 -1
  39. package/.output/public/assets/index-By11a28-.js +0 -1
  40. package/.output/server/_tanstack-start-manifest_v-B6yfnMHA.mjs +0 -4
@@ -17,6 +17,13 @@ import { Buffer } from "node:buffer";
17
17
  import { z } from "zod";
18
18
  import { LoopbackTimeoutError, type CallApiOptions } from "./loopback";
19
19
  import { extractLastUserMessagePreview, extractResponsePreview } from "./previewExtractor";
20
+ import {
21
+ EvidenceExportResultSchema,
22
+ InspectorRunSchema,
23
+ RecentFailuresResponseSchema,
24
+ type CreateInspectorRunInput,
25
+ type UpdateInspectorRunInput,
26
+ } from "../lib/runContract";
20
27
  import { SessionInfoSchema } from "../lib/sessionInfoContract";
21
28
  import { CapturedLogSchema, type CapturedLog } from "../proxy/schemas";
22
29
 
@@ -37,6 +44,15 @@ const LogsListResponseSchema = z.object({
37
44
  limit: z.number().optional(),
38
45
  });
39
46
 
47
+ const SearchLogsResponseSchema = z.object({
48
+ query: z.string(),
49
+ logs: z.array(CapturedLogSchema),
50
+ total: z.number(),
51
+ offset: z.number(),
52
+ limit: z.number(),
53
+ scanned: z.number().optional(),
54
+ });
55
+
40
56
  const PAGINATION_DEFAULTS = { offset: 0, limit: 3 };
41
57
  export const LIMIT_HARD_CAP = 5;
42
58
  const LOG_DETAIL_TEXT_LIMIT = 200_000;
@@ -169,6 +185,47 @@ export async function listLogsImpl(callApi: CallApiFn, args: ListLogsArgs): Prom
169
185
  return textJson(logs.map(buildLogSummary));
170
186
  }
171
187
 
188
+ export type SearchLogsArgs = {
189
+ query?: string;
190
+ offset?: number;
191
+ limit?: number;
192
+ scanLimit?: number;
193
+ sessionId?: string;
194
+ model?: string;
195
+ };
196
+
197
+ export async function searchLogsImpl(
198
+ callApi: CallApiFn,
199
+ args: SearchLogsArgs,
200
+ ): Promise<ToolResult> {
201
+ const offset = args.offset ?? PAGINATION_DEFAULTS.offset;
202
+ const limit = clampLimit(args.limit);
203
+ const params = new URLSearchParams({
204
+ q: args.query ?? "",
205
+ offset: String(offset),
206
+ limit: String(limit),
207
+ });
208
+ if (args.scanLimit !== undefined) {
209
+ params.set("scanLimit", String(args.scanLimit));
210
+ }
211
+ if (args.sessionId !== undefined && args.sessionId.length > 0) {
212
+ params.set("sessionId", args.sessionId);
213
+ }
214
+ if (args.model !== undefined && args.model.length > 0) {
215
+ params.set("model", args.model);
216
+ }
217
+
218
+ const res = await callApi(`/api/logs?${params.toString()}`);
219
+ if (!res.ok) return toolError(`GET /api/logs search returned ${res.status}`);
220
+ const rawBody: unknown = await res.json();
221
+ const parsed = SearchLogsResponseSchema.safeParse(rawBody);
222
+ if (!parsed.success) return toolError("GET /api/logs search returned an unparseable shape");
223
+ return textJson({
224
+ ...parsed.data,
225
+ logs: parsed.data.logs.map(buildLogSummary),
226
+ });
227
+ }
228
+
172
229
  export async function getLogImpl(callApi: CallApiFn, id: number): Promise<ToolResult> {
173
230
  const res = await callApi(`/api/logs/${id}`);
174
231
  if (!res.ok) return toolError(`GET /api/logs/${id} returned ${res.status}`);
@@ -218,6 +275,103 @@ export async function getSessionImpl(
218
275
  return textJson(parsed.data);
219
276
  }
220
277
 
278
+ export async function createRunImpl(
279
+ callApi: CallApiFn,
280
+ args: CreateInspectorRunInput,
281
+ ): Promise<ToolResult> {
282
+ const res = await callApi("/api/runs", {
283
+ method: "POST",
284
+ headers: { "content-type": "application/json" },
285
+ body: JSON.stringify(args ?? {}),
286
+ });
287
+ if (!res.ok) return toolError(`POST /api/runs returned ${res.status}`);
288
+ const rawBody: unknown = await res.json();
289
+ const parsed = InspectorRunSchema.safeParse(rawBody);
290
+ if (!parsed.success) return toolError("POST /api/runs returned an unparseable run");
291
+ return textJson(parsed.data);
292
+ }
293
+
294
+ export type GetRunArgs = {
295
+ runId: string;
296
+ };
297
+
298
+ export async function getRunImpl(callApi: CallApiFn, args: GetRunArgs): Promise<ToolResult> {
299
+ const path = `/api/runs/${encodeURIComponent(args.runId)}`;
300
+ const res = await callApi(path);
301
+ if (!res.ok) return toolError(`GET ${path} returned ${res.status}`);
302
+ const rawBody: unknown = await res.json();
303
+ const parsed = InspectorRunSchema.safeParse(rawBody);
304
+ if (!parsed.success) return toolError("GET /api/runs/{runId} returned an unparseable run");
305
+ return textJson(parsed.data);
306
+ }
307
+
308
+ export type UpdateRunArgs = {
309
+ runId: string;
310
+ } & UpdateInspectorRunInput;
311
+
312
+ export async function updateRunImpl(callApi: CallApiFn, args: UpdateRunArgs): Promise<ToolResult> {
313
+ const { runId, ...patch } = args;
314
+ const path = `/api/runs/${encodeURIComponent(runId)}`;
315
+ const res = await callApi(path, {
316
+ method: "PATCH",
317
+ headers: { "content-type": "application/json" },
318
+ body: JSON.stringify(patch),
319
+ });
320
+ if (!res.ok) return toolError(`PATCH ${path} returned ${res.status}`);
321
+ const rawBody: unknown = await res.json();
322
+ const parsed = InspectorRunSchema.safeParse(rawBody);
323
+ if (!parsed.success) return toolError("PATCH /api/runs/{runId} returned an unparseable run");
324
+ return textJson(parsed.data);
325
+ }
326
+
327
+ export type GetRecentFailuresArgs = {
328
+ limit?: number;
329
+ };
330
+
331
+ export async function getRecentFailuresImpl(
332
+ callApi: CallApiFn,
333
+ args: GetRecentFailuresArgs,
334
+ ): Promise<ToolResult> {
335
+ const limit = args.limit ?? 5;
336
+ const params = new URLSearchParams({ failures: "1", limit: String(limit) });
337
+ const res = await callApi(`/api/runs?${params.toString()}`);
338
+ if (!res.ok) return toolError(`GET /api/runs?failures=1 returned ${res.status}`);
339
+ const rawBody: unknown = await res.json();
340
+ const parsed = RecentFailuresResponseSchema.safeParse(rawBody);
341
+ if (!parsed.success) {
342
+ return toolError("GET /api/runs?failures=1 returned an unparseable failure list");
343
+ }
344
+ return textJson(parsed.data);
345
+ }
346
+
347
+ export type ExportEvidenceArgs = {
348
+ runId: string;
349
+ includeHistory?: boolean;
350
+ latestLogLimit?: number;
351
+ };
352
+
353
+ export async function exportEvidenceImpl(
354
+ callApi: CallApiFn,
355
+ args: ExportEvidenceArgs,
356
+ ): Promise<ToolResult> {
357
+ const path = `/api/runs/${encodeURIComponent(args.runId)}/evidence`;
358
+ const res = await callApi(path, {
359
+ method: "POST",
360
+ headers: { "content-type": "application/json" },
361
+ body: JSON.stringify({
362
+ includeHistory: args.includeHistory,
363
+ latestLogLimit: args.latestLogLimit,
364
+ }),
365
+ });
366
+ if (!res.ok) return toolError(`POST ${path} returned ${res.status}`);
367
+ const rawBody: unknown = await res.json();
368
+ const parsed = EvidenceExportResultSchema.safeParse(rawBody);
369
+ if (!parsed.success) {
370
+ return toolError("POST /api/runs/{runId}/evidence returned an unparseable evidence result");
371
+ }
372
+ return textJson(parsed.data);
373
+ }
374
+
221
375
  export async function listModelsImpl(callApi: CallApiFn): Promise<ToolResult> {
222
376
  const res = await callApi("/api/models");
223
377
  if (!res.ok) return toolError(`GET /api/models returned ${res.status}`);
@@ -0,0 +1,522 @@
1
+ import type { SessionInfo, SessionLogSummary } from "../lib/sessionInfoContract";
2
+ import type {
3
+ FailureCategory,
4
+ FailureClassification,
5
+ InspectorRun,
6
+ JenkinsReport,
7
+ RunTimelineEvent,
8
+ } from "../lib/runContract";
9
+
10
+ type AnalyzeEvidenceInput = {
11
+ run: InspectorRun;
12
+ session: SessionInfo | null;
13
+ exportedAt: string;
14
+ evidencePaths: {
15
+ jsonPath: string;
16
+ markdownPath: string;
17
+ htmlPath: string;
18
+ };
19
+ };
20
+
21
+ type ErrorCandidate = {
22
+ log: SessionLogSummary | null;
23
+ text: string;
24
+ };
25
+
26
+ function formatNullable(value: string | number | null): string {
27
+ return value === null ? "n/a" : String(value);
28
+ }
29
+
30
+ function formatList(values: readonly string[]): string {
31
+ return values.length > 0 ? values.join(", ") : "n/a";
32
+ }
33
+
34
+ function lower(value: string): string {
35
+ return value.toLowerCase();
36
+ }
37
+
38
+ function logEvidenceText(log: SessionLogSummary): string {
39
+ return lower(
40
+ [
41
+ log.error,
42
+ log.path,
43
+ log.model,
44
+ log.provider,
45
+ log.status === null ? null : String(log.status),
46
+ log.isStreaming ? "streaming" : "non-streaming",
47
+ ]
48
+ .filter((value) => value !== null)
49
+ .join(" "),
50
+ );
51
+ }
52
+
53
+ function collectErrorCandidates(session: SessionInfo): ErrorCandidate[] {
54
+ const candidates: ErrorCandidate[] = session.latestLogs
55
+ .filter((log) => log.hasError)
56
+ .map((log) => ({ log, text: logEvidenceText(log) }));
57
+
58
+ if (session.lastTaskError !== null) {
59
+ candidates.push({ log: null, text: lower(session.lastTaskError) });
60
+ }
61
+
62
+ return candidates;
63
+ }
64
+
65
+ function hasText(candidates: readonly ErrorCandidate[], patterns: readonly string[]): boolean {
66
+ return candidates.some((candidate) =>
67
+ patterns.some((pattern) => candidate.text.includes(pattern)),
68
+ );
69
+ }
70
+
71
+ function hasStatus(session: SessionInfo, predicate: (status: number) => boolean): boolean {
72
+ return session.latestLogs.some((log) => log.status !== null && predicate(log.status));
73
+ }
74
+
75
+ function evidenceIds(candidates: readonly ErrorCandidate[]): number[] {
76
+ const ids = new Set<number>();
77
+ for (const candidate of candidates) {
78
+ if (candidate.log !== null) {
79
+ ids.add(candidate.log.id);
80
+ }
81
+ }
82
+ return [...ids].sort((left, right) => left - right);
83
+ }
84
+
85
+ function hasStreamingMismatch(session: SessionInfo): boolean {
86
+ for (const failed of session.latestLogs.filter((log) => log.hasError)) {
87
+ const matchingSuccess = session.latestLogs.some(
88
+ (log) =>
89
+ !log.hasError &&
90
+ log.model === failed.model &&
91
+ log.provider === failed.provider &&
92
+ log.isStreaming !== failed.isStreaming,
93
+ );
94
+ if (matchingSuccess) return true;
95
+ }
96
+ return false;
97
+ }
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
+ ];
177
+ }
178
+ }
179
+
180
+ function classifyCategory(
181
+ session: SessionInfo,
182
+ candidates: readonly ErrorCandidate[],
183
+ ): FailureCategory {
184
+ if (session.status === "active") return "active";
185
+ if (session.errorCount === 0 && session.failedRequests === 0 && session.lastTaskError === null) {
186
+ return "none";
187
+ }
188
+ if (hasStreamingMismatch(session)) return "streaming-mismatch";
189
+ if (
190
+ hasText(candidates, [
191
+ "timeout",
192
+ "timed out",
193
+ "etimedout",
194
+ "deadline",
195
+ "aborterror",
196
+ "econnaborted",
197
+ ])
198
+ ) {
199
+ return "provider-timeout";
200
+ }
201
+ if (hasText(candidates, ["localhost", "econnrefused", "enotfound", "network", "fetch failed"])) {
202
+ return "network-or-container";
203
+ }
204
+ if (hasStatus(session, (status) => status === 401 || status === 403)) {
205
+ return "provider-auth-error";
206
+ }
207
+ if (hasText(candidates, ["unauthorized", "forbidden", "invalid api key", "api key"])) {
208
+ return "provider-auth-error";
209
+ }
210
+ if (hasStatus(session, (status) => status === 429)) return "provider-rate-limit";
211
+ if (hasText(candidates, ["rate limit", "quota"])) return "provider-rate-limit";
212
+ if (hasText(candidates, ["context", "too many tokens", "token limit", "maximum context"])) {
213
+ return "context-pressure";
214
+ }
215
+ if (session.lastTaskError !== null) return "runtime-task-error";
216
+ if (hasText(candidates, ["invalid json", "schema", "parse", "malformed"])) {
217
+ return "model-output-error";
218
+ }
219
+ if (hasStatus(session, (status) => status >= 400)) return "provider-http-error";
220
+ return "unknown-failure";
221
+ }
222
+
223
+ 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
+ }
243
+ }
244
+
245
+ 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
+ }
265
+ }
266
+
267
+ export function classifyRunFailure(
268
+ run: InspectorRun,
269
+ session: SessionInfo | null,
270
+ ): FailureClassification {
271
+ if (session === null) {
272
+ const category: FailureCategory = "no-session-data";
273
+ return {
274
+ outcome: outcomeFor(category),
275
+ severity: severityFor(category),
276
+ category,
277
+ summary: `${categorySummary(category)} Run ${run.id} is bound to session ${run.sessionId}.`,
278
+ evidenceLogIds: [],
279
+ hints: categoryHints(category),
280
+ };
281
+ }
282
+
283
+ const candidates = collectErrorCandidates(session);
284
+ const category = classifyCategory(session, candidates);
285
+ const summary =
286
+ category === "none"
287
+ ? `${categorySummary(category)} ${String(session.requestCount)} request(s), ${String(
288
+ session.tokenUsage.total,
289
+ )} token(s).`
290
+ : `${categorySummary(category)} ${String(session.errorCount)} error log(s), ${String(
291
+ session.failedRequests,
292
+ )} failed request(s).`;
293
+
294
+ return {
295
+ outcome: outcomeFor(category),
296
+ severity: severityFor(category),
297
+ category,
298
+ summary,
299
+ evidenceLogIds: evidenceIds(candidates),
300
+ hints: categoryHints(category),
301
+ };
302
+ }
303
+
304
+ function logTitle(log: SessionLogSummary): string {
305
+ if (log.hasError) return `Request failed: log ${String(log.id)}`;
306
+ if (log.status !== null && log.status >= 200 && log.status < 400) {
307
+ return `Request succeeded: log ${String(log.id)}`;
308
+ }
309
+ return `Request captured: log ${String(log.id)}`;
310
+ }
311
+
312
+ function logDetails(log: SessionLogSummary): string {
313
+ const parts = [
314
+ `path=${log.path}`,
315
+ `status=${formatNullable(log.status)}`,
316
+ `model=${formatNullable(log.model)}`,
317
+ `provider=${formatNullable(log.provider)}`,
318
+ `streaming=${log.isStreaming ? "yes" : "no"}`,
319
+ `latencyMs=${formatNullable(log.latencyMs)}`,
320
+ ];
321
+ if (log.error !== null) {
322
+ parts.push(`error=${log.error}`);
323
+ }
324
+ return parts.join(", ");
325
+ }
326
+
327
+ export function buildRunTimeline(
328
+ run: InspectorRun,
329
+ session: SessionInfo | null,
330
+ exportedAt: string,
331
+ ): RunTimelineEvent[] {
332
+ const events: RunTimelineEvent[] = [
333
+ {
334
+ timestamp: run.createdAt,
335
+ kind: "run-created",
336
+ severity: "info",
337
+ title: "Run declared",
338
+ details: `Run ${run.id} was declared for session ${run.sessionId}.`,
339
+ logId: null,
340
+ status: null,
341
+ model: null,
342
+ provider: null,
343
+ latencyMs: null,
344
+ isStreaming: null,
345
+ },
346
+ ];
347
+
348
+ if (session !== null && session.createdAt !== null) {
349
+ events.push({
350
+ timestamp: session.createdAt,
351
+ kind: "session-started",
352
+ severity: "info",
353
+ title: "Session started",
354
+ details: `Inspector session ${session.id} started with source ${session.source ?? "unknown"}.`,
355
+ logId: null,
356
+ status: null,
357
+ model: null,
358
+ provider: null,
359
+ latencyMs: null,
360
+ isStreaming: null,
361
+ });
362
+ }
363
+
364
+ if (session !== null) {
365
+ const logs = [...session.latestLogs].sort((left, right) =>
366
+ left.timestamp.localeCompare(right.timestamp),
367
+ );
368
+ for (const log of logs) {
369
+ events.push({
370
+ timestamp: log.timestamp,
371
+ kind: "request",
372
+ severity: log.hasError ? "error" : "success",
373
+ title: logTitle(log),
374
+ details: logDetails(log),
375
+ logId: log.id,
376
+ status: log.status,
377
+ model: log.model,
378
+ provider: log.provider,
379
+ latencyMs: log.latencyMs,
380
+ isStreaming: log.isStreaming,
381
+ });
382
+ }
383
+
384
+ if (session.updatedAt !== null) {
385
+ events.push({
386
+ timestamp: session.updatedAt,
387
+ kind: "session-finished",
388
+ severity:
389
+ session.status === "failed"
390
+ ? "error"
391
+ : session.status === "active"
392
+ ? "warning"
393
+ : "success",
394
+ title: `Session ${session.status}`,
395
+ details: `${String(session.requestCount)} request(s), ${String(
396
+ session.errorCount,
397
+ )} error(s), ${String(session.tokenUsage.total)} token(s).`,
398
+ logId: session.lastLogId,
399
+ status: null,
400
+ model: session.lastModel,
401
+ provider: null,
402
+ latencyMs: null,
403
+ isStreaming: null,
404
+ });
405
+ }
406
+ }
407
+
408
+ events.push({
409
+ timestamp: exportedAt,
410
+ kind: "evidence-exported",
411
+ severity: "info",
412
+ title: "Evidence exported",
413
+ details: `Evidence pack exported at ${exportedAt}.`,
414
+ logId: null,
415
+ status: null,
416
+ model: null,
417
+ provider: null,
418
+ latencyMs: null,
419
+ isStreaming: null,
420
+ });
421
+
422
+ return events.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
423
+ }
424
+
425
+ function reportStatus(classification: FailureClassification): JenkinsReport["status"] {
426
+ switch (classification.outcome) {
427
+ case "success":
428
+ return "PASS";
429
+ case "failure":
430
+ return "FAIL";
431
+ case "active":
432
+ return "RUNNING";
433
+ case "unknown":
434
+ return "UNKNOWN";
435
+ }
436
+ }
437
+
438
+ function timelineMarkdown(timeline: readonly RunTimelineEvent[]): string[] {
439
+ if (timeline.length === 0) return ["No timeline events were available."];
440
+ return [
441
+ "| Time | Event | Severity | Evidence |",
442
+ "| --- | --- | --- | --- |",
443
+ ...timeline.map((event) => {
444
+ const evidence = event.logId === null ? "n/a" : `log ${String(event.logId)}`;
445
+ return `| ${event.timestamp} | ${event.title} | ${event.severity} | ${evidence} |`;
446
+ }),
447
+ ];
448
+ }
449
+
450
+ export function buildJenkinsReport(input: {
451
+ run: InspectorRun;
452
+ session: SessionInfo | null;
453
+ timeline: readonly RunTimelineEvent[];
454
+ classification: FailureClassification;
455
+ evidencePaths: AnalyzeEvidenceInput["evidencePaths"];
456
+ }): JenkinsReport {
457
+ const status = reportStatus(input.classification);
458
+ const session = input.session;
459
+ const summary = `[${status}] ${input.classification.summary}`;
460
+ const lines = [
461
+ `# Jenkins Report: ${input.run.title}`,
462
+ "",
463
+ `- Status: ${status}`,
464
+ `- Run ID: ${input.run.id}`,
465
+ `- Session ID: ${input.run.sessionId}`,
466
+ `- Inspector URL: ${session?.inspectorUrl ?? "n/a"}`,
467
+ `- Category: ${input.classification.category}`,
468
+ `- Severity: ${input.classification.severity}`,
469
+ `- Summary: ${input.classification.summary}`,
470
+ `- Evidence Logs: ${
471
+ input.classification.evidenceLogIds.length > 0
472
+ ? input.classification.evidenceLogIds.map((id) => `#${String(id)}`).join(", ")
473
+ : "n/a"
474
+ }`,
475
+ "",
476
+ "## Session Metrics",
477
+ "",
478
+ `- Requests: ${String(session?.requestCount ?? 0)}`,
479
+ `- Errors: ${String(session?.errorCount ?? 0)}`,
480
+ `- Models: ${formatList(session?.models ?? [])}`,
481
+ `- Providers: ${formatList(session?.providers ?? [])}`,
482
+ `- Tokens: ${String(session?.tokenUsage.total ?? 0)}`,
483
+ "",
484
+ "## Timeline",
485
+ "",
486
+ ...timelineMarkdown(input.timeline),
487
+ "",
488
+ "## Artifacts",
489
+ "",
490
+ `- JSON: ${input.evidencePaths.jsonPath}`,
491
+ `- Markdown: ${input.evidencePaths.markdownPath}`,
492
+ `- HTML: ${input.evidencePaths.htmlPath}`,
493
+ "",
494
+ "## Next Actions",
495
+ "",
496
+ ...input.classification.hints.map((hint) => `- ${hint}`),
497
+ "",
498
+ ];
499
+
500
+ return {
501
+ status,
502
+ summary,
503
+ markdown: lines.join("\n"),
504
+ };
505
+ }
506
+
507
+ export function analyzeEvidence(input: AnalyzeEvidenceInput): {
508
+ classification: FailureClassification;
509
+ timeline: RunTimelineEvent[];
510
+ jenkinsReport: JenkinsReport;
511
+ } {
512
+ const classification = classifyRunFailure(input.run, input.session);
513
+ const timeline = buildRunTimeline(input.run, input.session, input.exportedAt);
514
+ const jenkinsReport = buildJenkinsReport({
515
+ run: input.run,
516
+ session: input.session,
517
+ timeline,
518
+ classification,
519
+ evidencePaths: input.evidencePaths,
520
+ });
521
+ return { classification, timeline, jenkinsReport };
522
+ }