@tonyclaw/agent-inspector 2.1.3 → 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.
Files changed (56) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-BLYcm6Dg.js → CompareDrawer-BihEPd3v.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-DYqXb6WW.js +117 -0
  4. package/.output/public/assets/{ReplayDialog-_kCy9L9E.js → ReplayDialog-DnX-6kY8.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-DWp5pWsv.js → RequestAnatomy-CIkHAxZO.js} +1 -1
  6. package/.output/public/assets/{ResponseView-Ci92o6mt.js → ResponseView-BFc8jjZM.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-C-1ZQl_b.js → StreamingChunkSequence-p8qkpF6K.js} +1 -1
  8. package/.output/public/assets/_sessionId-Csx4T_y6.js +1 -0
  9. package/.output/public/assets/index-Bcq8bZoK.css +1 -0
  10. package/.output/public/assets/index-KMuh-31x.js +1 -0
  11. package/.output/public/assets/{main-RgLvmIQk.js → main-xlSdu7_I.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +204 -173
  13. package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -0
  14. package/.output/server/{_sessionId-DnOYbKO7.mjs → _sessionId-CwVQgxoK.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-D44_F13_.mjs → CompareDrawer-Br9Ec0ah.mjs} +3 -3
  16. package/.output/server/_ssr/{ProxyViewerContainer-Cx4p_awW.mjs → ProxyViewerContainer-Oe5Tr3C2.mjs} +982 -70
  17. package/.output/server/_ssr/{ReplayDialog-wScCF3Oc.mjs → ReplayDialog-C1_QsoA-.mjs} +4 -4
  18. package/.output/server/_ssr/{RequestAnatomy-Cf7R7PXS.mjs → RequestAnatomy-cn08QSoz.mjs} +2 -2
  19. package/.output/server/_ssr/{ResponseView-7pVIZLL-.mjs → ResponseView-CIqUCCTG.mjs} +3 -3
  20. package/.output/server/_ssr/{StreamingChunkSequence-Bs57_GbC.mjs → StreamingChunkSequence-0MhVWD2F.mjs} +3 -3
  21. package/.output/server/_ssr/{index-6eY__la9.mjs → index-jpnI5ghp.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{router-DRZPhZB6.mjs → router-SSOn7c09.mjs} +1970 -221
  24. package/.output/server/_tanstack-start-manifest_v-BCgy_9er.mjs +4 -0
  25. package/.output/server/index.mjs +64 -64
  26. package/package.json +2 -1
  27. package/src/components/ProxyViewer.tsx +87 -1
  28. package/src/components/ProxyViewerContainer.tsx +26 -0
  29. package/src/components/alerts/AlertsDialog.tsx +610 -0
  30. package/src/components/proxy-viewer/LogEntry.tsx +324 -51
  31. package/src/components/ui/dialog.tsx +1 -0
  32. package/src/contracts/index.ts +15 -2
  33. package/src/contracts/log.ts +18 -0
  34. package/src/lib/alertContract.ts +79 -0
  35. package/src/lib/apiClient.ts +39 -0
  36. package/src/lib/export-logs.ts +47 -9
  37. package/src/lib/logImportContract.ts +12 -0
  38. package/src/lib/useAlerts.ts +82 -0
  39. package/src/lib/useGroupEvidence.ts +6 -2
  40. package/src/lib/useGroups.ts +6 -2
  41. package/src/proxy/alerts.ts +664 -0
  42. package/src/proxy/logBodyChunks.ts +91 -0
  43. package/src/proxy/logImporter.ts +491 -0
  44. package/src/proxy/logIndex.ts +63 -2
  45. package/src/proxy/sqliteLogIndex.ts +612 -0
  46. package/src/proxy/store.ts +32 -7
  47. package/src/routes/api/alerts.summary.ts +24 -0
  48. package/src/routes/api/alerts.ts +52 -0
  49. package/src/routes/api/logs.$id.body.ts +44 -0
  50. package/src/routes/api/logs.import.ts +50 -0
  51. package/src/services/alerts.ts +12 -0
  52. package/.output/public/assets/ProxyViewerContainer-Ba5wFn00.js +0 -117
  53. package/.output/public/assets/_sessionId-DKzAmSNP.js +0 -1
  54. package/.output/public/assets/index-B-MZ9lQM.css +0 -1
  55. package/.output/public/assets/index-ZZHwgkWJ.js +0 -1
  56. package/.output/server/_tanstack-start-manifest_v-DfAVFkZ5.mjs +0 -4
@@ -0,0 +1,664 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { JsonValue } from "../contracts";
3
+ import type {
4
+ Alert,
5
+ AlertCategory,
6
+ AlertEvidenceLink,
7
+ AlertListResponse,
8
+ AlertSeverity,
9
+ AlertSource,
10
+ AlertSummary,
11
+ } from "../lib/alertContract";
12
+ import type { InspectorGroup, InspectorGroupMember } from "../lib/groupContract";
13
+ import type { RecentFailure } from "../lib/runContract";
14
+ import { getSessionPath } from "../lib/sessionUrl";
15
+ import { getConfig } from "./config";
16
+ import { listGroups } from "./groupStore";
17
+ import { listRecentFailures } from "./runFailures";
18
+ import type { CapturedLog } from "./schemas";
19
+ import { listLogsCursorPage } from "./store";
20
+
21
+ export const DEFAULT_ALERT_LIMIT = 50;
22
+ export const MAX_ALERT_LIMIT = 200;
23
+ export const DEFAULT_ALERT_SCAN_LIMIT = 500;
24
+ export const MAX_ALERT_SCAN_LIMIT = 2_000;
25
+
26
+ type AlertFilters = {
27
+ severity?: AlertSeverity;
28
+ category?: AlertCategory;
29
+ source?: AlertSource;
30
+ };
31
+
32
+ export type ListAlertsOptions = AlertFilters & {
33
+ limit?: number;
34
+ scanLimit?: number;
35
+ };
36
+
37
+ type AlertObservation = {
38
+ fingerprint: string;
39
+ severity: AlertSeverity;
40
+ category: AlertCategory;
41
+ source: AlertSource;
42
+ title: string;
43
+ message: string;
44
+ observedAt: string;
45
+ logId: number | null;
46
+ sessionId: string | null;
47
+ runId: string | null;
48
+ groupId: string | null;
49
+ provider: string | null;
50
+ model: string | null;
51
+ apiFormat: Alert["apiFormat"];
52
+ evidence: AlertEvidenceLink[];
53
+ metadata: Record<string, JsonValue>;
54
+ };
55
+
56
+ export type AlertCollectionInput = {
57
+ logs: readonly CapturedLog[];
58
+ recentFailures: readonly RecentFailure[];
59
+ groups: readonly InspectorGroup[];
60
+ slowResponseThresholdSeconds: number;
61
+ };
62
+
63
+ function clampPositiveInt(value: number | undefined, fallback: number, max: number): number {
64
+ if (value === undefined) return fallback;
65
+ if (!Number.isInteger(value) || value < 1) return fallback;
66
+ return Math.min(value, max);
67
+ }
68
+
69
+ export function clampAlertLimit(value: number | undefined): number {
70
+ return clampPositiveInt(value, DEFAULT_ALERT_LIMIT, MAX_ALERT_LIMIT);
71
+ }
72
+
73
+ export function clampAlertScanLimit(value: number | undefined): number {
74
+ return clampPositiveInt(value, DEFAULT_ALERT_SCAN_LIMIT, MAX_ALERT_SCAN_LIMIT);
75
+ }
76
+
77
+ function alertIdFor(value: string): string {
78
+ const digest = createHash("sha1").update(value).digest("hex").slice(0, 16);
79
+ return `alert_${digest}`;
80
+ }
81
+
82
+ function sanitizeFingerprintPart(value: string | number | null): string {
83
+ if (value === null) return "-";
84
+ return String(value).trim().toLowerCase().replace(/\s+/g, " ").slice(0, 240);
85
+ }
86
+
87
+ function fingerprint(parts: readonly (string | number | null)[]): string {
88
+ return parts.map(sanitizeFingerprintPart).join("|");
89
+ }
90
+
91
+ function uniqueStrings(values: readonly string[]): string[] {
92
+ return [...new Set(values)].sort();
93
+ }
94
+
95
+ function uniqueNumbers(values: readonly number[]): number[] {
96
+ return [...new Set(values)].sort((left, right) => left - right);
97
+ }
98
+
99
+ function logEvidence(logId: number): AlertEvidenceLink {
100
+ const id = String(logId);
101
+ return { kind: "log", id, label: `Log #${id}`, href: `#log-${id}` };
102
+ }
103
+
104
+ function sessionEvidence(sessionId: string): AlertEvidenceLink {
105
+ return {
106
+ kind: "session",
107
+ id: sessionId,
108
+ label: `Session ${sessionId}`,
109
+ href: getSessionPath(sessionId),
110
+ };
111
+ }
112
+
113
+ function runEvidence(runId: string): AlertEvidenceLink {
114
+ return { kind: "run", id: runId, label: `Run ${runId}`, href: null };
115
+ }
116
+
117
+ function groupEvidence(groupId: string): AlertEvidenceLink {
118
+ return { kind: "group", id: groupId, label: `Group ${groupId}`, href: null };
119
+ }
120
+
121
+ function baseEvidence(
122
+ logId: number | null,
123
+ sessionId: string | null,
124
+ runId: string | null,
125
+ groupId: string | null,
126
+ ): AlertEvidenceLink[] {
127
+ const links: AlertEvidenceLink[] = [];
128
+ if (logId !== null) links.push(logEvidence(logId));
129
+ if (sessionId !== null) links.push(sessionEvidence(sessionId));
130
+ if (runId !== null) links.push(runEvidence(runId));
131
+ if (groupId !== null) links.push(groupEvidence(groupId));
132
+ return links;
133
+ }
134
+
135
+ function hasEquivalentEvidence(
136
+ links: readonly AlertEvidenceLink[],
137
+ candidate: AlertEvidenceLink,
138
+ ): boolean {
139
+ return links.some((link) => link.kind === candidate.kind && link.id === candidate.id);
140
+ }
141
+
142
+ function mergeEvidence(
143
+ left: readonly AlertEvidenceLink[],
144
+ right: readonly AlertEvidenceLink[],
145
+ ): AlertEvidenceLink[] {
146
+ const merged: AlertEvidenceLink[] = [...left];
147
+ for (const link of right) {
148
+ if (!hasEquivalentEvidence(merged, link)) merged.push(link);
149
+ }
150
+ return merged;
151
+ }
152
+
153
+ function firstString(left: string | null, right: string | null): string | null {
154
+ return left ?? right;
155
+ }
156
+
157
+ function mergeAlert(existing: Alert, observation: AlertObservation): Alert {
158
+ const logIds =
159
+ observation.logId === null ? existing.logIds : [...existing.logIds, observation.logId];
160
+ const sessionIds =
161
+ observation.sessionId === null
162
+ ? existing.sessionIds
163
+ : [...existing.sessionIds, observation.sessionId];
164
+ const runIds =
165
+ observation.runId === null ? existing.runIds : [...existing.runIds, observation.runId];
166
+ const groupIds =
167
+ observation.groupId === null ? existing.groupIds : [...existing.groupIds, observation.groupId];
168
+
169
+ return {
170
+ ...existing,
171
+ count: existing.count + 1,
172
+ firstSeenAt:
173
+ observation.observedAt < existing.firstSeenAt ? observation.observedAt : existing.firstSeenAt,
174
+ lastSeenAt:
175
+ observation.observedAt > existing.lastSeenAt ? observation.observedAt : existing.lastSeenAt,
176
+ logIds: uniqueNumbers(logIds),
177
+ sessionIds: uniqueStrings(sessionIds),
178
+ runIds: uniqueStrings(runIds),
179
+ groupIds: uniqueStrings(groupIds),
180
+ provider: firstString(existing.provider, observation.provider),
181
+ model: firstString(existing.model, observation.model),
182
+ apiFormat: existing.apiFormat ?? observation.apiFormat,
183
+ evidence: mergeEvidence(existing.evidence, observation.evidence),
184
+ metadata: { ...existing.metadata, ...observation.metadata },
185
+ };
186
+ }
187
+
188
+ function observationToAlert(observation: AlertObservation): Alert {
189
+ return {
190
+ id: alertIdFor(observation.fingerprint),
191
+ fingerprint: observation.fingerprint,
192
+ severity: observation.severity,
193
+ category: observation.category,
194
+ source: observation.source,
195
+ status: "open",
196
+ title: observation.title,
197
+ message: observation.message,
198
+ count: 1,
199
+ firstSeenAt: observation.observedAt,
200
+ lastSeenAt: observation.observedAt,
201
+ logIds: observation.logId === null ? [] : [observation.logId],
202
+ sessionIds: observation.sessionId === null ? [] : [observation.sessionId],
203
+ runIds: observation.runId === null ? [] : [observation.runId],
204
+ groupIds: observation.groupId === null ? [] : [observation.groupId],
205
+ provider: observation.provider,
206
+ model: observation.model,
207
+ apiFormat: observation.apiFormat,
208
+ evidence: observation.evidence,
209
+ metadata: observation.metadata,
210
+ };
211
+ }
212
+
213
+ function addObservation(map: Map<string, Alert>, observation: AlertObservation): void {
214
+ const existing = map.get(observation.fingerprint);
215
+ if (existing === undefined) {
216
+ map.set(observation.fingerprint, observationToAlert(observation));
217
+ return;
218
+ }
219
+ map.set(observation.fingerprint, mergeAlert(existing, observation));
220
+ }
221
+
222
+ function lowerText(value: string | null | undefined): string {
223
+ if (value === null || value === undefined) return "";
224
+ return value.toLowerCase();
225
+ }
226
+
227
+ function isTimeoutStatus(status: number | null): boolean {
228
+ return status === 408 || status === 504;
229
+ }
230
+
231
+ function isTimeoutLog(log: CapturedLog): boolean {
232
+ const text = `${lowerText(log.error)} ${lowerText(log.responseText)}`;
233
+ return (
234
+ isTimeoutStatus(log.responseStatus) ||
235
+ text.includes("timeout") ||
236
+ text.includes("timed out") ||
237
+ text.includes("abort")
238
+ );
239
+ }
240
+
241
+ function hasFailureStatus(status: number | null): boolean {
242
+ return status !== null && status >= 400;
243
+ }
244
+
245
+ function isFailedLog(log: CapturedLog): boolean {
246
+ return log.error !== null && log.error !== undefined && log.error.length > 0
247
+ ? true
248
+ : hasFailureStatus(log.responseStatus);
249
+ }
250
+
251
+ function statusLabel(status: number | null): string {
252
+ return status === null ? "no status" : `HTTP ${String(status)}`;
253
+ }
254
+
255
+ function errorDetail(log: CapturedLog): string {
256
+ if (log.error !== null && log.error !== undefined && log.error.length > 0) return log.error;
257
+ return statusLabel(log.responseStatus);
258
+ }
259
+
260
+ function requestFailureTitle(log: CapturedLog, timeout: boolean): string {
261
+ if (log.isTest === true && timeout) return "Provider test timed out";
262
+ if (log.isTest === true) return "Provider test failed";
263
+ return timeout ? "Request timed out" : "Request failed";
264
+ }
265
+
266
+ function requestFailureCategory(timeout: boolean): AlertCategory {
267
+ return timeout ? "request-timeout" : "request-failure";
268
+ }
269
+
270
+ function logProvider(log: CapturedLog): string | null {
271
+ return log.providerName ?? null;
272
+ }
273
+
274
+ function requestFailureObservation(log: CapturedLog): AlertObservation | null {
275
+ if (!isFailedLog(log)) return null;
276
+
277
+ const timeout = isTimeoutLog(log);
278
+ const category = requestFailureCategory(timeout);
279
+ const message = `${errorDetail(log)} while calling ${log.path}.`;
280
+ return {
281
+ fingerprint: fingerprint([
282
+ "proxy",
283
+ category,
284
+ logProvider(log),
285
+ log.model,
286
+ log.responseStatus,
287
+ errorDetail(log),
288
+ ]),
289
+ severity: "critical",
290
+ category,
291
+ source: "proxy",
292
+ title: requestFailureTitle(log, timeout),
293
+ message,
294
+ observedAt: log.timestamp,
295
+ logId: log.id,
296
+ sessionId: log.sessionId,
297
+ runId: null,
298
+ groupId: null,
299
+ provider: logProvider(log),
300
+ model: log.model,
301
+ apiFormat: log.apiFormat,
302
+ evidence: baseEvidence(log.id, log.sessionId, null, null),
303
+ metadata: {
304
+ path: log.path,
305
+ status: log.responseStatus,
306
+ error: log.error ?? null,
307
+ isTest: log.isTest,
308
+ streaming: log.streaming,
309
+ },
310
+ };
311
+ }
312
+
313
+ function slowResponseObservation(
314
+ log: CapturedLog,
315
+ slowResponseThresholdSeconds: number,
316
+ ): AlertObservation | null {
317
+ if (slowResponseThresholdSeconds <= 0) return null;
318
+ if (isFailedLog(log)) return null;
319
+ if (log.elapsedMs === null) return null;
320
+
321
+ const thresholdMs = slowResponseThresholdSeconds * 1000;
322
+ if (log.elapsedMs < thresholdMs) return null;
323
+
324
+ const elapsedSeconds = (log.elapsedMs / 1000).toFixed(1);
325
+ return {
326
+ fingerprint: fingerprint(["proxy", "slow-response", logProvider(log), log.model, log.path]),
327
+ severity: "warning",
328
+ category: "slow-response",
329
+ source: "proxy",
330
+ title: "Slow model response",
331
+ message: `Response took ${elapsedSeconds}s, above the ${String(
332
+ slowResponseThresholdSeconds,
333
+ )}s threshold.`,
334
+ observedAt: log.timestamp,
335
+ logId: log.id,
336
+ sessionId: log.sessionId,
337
+ runId: null,
338
+ groupId: null,
339
+ provider: logProvider(log),
340
+ model: log.model,
341
+ apiFormat: log.apiFormat,
342
+ evidence: baseEvidence(log.id, log.sessionId, null, null),
343
+ metadata: {
344
+ path: log.path,
345
+ elapsedMs: log.elapsedMs,
346
+ thresholdMs,
347
+ streaming: log.streaming,
348
+ firstChunkMs: log.firstChunkMs ?? null,
349
+ totalStreamMs: log.totalStreamMs ?? null,
350
+ },
351
+ };
352
+ }
353
+
354
+ function warningObservation(log: CapturedLog, warning: string): AlertObservation {
355
+ return {
356
+ fingerprint: fingerprint(["tool-schema", logProvider(log), log.model, warning]),
357
+ severity: "warning",
358
+ category: "tool-schema",
359
+ source: "tool-schema",
360
+ title: "Tool calling argument warning",
361
+ message: warning,
362
+ observedAt: log.timestamp,
363
+ logId: log.id,
364
+ sessionId: log.sessionId,
365
+ runId: null,
366
+ groupId: null,
367
+ provider: logProvider(log),
368
+ model: log.model,
369
+ apiFormat: log.apiFormat,
370
+ evidence: baseEvidence(log.id, log.sessionId, null, null),
371
+ metadata: {
372
+ path: log.path,
373
+ warning,
374
+ },
375
+ };
376
+ }
377
+
378
+ function addLogObservations(
379
+ map: Map<string, Alert>,
380
+ logs: readonly CapturedLog[],
381
+ slowResponseThresholdSeconds: number,
382
+ ): void {
383
+ for (const log of logs) {
384
+ const failure = requestFailureObservation(log);
385
+ if (failure !== null) addObservation(map, failure);
386
+
387
+ const slow = slowResponseObservation(log, slowResponseThresholdSeconds);
388
+ if (slow !== null) addObservation(map, slow);
389
+
390
+ for (const warning of log.warnings ?? []) {
391
+ addObservation(map, warningObservation(log, warning));
392
+ }
393
+ }
394
+ }
395
+
396
+ function alertSeverityFromFailure(failure: RecentFailure): AlertSeverity {
397
+ switch (failure.classification.severity) {
398
+ case "high":
399
+ return "critical";
400
+ case "medium":
401
+ case "low":
402
+ return "warning";
403
+ case "none":
404
+ return "notice";
405
+ }
406
+ }
407
+
408
+ function providerFromRun(failure: RecentFailure): string | null {
409
+ const provider = failure.run.metadata["provider"] ?? failure.run.metadata["providerName"];
410
+ return typeof provider === "string" && provider.length > 0 ? provider : null;
411
+ }
412
+
413
+ function modelFromRun(failure: RecentFailure): string | null {
414
+ const model = failure.run.metadata["model"] ?? failure.run.metadata["modelName"];
415
+ return typeof model === "string" && model.length > 0 ? model : null;
416
+ }
417
+
418
+ function runFailureObservation(failure: RecentFailure): AlertObservation {
419
+ const groupId = failure.run.groupId;
420
+ const logEvidenceLinks = failure.classification.evidenceLogIds.map(logEvidence);
421
+ const links = mergeEvidence(
422
+ baseEvidence(null, failure.sessionId, failure.run.id, groupId),
423
+ logEvidenceLinks,
424
+ );
425
+
426
+ return {
427
+ fingerprint: fingerprint(["run", failure.run.id, failure.classification.category]),
428
+ severity: alertSeverityFromFailure(failure),
429
+ category: "run-failure",
430
+ source: "run",
431
+ title: `Run ${failure.run.status}`,
432
+ message: failure.classification.summary,
433
+ observedAt: failure.updatedAt,
434
+ logId: failure.classification.evidenceLogIds[0] ?? null,
435
+ sessionId: failure.sessionId,
436
+ runId: failure.run.id,
437
+ groupId,
438
+ provider: providerFromRun(failure),
439
+ model: modelFromRun(failure),
440
+ apiFormat: null,
441
+ evidence: links,
442
+ metadata: {
443
+ runStatus: failure.run.status,
444
+ failureCategory: failure.classification.category,
445
+ evidenceMarkdownPath: failure.evidenceMarkdownPath,
446
+ },
447
+ };
448
+ }
449
+
450
+ function addRunFailureObservations(
451
+ map: Map<string, Alert>,
452
+ recentFailures: readonly RecentFailure[],
453
+ ): void {
454
+ for (const failure of recentFailures) {
455
+ addObservation(map, runFailureObservation(failure));
456
+ }
457
+ }
458
+
459
+ function memberProvider(member: InspectorGroupMember): string | null {
460
+ return member.provider !== null && member.provider.length > 0 ? member.provider : null;
461
+ }
462
+
463
+ function memberModel(member: InspectorGroupMember): string | null {
464
+ return member.model !== null && member.model.length > 0 ? member.model : null;
465
+ }
466
+
467
+ function isFailedMember(member: InspectorGroupMember): boolean {
468
+ return member.status === "failed" || member.status === "cancelled";
469
+ }
470
+
471
+ function groupFailureObservation(group: InspectorGroup): AlertObservation | null {
472
+ if (group.status !== "failed" && group.status !== "cancelled") return null;
473
+ return {
474
+ fingerprint: fingerprint(["group", group.id, group.status]),
475
+ severity: "critical",
476
+ category: "group-failure",
477
+ source: "group",
478
+ title: `Group ${group.status}`,
479
+ message: `${group.title} is marked ${group.status}.`,
480
+ observedAt: group.updatedAt,
481
+ logId: null,
482
+ sessionId: null,
483
+ runId: null,
484
+ groupId: group.id,
485
+ provider: null,
486
+ model: null,
487
+ apiFormat: null,
488
+ evidence: baseEvidence(null, null, null, group.id),
489
+ metadata: {
490
+ groupStatus: group.status,
491
+ memberCount: group.members.length,
492
+ },
493
+ };
494
+ }
495
+
496
+ function groupMemberFailureObservation(
497
+ group: InspectorGroup,
498
+ member: InspectorGroupMember,
499
+ ): AlertObservation {
500
+ return {
501
+ fingerprint: fingerprint(["group-member", group.id, member.id, member.status]),
502
+ severity: "critical",
503
+ category: "group-failure",
504
+ source: "group",
505
+ title: "Group member failed",
506
+ message: `${member.label ?? member.sessionId} is marked ${member.status ?? "unknown"}.`,
507
+ observedAt: member.updatedAt,
508
+ logId: null,
509
+ sessionId: member.sessionId,
510
+ runId: member.runId,
511
+ groupId: group.id,
512
+ provider: memberProvider(member),
513
+ model: memberModel(member),
514
+ apiFormat: null,
515
+ evidence: baseEvidence(null, member.sessionId, member.runId, group.id),
516
+ metadata: {
517
+ groupTitle: group.title,
518
+ memberId: member.id,
519
+ memberStatus: member.status,
520
+ },
521
+ };
522
+ }
523
+
524
+ function groupIncompleteObservation(
525
+ group: InspectorGroup,
526
+ missingStatusCount: number,
527
+ ): AlertObservation {
528
+ return {
529
+ fingerprint: fingerprint(["group-incomplete", group.id, missingStatusCount]),
530
+ severity: "notice",
531
+ category: "group-incomplete",
532
+ source: "group",
533
+ title: "Group has unclassified members",
534
+ message: `${group.title} has ${String(missingStatusCount)} member(s) without a final status.`,
535
+ observedAt: group.updatedAt,
536
+ logId: null,
537
+ sessionId: null,
538
+ runId: null,
539
+ groupId: group.id,
540
+ provider: null,
541
+ model: null,
542
+ apiFormat: null,
543
+ evidence: baseEvidence(null, null, null, group.id),
544
+ metadata: {
545
+ groupTitle: group.title,
546
+ missingStatusCount,
547
+ },
548
+ };
549
+ }
550
+
551
+ function addGroupObservations(map: Map<string, Alert>, groups: readonly InspectorGroup[]): void {
552
+ for (const group of groups) {
553
+ const groupFailure = groupFailureObservation(group);
554
+ if (groupFailure !== null) addObservation(map, groupFailure);
555
+
556
+ let missingStatusCount = 0;
557
+ for (const member of group.members) {
558
+ if (isFailedMember(member)) {
559
+ addObservation(map, groupMemberFailureObservation(group, member));
560
+ }
561
+ if (member.status === null) missingStatusCount += 1;
562
+ }
563
+
564
+ if (missingStatusCount > 0) {
565
+ addObservation(map, groupIncompleteObservation(group, missingStatusCount));
566
+ }
567
+ }
568
+ }
569
+
570
+ function alertMatchesFilters(alert: Alert, filters: AlertFilters): boolean {
571
+ if (filters.severity !== undefined && alert.severity !== filters.severity) return false;
572
+ if (filters.category !== undefined && alert.category !== filters.category) return false;
573
+ if (filters.source !== undefined && alert.source !== filters.source) return false;
574
+ return true;
575
+ }
576
+
577
+ function sortAlerts(alerts: readonly Alert[]): Alert[] {
578
+ return [...alerts].sort((left, right) => {
579
+ const timeCompare = right.lastSeenAt.localeCompare(left.lastSeenAt);
580
+ if (timeCompare !== 0) return timeCompare;
581
+ return left.id.localeCompare(right.id);
582
+ });
583
+ }
584
+
585
+ export function buildAlertSummary(alerts: readonly Alert[]): AlertSummary {
586
+ let critical = 0;
587
+ let warning = 0;
588
+ let notice = 0;
589
+ const byCategory = new Map<AlertCategory, number>();
590
+
591
+ for (const alert of alerts) {
592
+ switch (alert.severity) {
593
+ case "critical":
594
+ critical += 1;
595
+ break;
596
+ case "warning":
597
+ warning += 1;
598
+ break;
599
+ case "notice":
600
+ notice += 1;
601
+ break;
602
+ }
603
+ byCategory.set(alert.category, (byCategory.get(alert.category) ?? 0) + 1);
604
+ }
605
+
606
+ return {
607
+ total: alerts.length,
608
+ open: alerts.length,
609
+ critical,
610
+ warning,
611
+ notice,
612
+ byCategory: [...byCategory.entries()]
613
+ .map(([category, count]) => ({ category, count }))
614
+ .sort(
615
+ (left, right) => right.count - left.count || left.category.localeCompare(right.category),
616
+ ),
617
+ };
618
+ }
619
+
620
+ export function collectAlertsFromEvidence(input: AlertCollectionInput): Alert[] {
621
+ const map = new Map<string, Alert>();
622
+ addLogObservations(map, input.logs, input.slowResponseThresholdSeconds);
623
+ addRunFailureObservations(map, input.recentFailures);
624
+ addGroupObservations(map, input.groups);
625
+ return sortAlerts([...map.values()]);
626
+ }
627
+
628
+ function filterAlerts(alerts: readonly Alert[], filters: AlertFilters): Alert[] {
629
+ return alerts.filter((alert) => alertMatchesFilters(alert, filters));
630
+ }
631
+
632
+ export async function listAlerts(options: ListAlertsOptions = {}): Promise<AlertListResponse> {
633
+ const limit = clampAlertLimit(options.limit);
634
+ const scanLimit = clampAlertScanLimit(options.scanLimit);
635
+ const logPage = await listLogsCursorPage({
636
+ limit: scanLimit,
637
+ includeBodies: false,
638
+ anchor: "newest",
639
+ });
640
+ const config = getConfig();
641
+ const alerts = collectAlertsFromEvidence({
642
+ logs: logPage.logs,
643
+ recentFailures: listRecentFailures(scanLimit).failures,
644
+ groups: listGroups(),
645
+ slowResponseThresholdSeconds: config.slowResponseThresholdSeconds,
646
+ });
647
+ const filtered = filterAlerts(alerts, options);
648
+ return {
649
+ alerts: filtered.slice(0, limit),
650
+ total: filtered.length,
651
+ limit,
652
+ summary: buildAlertSummary(filtered),
653
+ };
654
+ }
655
+
656
+ export async function getAlertSummary(
657
+ options: Pick<ListAlertsOptions, "scanLimit"> = {},
658
+ ): Promise<AlertSummary> {
659
+ const response = await listAlerts({
660
+ limit: MAX_ALERT_LIMIT,
661
+ scanLimit: options.scanLimit,
662
+ });
663
+ return response.summary;
664
+ }