@agent-inspect/studio 6.0.0

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.
package/dist/index.mjs ADDED
@@ -0,0 +1,991 @@
1
+ import { readFile, access, mkdir, readdir, stat } from 'fs/promises';
2
+ import path5 from 'path';
3
+ import { loadSessionRunRecords, buildSessionIndex, runSuite, buildRunTimeline, extractOutcomesFromTraceEvents, resolveTraceDir, searchTraces, TraceDirectory, loadTraceMetadataList } from 'agent-inspect/advanced';
4
+ import { resolveWorkspaceLocation, readWorkspaceManifestFile } from 'agent-inspect/workspace';
5
+ import Database from 'better-sqlite3';
6
+ import { createServer } from 'http';
7
+ import { runTraceChecks, createRunStatusRule } from 'agent-inspect/checks';
8
+ import { diffRuns, manualTraceEventsToComparableRun } from 'agent-inspect/diff';
9
+ import { persistedInspectEventsToTraceEvents } from 'agent-inspect/persisted';
10
+ import { openTrace } from 'agent-inspect/readers';
11
+
12
+ // packages/studio/src/registry.ts
13
+ var STUDIO_REGISTRY_SCHEMA_VERSION = "1.0";
14
+ var STUDIO_REGISTRY_FILENAMES = [
15
+ "studio-registry.json",
16
+ ".agent-inspect/studio-registry.json"
17
+ ];
18
+ var MAX_REGISTRY_BYTES = 256 * 1024;
19
+ function isPlainObject(value) {
20
+ return typeof value === "object" && value !== null && !Array.isArray(value);
21
+ }
22
+ function isSafeRelativePath(p) {
23
+ const trimmed = p.trim();
24
+ if (trimmed === "" || trimmed.startsWith("/") || trimmed.startsWith("\\")) return false;
25
+ if (/^[a-zA-Z]:/.test(trimmed)) return false;
26
+ return !trimmed.split(/[/\\]+/).some((seg) => seg === "..");
27
+ }
28
+ function parseStudioRegistry(input) {
29
+ const errors = [];
30
+ if (!isPlainObject(input)) {
31
+ return { ok: false, errors: ["registry must be a JSON object"] };
32
+ }
33
+ if (input.schemaVersion !== STUDIO_REGISTRY_SCHEMA_VERSION) {
34
+ errors.push(`schemaVersion must be "${STUDIO_REGISTRY_SCHEMA_VERSION}"`);
35
+ }
36
+ if (typeof input.name !== "string" || input.name.trim() === "") {
37
+ errors.push("name must be a non-empty string");
38
+ }
39
+ if (!Array.isArray(input.projects) || input.projects.length === 0) {
40
+ errors.push("projects must be a non-empty array");
41
+ }
42
+ const projects = [];
43
+ if (Array.isArray(input.projects)) {
44
+ for (const [index, item] of input.projects.entries()) {
45
+ if (!isPlainObject(item)) {
46
+ errors.push(`projects[${index}] must be an object`);
47
+ continue;
48
+ }
49
+ const id = typeof item.id === "string" ? item.id.trim() : "";
50
+ const projectPath = typeof item.path === "string" ? item.path.trim() : "";
51
+ if (!id) errors.push(`projects[${index}].id must be a non-empty string`);
52
+ if (!projectPath) errors.push(`projects[${index}].path must be a non-empty string`);
53
+ const label = typeof item.label === "string" ? item.label.trim() : void 0;
54
+ const suiteConfigs = Array.isArray(item.suiteConfigs) ? item.suiteConfigs.filter((value) => typeof value === "string") : void 0;
55
+ projects.push({
56
+ id,
57
+ path: projectPath,
58
+ ...label ? { label } : {},
59
+ ...suiteConfigs && suiteConfigs.length > 0 ? { suiteConfigs } : {}
60
+ });
61
+ }
62
+ }
63
+ let importConfig;
64
+ if (input.import !== void 0) {
65
+ if (!isPlainObject(input.import)) {
66
+ errors.push("import must be an object");
67
+ } else {
68
+ importConfig = {};
69
+ if (input.import.ciArtifactsDir !== void 0) {
70
+ const dir = String(input.import.ciArtifactsDir).trim();
71
+ if (!isSafeRelativePath(dir)) {
72
+ errors.push("import.ciArtifactsDir must be a safe relative path");
73
+ } else {
74
+ importConfig.ciArtifactsDir = dir;
75
+ }
76
+ }
77
+ if (input.import.bundlesDir !== void 0) {
78
+ const dir = String(input.import.bundlesDir).trim();
79
+ if (!isSafeRelativePath(dir)) {
80
+ errors.push("import.bundlesDir must be a safe relative path");
81
+ } else {
82
+ importConfig.bundlesDir = dir;
83
+ }
84
+ }
85
+ }
86
+ }
87
+ if (errors.length > 0) return { ok: false, errors };
88
+ return {
89
+ ok: true,
90
+ registry: {
91
+ schemaVersion: STUDIO_REGISTRY_SCHEMA_VERSION,
92
+ name: String(input.name).trim(),
93
+ projects,
94
+ ...importConfig ? { import: importConfig } : {}
95
+ },
96
+ errors: []
97
+ };
98
+ }
99
+ async function readStudioRegistryFile(filePath) {
100
+ try {
101
+ const raw = await readFile(filePath, "utf8");
102
+ if (raw.length > MAX_REGISTRY_BYTES) {
103
+ return { ok: false, path: filePath, errors: ["registry file exceeds size limit"] };
104
+ }
105
+ const parsed = parseStudioRegistry(JSON.parse(raw));
106
+ return { ...parsed, path: filePath };
107
+ } catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ return { ok: false, path: filePath, errors: [message] };
110
+ }
111
+ }
112
+ function resolveRegistryProjectPath(registryDir, projectPath) {
113
+ return path5.isAbsolute(projectPath) ? path5.resolve(projectPath) : path5.resolve(registryDir, projectPath);
114
+ }
115
+ var STUDIO_DB_SCHEMA_VERSION = "1.0";
116
+ var DEFAULT_STUDIO_DB_FILENAME = "studio.db";
117
+ var SCHEMA_SQL = `
118
+ CREATE TABLE IF NOT EXISTS meta (
119
+ key TEXT PRIMARY KEY,
120
+ value TEXT NOT NULL
121
+ );
122
+ CREATE TABLE IF NOT EXISTS projects (
123
+ id TEXT PRIMARY KEY,
124
+ label TEXT,
125
+ path TEXT NOT NULL,
126
+ workspace_dir TEXT NOT NULL,
127
+ project_name TEXT,
128
+ redaction_profile TEXT,
129
+ trace_count INTEGER NOT NULL DEFAULT 0,
130
+ imported_at TEXT NOT NULL
131
+ );
132
+ CREATE TABLE IF NOT EXISTS runs (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ project_id TEXT NOT NULL,
135
+ run_id TEXT NOT NULL,
136
+ name TEXT,
137
+ status TEXT,
138
+ file TEXT,
139
+ started_at REAL,
140
+ duration_ms REAL,
141
+ session_id TEXT,
142
+ UNIQUE(project_id, run_id)
143
+ );
144
+ CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
145
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
146
+ `;
147
+ function resolveStudioDbPath(options) {
148
+ if (options.dbPath && options.dbPath.trim() !== "") {
149
+ const raw = options.dbPath.trim();
150
+ if (raw.startsWith("postgres://") || raw.startsWith("postgresql://")) {
151
+ return raw;
152
+ }
153
+ return path5.resolve(options.cwd ?? process.cwd(), raw);
154
+ }
155
+ return path5.resolve(
156
+ options.cwd ?? process.cwd(),
157
+ ".agent-inspect",
158
+ DEFAULT_STUDIO_DB_FILENAME
159
+ );
160
+ }
161
+ function isPostgresDbPath(dbPath) {
162
+ return dbPath.startsWith("postgres://") || dbPath.startsWith("postgresql://");
163
+ }
164
+ function openStudioDb(dbPath) {
165
+ if (isPostgresDbPath(dbPath)) {
166
+ throw new Error(
167
+ "Postgres studio databases are not implemented in v6.0.0; use a SQLite file path."
168
+ );
169
+ }
170
+ const dir = path5.dirname(dbPath);
171
+ void mkdir(dir, { recursive: true });
172
+ const db = new Database(dbPath);
173
+ db.pragma("journal_mode = WAL");
174
+ db.exec(SCHEMA_SQL);
175
+ const insertMeta = db.prepare(
176
+ "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
177
+ );
178
+ insertMeta.run("schemaVersion", STUDIO_DB_SCHEMA_VERSION);
179
+ insertMeta.run("driver", "better-sqlite3");
180
+ return db;
181
+ }
182
+ function upsertStudioProject(db, row) {
183
+ db.prepare(
184
+ `INSERT INTO projects(id, label, path, workspace_dir, project_name, redaction_profile, trace_count, imported_at)
185
+ VALUES (@id, @label, @path, @workspaceDir, @projectName, @redactionProfile, @traceCount, @importedAt)
186
+ ON CONFLICT(id) DO UPDATE SET
187
+ label = excluded.label,
188
+ path = excluded.path,
189
+ workspace_dir = excluded.workspace_dir,
190
+ project_name = excluded.project_name,
191
+ redaction_profile = excluded.redaction_profile,
192
+ trace_count = excluded.trace_count,
193
+ imported_at = excluded.imported_at`
194
+ ).run({
195
+ id: row.id,
196
+ label: row.label,
197
+ path: row.path,
198
+ workspaceDir: row.workspaceDir,
199
+ projectName: row.projectName,
200
+ redactionProfile: row.redactionProfile,
201
+ traceCount: row.traceCount,
202
+ importedAt: row.importedAt
203
+ });
204
+ }
205
+ function replaceProjectRuns(db, projectId, runs) {
206
+ const tx = db.transaction(() => {
207
+ db.prepare("DELETE FROM runs WHERE project_id = ?").run(projectId);
208
+ const insert = db.prepare(
209
+ `INSERT INTO runs(project_id, run_id, name, status, file, started_at, duration_ms, session_id)
210
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
211
+ );
212
+ for (const run of runs) {
213
+ insert.run(
214
+ projectId,
215
+ run.runId,
216
+ run.name ?? null,
217
+ run.status ?? null,
218
+ run.file ?? null,
219
+ run.startedAt ?? null,
220
+ run.durationMs ?? null,
221
+ run.sessionId ?? null
222
+ );
223
+ }
224
+ });
225
+ tx();
226
+ }
227
+ function getStudioProject(db, projectId) {
228
+ return db.prepare(
229
+ `SELECT id, label, path, workspace_dir AS workspaceDir, project_name AS projectName,
230
+ redaction_profile AS redactionProfile, trace_count AS traceCount, imported_at AS importedAt
231
+ FROM projects WHERE id = ?`
232
+ ).get(projectId);
233
+ }
234
+ function listProjectRuns(db, projectId, limit = 200) {
235
+ const rows = db.prepare(
236
+ `SELECT project_id AS projectId, run_id AS runId, name, status, file,
237
+ started_at AS startedAt, duration_ms AS durationMs, session_id AS sessionId
238
+ FROM runs WHERE project_id = ? ORDER BY started_at DESC LIMIT ?`
239
+ ).all(projectId, limit);
240
+ return rows;
241
+ }
242
+ function searchProjectRuns(db, projectId, query, limit = 50) {
243
+ const pattern = `%${query}%`;
244
+ return db.prepare(
245
+ `SELECT project_id AS projectId, run_id AS runId, name, status, file,
246
+ started_at AS startedAt, duration_ms AS durationMs, session_id AS sessionId
247
+ FROM runs
248
+ WHERE project_id = ?
249
+ AND (run_id LIKE ? OR IFNULL(name, '') LIKE ? OR IFNULL(status, '') LIKE ?)
250
+ ORDER BY started_at DESC
251
+ LIMIT ?`
252
+ ).all(projectId, pattern, pattern, pattern, limit);
253
+ }
254
+
255
+ // packages/studio/src/import.ts
256
+ async function discoverSuiteConfigs(projectRoot, configured) {
257
+ if (configured && configured.length > 0) {
258
+ return configured.map((rel) => path5.resolve(projectRoot, rel));
259
+ }
260
+ const found = [];
261
+ try {
262
+ const entries = await readdir(projectRoot);
263
+ for (const entry of entries) {
264
+ if (entry.endsWith(".suite.json")) {
265
+ found.push(path5.join(projectRoot, entry));
266
+ }
267
+ }
268
+ } catch {
269
+ }
270
+ return found.sort();
271
+ }
272
+ async function loadProjectRuns(workspaceDir, traceDirs) {
273
+ const runs = [];
274
+ for (const rel of traceDirs) {
275
+ const traceDir = resolveTraceDir({ dir: path5.join(workspaceDir, rel) });
276
+ const td = new TraceDirectory({ dir: traceDir });
277
+ const files = await td.list();
278
+ const metas = await loadTraceMetadataList(
279
+ traceDir,
280
+ files,
281
+ (fileName) => td.getPath(fileName)
282
+ );
283
+ for (const meta of metas) {
284
+ runs.push({
285
+ projectId: "",
286
+ runId: meta.runId,
287
+ ...meta.name !== void 0 ? { name: meta.name } : {},
288
+ status: meta.status,
289
+ file: path5.basename(meta.filePath),
290
+ ...meta.startedAt !== void 0 ? { startedAt: meta.startedAt } : {},
291
+ ...meta.durationMs !== void 0 ? { durationMs: meta.durationMs } : {}
292
+ });
293
+ }
294
+ }
295
+ return runs.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
296
+ }
297
+ async function importStudioRegistry(options) {
298
+ const registryDir = path5.dirname(options.registryPath);
299
+ const warnings = [];
300
+ const projects = [];
301
+ const importedAt = (/* @__PURE__ */ new Date()).toISOString();
302
+ for (const project of options.registry.projects) {
303
+ const imported = await importStudioProject({
304
+ db: options.db,
305
+ registryDir,
306
+ project,
307
+ importedAt,
308
+ warnings
309
+ });
310
+ if (imported) projects.push(imported);
311
+ }
312
+ return {
313
+ registryName: options.registry.name,
314
+ projects,
315
+ warnings
316
+ };
317
+ }
318
+ async function importStudioProject(options) {
319
+ const projectRoot = resolveRegistryProjectPath(
320
+ options.registryDir,
321
+ options.project.path
322
+ );
323
+ const location = resolveWorkspaceLocation(projectRoot);
324
+ const manifestRead = await readWorkspaceManifestFile(location);
325
+ if (!manifestRead.ok || manifestRead.manifest === void 0) {
326
+ options.warnings.push(
327
+ `project "${options.project.id}": ${manifestRead.errors.join("; ") || "workspace.json missing"}`
328
+ );
329
+ return void 0;
330
+ }
331
+ const runs = await loadProjectRuns(
332
+ location.workspaceDir,
333
+ manifestRead.manifest.traceDirs
334
+ );
335
+ const suiteConfigPaths = await discoverSuiteConfigs(
336
+ projectRoot,
337
+ options.project.suiteConfigs
338
+ );
339
+ const row = {
340
+ id: options.project.id,
341
+ label: options.project.label ?? options.project.id,
342
+ path: projectRoot,
343
+ workspaceDir: location.workspaceDir,
344
+ projectName: manifestRead.manifest.project,
345
+ redactionProfile: manifestRead.manifest.redactionProfile,
346
+ traceCount: runs.length,
347
+ importedAt: options.importedAt
348
+ };
349
+ upsertStudioProject(options.db, row);
350
+ replaceProjectRuns(
351
+ options.db,
352
+ options.project.id,
353
+ runs.map((run) => ({ ...run, projectId: options.project.id }))
354
+ );
355
+ return { ...row, suiteConfigPaths };
356
+ }
357
+ async function resolveStudioRegistryPath(options) {
358
+ if (options.workspacePath && options.workspacePath.trim() !== "") {
359
+ return path5.resolve(options.cwd ?? process.cwd(), options.workspacePath);
360
+ }
361
+ const cwd = path5.resolve(options.cwd ?? process.cwd());
362
+ for (const rel of STUDIO_REGISTRY_FILENAMES) {
363
+ const candidate = path5.join(cwd, rel);
364
+ try {
365
+ await access(candidate);
366
+ return candidate;
367
+ } catch {
368
+ }
369
+ }
370
+ return path5.join(cwd, STUDIO_REGISTRY_FILENAMES[0]);
371
+ }
372
+
373
+ // packages/studio/src/context.ts
374
+ async function createStudioContext(options = {}) {
375
+ const cwd = options.cwd ?? process.cwd();
376
+ const registryPath = await resolveStudioRegistryPath({
377
+ ...options.workspacePath !== void 0 ? { workspacePath: options.workspacePath } : {},
378
+ cwd
379
+ });
380
+ const registryRead = await readStudioRegistryFile(registryPath);
381
+ if (!registryRead.ok || registryRead.registry === void 0) {
382
+ throw new Error(registryRead.errors.join("; ") || "invalid studio registry");
383
+ }
384
+ const dbPath = resolveStudioDbPath({
385
+ ...options.dbPath !== void 0 ? { dbPath: options.dbPath } : {},
386
+ cwd
387
+ });
388
+ const db = openStudioDb(dbPath);
389
+ const importResult = await importStudioRegistry({
390
+ db,
391
+ registry: registryRead.registry,
392
+ registryPath
393
+ });
394
+ return {
395
+ db,
396
+ dbPath,
397
+ registryPath,
398
+ registry: registryRead.registry,
399
+ importResult,
400
+ projects: importResult.projects
401
+ };
402
+ }
403
+ function summarizeProjects(projects) {
404
+ return projects.map((project) => ({
405
+ id: project.id,
406
+ label: project.label ?? project.id,
407
+ projectName: project.projectName,
408
+ traceCount: project.traceCount,
409
+ redactionProfile: project.redactionProfile,
410
+ importedAt: project.importedAt
411
+ }));
412
+ }
413
+
414
+ // packages/studio/src/html.ts
415
+ var studioIndexHtml = `<!DOCTYPE html>
416
+ <html lang="en">
417
+ <head>
418
+ <meta charset="utf-8" />
419
+ <title>AgentInspect Studio</title>
420
+ <style>
421
+ body { font-family: system-ui, sans-serif; margin: 1.5rem; line-height: 1.4; }
422
+ h1 { font-size: 1.25rem; }
423
+ .muted { color: #666; }
424
+ </style>
425
+ </head>
426
+ <body>
427
+ <h1>AgentInspect Studio</h1>
428
+ <p class="muted">Self-hosted, read-only. JSONL and workspace manifests remain canonical.</p>
429
+ <p id="status">Loading health\u2026</p>
430
+ <script>
431
+ fetch("/api/health")
432
+ .then((res) => res.json())
433
+ .then((data) => {
434
+ document.getElementById("status").textContent =
435
+ data.ok ? "Studio is running (read-only)." : "Studio health check failed.";
436
+ })
437
+ .catch(() => {
438
+ document.getElementById("status").textContent = "Studio health check failed.";
439
+ });
440
+ </script>
441
+ </body>
442
+ </html>`;
443
+ function getImportedProject(db, projects, projectId) {
444
+ const cached = projects.find((item) => item.id === projectId);
445
+ if (cached) return { project: cached };
446
+ const row = getStudioProject(db, projectId);
447
+ if (!row) return void 0;
448
+ return {
449
+ project: {
450
+ ...row,
451
+ suiteConfigPaths: []
452
+ }
453
+ };
454
+ }
455
+ async function loadTraceDirMetas(workspaceDir, traceDirs) {
456
+ const metas = [];
457
+ for (const rel of traceDirs) {
458
+ const traceDir = resolveTraceDir({ dir: path5.join(workspaceDir, rel) });
459
+ const td = new TraceDirectory({ dir: traceDir });
460
+ const files = await td.list();
461
+ const listed = await loadTraceMetadataList(
462
+ traceDir,
463
+ files,
464
+ (fileName) => td.getPath(fileName)
465
+ );
466
+ metas.push(...listed);
467
+ }
468
+ return metas;
469
+ }
470
+ async function loadProjectRunsView(ctx, db, limit = 200) {
471
+ return listProjectRuns(db, ctx.project.id, limit);
472
+ }
473
+ async function loadProjectSessionsView(ctx) {
474
+ const location = resolveWorkspaceLocation(ctx.project.path);
475
+ const manifestRead = await readWorkspaceManifestFile(location);
476
+ const traceDirs = manifestRead.manifest?.traceDirs ?? ["runs"];
477
+ const metas = await loadTraceDirMetas(ctx.project.workspaceDir, traceDirs);
478
+ const runs = await loadSessionRunRecords(metas);
479
+ return buildSessionIndex(runs, { correlateByGroupId: true });
480
+ }
481
+ async function loadProjectSuitesView(ctx) {
482
+ const suites = [];
483
+ for (const configPath of ctx.project.suiteConfigPaths) {
484
+ try {
485
+ const result = await runSuite({
486
+ configPath,
487
+ cwd: path5.dirname(configPath)
488
+ });
489
+ suites.push({
490
+ suiteName: result.suiteName,
491
+ configPath: result.configPath,
492
+ ok: result.ok,
493
+ status: result.status,
494
+ summary: result.summary,
495
+ cases: result.cases.map((item) => ({
496
+ id: item.id,
497
+ status: item.status,
498
+ ...item.runId !== void 0 ? { runId: item.runId } : {},
499
+ ...item.message !== void 0 ? { message: item.message } : {}
500
+ }))
501
+ });
502
+ } catch (error) {
503
+ const message = error instanceof Error ? error.message : String(error);
504
+ suites.push({
505
+ suiteName: path5.basename(configPath),
506
+ configPath,
507
+ ok: false,
508
+ status: "error",
509
+ summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
510
+ cases: [],
511
+ error: message
512
+ });
513
+ }
514
+ }
515
+ return { suites };
516
+ }
517
+ async function loadProjectChecksView(ctx) {
518
+ const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
519
+ const results = [];
520
+ for (const meta of metas.slice(0, 20)) {
521
+ try {
522
+ const read = await openTrace({ type: "file", path: meta.filePath });
523
+ const result = runTraceChecks(
524
+ { read },
525
+ { rules: [createRunStatusRule()], select: ["run.status"], runId: meta.runId }
526
+ );
527
+ results.push({
528
+ runId: meta.runId,
529
+ ok: result.ok,
530
+ summary: result.summary
531
+ });
532
+ } catch (error) {
533
+ const message = error instanceof Error ? error.message : String(error);
534
+ results.push({ runId: meta.runId, ok: false, error: message });
535
+ }
536
+ }
537
+ return { checks: results };
538
+ }
539
+ async function loadProjectSearchView(ctx, db, params) {
540
+ const q = params.get("q")?.trim() ?? "";
541
+ if (q) {
542
+ return {
543
+ query: q,
544
+ results: searchProjectRuns(
545
+ db,
546
+ ctx.project.id,
547
+ q,
548
+ Number(params.get("limit") ?? 50)
549
+ )
550
+ };
551
+ }
552
+ const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
553
+ const traceDir = resolveTraceDir({
554
+ dir: path5.join(ctx.project.workspaceDir, "runs")
555
+ });
556
+ const results = await searchTraces(metas, {
557
+ traceDir,
558
+ ...params.get("status") ? { status: params.get("status") } : {},
559
+ ...params.get("name") ? { name: params.get("name") ?? void 0 } : {},
560
+ ...params.get("tool") ? { tool: params.get("tool") ?? void 0 } : {},
561
+ limit: Number(params.get("limit") ?? 50)
562
+ });
563
+ return { query: q, results };
564
+ }
565
+ async function loadProjectDiffView(ctx, params) {
566
+ const leftRunId = params.get("left");
567
+ const rightRunId = params.get("right");
568
+ if (!leftRunId || !rightRunId) {
569
+ throw new Error("left and right run ids are required.");
570
+ }
571
+ const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
572
+ const leftMeta = metas.find((item) => item.runId === leftRunId);
573
+ const rightMeta = metas.find((item) => item.runId === rightRunId);
574
+ if (!leftMeta || !rightMeta) {
575
+ throw new Error("One or both runs were not found in the project workspace.");
576
+ }
577
+ const leftRead = await openTrace({ type: "file", path: leftMeta.filePath });
578
+ const rightRead = await openTrace({ type: "file", path: rightMeta.filePath });
579
+ const diff = diffRuns(
580
+ manualTraceEventsToComparableRun(
581
+ persistedInspectEventsToTraceEvents(leftRead.events)
582
+ ),
583
+ manualTraceEventsToComparableRun(
584
+ persistedInspectEventsToTraceEvents(rightRead.events)
585
+ )
586
+ );
587
+ return {
588
+ leftRunId,
589
+ rightRunId,
590
+ summary: diff.summary,
591
+ differences: diff.differences.slice(0, 50).map((item) => ({
592
+ kind: item.kind,
593
+ message: item.message
594
+ }))
595
+ };
596
+ }
597
+ async function loadProjectReportsView(ctx) {
598
+ const reportsDir = path5.join(ctx.project.workspaceDir, "reports");
599
+ const reports = [];
600
+ try {
601
+ const files = await readdir(reportsDir);
602
+ for (const file of files) {
603
+ const filePath = path5.join(reportsDir, file);
604
+ const info = await stat(filePath);
605
+ if (!info.isFile()) continue;
606
+ reports.push({ name: file, path: filePath, sizeBytes: info.size });
607
+ }
608
+ } catch {
609
+ }
610
+ return { reports: reports.sort((a, b) => a.name.localeCompare(b.name)) };
611
+ }
612
+ async function loadProjectObservationsView(ctx) {
613
+ const metas = await loadTraceDirMetas(ctx.project.workspaceDir, ["runs"]);
614
+ const observations = [];
615
+ for (const meta of metas.slice(0, 20)) {
616
+ try {
617
+ const read = await openTrace({ type: "file", path: meta.filePath });
618
+ const legacy = persistedInspectEventsToTraceEvents(read.events);
619
+ observations.push({
620
+ runId: meta.runId,
621
+ items: extractOutcomesFromTraceEvents(legacy),
622
+ timeline: buildRunTimeline(legacy, { focus: "all" }).entries.filter((entry) => entry.type === "tool").map((entry) => entry.name)
623
+ });
624
+ } catch {
625
+ observations.push({ runId: meta.runId, items: [], timeline: [] });
626
+ }
627
+ }
628
+ return { observations };
629
+ }
630
+ async function loadProjectGuardrailsView(ctx) {
631
+ return {
632
+ guardrails: {
633
+ message: "Guardrail evaluation is available via CLI (`agent-inspect scan`). Studio surfaces structural status only in v6.0.",
634
+ redactionProfile: ctx.project.redactionProfile ?? "share",
635
+ circuitWarnings: []
636
+ }
637
+ };
638
+ }
639
+ async function loadProjectRedactionView(ctx) {
640
+ return {
641
+ redaction: {
642
+ profile: ctx.project.redactionProfile ?? "share",
643
+ hint: "Structural share-safety posture from workspace.json \u2014 not certification.",
644
+ bundleCommand: `npx agent-inspect bundle <runId> --profile ${ctx.project.redactionProfile ?? "share"}`
645
+ }
646
+ };
647
+ }
648
+ async function loadBundleExportView(ctx, params) {
649
+ const runId = params.get("runId");
650
+ if (!runId) {
651
+ throw new Error("runId query parameter is required.");
652
+ }
653
+ return {
654
+ projectId: ctx.project.id,
655
+ runId,
656
+ readOnly: true,
657
+ redactionProfile: ctx.project.redactionProfile ?? "share",
658
+ cliHint: `npx agent-inspect bundle ${runId} --profile ${ctx.project.redactionProfile ?? "share"} --dir ${path5.join(ctx.project.workspaceDir, "runs")}`,
659
+ note: "Studio does not mutate traces or upload bundles. Run the CLI locally to assemble a share-safe bundle."
660
+ };
661
+ }
662
+
663
+ // packages/studio/src/auth.ts
664
+ function resolveStudioAuthMode(options) {
665
+ return options.auth === "basic" ? "basic" : "none";
666
+ }
667
+ function resolveStudioPassword(options) {
668
+ if (!options.passwordEnv) return void 0;
669
+ const value = process.env[options.passwordEnv]?.trim();
670
+ return value && value.length > 0 ? value : void 0;
671
+ }
672
+ function parseBasicAuth(header) {
673
+ if (!header || !header.startsWith("Basic ")) return void 0;
674
+ try {
675
+ const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
676
+ const separator = decoded.indexOf(":");
677
+ if (separator < 0) return void 0;
678
+ return decoded.slice(separator + 1);
679
+ } catch {
680
+ return void 0;
681
+ }
682
+ }
683
+ function isStudioRequestAuthorized(req, options) {
684
+ const mode = resolveStudioAuthMode(options);
685
+ if (mode === "none") return true;
686
+ const expected = resolveStudioPassword(options);
687
+ if (!expected) return false;
688
+ const provided = parseBasicAuth(req.headers.authorization);
689
+ return provided === expected;
690
+ }
691
+ function studioAuthRequiredResponse() {
692
+ return {
693
+ status: 401,
694
+ body: { error: "Unauthorized" },
695
+ headers: {
696
+ "www-authenticate": 'Basic realm="AgentInspect Studio"'
697
+ }
698
+ };
699
+ }
700
+
701
+ // packages/studio/src/routes.ts
702
+ function sendJson(res, status, body, headers = {}) {
703
+ const payload = JSON.stringify(body);
704
+ res.writeHead(status, {
705
+ "content-type": "application/json; charset=utf-8",
706
+ "cache-control": "no-store",
707
+ ...headers
708
+ });
709
+ res.end(payload);
710
+ }
711
+ function notFound(res, message) {
712
+ sendJson(res, 404, { error: message });
713
+ }
714
+ function decodeSegment(segment) {
715
+ if (!segment) return "";
716
+ try {
717
+ return decodeURIComponent(segment);
718
+ } catch {
719
+ return segment;
720
+ }
721
+ }
722
+ async function handleStudioRoute(req, res, ctx, options, pathname, url) {
723
+ if (!isStudioRequestAuthorized(req, options)) {
724
+ const auth = studioAuthRequiredResponse();
725
+ sendJson(res, auth.status, auth.body, auth.headers);
726
+ return true;
727
+ }
728
+ if (pathname === "/api/health") {
729
+ sendJson(res, 200, {
730
+ ok: true,
731
+ readOnly: true,
732
+ mode: "studio",
733
+ registryName: ctx.registry.name,
734
+ registryPath: ctx.registryPath,
735
+ dbPath: ctx.dbPath,
736
+ projects: summarizeProjects(ctx.projects),
737
+ warnings: ctx.importResult.warnings
738
+ });
739
+ return true;
740
+ }
741
+ if (pathname === "/api/projects") {
742
+ sendJson(res, 200, {
743
+ registryName: ctx.registry.name,
744
+ projects: summarizeProjects(ctx.projects),
745
+ warnings: ctx.importResult.warnings
746
+ });
747
+ return true;
748
+ }
749
+ const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)(?:\/(.*))?$/);
750
+ if (projectMatch) {
751
+ const projectId = decodeSegment(projectMatch[1]);
752
+ const subpath = projectMatch[2] ?? "";
753
+ const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
754
+ if (!projectCtx) {
755
+ notFound(res, `Project not found: ${projectId}`);
756
+ return true;
757
+ }
758
+ if (subpath === "runs" || subpath === "") {
759
+ sendJson(res, 200, {
760
+ projectId,
761
+ runs: await loadProjectRunsView(projectCtx, ctx.db)
762
+ });
763
+ return true;
764
+ }
765
+ if (subpath === "sessions") {
766
+ sendJson(res, 200, {
767
+ projectId,
768
+ ...await loadProjectSessionsView(projectCtx)
769
+ });
770
+ return true;
771
+ }
772
+ if (subpath === "suites") {
773
+ sendJson(res, 200, {
774
+ projectId,
775
+ ...await loadProjectSuitesView(projectCtx)
776
+ });
777
+ return true;
778
+ }
779
+ if (subpath === "checks") {
780
+ sendJson(res, 200, {
781
+ projectId,
782
+ ...await loadProjectChecksView(projectCtx)
783
+ });
784
+ return true;
785
+ }
786
+ if (subpath === "observations") {
787
+ sendJson(res, 200, {
788
+ projectId,
789
+ ...await loadProjectObservationsView(projectCtx)
790
+ });
791
+ return true;
792
+ }
793
+ if (subpath === "guardrails") {
794
+ sendJson(res, 200, {
795
+ projectId,
796
+ ...await loadProjectGuardrailsView(projectCtx)
797
+ });
798
+ return true;
799
+ }
800
+ if (subpath === "redaction") {
801
+ sendJson(res, 200, {
802
+ projectId,
803
+ ...await loadProjectRedactionView(projectCtx)
804
+ });
805
+ return true;
806
+ }
807
+ if (subpath === "reports") {
808
+ sendJson(res, 200, {
809
+ projectId,
810
+ ...await loadProjectReportsView(projectCtx)
811
+ });
812
+ return true;
813
+ }
814
+ notFound(res, `Unknown project route: /${subpath}`);
815
+ return true;
816
+ }
817
+ if (pathname === "/api/search") {
818
+ const projectId = url.searchParams.get("projectId");
819
+ if (!projectId) {
820
+ sendJson(res, 400, { error: "projectId query parameter is required." });
821
+ return true;
822
+ }
823
+ const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
824
+ if (!projectCtx) {
825
+ notFound(res, `Project not found: ${projectId}`);
826
+ return true;
827
+ }
828
+ sendJson(res, 200, {
829
+ projectId,
830
+ ...await loadProjectSearchView(projectCtx, ctx.db, url.searchParams)
831
+ });
832
+ return true;
833
+ }
834
+ if (pathname === "/api/diff") {
835
+ const projectId = url.searchParams.get("projectId");
836
+ if (!projectId) {
837
+ sendJson(res, 400, { error: "projectId query parameter is required." });
838
+ return true;
839
+ }
840
+ const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
841
+ if (!projectCtx) {
842
+ notFound(res, `Project not found: ${projectId}`);
843
+ return true;
844
+ }
845
+ try {
846
+ sendJson(res, 200, {
847
+ projectId,
848
+ ...await loadProjectDiffView(projectCtx, url.searchParams)
849
+ });
850
+ } catch (error) {
851
+ const message = error instanceof Error ? error.message : String(error);
852
+ sendJson(res, 400, { error: message });
853
+ }
854
+ return true;
855
+ }
856
+ if (pathname === "/api/reports") {
857
+ const projectId = url.searchParams.get("projectId");
858
+ if (!projectId) {
859
+ sendJson(res, 400, { error: "projectId query parameter is required." });
860
+ return true;
861
+ }
862
+ const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
863
+ if (!projectCtx) {
864
+ notFound(res, `Project not found: ${projectId}`);
865
+ return true;
866
+ }
867
+ sendJson(res, 200, {
868
+ projectId,
869
+ ...await loadProjectReportsView(projectCtx)
870
+ });
871
+ return true;
872
+ }
873
+ if (pathname === "/api/bundles/export") {
874
+ const projectId = url.searchParams.get("projectId");
875
+ if (!projectId) {
876
+ sendJson(res, 400, { error: "projectId query parameter is required." });
877
+ return true;
878
+ }
879
+ const projectCtx = getImportedProject(ctx.db, ctx.projects, projectId);
880
+ if (!projectCtx) {
881
+ notFound(res, `Project not found: ${projectId}`);
882
+ return true;
883
+ }
884
+ try {
885
+ sendJson(res, 200, await loadBundleExportView(projectCtx, url.searchParams));
886
+ } catch (error) {
887
+ const message = error instanceof Error ? error.message : String(error);
888
+ sendJson(res, 400, { error: message });
889
+ }
890
+ return true;
891
+ }
892
+ return false;
893
+ }
894
+
895
+ // packages/studio/src/server.ts
896
+ var DEFAULT_HOST = "127.0.0.1";
897
+ var DEFAULT_PORT = 7340;
898
+ function badRequest(res, message) {
899
+ res.writeHead(400, {
900
+ "content-type": "application/json; charset=utf-8",
901
+ "cache-control": "no-store"
902
+ });
903
+ res.end(JSON.stringify({ error: message }));
904
+ }
905
+ function resolveHost(options) {
906
+ if (options.server === true) {
907
+ return options.host ?? "0.0.0.0";
908
+ }
909
+ return options.host ?? DEFAULT_HOST;
910
+ }
911
+ function createStudioServer(options = {}) {
912
+ const host = resolveHost(options);
913
+ const port = options.port ?? DEFAULT_PORT;
914
+ let contextPromise = options.context ? Promise.resolve(options.context) : void 0;
915
+ if (host === "0.0.0.0") {
916
+ console.warn(
917
+ "[AgentInspect studio] Binding to 0.0.0.0 exposes workspace evidence on the network. Use 127.0.0.1 unless you accept that risk."
918
+ );
919
+ if (options.auth !== "basic") {
920
+ console.warn(
921
+ "[AgentInspect studio] Non-localhost binding without --auth basic is discouraged for production use."
922
+ );
923
+ }
924
+ }
925
+ const server = createServer(async (req, res) => {
926
+ try {
927
+ if (req.method !== "GET" && req.method !== "HEAD") {
928
+ return badRequest(res, "Only GET is supported.");
929
+ }
930
+ if (!contextPromise) {
931
+ contextPromise = createStudioContext(options);
932
+ }
933
+ const ctx = await contextPromise;
934
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
935
+ const pathname = url.pathname;
936
+ if (pathname === "/" || pathname === "/index.html") {
937
+ if (req.method === "HEAD") {
938
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
939
+ res.end();
940
+ return;
941
+ }
942
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
943
+ res.end(studioIndexHtml);
944
+ return;
945
+ }
946
+ const handled = await handleStudioRoute(req, res, ctx, options, pathname, url);
947
+ if (!handled) {
948
+ res.writeHead(404, {
949
+ "content-type": "application/json; charset=utf-8",
950
+ "cache-control": "no-store"
951
+ });
952
+ res.end(JSON.stringify({ error: `Unknown route: ${pathname}` }));
953
+ }
954
+ } catch (error) {
955
+ const message = error instanceof Error ? error.message : String(error);
956
+ res.writeHead(500, {
957
+ "content-type": "application/json; charset=utf-8",
958
+ "cache-control": "no-store"
959
+ });
960
+ res.end(JSON.stringify({ error: message }));
961
+ }
962
+ });
963
+ return server;
964
+ }
965
+ async function startStudioServer(options = {}) {
966
+ const host = resolveHost(options);
967
+ const port = options.port ?? DEFAULT_PORT;
968
+ const ctx = options.context ?? await createStudioContext(options);
969
+ const server = createStudioServer({ ...options, context: ctx });
970
+ return new Promise((resolve, reject) => {
971
+ server.once("error", reject);
972
+ server.listen(port, host, () => {
973
+ const address = server.address();
974
+ const resolvedPort = typeof address === "object" && address ? address.port : port;
975
+ resolve({
976
+ host,
977
+ port: resolvedPort,
978
+ url: `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${resolvedPort}/`,
979
+ mode: "studio",
980
+ ...options.workspacePath !== void 0 ? { workspacePath: options.workspacePath } : {},
981
+ dbPath: ctx.dbPath,
982
+ registryName: ctx.registry.name,
983
+ projectCount: ctx.projects.length
984
+ });
985
+ });
986
+ });
987
+ }
988
+
989
+ export { createStudioContext, createStudioServer, parseStudioRegistry, readStudioRegistryFile, startStudioServer, studioIndexHtml, summarizeProjects };
990
+ //# sourceMappingURL=index.mjs.map
991
+ //# sourceMappingURL=index.mjs.map