@agent-inspect/studio 6.4.0 → 6.4.1

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.d.cts CHANGED
@@ -179,7 +179,7 @@ interface StudioServerInfo {
179
179
  declare function createStudioServer(options?: StudioServerOptions): Server;
180
180
  declare function startStudioServer(options?: StudioServerOptions): Promise<StudioServerInfo>;
181
181
 
182
- declare const studioIndexHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <title>AgentInspect Studio</title>\n <style>\n body { font-family: system-ui, sans-serif; margin: 1.5rem; line-height: 1.4; }\n h1 { font-size: 1.25rem; }\n .muted { color: #666; }\n </style>\n</head>\n<body>\n <h1>AgentInspect Studio</h1>\n <p class=\"muted\">Self-hosted, read-only. JSONL and workspace manifests remain canonical.</p>\n <p id=\"status\">Loading health\u2026</p>\n <script>\n fetch(\"/api/health\")\n .then((res) => res.json())\n .then((data) => {\n document.getElementById(\"status\").textContent =\n data.ok ? \"Studio is running (read-only).\" : \"Studio health check failed.\";\n })\n .catch(() => {\n document.getElementById(\"status\").textContent = \"Studio health check failed.\";\n });\n </script>\n</body>\n</html>";
182
+ declare const studioIndexHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'\" />\n <title>AgentInspect Studio</title>\n <style>\n body { font-family: system-ui, sans-serif; margin: 0; line-height: 1.4; color: #111; }\n header { padding: 1rem 1.5rem; border-bottom: 1px solid #ddd; background: #fafafa; }\n nav a { margin-right: 1rem; color: #0b57d0; text-decoration: none; }\n nav a.active { font-weight: 600; text-decoration: underline; }\n main { padding: 1.5rem; max-width: 1100px; }\n .muted { color: #666; }\n table { border-collapse: collapse; width: 100%; margin: 1rem 0; }\n th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; vertical-align: top; }\n th { background: #f6f6f6; }\n pre { background: #f4f4f5; padding: 1rem; overflow: auto; max-height: 60vh; }\n .error { color: #b42318; }\n </style>\n</head>\n<body>\n <header>\n <h1>AgentInspect Studio</h1>\n <p class=\"muted\">Self-hosted, read-only. SQLite only. JSONL remains canonical.</p>\n <nav id=\"nav\">\n <a href=\"#projects\" data-page=\"projects\">Projects</a>\n <a href=\"#runs\" data-page=\"runs\">Runs</a>\n <a href=\"#sessions\" data-page=\"sessions\">Sessions</a>\n <a href=\"#suites\" data-page=\"suites\">Suites</a>\n <a href=\"#safety\" data-page=\"safety\">Safety</a>\n <a href=\"#imports\" data-page=\"imports\">Imports</a>\n <a href=\"#search\" data-page=\"search\">Search</a>\n </nav>\n </header>\n <main id=\"content\"><p class=\"muted\">Loading\u2026</p></main>\n <script>\n function escapeHtml(value) {\n return String(value ?? \"\")\n .replaceAll(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\")\n .replaceAll(\"'\", \"&#39;\");\n }\n\n let state = { projects: [], projectId: null };\n\n async function api(path) {\n const res = await fetch(path);\n const data = await res.json();\n if (!res.ok) throw new Error(data.error || res.statusText);\n return data;\n }\n\n function setPage(page) {\n document.querySelectorAll(\"#nav a\").forEach((a) => {\n a.classList.toggle(\"active\", a.dataset.page === page);\n });\n }\n\n function renderProjects() {\n setPage(\"projects\");\n const rows = state.projects.map((p) =>\n '<tr><td><a href=\"#runs\" data-select-project=\"' + escapeHtml(p.id) + '\">' +\n escapeHtml(p.label || p.id) + '</a></td><td>' + escapeHtml(p.traceCount) +\n '</td><td>' + escapeHtml(p.path) + '</td></tr>'\n ).join(\"\");\n document.getElementById(\"content\").innerHTML =\n '<h2>Projects</h2><table><thead><tr><th>Project</th><th>Runs</th><th>Path</th></tr></thead><tbody>' +\n (rows || '<tr><td colspan=\"3\">No projects imported</td></tr>') + '</tbody></table>';\n document.querySelectorAll(\"[data-select-project]\").forEach((el) => {\n el.addEventListener(\"click\", (ev) => {\n ev.preventDefault();\n state.projectId = el.getAttribute(\"data-select-project\");\n location.hash = \"runs\";\n renderRuns();\n });\n });\n }\n\n async function renderRuns() {\n setPage(\"runs\");\n if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;\n if (!state.projectId) {\n document.getElementById(\"content\").innerHTML = '<p>Select a project first.</p>';\n return;\n }\n const data = await api(\"/api/projects/\" + encodeURIComponent(state.projectId) + \"/runs\");\n const rows = (data.runs || []).map((r) =>\n '<tr><td>' + escapeHtml(r.runId) + '</td><td>' + escapeHtml(r.status) +\n '</td><td>' + escapeHtml(r.name) + '</td><td>' + escapeHtml(r.durationMs) + '</td></tr>'\n ).join(\"\");\n document.getElementById(\"content\").innerHTML =\n '<h2>Runs \u2014 ' + escapeHtml(state.projectId) + '</h2>' +\n '<table><thead><tr><th>Run ID</th><th>Status</th><th>Name</th><th>Duration ms</th></tr></thead><tbody>' +\n rows + '</tbody></table>';\n }\n\n async function renderSimple(title, path) {\n setPage(title.toLowerCase());\n if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;\n const data = await api(\"/api/projects/\" + encodeURIComponent(state.projectId) + \"/\" + path);\n document.getElementById(\"content\").innerHTML =\n '<h2>' + escapeHtml(title) + '</h2><pre>' + escapeHtml(JSON.stringify(data, null, 2)) + '</pre>';\n }\n\n async function renderImports() {\n setPage(\"imports\");\n const health = await api(\"/api/health\");\n document.getElementById(\"content\").innerHTML =\n '<h2>Imports</h2><p>Registry: ' + escapeHtml(health.registryName) + '</p>' +\n '<pre>' + escapeHtml(JSON.stringify(health.warnings || [], null, 2)) + '</pre>';\n }\n\n async function renderSearch() {\n setPage(\"search\");\n if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;\n document.getElementById(\"content\").innerHTML =\n '<h2>Search</h2><p><input id=\"q\" placeholder=\"run id or name\" /> <button id=\"go\">Search</button></p><pre id=\"out\"></pre>';\n document.getElementById(\"go\").onclick = async () => {\n const q = document.getElementById(\"q\").value;\n const data = await api(\"/api/search?projectId=\" + encodeURIComponent(state.projectId) + \"&q=\" + encodeURIComponent(q));\n document.getElementById(\"out\").textContent = JSON.stringify(data, null, 2);\n };\n }\n\n async function boot() {\n const health = await api(\"/api/health\");\n const projects = await api(\"/api/projects\");\n state.projects = projects.projects || health.projects || [];\n const page = (location.hash || \"#projects\").slice(1);\n if (page === \"runs\") return renderRuns();\n if (page === \"sessions\") return renderSimple(\"Sessions\", \"sessions\");\n if (page === \"suites\") return renderSimple(\"Suites\", \"suites\");\n if (page === \"safety\") return renderSimple(\"Safety\", \"redaction\");\n if (page === \"imports\") return renderImports();\n if (page === \"search\") return renderSearch();\n return renderProjects();\n }\n\n window.addEventListener(\"hashchange\", () => boot().catch(showError));\n function showError(err) {\n document.getElementById(\"content\").innerHTML = '<p class=\"error\">' + escapeHtml(String(err)) + '</p>';\n }\n boot().catch(showError);\n </script>\n</body>\n</html>";
183
183
 
184
184
  type StudioFetch = typeof fetch;
185
185
  interface GitHubArtifactImportOptions {
package/dist/index.d.ts CHANGED
@@ -179,7 +179,7 @@ interface StudioServerInfo {
179
179
  declare function createStudioServer(options?: StudioServerOptions): Server;
180
180
  declare function startStudioServer(options?: StudioServerOptions): Promise<StudioServerInfo>;
181
181
 
182
- declare const studioIndexHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <title>AgentInspect Studio</title>\n <style>\n body { font-family: system-ui, sans-serif; margin: 1.5rem; line-height: 1.4; }\n h1 { font-size: 1.25rem; }\n .muted { color: #666; }\n </style>\n</head>\n<body>\n <h1>AgentInspect Studio</h1>\n <p class=\"muted\">Self-hosted, read-only. JSONL and workspace manifests remain canonical.</p>\n <p id=\"status\">Loading health\u2026</p>\n <script>\n fetch(\"/api/health\")\n .then((res) => res.json())\n .then((data) => {\n document.getElementById(\"status\").textContent =\n data.ok ? \"Studio is running (read-only).\" : \"Studio health check failed.\";\n })\n .catch(() => {\n document.getElementById(\"status\").textContent = \"Studio health check failed.\";\n });\n </script>\n</body>\n</html>";
182
+ declare const studioIndexHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'\" />\n <title>AgentInspect Studio</title>\n <style>\n body { font-family: system-ui, sans-serif; margin: 0; line-height: 1.4; color: #111; }\n header { padding: 1rem 1.5rem; border-bottom: 1px solid #ddd; background: #fafafa; }\n nav a { margin-right: 1rem; color: #0b57d0; text-decoration: none; }\n nav a.active { font-weight: 600; text-decoration: underline; }\n main { padding: 1.5rem; max-width: 1100px; }\n .muted { color: #666; }\n table { border-collapse: collapse; width: 100%; margin: 1rem 0; }\n th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; vertical-align: top; }\n th { background: #f6f6f6; }\n pre { background: #f4f4f5; padding: 1rem; overflow: auto; max-height: 60vh; }\n .error { color: #b42318; }\n </style>\n</head>\n<body>\n <header>\n <h1>AgentInspect Studio</h1>\n <p class=\"muted\">Self-hosted, read-only. SQLite only. JSONL remains canonical.</p>\n <nav id=\"nav\">\n <a href=\"#projects\" data-page=\"projects\">Projects</a>\n <a href=\"#runs\" data-page=\"runs\">Runs</a>\n <a href=\"#sessions\" data-page=\"sessions\">Sessions</a>\n <a href=\"#suites\" data-page=\"suites\">Suites</a>\n <a href=\"#safety\" data-page=\"safety\">Safety</a>\n <a href=\"#imports\" data-page=\"imports\">Imports</a>\n <a href=\"#search\" data-page=\"search\">Search</a>\n </nav>\n </header>\n <main id=\"content\"><p class=\"muted\">Loading\u2026</p></main>\n <script>\n function escapeHtml(value) {\n return String(value ?? \"\")\n .replaceAll(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\")\n .replaceAll(\"'\", \"&#39;\");\n }\n\n let state = { projects: [], projectId: null };\n\n async function api(path) {\n const res = await fetch(path);\n const data = await res.json();\n if (!res.ok) throw new Error(data.error || res.statusText);\n return data;\n }\n\n function setPage(page) {\n document.querySelectorAll(\"#nav a\").forEach((a) => {\n a.classList.toggle(\"active\", a.dataset.page === page);\n });\n }\n\n function renderProjects() {\n setPage(\"projects\");\n const rows = state.projects.map((p) =>\n '<tr><td><a href=\"#runs\" data-select-project=\"' + escapeHtml(p.id) + '\">' +\n escapeHtml(p.label || p.id) + '</a></td><td>' + escapeHtml(p.traceCount) +\n '</td><td>' + escapeHtml(p.path) + '</td></tr>'\n ).join(\"\");\n document.getElementById(\"content\").innerHTML =\n '<h2>Projects</h2><table><thead><tr><th>Project</th><th>Runs</th><th>Path</th></tr></thead><tbody>' +\n (rows || '<tr><td colspan=\"3\">No projects imported</td></tr>') + '</tbody></table>';\n document.querySelectorAll(\"[data-select-project]\").forEach((el) => {\n el.addEventListener(\"click\", (ev) => {\n ev.preventDefault();\n state.projectId = el.getAttribute(\"data-select-project\");\n location.hash = \"runs\";\n renderRuns();\n });\n });\n }\n\n async function renderRuns() {\n setPage(\"runs\");\n if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;\n if (!state.projectId) {\n document.getElementById(\"content\").innerHTML = '<p>Select a project first.</p>';\n return;\n }\n const data = await api(\"/api/projects/\" + encodeURIComponent(state.projectId) + \"/runs\");\n const rows = (data.runs || []).map((r) =>\n '<tr><td>' + escapeHtml(r.runId) + '</td><td>' + escapeHtml(r.status) +\n '</td><td>' + escapeHtml(r.name) + '</td><td>' + escapeHtml(r.durationMs) + '</td></tr>'\n ).join(\"\");\n document.getElementById(\"content\").innerHTML =\n '<h2>Runs \u2014 ' + escapeHtml(state.projectId) + '</h2>' +\n '<table><thead><tr><th>Run ID</th><th>Status</th><th>Name</th><th>Duration ms</th></tr></thead><tbody>' +\n rows + '</tbody></table>';\n }\n\n async function renderSimple(title, path) {\n setPage(title.toLowerCase());\n if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;\n const data = await api(\"/api/projects/\" + encodeURIComponent(state.projectId) + \"/\" + path);\n document.getElementById(\"content\").innerHTML =\n '<h2>' + escapeHtml(title) + '</h2><pre>' + escapeHtml(JSON.stringify(data, null, 2)) + '</pre>';\n }\n\n async function renderImports() {\n setPage(\"imports\");\n const health = await api(\"/api/health\");\n document.getElementById(\"content\").innerHTML =\n '<h2>Imports</h2><p>Registry: ' + escapeHtml(health.registryName) + '</p>' +\n '<pre>' + escapeHtml(JSON.stringify(health.warnings || [], null, 2)) + '</pre>';\n }\n\n async function renderSearch() {\n setPage(\"search\");\n if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;\n document.getElementById(\"content\").innerHTML =\n '<h2>Search</h2><p><input id=\"q\" placeholder=\"run id or name\" /> <button id=\"go\">Search</button></p><pre id=\"out\"></pre>';\n document.getElementById(\"go\").onclick = async () => {\n const q = document.getElementById(\"q\").value;\n const data = await api(\"/api/search?projectId=\" + encodeURIComponent(state.projectId) + \"&q=\" + encodeURIComponent(q));\n document.getElementById(\"out\").textContent = JSON.stringify(data, null, 2);\n };\n }\n\n async function boot() {\n const health = await api(\"/api/health\");\n const projects = await api(\"/api/projects\");\n state.projects = projects.projects || health.projects || [];\n const page = (location.hash || \"#projects\").slice(1);\n if (page === \"runs\") return renderRuns();\n if (page === \"sessions\") return renderSimple(\"Sessions\", \"sessions\");\n if (page === \"suites\") return renderSimple(\"Suites\", \"suites\");\n if (page === \"safety\") return renderSimple(\"Safety\", \"redaction\");\n if (page === \"imports\") return renderImports();\n if (page === \"search\") return renderSearch();\n return renderProjects();\n }\n\n window.addEventListener(\"hashchange\", () => boot().catch(showError));\n function showError(err) {\n document.getElementById(\"content\").innerHTML = '<p class=\"error\">' + escapeHtml(String(err)) + '</p>';\n }\n boot().catch(showError);\n </script>\n</body>\n</html>";
183
183
 
184
184
  type StudioFetch = typeof fetch;
185
185
  interface GitHubArtifactImportOptions {
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
- import { readFile, mkdir, access, readdir, stat, writeFile, copyFile, rename } from 'fs/promises';
1
+ import { readFile, access, readdir, stat, mkdir, writeFile, copyFile, rename } from 'fs/promises';
2
2
  import path8 from 'path';
3
3
  import { loadSessionRunRecords, buildSessionIndex, runSuite, buildRunTimeline, extractOutcomesFromTraceEvents, resolveTraceDir, searchTraces, TraceDirectory, loadTraceMetadataList } from 'agent-inspect/advanced';
4
4
  import { resolveWorkspaceLocation, readWorkspaceManifestFile } from 'agent-inspect/workspace';
5
+ import { mkdirSync } from 'fs';
5
6
  import Database from 'better-sqlite3';
6
7
  import { timingSafeEqual, createHash } from 'crypto';
7
8
  import { createServer } from 'http';
@@ -310,11 +311,16 @@ function isPostgresDbPath(dbPath) {
310
311
  function openStudioDb(dbPath) {
311
312
  if (isPostgresDbPath(dbPath)) {
312
313
  throw new Error(
313
- "Postgres studio databases are not implemented in v6.0.0; use a SQLite file path."
314
+ "Postgres studio databases are not supported; use a SQLite file path (preview only)."
314
315
  );
315
316
  }
316
317
  const dir = path8.dirname(dbPath);
317
- void mkdir(dir, { recursive: true });
318
+ try {
319
+ mkdirSync(dir, { recursive: true });
320
+ } catch (error) {
321
+ const message = error instanceof Error ? error.message : String(error);
322
+ throw new Error(`Failed to create studio database directory: ${message}`);
323
+ }
318
324
  const db = new Database(dbPath);
319
325
  db.pragma("journal_mode = WAL");
320
326
  db.exec(SCHEMA_SQL);
@@ -968,27 +974,147 @@ var studioIndexHtml = `<!DOCTYPE html>
968
974
  <html lang="en">
969
975
  <head>
970
976
  <meta charset="utf-8" />
977
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" />
971
978
  <title>AgentInspect Studio</title>
972
979
  <style>
973
- body { font-family: system-ui, sans-serif; margin: 1.5rem; line-height: 1.4; }
974
- h1 { font-size: 1.25rem; }
980
+ body { font-family: system-ui, sans-serif; margin: 0; line-height: 1.4; color: #111; }
981
+ header { padding: 1rem 1.5rem; border-bottom: 1px solid #ddd; background: #fafafa; }
982
+ nav a { margin-right: 1rem; color: #0b57d0; text-decoration: none; }
983
+ nav a.active { font-weight: 600; text-decoration: underline; }
984
+ main { padding: 1.5rem; max-width: 1100px; }
975
985
  .muted { color: #666; }
986
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
987
+ th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; vertical-align: top; }
988
+ th { background: #f6f6f6; }
989
+ pre { background: #f4f4f5; padding: 1rem; overflow: auto; max-height: 60vh; }
990
+ .error { color: #b42318; }
976
991
  </style>
977
992
  </head>
978
993
  <body>
979
- <h1>AgentInspect Studio</h1>
980
- <p class="muted">Self-hosted, read-only. JSONL and workspace manifests remain canonical.</p>
981
- <p id="status">Loading health\u2026</p>
994
+ <header>
995
+ <h1>AgentInspect Studio</h1>
996
+ <p class="muted">Self-hosted, read-only. SQLite only. JSONL remains canonical.</p>
997
+ <nav id="nav">
998
+ <a href="#projects" data-page="projects">Projects</a>
999
+ <a href="#runs" data-page="runs">Runs</a>
1000
+ <a href="#sessions" data-page="sessions">Sessions</a>
1001
+ <a href="#suites" data-page="suites">Suites</a>
1002
+ <a href="#safety" data-page="safety">Safety</a>
1003
+ <a href="#imports" data-page="imports">Imports</a>
1004
+ <a href="#search" data-page="search">Search</a>
1005
+ </nav>
1006
+ </header>
1007
+ <main id="content"><p class="muted">Loading\u2026</p></main>
982
1008
  <script>
983
- fetch("/api/health")
984
- .then((res) => res.json())
985
- .then((data) => {
986
- document.getElementById("status").textContent =
987
- data.ok ? "Studio is running (read-only)." : "Studio health check failed.";
988
- })
989
- .catch(() => {
990
- document.getElementById("status").textContent = "Studio health check failed.";
1009
+ function escapeHtml(value) {
1010
+ return String(value ?? "")
1011
+ .replaceAll("&", "&amp;")
1012
+ .replaceAll("<", "&lt;")
1013
+ .replaceAll(">", "&gt;")
1014
+ .replaceAll('"', "&quot;")
1015
+ .replaceAll("'", "&#39;");
1016
+ }
1017
+
1018
+ let state = { projects: [], projectId: null };
1019
+
1020
+ async function api(path) {
1021
+ const res = await fetch(path);
1022
+ const data = await res.json();
1023
+ if (!res.ok) throw new Error(data.error || res.statusText);
1024
+ return data;
1025
+ }
1026
+
1027
+ function setPage(page) {
1028
+ document.querySelectorAll("#nav a").forEach((a) => {
1029
+ a.classList.toggle("active", a.dataset.page === page);
991
1030
  });
1031
+ }
1032
+
1033
+ function renderProjects() {
1034
+ setPage("projects");
1035
+ const rows = state.projects.map((p) =>
1036
+ '<tr><td><a href="#runs" data-select-project="' + escapeHtml(p.id) + '">' +
1037
+ escapeHtml(p.label || p.id) + '</a></td><td>' + escapeHtml(p.traceCount) +
1038
+ '</td><td>' + escapeHtml(p.path) + '</td></tr>'
1039
+ ).join("");
1040
+ document.getElementById("content").innerHTML =
1041
+ '<h2>Projects</h2><table><thead><tr><th>Project</th><th>Runs</th><th>Path</th></tr></thead><tbody>' +
1042
+ (rows || '<tr><td colspan="3">No projects imported</td></tr>') + '</tbody></table>';
1043
+ document.querySelectorAll("[data-select-project]").forEach((el) => {
1044
+ el.addEventListener("click", (ev) => {
1045
+ ev.preventDefault();
1046
+ state.projectId = el.getAttribute("data-select-project");
1047
+ location.hash = "runs";
1048
+ renderRuns();
1049
+ });
1050
+ });
1051
+ }
1052
+
1053
+ async function renderRuns() {
1054
+ setPage("runs");
1055
+ if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;
1056
+ if (!state.projectId) {
1057
+ document.getElementById("content").innerHTML = '<p>Select a project first.</p>';
1058
+ return;
1059
+ }
1060
+ const data = await api("/api/projects/" + encodeURIComponent(state.projectId) + "/runs");
1061
+ const rows = (data.runs || []).map((r) =>
1062
+ '<tr><td>' + escapeHtml(r.runId) + '</td><td>' + escapeHtml(r.status) +
1063
+ '</td><td>' + escapeHtml(r.name) + '</td><td>' + escapeHtml(r.durationMs) + '</td></tr>'
1064
+ ).join("");
1065
+ document.getElementById("content").innerHTML =
1066
+ '<h2>Runs \u2014 ' + escapeHtml(state.projectId) + '</h2>' +
1067
+ '<table><thead><tr><th>Run ID</th><th>Status</th><th>Name</th><th>Duration ms</th></tr></thead><tbody>' +
1068
+ rows + '</tbody></table>';
1069
+ }
1070
+
1071
+ async function renderSimple(title, path) {
1072
+ setPage(title.toLowerCase());
1073
+ if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;
1074
+ const data = await api("/api/projects/" + encodeURIComponent(state.projectId) + "/" + path);
1075
+ document.getElementById("content").innerHTML =
1076
+ '<h2>' + escapeHtml(title) + '</h2><pre>' + escapeHtml(JSON.stringify(data, null, 2)) + '</pre>';
1077
+ }
1078
+
1079
+ async function renderImports() {
1080
+ setPage("imports");
1081
+ const health = await api("/api/health");
1082
+ document.getElementById("content").innerHTML =
1083
+ '<h2>Imports</h2><p>Registry: ' + escapeHtml(health.registryName) + '</p>' +
1084
+ '<pre>' + escapeHtml(JSON.stringify(health.warnings || [], null, 2)) + '</pre>';
1085
+ }
1086
+
1087
+ async function renderSearch() {
1088
+ setPage("search");
1089
+ if (!state.projectId && state.projects[0]) state.projectId = state.projects[0].id;
1090
+ document.getElementById("content").innerHTML =
1091
+ '<h2>Search</h2><p><input id="q" placeholder="run id or name" /> <button id="go">Search</button></p><pre id="out"></pre>';
1092
+ document.getElementById("go").onclick = async () => {
1093
+ const q = document.getElementById("q").value;
1094
+ const data = await api("/api/search?projectId=" + encodeURIComponent(state.projectId) + "&q=" + encodeURIComponent(q));
1095
+ document.getElementById("out").textContent = JSON.stringify(data, null, 2);
1096
+ };
1097
+ }
1098
+
1099
+ async function boot() {
1100
+ const health = await api("/api/health");
1101
+ const projects = await api("/api/projects");
1102
+ state.projects = projects.projects || health.projects || [];
1103
+ const page = (location.hash || "#projects").slice(1);
1104
+ if (page === "runs") return renderRuns();
1105
+ if (page === "sessions") return renderSimple("Sessions", "sessions");
1106
+ if (page === "suites") return renderSimple("Suites", "suites");
1107
+ if (page === "safety") return renderSimple("Safety", "redaction");
1108
+ if (page === "imports") return renderImports();
1109
+ if (page === "search") return renderSearch();
1110
+ return renderProjects();
1111
+ }
1112
+
1113
+ window.addEventListener("hashchange", () => boot().catch(showError));
1114
+ function showError(err) {
1115
+ document.getElementById("content").innerHTML = '<p class="error">' + escapeHtml(String(err)) + '</p>';
1116
+ }
1117
+ boot().catch(showError);
992
1118
  </script>
993
1119
  </body>
994
1120
  </html>`;