@tonyclaw/agent-inspector 2.0.29 → 2.0.30

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 (36) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-DcqxlgSQ.js → CompareDrawer-BDzfRSt1.js} +1 -1
  3. package/.output/public/assets/{ProxyViewerContainer-D650kQlv.js → ProxyViewerContainer-NtOD8Ex_.js} +4 -4
  4. package/.output/public/assets/{ReplayDialog-CBcnPgx1.js → ReplayDialog-p-5TKg75.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-DSgSqCYt.js → RequestAnatomy-G0dV8-D_.js} +1 -1
  6. package/.output/public/assets/{ResponseView-CjqzBSoF.js → ResponseView-D2wE6lD-.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-DdBBKolI.js → StreamingChunkSequence-D8JRK-j_.js} +1 -1
  8. package/.output/public/assets/_sessionId-zimzVrJE.js +1 -0
  9. package/.output/public/assets/index-Cv5vPgpr.js +1 -0
  10. package/.output/public/assets/{main-B6OLZCp9.js → main-XHHZsOBK.js} +2 -2
  11. package/.output/server/{_sessionId-CCfKJJ19.mjs → _sessionId-CK9QEhkB.mjs} +3 -3
  12. package/.output/server/_ssr/{CompareDrawer-uaPI5R6e.mjs → CompareDrawer-BJVPFMIx.mjs} +2 -2
  13. package/.output/server/_ssr/{ProxyViewerContainer-BV2nIxXy.mjs → ProxyViewerContainer-DhbmJ2zj.mjs} +7 -7
  14. package/.output/server/_ssr/{ReplayDialog-eE2Hy2Nl.mjs → ReplayDialog-DXRj95Gj.mjs} +3 -3
  15. package/.output/server/_ssr/{RequestAnatomy-B7vwWo57.mjs → RequestAnatomy-CrYJTXdJ.mjs} +2 -2
  16. package/.output/server/_ssr/{ResponseView-Bm6Qp8mo.mjs → ResponseView-4ESqSITr.mjs} +2 -2
  17. package/.output/server/_ssr/{StreamingChunkSequence-CbAJKJQx.mjs → StreamingChunkSequence-Czdko5n_.mjs} +2 -2
  18. package/.output/server/_ssr/{index-u14_Aj60.mjs → index-bei0I7qL.mjs} +2 -2
  19. package/.output/server/_ssr/index.mjs +2 -2
  20. package/.output/server/_ssr/{router-qgeGUp1k.mjs → router-sQh4Gk8J.mjs} +1189 -131
  21. package/.output/server/_tanstack-start-manifest_v-C0fdSWnA.mjs +4 -0
  22. package/.output/server/index.mjs +62 -62
  23. package/README.md +24 -1
  24. package/package.json +1 -1
  25. package/src/lib/groupContract.ts +139 -0
  26. package/src/mcp/server.ts +278 -0
  27. package/src/mcp/toolHandlers.ts +123 -0
  28. package/src/proxy/groupEvidenceExporter.ts +341 -0
  29. package/src/proxy/groupStore.ts +259 -0
  30. package/src/routes/api/groups.$groupId.evidence.ts +62 -0
  31. package/src/routes/api/groups.$groupId.sessions.ts +50 -0
  32. package/src/routes/api/groups.$groupId.ts +50 -0
  33. package/src/routes/api/groups.ts +39 -0
  34. package/.output/public/assets/_sessionId-wctOoGh_.js +0 -1
  35. package/.output/public/assets/index-BhQGSdhG.js +0 -1
  36. package/.output/server/_tanstack-start-manifest_v-BrfUXnre.mjs +0 -4
@@ -0,0 +1,341 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ type GroupEvidenceExportOptions,
5
+ type GroupEvidenceExportResult,
6
+ type InspectorGroup,
7
+ type InspectorGroupEvidence,
8
+ type InspectorGroupEvidenceSummary,
9
+ type InspectorGroupExportMember,
10
+ } from "../lib/groupContract";
11
+ import type { JenkinsReport } from "../lib/runContract";
12
+ import type { SessionInfo, SessionTokenUsage } from "../lib/sessionInfoContract";
13
+ import { getDataDir } from "./dataDir";
14
+ import { getRun } from "./runStore";
15
+ import { getSessionInfo } from "./store";
16
+ import { updateGroupEvidence } from "./groupStore";
17
+
18
+ type GroupEvidenceDocument = GroupEvidenceExportResult;
19
+
20
+ function groupEvidenceDir(groupId: string): string {
21
+ return join(getDataDir(), "evidence", "groups", groupId);
22
+ }
23
+
24
+ function jsonPath(groupId: string): string {
25
+ return join(groupEvidenceDir(groupId), "evidence.json");
26
+ }
27
+
28
+ function markdownPath(groupId: string): string {
29
+ return join(groupEvidenceDir(groupId), "evidence.md");
30
+ }
31
+
32
+ function htmlPath(groupId: string): string {
33
+ return join(groupEvidenceDir(groupId), "evidence.html");
34
+ }
35
+
36
+ function escapeMarkdownCell(value: string): string {
37
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
38
+ }
39
+
40
+ function escapeHtml(value: string): string {
41
+ return value
42
+ .replace(/&/g, "&")
43
+ .replace(/</g, "&lt;")
44
+ .replace(/>/g, "&gt;")
45
+ .replace(/"/g, "&quot;");
46
+ }
47
+
48
+ function formatText(value: string | null | undefined): string {
49
+ if (value === null || value === undefined || value.length === 0) return "n/a";
50
+ return value;
51
+ }
52
+
53
+ function formatNumber(value: number | null | undefined): string {
54
+ if (value === null || value === undefined) return "n/a";
55
+ return String(value);
56
+ }
57
+
58
+ function collectSortedValues(values: readonly (string | null | undefined)[]): string[] {
59
+ const set = new Set<string>();
60
+ for (const value of values) {
61
+ if (value !== null && value !== undefined && value.length > 0) {
62
+ set.add(value);
63
+ }
64
+ }
65
+ return [...set].sort();
66
+ }
67
+
68
+ function addTokenUsage(total: SessionTokenUsage, session: SessionInfo | null): SessionTokenUsage {
69
+ if (session === null) return total;
70
+ return {
71
+ input: total.input + session.tokenUsage.input,
72
+ output: total.output + session.tokenUsage.output,
73
+ cacheCreate: total.cacheCreate + session.tokenUsage.cacheCreate,
74
+ cacheRead: total.cacheRead + session.tokenUsage.cacheRead,
75
+ total: total.total + session.tokenUsage.total,
76
+ };
77
+ }
78
+
79
+ function memberEffectiveStatus(member: InspectorGroupExportMember): string {
80
+ if (member.member.status !== null) return member.member.status;
81
+ if (member.run !== null) return member.run.status;
82
+ return member.session?.status ?? "unknown";
83
+ }
84
+
85
+ function isCompletedMember(member: InspectorGroupExportMember): boolean {
86
+ const status = memberEffectiveStatus(member);
87
+ return status === "completed";
88
+ }
89
+
90
+ function isFailedMember(member: InspectorGroupExportMember): boolean {
91
+ const status = memberEffectiveStatus(member);
92
+ return status === "failed" || status === "cancelled";
93
+ }
94
+
95
+ function isRunningMember(member: InspectorGroupExportMember): boolean {
96
+ const status = memberEffectiveStatus(member);
97
+ return status === "running" || status === "active";
98
+ }
99
+
100
+ function latestLogMetric(
101
+ session: SessionInfo | null,
102
+ field: "firstChunkMs" | "totalStreamMs" | "tokensPerSecond",
103
+ ): number | null {
104
+ if (session === null) return null;
105
+ for (const log of session.latestLogs) {
106
+ const value = log[field];
107
+ if (value !== undefined && value !== null) return value;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function buildSummary(
113
+ members: readonly InspectorGroupExportMember[],
114
+ ): InspectorGroupEvidenceSummary {
115
+ const tokenUsage = members.reduce<SessionTokenUsage>(
116
+ (total, member) => addTokenUsage(total, member.session),
117
+ { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 },
118
+ );
119
+ const sessionsWithData = members.filter((member) => member.session !== null).length;
120
+ const requestCount = members.reduce(
121
+ (total, member) => total + (member.session?.requestCount ?? 0),
122
+ 0,
123
+ );
124
+ const errorCount = members.reduce(
125
+ (total, member) => total + (member.session?.errorCount ?? 0),
126
+ 0,
127
+ );
128
+
129
+ return {
130
+ memberCount: members.length,
131
+ sessionsWithData,
132
+ missingSessions: members.length - sessionsWithData,
133
+ requestCount,
134
+ errorCount,
135
+ tokenUsage,
136
+ models: collectSortedValues(
137
+ members.flatMap((member) => [member.member.model, ...(member.session?.models ?? [])]),
138
+ ),
139
+ providers: collectSortedValues(
140
+ members.flatMap((member) => [member.member.provider, ...(member.session?.providers ?? [])]),
141
+ ),
142
+ completedMembers: members.filter(isCompletedMember).length,
143
+ failedMembers: members.filter(isFailedMember).length,
144
+ runningMembers: members.filter(isRunningMember).length,
145
+ };
146
+ }
147
+
148
+ function buildJenkinsReport(
149
+ group: InspectorGroup,
150
+ summary: InspectorGroupEvidenceSummary,
151
+ ): JenkinsReport {
152
+ if (summary.failedMembers > 0 || summary.errorCount > 0 || group.status === "failed") {
153
+ return {
154
+ status: "FAIL",
155
+ summary: `[FAIL] ${summary.failedMembers} failed members, ${summary.errorCount} captured errors.`,
156
+ markdown: `# Jenkins Report: ${group.title}\n\nStatus: FAIL\n\n${summary.failedMembers} failed members and ${summary.errorCount} captured errors need triage.`,
157
+ };
158
+ }
159
+
160
+ if (summary.runningMembers > 0 || group.status === "running") {
161
+ return {
162
+ status: "RUNNING",
163
+ summary: `[RUNNING] ${summary.runningMembers} group members are still active.`,
164
+ markdown: `# Jenkins Report: ${group.title}\n\nStatus: RUNNING\n\n${summary.runningMembers} group members are still active.`,
165
+ };
166
+ }
167
+
168
+ if (
169
+ summary.memberCount > 0 &&
170
+ summary.sessionsWithData > 0 &&
171
+ summary.errorCount === 0 &&
172
+ group.status === "completed"
173
+ ) {
174
+ return {
175
+ status: "PASS",
176
+ summary: `[PASS] ${summary.memberCount} group members completed with no captured errors.`,
177
+ markdown: `# Jenkins Report: ${group.title}\n\nStatus: PASS\n\n${summary.memberCount} group members completed with no captured errors.`,
178
+ };
179
+ }
180
+
181
+ return {
182
+ status: "UNKNOWN",
183
+ summary: `[UNKNOWN] Group has ${summary.memberCount} members and ${summary.missingSessions} missing sessions.`,
184
+ markdown: `# Jenkins Report: ${group.title}\n\nStatus: UNKNOWN\n\nExported group evidence, but the outcome is not conclusive yet.`,
185
+ };
186
+ }
187
+
188
+ function buildMemberRows(members: readonly InspectorGroupExportMember[]): string[] {
189
+ if (members.length === 0) return ["No sessions have been attached to this group.", ""];
190
+
191
+ const rows = [
192
+ "| Label | Provider | Model | Session | Run | Status | Requests | Errors | Tokens | First Chunk | Stream Total | TPS | Inspector |",
193
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
194
+ ];
195
+
196
+ for (const member of members) {
197
+ const session = member.session;
198
+ rows.push(
199
+ [
200
+ formatText(member.member.label),
201
+ formatText(member.member.provider ?? session?.providers[0]),
202
+ formatText(member.member.model ?? session?.lastModel),
203
+ member.member.sessionId,
204
+ formatText(member.member.runId),
205
+ memberEffectiveStatus(member),
206
+ formatNumber(session?.requestCount),
207
+ formatNumber(session?.errorCount),
208
+ formatNumber(session?.tokenUsage.total),
209
+ formatNumber(latestLogMetric(session, "firstChunkMs")),
210
+ formatNumber(latestLogMetric(session, "totalStreamMs")),
211
+ formatNumber(latestLogMetric(session, "tokensPerSecond")),
212
+ formatText(session?.inspectorUrl),
213
+ ]
214
+ .map(escapeMarkdownCell)
215
+ .join(" | ")
216
+ .replace(/^/, "| ")
217
+ .replace(/$/, " |"),
218
+ );
219
+ }
220
+
221
+ rows.push("");
222
+ return rows;
223
+ }
224
+
225
+ function buildMarkdown(document: GroupEvidenceDocument): string {
226
+ const group = document.group;
227
+ const summary = document.summary;
228
+ const lines = [
229
+ `# ${group.title}`,
230
+ "",
231
+ `- Group ID: ${group.id}`,
232
+ `- Kind: ${group.kind}`,
233
+ `- Status: ${group.status}`,
234
+ `- Project: ${group.project ?? "n/a"}`,
235
+ `- Created: ${group.createdAt}`,
236
+ `- Members: ${summary.memberCount}`,
237
+ `- Sessions With Data: ${summary.sessionsWithData}`,
238
+ `- Missing Sessions: ${summary.missingSessions}`,
239
+ `- Requests: ${summary.requestCount}`,
240
+ `- Errors: ${summary.errorCount}`,
241
+ `- Tokens: ${summary.tokenUsage.total}`,
242
+ `- Models: ${summary.models.length > 0 ? summary.models.join(", ") : "n/a"}`,
243
+ `- Providers: ${summary.providers.length > 0 ? summary.providers.join(", ") : "n/a"}`,
244
+ `- Evidence JSON: ${document.evidence.jsonPath}`,
245
+ `- Evidence HTML: ${document.evidence.htmlPath}`,
246
+ "",
247
+ ];
248
+
249
+ if (group.task !== null) {
250
+ lines.push("## Task", "", group.task, "");
251
+ }
252
+
253
+ lines.push("## Evaluation Matrix", "", ...buildMemberRows(document.members));
254
+ lines.push("## Jenkins Report", "", document.jenkinsReport.markdown, "");
255
+
256
+ return `${lines.join("\n")}\n`;
257
+ }
258
+
259
+ function buildHtml(markdown: string, title: string): string {
260
+ return `<!doctype html>
261
+ <html lang="en">
262
+ <head>
263
+ <meta charset="utf-8">
264
+ <title>${escapeHtml(title)}</title>
265
+ <style>
266
+ body { font-family: system-ui, sans-serif; max-width: 1120px; margin: 40px auto; line-height: 1.5; }
267
+ pre { white-space: pre-wrap; background: #f6f8fa; padding: 16px; border-radius: 8px; }
268
+ </style>
269
+ </head>
270
+ <body>
271
+ <pre>${escapeHtml(markdown)}</pre>
272
+ </body>
273
+ </html>
274
+ `;
275
+ }
276
+
277
+ async function buildExportMembers(
278
+ group: InspectorGroup,
279
+ options: GroupEvidenceExportOptions,
280
+ baseUrl: string,
281
+ ): Promise<InspectorGroupExportMember[]> {
282
+ const members: InspectorGroupExportMember[] = [];
283
+ for (const member of group.members) {
284
+ const run = member.runId === null ? null : getRun(member.runId);
285
+ const session = await getSessionInfo(member.sessionId, {
286
+ baseUrl,
287
+ includeHistory: options?.includeHistory,
288
+ latestLogLimit: options?.latestLogLimit,
289
+ });
290
+ members.push({ member, run, session });
291
+ }
292
+ return members;
293
+ }
294
+
295
+ export async function exportGroupEvidence(
296
+ group: InspectorGroup,
297
+ options: GroupEvidenceExportOptions,
298
+ baseUrl: string,
299
+ ): Promise<GroupEvidenceExportResult> {
300
+ const members = await buildExportMembers(group, options, baseUrl);
301
+ const exportedAt = new Date().toISOString();
302
+ const evidence: InspectorGroupEvidence = {
303
+ jsonPath: jsonPath(group.id),
304
+ markdownPath: markdownPath(group.id),
305
+ htmlPath: htmlPath(group.id),
306
+ exportedAt,
307
+ };
308
+ const documentGroup: InspectorGroup = {
309
+ ...group,
310
+ evidence,
311
+ updatedAt: exportedAt,
312
+ };
313
+ const summary = buildSummary(members);
314
+ const jenkinsReport = buildJenkinsReport(documentGroup, summary);
315
+ const document: GroupEvidenceDocument = {
316
+ group: documentGroup,
317
+ evidence,
318
+ members,
319
+ summary,
320
+ jenkinsReport,
321
+ };
322
+ const markdown = buildMarkdown(document);
323
+
324
+ mkdirSync(groupEvidenceDir(group.id), { recursive: true });
325
+ writeFileSync(evidence.jsonPath, `${JSON.stringify(document, null, 2)}\n`, "utf8");
326
+ writeFileSync(evidence.markdownPath, markdown, "utf8");
327
+ writeFileSync(evidence.htmlPath, buildHtml(markdown, group.title), "utf8");
328
+
329
+ const updatedGroup = updateGroupEvidence(group.id, evidence) ?? documentGroup;
330
+ return { ...document, group: updatedGroup };
331
+ }
332
+
333
+ export function readGroupEvidenceMarkdown(group: InspectorGroup): string | null {
334
+ const path = group.evidence?.markdownPath;
335
+ if (path === undefined || !existsSync(path)) return null;
336
+ try {
337
+ return readFileSync(path, "utf8");
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
@@ -0,0 +1,259 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ import {
6
+ AddInspectorGroupSessionInputSchema,
7
+ CreateInspectorGroupInputSchema,
8
+ InspectorGroupSchema,
9
+ UpdateInspectorGroupInputSchema,
10
+ type AddInspectorGroupSessionInput,
11
+ type CreateInspectorGroupInput,
12
+ type InspectorGroup,
13
+ type InspectorGroupEvidence,
14
+ type InspectorGroupMember,
15
+ type UpdateInspectorGroupInput,
16
+ } from "../lib/groupContract";
17
+ import { getDataDir } from "./dataDir";
18
+
19
+ const GroupsFileSchema = z.object({
20
+ groups: z.array(InspectorGroupSchema),
21
+ });
22
+
23
+ function groupsFilePath(): string {
24
+ return join(getDataDir(), "groups.json");
25
+ }
26
+
27
+ function nowIso(): string {
28
+ return new Date().toISOString();
29
+ }
30
+
31
+ function stableId(input: string): string {
32
+ return input
33
+ .trim()
34
+ .replace(/[^A-Za-z0-9_.-]+/g, "-")
35
+ .replace(/^-+|-+$/g, "")
36
+ .slice(0, 80);
37
+ }
38
+
39
+ function createGroupId(): string {
40
+ return `grp_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
41
+ }
42
+
43
+ function createMemberId(): string {
44
+ return `mem_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
45
+ }
46
+
47
+ function normalizeGroupId(input: string | undefined): string {
48
+ if (input === undefined) return createGroupId();
49
+ const normalized = stableId(input);
50
+ return normalized.length > 0 ? normalized : createGroupId();
51
+ }
52
+
53
+ function normalizeMemberId(input: string | undefined, sessionId: string): string {
54
+ if (input === undefined) {
55
+ const normalizedSession = stableId(sessionId);
56
+ return normalizedSession.length > 0 ? `mem_${normalizedSession}` : createMemberId();
57
+ }
58
+ const normalized = stableId(input);
59
+ return normalized.length > 0 ? normalized : createMemberId();
60
+ }
61
+
62
+ function nullableText(value: string | null | undefined): string | null {
63
+ if (value === undefined || value === null) return null;
64
+ const trimmed = value.trim();
65
+ return trimmed.length > 0 ? trimmed : null;
66
+ }
67
+
68
+ function readGroups(): InspectorGroup[] {
69
+ const filePath = groupsFilePath();
70
+ if (!existsSync(filePath)) return [];
71
+
72
+ try {
73
+ const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
74
+ const result = GroupsFileSchema.safeParse(parsed);
75
+ return result.success ? result.data.groups : [];
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ function writeGroups(groups: readonly InspectorGroup[]): void {
82
+ const filePath = groupsFilePath();
83
+ mkdirSync(dirname(filePath), { recursive: true });
84
+ writeFileSync(filePath, `${JSON.stringify({ groups }, null, 2)}\n`, "utf8");
85
+ }
86
+
87
+ function sortedGroups(groups: readonly InspectorGroup[]): InspectorGroup[] {
88
+ return [...groups].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
89
+ }
90
+
91
+ export function listGroups(): InspectorGroup[] {
92
+ return sortedGroups(readGroups());
93
+ }
94
+
95
+ export function getGroup(groupId: string): InspectorGroup | null {
96
+ return readGroups().find((group) => group.id === groupId) ?? null;
97
+ }
98
+
99
+ export function createGroup(input: CreateInspectorGroupInput): InspectorGroup {
100
+ const parsed = CreateInspectorGroupInputSchema.parse(input);
101
+ const groupId = normalizeGroupId(parsed?.groupId);
102
+ const existing = getGroup(groupId);
103
+ if (existing !== null) return existing;
104
+
105
+ const timestamp = nowIso();
106
+ const group: InspectorGroup = {
107
+ id: groupId,
108
+ title: parsed?.title ?? `Inspector Group ${groupId}`,
109
+ kind: parsed?.kind ?? "evaluation",
110
+ task: parsed?.task ?? null,
111
+ project: parsed?.project ?? null,
112
+ status: parsed?.status ?? "created",
113
+ tags: parsed?.tags ?? [],
114
+ metadata: parsed?.metadata ?? {},
115
+ evidence: null,
116
+ members: [],
117
+ createdAt: timestamp,
118
+ updatedAt: timestamp,
119
+ };
120
+
121
+ writeGroups([...readGroups(), group]);
122
+ return group;
123
+ }
124
+
125
+ export function updateGroup(
126
+ groupId: string,
127
+ input: UpdateInspectorGroupInput,
128
+ ): InspectorGroup | null {
129
+ const parsed = UpdateInspectorGroupInputSchema.parse(input);
130
+ const groups = readGroups();
131
+ let updatedGroup: InspectorGroup | null = null;
132
+ const updatedAt = nowIso();
133
+
134
+ const updatedGroups = groups.map((group) => {
135
+ if (group.id !== groupId) return group;
136
+ updatedGroup = {
137
+ ...group,
138
+ title: parsed.title ?? group.title,
139
+ kind: parsed.kind ?? group.kind,
140
+ task: parsed.task === undefined ? group.task : parsed.task,
141
+ project: parsed.project === undefined ? group.project : parsed.project,
142
+ status: parsed.status ?? group.status,
143
+ tags: parsed.tags ?? group.tags,
144
+ metadata:
145
+ parsed.metadata === undefined ? group.metadata : { ...group.metadata, ...parsed.metadata },
146
+ updatedAt,
147
+ };
148
+ return updatedGroup;
149
+ });
150
+
151
+ if (updatedGroup === null) return null;
152
+ writeGroups(updatedGroups);
153
+ return updatedGroup;
154
+ }
155
+
156
+ function matchesExistingMember(
157
+ member: InspectorGroupMember,
158
+ memberId: string,
159
+ sessionId: string,
160
+ runId: string | null,
161
+ ): boolean {
162
+ if (member.id === memberId) return true;
163
+ if (member.sessionId !== sessionId) return false;
164
+ return member.runId === runId;
165
+ }
166
+
167
+ function upsertMember(
168
+ group: InspectorGroup,
169
+ input: AddInspectorGroupSessionInput,
170
+ updatedAt: string,
171
+ ): InspectorGroup {
172
+ const parsed = AddInspectorGroupSessionInputSchema.parse(input);
173
+ const sessionId = parsed.sessionId.trim();
174
+ const runId = nullableText(parsed.runId);
175
+ const memberId = normalizeMemberId(parsed.memberId, sessionId);
176
+ let matched = false;
177
+
178
+ const members = group.members.map((member) => {
179
+ if (!matchesExistingMember(member, memberId, sessionId, runId)) return member;
180
+ matched = true;
181
+ return {
182
+ ...member,
183
+ sessionId,
184
+ runId,
185
+ label: parsed.label === undefined ? member.label : nullableText(parsed.label),
186
+ agent: parsed.agent === undefined ? member.agent : nullableText(parsed.agent),
187
+ provider: parsed.provider === undefined ? member.provider : nullableText(parsed.provider),
188
+ model: parsed.model === undefined ? member.model : nullableText(parsed.model),
189
+ status: parsed.status === undefined ? member.status : parsed.status,
190
+ metadata:
191
+ parsed.metadata === undefined
192
+ ? member.metadata
193
+ : { ...member.metadata, ...parsed.metadata },
194
+ updatedAt,
195
+ };
196
+ });
197
+
198
+ if (!matched) {
199
+ members.push({
200
+ id: memberId,
201
+ groupId: group.id,
202
+ sessionId,
203
+ runId,
204
+ label: nullableText(parsed.label),
205
+ agent: nullableText(parsed.agent),
206
+ provider: nullableText(parsed.provider),
207
+ model: nullableText(parsed.model),
208
+ status: parsed.status ?? null,
209
+ metadata: parsed.metadata ?? {},
210
+ addedAt: updatedAt,
211
+ updatedAt,
212
+ });
213
+ }
214
+
215
+ return { ...group, members, updatedAt };
216
+ }
217
+
218
+ export function addGroupSession(
219
+ groupId: string,
220
+ input: AddInspectorGroupSessionInput,
221
+ ): InspectorGroup | null {
222
+ const groups = readGroups();
223
+ let updatedGroup: InspectorGroup | null = null;
224
+ const updatedAt = nowIso();
225
+
226
+ const updatedGroups = groups.map((group) => {
227
+ if (group.id !== groupId) return group;
228
+ updatedGroup = upsertMember(group, input, updatedAt);
229
+ return updatedGroup;
230
+ });
231
+
232
+ if (updatedGroup === null) return null;
233
+ writeGroups(updatedGroups);
234
+ return updatedGroup;
235
+ }
236
+
237
+ export function updateGroupEvidence(
238
+ groupId: string,
239
+ evidence: InspectorGroupEvidence,
240
+ ): InspectorGroup | null {
241
+ const groups = readGroups();
242
+ let updatedGroup: InspectorGroup | null = null;
243
+ const updatedGroups = groups.map((group) => {
244
+ if (group.id !== groupId) return group;
245
+ updatedGroup = {
246
+ ...group,
247
+ evidence,
248
+ updatedAt: evidence.exportedAt,
249
+ };
250
+ return updatedGroup;
251
+ });
252
+ if (updatedGroup === null) return null;
253
+ writeGroups(updatedGroups);
254
+ return updatedGroup;
255
+ }
256
+
257
+ export function _resetGroupsForTests(): void {
258
+ writeGroups([]);
259
+ }
@@ -0,0 +1,62 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { GroupEvidenceExportOptionsSchema } from "../../lib/groupContract";
3
+ import { exportGroupEvidence, readGroupEvidenceMarkdown } from "../../proxy/groupEvidenceExporter";
4
+ import { getGroup } from "../../proxy/groupStore";
5
+
6
+ type JsonBodyResult = { ok: true; value: unknown } | { ok: false };
7
+
8
+ async function readJsonBody(request: Request): Promise<JsonBodyResult> {
9
+ try {
10
+ const raw = await request.text();
11
+ if (raw.trim().length === 0) return { ok: true, value: undefined };
12
+ return { ok: true, value: JSON.parse(raw) };
13
+ } catch {
14
+ return { ok: false };
15
+ }
16
+ }
17
+
18
+ export const Route = createFileRoute("/api/groups/$groupId/evidence")({
19
+ server: {
20
+ handlers: {
21
+ GET: ({ params }: { params: { groupId: string } }) => {
22
+ const group = getGroup(params.groupId);
23
+ if (group === null) {
24
+ return Response.json({ error: "Group not found" }, { status: 404 });
25
+ }
26
+
27
+ const markdown = readGroupEvidenceMarkdown(group);
28
+ if (group.evidence === null || markdown === null) {
29
+ return Response.json(
30
+ { error: "Evidence has not been exported for this group" },
31
+ { status: 404 },
32
+ );
33
+ }
34
+
35
+ return Response.json({ group, evidence: group.evidence, markdown });
36
+ },
37
+ POST: async ({ params, request }: { params: { groupId: string }; request: Request }) => {
38
+ const group = getGroup(params.groupId);
39
+ if (group === null) {
40
+ return Response.json({ error: "Group not found" }, { status: 404 });
41
+ }
42
+
43
+ const body = await readJsonBody(request);
44
+ if (!body.ok) {
45
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
46
+ }
47
+
48
+ const parsed = GroupEvidenceExportOptionsSchema.safeParse(body.value);
49
+ if (!parsed.success) {
50
+ return Response.json(
51
+ { error: "Invalid request body", details: parsed.error.format() },
52
+ { status: 400 },
53
+ );
54
+ }
55
+
56
+ return Response.json(
57
+ await exportGroupEvidence(group, parsed.data, new URL(request.url).origin),
58
+ );
59
+ },
60
+ },
61
+ },
62
+ });