@prismatic-io/lux 0.0.2-preview.4 → 0.0.2-preview.5

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 (110) hide show
  1. package/lib/assertions/authoring.d.ts +2 -2
  2. package/lib/assertions/authoring.d.ts.map +1 -1
  3. package/lib/assertions/authoring.js.map +1 -1
  4. package/lib/assertions/core/index.d.ts.map +1 -1
  5. package/lib/assertions/core/index.js +2 -1
  6. package/lib/assertions/core/index.js.map +1 -1
  7. package/lib/assertions/core/resource-checks.d.ts +19 -0
  8. package/lib/assertions/core/resource-checks.d.ts.map +1 -1
  9. package/lib/assertions/core/resource-checks.js +32 -0
  10. package/lib/assertions/core/resource-checks.js.map +1 -1
  11. package/lib/cli/program.d.ts.map +1 -1
  12. package/lib/cli/program.js +6 -65
  13. package/lib/cli/program.js.map +1 -1
  14. package/lib/cli/view.d.ts +13 -0
  15. package/lib/cli/view.d.ts.map +1 -0
  16. package/lib/cli/view.js +126 -0
  17. package/lib/cli/view.js.map +1 -0
  18. package/lib/core/action-events.d.ts +29 -0
  19. package/lib/core/action-events.d.ts.map +1 -0
  20. package/lib/core/action-events.js +51 -0
  21. package/lib/core/action-events.js.map +1 -0
  22. package/lib/core/driver-metrics.d.ts +98 -0
  23. package/lib/core/driver-metrics.d.ts.map +1 -0
  24. package/lib/core/driver-metrics.js +36 -0
  25. package/lib/core/driver-metrics.js.map +1 -0
  26. package/lib/core/events.js +2 -2
  27. package/lib/core/experiment.d.ts +19 -0
  28. package/lib/core/experiment.d.ts.map +1 -1
  29. package/lib/core/experiment.js +2 -0
  30. package/lib/core/experiment.js.map +1 -1
  31. package/lib/core/index.d.ts +2 -0
  32. package/lib/core/index.d.ts.map +1 -1
  33. package/lib/core/index.js +2 -0
  34. package/lib/core/index.js.map +1 -1
  35. package/lib/core/run.d.ts +50 -0
  36. package/lib/core/run.d.ts.map +1 -1
  37. package/lib/core/run.js +6 -0
  38. package/lib/core/run.js.map +1 -1
  39. package/lib/index.d.ts +3 -1
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +3 -1
  42. package/lib/index.js.map +1 -1
  43. package/lib/orchestrator/compare.d.ts +9 -1
  44. package/lib/orchestrator/compare.d.ts.map +1 -1
  45. package/lib/orchestrator/compare.js +8 -0
  46. package/lib/orchestrator/compare.js.map +1 -1
  47. package/lib/orchestrator/experiment-contracts.d.ts +19 -0
  48. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  49. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  50. package/lib/orchestrator/experiment-evaluation.js +17 -0
  51. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  52. package/lib/orchestrator/experiment-report.d.ts +19 -0
  53. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  54. package/lib/orchestrator/experiment-runs.d.ts +38 -0
  55. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  56. package/lib/orchestrator/index.d.ts +1 -1
  57. package/lib/orchestrator/index.d.ts.map +1 -1
  58. package/lib/orchestrator/index.js +1 -1
  59. package/lib/orchestrator/index.js.map +1 -1
  60. package/lib/orchestrator/list-runs.d.ts +6 -1
  61. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  62. package/lib/orchestrator/list-runs.js +15 -1
  63. package/lib/orchestrator/list-runs.js.map +1 -1
  64. package/lib/orchestrator/run-execution.d.ts.map +1 -1
  65. package/lib/orchestrator/run-execution.js +11 -1
  66. package/lib/orchestrator/run-execution.js.map +1 -1
  67. package/lib/orchestrator/suite-compare.d.ts +34 -0
  68. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  69. package/lib/orchestrator/suite-summary.d.ts +68 -0
  70. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  71. package/lib/orchestrator/suite-summary.js +22 -7
  72. package/lib/orchestrator/suite-summary.js.map +1 -1
  73. package/lib/previewer/artifacts.d.ts +25 -0
  74. package/lib/previewer/artifacts.d.ts.map +1 -0
  75. package/lib/previewer/artifacts.js +180 -0
  76. package/lib/previewer/artifacts.js.map +1 -0
  77. package/lib/previewer/server.d.ts +104 -0
  78. package/lib/previewer/server.d.ts.map +1 -0
  79. package/lib/previewer/server.js +541 -0
  80. package/lib/previewer/server.js.map +1 -0
  81. package/package.json +2 -1
  82. package/skills/lux/references/eval-authoring.md +4 -2
  83. package/skills/lux-answerer/SKILL.md +1 -1
  84. package/src/assertions/authoring.ts +2 -0
  85. package/src/assertions/core/index.ts +2 -0
  86. package/src/assertions/core/resource-checks.ts +35 -0
  87. package/src/cli/program.ts +5 -91
  88. package/src/cli/view.ts +168 -0
  89. package/src/core/action-events.ts +59 -0
  90. package/src/core/driver-metrics.ts +51 -0
  91. package/src/core/events.ts +2 -2
  92. package/src/core/experiment.ts +2 -0
  93. package/src/core/index.ts +2 -0
  94. package/src/core/run.ts +6 -0
  95. package/src/index.ts +25 -0
  96. package/src/orchestrator/compare.ts +12 -0
  97. package/src/orchestrator/experiment-evaluation.ts +23 -0
  98. package/src/orchestrator/index.ts +1 -0
  99. package/src/orchestrator/list-runs.ts +25 -0
  100. package/src/orchestrator/run-execution.ts +12 -0
  101. package/src/orchestrator/suite-summary.ts +36 -8
  102. package/src/previewer/artifacts.ts +221 -0
  103. package/src/previewer/server.ts +763 -0
  104. package/viewer/app.css +2022 -0
  105. package/viewer/app.js +601 -0
  106. package/viewer/artifact-view.js +48 -0
  107. package/viewer/charts.js +86 -0
  108. package/viewer/detail.js +1108 -0
  109. package/viewer/format.js +129 -0
  110. package/viewer/index.html +151 -0
package/viewer/app.js ADDED
@@ -0,0 +1,601 @@
1
+ import {
2
+ detailEmpty,
3
+ renderArtifactComparison,
4
+ renderArtifactPreview,
5
+ renderExperimentDetail,
6
+ renderRunComparison,
7
+ renderRunDetail,
8
+ renderSuiteComparison,
9
+ renderSuiteDetail,
10
+ } from "./detail.js";
11
+ import {
12
+ detailError,
13
+ detailLoading,
14
+ escapeHtml,
15
+ fullDate,
16
+ percentage,
17
+ relativeTime,
18
+ statusLabel,
19
+ } from "./format.js";
20
+
21
+ const state = {
22
+ snapshot: null,
23
+ initialized: false,
24
+ tab: "runs",
25
+ status: "all",
26
+ query: "",
27
+ selectedId: null,
28
+ detail: null,
29
+ loadingDetail: false,
30
+ detailRequest: 0,
31
+ compareMode: false,
32
+ compareIds: [],
33
+ comparison: null,
34
+ loadingComparison: false,
35
+ comparisonRequest: 0,
36
+ candidateTargetId: null,
37
+ overlay: null,
38
+ };
39
+
40
+ const elements = {
41
+ list: document.querySelector("#collection-list"),
42
+ inspector: document.querySelector("#inspector"),
43
+ filters: document.querySelector("#status-filters"),
44
+ title: document.querySelector("#collection-title"),
45
+ eyebrow: document.querySelector("#collection-eyebrow"),
46
+ count: document.querySelector("#result-count"),
47
+ search: document.querySelector("#search-input"),
48
+ refresh: document.querySelector("#refresh-button"),
49
+ compare: document.querySelector("#compare-button"),
50
+ compareTray: document.querySelector("#compare-tray"),
51
+ connection: document.querySelector("#connection-state"),
52
+ };
53
+
54
+ const currentItems = () => state.snapshot?.[state.tab] ?? [];
55
+
56
+ const searchableText = (item) =>
57
+ [
58
+ item.caseId,
59
+ item.driver,
60
+ item.agentId,
61
+ item.suiteId,
62
+ item.profileId,
63
+ item.experimentId,
64
+ item.campaignId,
65
+ item.championCandidateId,
66
+ item.path,
67
+ ]
68
+ .filter(Boolean)
69
+ .join(" ")
70
+ .toLowerCase();
71
+
72
+ const visibleItems = () => {
73
+ const query = state.query.trim().toLowerCase();
74
+ return currentItems()
75
+ .filter((item) => state.status === "all" || item.status === state.status)
76
+ .filter((item) => !query || searchableText(item).includes(query))
77
+ .sort((left, right) =>
78
+ String(right.startedAt ?? right.experimentId ?? right.suiteId).localeCompare(
79
+ String(left.startedAt ?? left.experimentId ?? left.suiteId),
80
+ ),
81
+ );
82
+ };
83
+
84
+ const rowTitle = (item) => item.caseId ?? item.campaignId ?? item.suiteId;
85
+
86
+ const rowMetric = (item) => {
87
+ if (item.kind === "run") {
88
+ return `<strong>${escapeHtml(percentage(item.score))}</strong><small>${escapeHtml(
89
+ `${item.assertions.passed}/${item.assertions.total} checks`,
90
+ )}</small>`;
91
+ }
92
+ if (item.kind === "suite") {
93
+ const complete = item.items.filter((entry) => entry.status === "completed").length;
94
+ return `<strong>${complete}/${item.items.length}</strong><small>complete</small>`;
95
+ }
96
+ return `<strong>${item.candidateCount}</strong><small>candidates</small>`;
97
+ };
98
+
99
+ const rowMeta = (item) => {
100
+ if (item.kind === "run") {
101
+ return [item.driver, item.agentId, relativeTime(item.startedAt)].filter(Boolean);
102
+ }
103
+ if (item.kind === "suite") {
104
+ return [item.profileId ?? "default profile", fullDate(item.startedAt), `j=${item.concurrency}`];
105
+ }
106
+ return [item.mode, fullDate(item.startedAt), item.championCandidateId ?? "no champion"];
107
+ };
108
+
109
+ const compareSlot = (id) => {
110
+ const index = state.compareIds.indexOf(id);
111
+ return index < 0 ? "" : `<span class="compare-slot">${index === 0 ? "A" : "B"}</span>`;
112
+ };
113
+
114
+ const renderRows = () => {
115
+ const items = visibleItems();
116
+ elements.count.textContent = `${items.length} ${items.length === 1 ? "record" : "records"}`;
117
+ if (items.length === 0) {
118
+ elements.list.innerHTML = `
119
+ <div class="empty-list">
120
+ <strong>No matching ${escapeHtml(state.tab)}</strong>
121
+ <p>Try another status or search term.</p>
122
+ </div>`;
123
+ return;
124
+ }
125
+ elements.list.innerHTML = items
126
+ .map(
127
+ (item) => `
128
+ <button
129
+ class="evidence-row${state.compareMode ? " is-compare-mode" : ""}${state.selectedId === item.id && !state.compareMode ? " is-selected" : ""}${
130
+ state.compareIds.includes(item.id) ? " is-compared" : ""
131
+ }"
132
+ type="button"
133
+ data-select="${escapeHtml(item.id)}"
134
+ >
135
+ ${state.compareMode ? compareSlot(item.id) || '<span class="compare-slot empty">+</span>' : ""}
136
+ <span class="status-pin ${escapeHtml(item.status)}" aria-label="${escapeHtml(
137
+ statusLabel(item.status),
138
+ )}"></span>
139
+ <span class="row-main">
140
+ <span class="row-title">${escapeHtml(rowTitle(item))}</span>
141
+ <span class="row-meta">
142
+ ${rowMeta(item)
143
+ .map((value) => `<span>${escapeHtml(value)}</span>`)
144
+ .join("<b>·</b>")}
145
+ </span>
146
+ </span>
147
+ <span class="row-metric">${rowMetric(item)}</span>
148
+ </button>`,
149
+ )
150
+ .join("");
151
+ };
152
+
153
+ const renderFilters = () => {
154
+ const items = currentItems();
155
+ const statuses = ["all", "passed", "failed", "error", "running", "aborted", "ungraded"].filter(
156
+ (status) => status === "all" || items.some((item) => item.status === status),
157
+ );
158
+ elements.filters.innerHTML = statuses
159
+ .map((status) => {
160
+ const count =
161
+ status === "all" ? items.length : items.filter((item) => item.status === status).length;
162
+ return `<button
163
+ class="filter-chip${state.status === status ? " is-active" : ""}"
164
+ type="button"
165
+ data-status="${escapeHtml(status)}"
166
+ >${escapeHtml(status === "all" ? "All" : statusLabel(status))} · ${count}</button>`;
167
+ })
168
+ .join("");
169
+ };
170
+
171
+ const renderCompareControls = () => {
172
+ const available = state.tab === "runs" || state.tab === "suites";
173
+ elements.compare.hidden = !available;
174
+ elements.compare.classList.toggle("is-active", state.compareMode);
175
+ elements.compare.textContent = state.compareMode ? "Cancel compare" : "Compare";
176
+ if (!state.compareMode) {
177
+ elements.compareTray.hidden = true;
178
+ return;
179
+ }
180
+ const selected = state.compareIds.map((id) => currentItems().find((item) => item.id === id));
181
+ elements.compareTray.hidden = false;
182
+ elements.compareTray.innerHTML = `
183
+ <span><b>A</b>${escapeHtml(selected[0] ? rowTitle(selected[0]) : "Choose baseline")}</span>
184
+ <i>→</i>
185
+ <span><b>B</b>${escapeHtml(selected[1] ? rowTitle(selected[1]) : "Choose candidate")}</span>
186
+ <small>${state.compareIds.length < 2 ? "Select two rows" : "Exact persisted evidence"}</small>`;
187
+ };
188
+
189
+ const comparisonLabels = () => {
190
+ const [beforeId, afterId] = state.compareIds;
191
+ return {
192
+ before: rowTitle(currentItems().find((item) => item.id === beforeId) ?? {}) ?? "A",
193
+ after: rowTitle(currentItems().find((item) => item.id === afterId) ?? {}) ?? "B",
194
+ };
195
+ };
196
+
197
+ const renderInspector = () => {
198
+ if (state.overlay?.loading) {
199
+ elements.inspector.innerHTML = detailLoading("Reading artifact…");
200
+ return;
201
+ }
202
+ if (state.overlay?.error) {
203
+ elements.inspector.innerHTML = detailError(state.overlay.error);
204
+ return;
205
+ }
206
+ if (state.overlay?.type === "artifact") {
207
+ elements.inspector.innerHTML = renderArtifactPreview(state.overlay.data);
208
+ return;
209
+ }
210
+ if (state.overlay?.type === "artifact-comparison") {
211
+ elements.inspector.innerHTML = renderArtifactComparison(state.overlay.data);
212
+ return;
213
+ }
214
+ if (state.compareMode) {
215
+ if (state.loadingComparison) {
216
+ elements.inspector.innerHTML = detailLoading("Comparing persisted evidence…");
217
+ return;
218
+ }
219
+ if (state.comparison) {
220
+ elements.inspector.innerHTML =
221
+ state.tab === "runs"
222
+ ? renderRunComparison(state.comparison, comparisonLabels())
223
+ : renderSuiteComparison(state.comparison, comparisonLabels());
224
+ return;
225
+ }
226
+ elements.inspector.innerHTML = `
227
+ <div class="inspector-empty compare-empty">
228
+ <span class="empty-glyph" aria-hidden="true">A/B</span>
229
+ <h2>Choose a baseline and candidate</h2>
230
+ <p>Lux will compare identity, outcomes, judgments, resources, and artifacts from the exact persisted evidence.</p>
231
+ </div>`;
232
+ return;
233
+ }
234
+ const item = currentItems().find((entry) => entry.id === state.selectedId);
235
+ if (!item) {
236
+ elements.inspector.innerHTML = detailEmpty();
237
+ return;
238
+ }
239
+ if (state.loadingDetail) {
240
+ elements.inspector.innerHTML = detailLoading(`Loading ${item.kind} evidence…`);
241
+ return;
242
+ }
243
+ if (!state.detail) {
244
+ elements.inspector.innerHTML = detailEmpty();
245
+ return;
246
+ }
247
+ if (item.kind === "run") {
248
+ elements.inspector.innerHTML = renderRunDetail(item, state.detail);
249
+ } else if (item.kind === "suite") {
250
+ elements.inspector.innerHTML = renderSuiteDetail(item, state.detail);
251
+ } else {
252
+ elements.inspector.innerHTML = renderExperimentDetail(
253
+ item,
254
+ state.detail,
255
+ state.candidateTargetId,
256
+ );
257
+ }
258
+ };
259
+
260
+ const renderMetrics = () => {
261
+ const runs = state.snapshot?.runs ?? [];
262
+ const graded = runs.filter((run) => run.status === "passed" || run.status === "failed");
263
+ const passed = graded.filter((run) => run.status === "passed").length;
264
+ const failures = runs.filter((run) => run.status === "failed" || run.status === "error").length;
265
+ const experiments = state.snapshot?.experiments ?? [];
266
+ const running = experiments.filter((experiment) => experiment.status === "running").length;
267
+ const latest = runs
268
+ .map((run) => run.startedAt)
269
+ .filter(Boolean)
270
+ .sort()
271
+ .at(-1);
272
+ document.querySelector("#metric-pass-rate").textContent =
273
+ graded.length > 0 ? percentage(passed / graded.length) : "—";
274
+ document.querySelector("#metric-pass-note").textContent =
275
+ graded.length > 0 ? `${passed} of ${graded.length} graded runs passed` : "No graded runs";
276
+ document.querySelector("#metric-failures").textContent = String(failures);
277
+ document.querySelector("#metric-experiments").textContent = String(experiments.length);
278
+ let experimentNote = "No campaigns yet";
279
+ if (running) experimentNote = `${running} campaign${running === 1 ? "" : "s"} running`;
280
+ else if (experiments.length) experimentNote = "All campaigns settled";
281
+ document.querySelector("#metric-experiment-note").textContent = experimentNote;
282
+ document.querySelector("#metric-recency").textContent = latest ? relativeTime(latest) : "—";
283
+ document.querySelector("#metric-refresh").textContent = `Refreshed ${relativeTime(
284
+ state.snapshot?.generatedAt,
285
+ )} ago`;
286
+ };
287
+
288
+ const renderChrome = () => {
289
+ const copy = {
290
+ runs: ["Individual evidence", "Runs"],
291
+ suites: ["Invocation groups", "Suites"],
292
+ experiments: ["Controlled change", "Experiments"],
293
+ }[state.tab];
294
+ elements.eyebrow.textContent = copy[0];
295
+ elements.title.textContent = copy[1];
296
+ document.querySelectorAll("[data-tab]").forEach((button) => {
297
+ button.classList.toggle("is-active", button.dataset.tab === state.tab);
298
+ });
299
+ for (const tab of ["runs", "suites", "experiments"]) {
300
+ document.querySelector(`[data-count="${tab}"]`).textContent = String(
301
+ state.snapshot?.[tab]?.length ?? 0,
302
+ );
303
+ }
304
+ document.querySelector("#runs-root").textContent =
305
+ state.snapshot?.runsRoot ?? "Local Lux run store";
306
+ renderMetrics();
307
+ renderFilters();
308
+ renderCompareControls();
309
+ renderRows();
310
+ renderInspector();
311
+ };
312
+
313
+ const setConnection = (connected, message) => {
314
+ elements.connection.classList.toggle("is-offline", !connected);
315
+ elements.connection.lastChild.textContent = ` ${message}`;
316
+ };
317
+
318
+ const updateUrl = () => {
319
+ if (!state.initialized) return;
320
+ const params = new URLSearchParams();
321
+ if (state.tab !== "runs") params.set("tab", state.tab);
322
+ if (state.status !== "all") params.set("status", state.status);
323
+ if (state.query) params.set("q", state.query);
324
+ if (state.selectedId && !state.compareMode) params.set("item", state.selectedId);
325
+ if (state.compareIds[0]) params.set("before", state.compareIds[0]);
326
+ if (state.compareIds[1]) params.set("after", state.compareIds[1]);
327
+ if (state.candidateTargetId) params.set("target", state.candidateTargetId);
328
+ const query = params.toString();
329
+ history.replaceState(null, "", query ? `?${query}` : location.pathname);
330
+ };
331
+
332
+ const fetchJson = async (url) => {
333
+ const response = await fetch(url, { cache: "no-store" });
334
+ const payload = await response.json();
335
+ if (!response.ok) throw new Error(payload.error ?? `Request failed (${response.status})`);
336
+ return payload;
337
+ };
338
+
339
+ const fetchDetail = async (id, { silent = false } = {}) => {
340
+ const request = ++state.detailRequest;
341
+ if (!silent) {
342
+ state.loadingDetail = true;
343
+ state.detail = null;
344
+ renderInspector();
345
+ }
346
+ try {
347
+ const payload = await fetchJson(`/api/${state.tab}/${encodeURIComponent(id)}`);
348
+ if (request !== state.detailRequest || id !== state.selectedId) return;
349
+ state.detail = payload;
350
+ state.loadingDetail = false;
351
+ renderInspector();
352
+ } catch (error) {
353
+ if (request !== state.detailRequest) return;
354
+ state.loadingDetail = false;
355
+ elements.inspector.innerHTML = detailError(error.message);
356
+ }
357
+ };
358
+
359
+ const fetchComparison = async ({ silent = false } = {}) => {
360
+ const [before, after] = state.compareIds;
361
+ if (!before || !after) return;
362
+ const request = ++state.comparisonRequest;
363
+ if (!silent) {
364
+ state.loadingComparison = true;
365
+ state.comparison = null;
366
+ renderInspector();
367
+ }
368
+ try {
369
+ const params = new URLSearchParams({ before, after });
370
+ const payload = await fetchJson(`/api/compare/${state.tab}?${params}`);
371
+ if (request !== state.comparisonRequest) return;
372
+ state.comparison = payload;
373
+ state.loadingComparison = false;
374
+ renderInspector();
375
+ } catch (error) {
376
+ if (request !== state.comparisonRequest) return;
377
+ state.loadingComparison = false;
378
+ elements.inspector.innerHTML = detailError(error.message);
379
+ }
380
+ };
381
+
382
+ const selectItem = (id) => {
383
+ state.selectedId = id;
384
+ state.detail = null;
385
+ state.overlay = null;
386
+ state.candidateTargetId = null;
387
+ updateUrl();
388
+ renderRows();
389
+ void fetchDetail(id);
390
+ };
391
+
392
+ const selectComparisonItem = (id) => {
393
+ const currentIndex = state.compareIds.indexOf(id);
394
+ if (currentIndex >= 0) state.compareIds.splice(currentIndex, 1);
395
+ else if (state.compareIds.length < 2) state.compareIds.push(id);
396
+ else state.compareIds[1] = id;
397
+ state.comparison = null;
398
+ state.overlay = null;
399
+ renderChrome();
400
+ updateUrl();
401
+ if (state.compareIds.length === 2) void fetchComparison();
402
+ };
403
+
404
+ const setCompareMode = (enabled) => {
405
+ state.compareMode = enabled;
406
+ state.compareIds = [];
407
+ state.comparison = null;
408
+ state.overlay = null;
409
+ state.comparisonRequest++;
410
+ renderChrome();
411
+ updateUrl();
412
+ };
413
+
414
+ const applyInitialUrl = (snapshot) => {
415
+ const params = new URLSearchParams(location.search);
416
+ const requestedTab = params.get("tab");
417
+ const validTabs = new Set(["runs", "suites", "experiments"]);
418
+ if (requestedTab && validTabs.has(requestedTab)) state.tab = requestedTab;
419
+ else if (snapshot.initial) state.tab = `${snapshot.initial.kind}s`;
420
+ state.status = params.get("status") ?? "all";
421
+ state.query = params.get("q") ?? "";
422
+ elements.search.value = state.query;
423
+ state.selectedId = params.get("item") ?? snapshot.initial?.id ?? null;
424
+ state.candidateTargetId = params.get("target");
425
+ const pair = [params.get("before"), params.get("after")].filter(Boolean);
426
+ if (state.tab !== "experiments" && pair.length) {
427
+ state.compareMode = true;
428
+ state.compareIds = pair
429
+ .filter((id) => currentItems().some((item) => item.id === id))
430
+ .slice(0, 2);
431
+ }
432
+ state.initialized = true;
433
+ };
434
+
435
+ const refresh = async ({ first = false } = {}) => {
436
+ elements.refresh.classList.add("is-spinning");
437
+ try {
438
+ state.snapshot = await fetchJson("/api/snapshot");
439
+ if (first && !state.initialized) applyInitialUrl(state.snapshot);
440
+ const selectedExists = currentItems().some((entry) => entry.id === state.selectedId);
441
+ if (!selectedExists && !state.compareMode) state.selectedId = visibleItems()[0]?.id ?? null;
442
+ state.compareIds = state.compareIds.filter((id) =>
443
+ currentItems().some((item) => item.id === id),
444
+ );
445
+ setConnection(true, "Connected");
446
+ renderChrome();
447
+ updateUrl();
448
+ if (state.compareMode && state.compareIds.length === 2) {
449
+ void fetchComparison({ silent: !first });
450
+ } else if (state.selectedId) {
451
+ void fetchDetail(state.selectedId, { silent: !first });
452
+ }
453
+ } catch (error) {
454
+ setConnection(false, "Evidence unavailable");
455
+ if (first) elements.list.innerHTML = detailError(error.message);
456
+ } finally {
457
+ elements.refresh.classList.remove("is-spinning");
458
+ }
459
+ };
460
+
461
+ const showArtifact = async (path, comparison = false) => {
462
+ state.overlay = { type: comparison ? "artifact-comparison" : "artifact", loading: true };
463
+ renderInspector();
464
+ try {
465
+ const params = comparison
466
+ ? new URLSearchParams({
467
+ before: state.compareIds[0],
468
+ after: state.compareIds[1],
469
+ path,
470
+ })
471
+ : new URLSearchParams({ run: state.selectedId, path });
472
+ state.overlay = {
473
+ type: comparison ? "artifact-comparison" : "artifact",
474
+ loading: false,
475
+ data: await fetchJson(
476
+ comparison ? `/api/compare/artifacts?${params}` : `/api/artifact?${params}`,
477
+ ),
478
+ };
479
+ } catch (error) {
480
+ state.overlay = { type: comparison ? "artifact-comparison" : "artifact", error: error.message };
481
+ }
482
+ renderInspector();
483
+ };
484
+
485
+ document.addEventListener("click", (event) => {
486
+ const tab = event.target.closest("[data-tab]");
487
+ if (tab) {
488
+ state.tab = tab.dataset.tab;
489
+ state.status = "all";
490
+ state.selectedId = null;
491
+ state.detail = null;
492
+ state.candidateTargetId = null;
493
+ setCompareMode(false);
494
+ return;
495
+ }
496
+ const filter = event.target.closest("[data-status]");
497
+ if (filter) {
498
+ state.status = filter.dataset.status;
499
+ renderFilters();
500
+ renderRows();
501
+ updateUrl();
502
+ return;
503
+ }
504
+ if (event.target.closest("#compare-button")) {
505
+ setCompareMode(!state.compareMode);
506
+ return;
507
+ }
508
+ if (event.target.closest("[data-close-compare]")) {
509
+ setCompareMode(false);
510
+ return;
511
+ }
512
+ const artifact = event.target.closest("[data-artifact]");
513
+ if (artifact) {
514
+ void showArtifact(artifact.dataset.artifact);
515
+ return;
516
+ }
517
+ const comparedArtifact = event.target.closest("[data-compare-artifact]");
518
+ if (comparedArtifact) {
519
+ void showArtifact(comparedArtifact.dataset.compareArtifact, true);
520
+ return;
521
+ }
522
+ if (event.target.closest("[data-back-detail], [data-back-comparison]")) {
523
+ state.overlay = null;
524
+ renderInspector();
525
+ return;
526
+ }
527
+ const openRun = event.target.closest("[data-open-run]");
528
+ if (openRun) {
529
+ state.tab = "runs";
530
+ state.status = "all";
531
+ selectItem(openRun.dataset.openRun);
532
+ renderChrome();
533
+ return;
534
+ }
535
+ const row = event.target.closest("[data-select]");
536
+ if (row) {
537
+ if (state.compareMode) selectComparisonItem(row.dataset.select);
538
+ else selectItem(row.dataset.select);
539
+ }
540
+ });
541
+
542
+ document.addEventListener("change", (event) => {
543
+ const target = event.target.closest("[data-candidate-target]");
544
+ if (!target) return;
545
+ state.candidateTargetId = target.value;
546
+ updateUrl();
547
+ renderInspector();
548
+ });
549
+
550
+ elements.search.addEventListener("input", () => {
551
+ state.query = elements.search.value;
552
+ renderRows();
553
+ updateUrl();
554
+ });
555
+
556
+ elements.refresh.addEventListener("click", () => void refresh());
557
+
558
+ document.addEventListener("keydown", (event) => {
559
+ const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
560
+ if (event.key === "/" && !typing) {
561
+ event.preventDefault();
562
+ elements.search.focus();
563
+ return;
564
+ }
565
+ if (event.key === "Escape") {
566
+ if (typing) elements.search.blur();
567
+ else if (state.overlay) {
568
+ state.overlay = null;
569
+ renderInspector();
570
+ } else if (state.compareMode) setCompareMode(false);
571
+ return;
572
+ }
573
+ if (!typing && event.key.toLowerCase() === "r") {
574
+ event.preventDefault();
575
+ void refresh();
576
+ return;
577
+ }
578
+ if (!typing && ["j", "k"].includes(event.key.toLowerCase())) {
579
+ event.preventDefault();
580
+ const items = visibleItems();
581
+ if (!items.length) return;
582
+ const referenceId = state.compareMode ? state.compareIds.at(-1) : state.selectedId;
583
+ const current = items.findIndex((item) => item.id === referenceId);
584
+ const offset = event.key.toLowerCase() === "j" ? 1 : -1;
585
+ const next = current < 0 ? 0 : Math.max(0, Math.min(items.length - 1, current + offset));
586
+ if (state.compareMode) selectComparisonItem(items[next].id);
587
+ else selectItem(items[next].id);
588
+ document.querySelector(`[data-select="${CSS.escape(items[next].id)}"]`)?.scrollIntoView({
589
+ block: "nearest",
590
+ });
591
+ }
592
+ });
593
+
594
+ document.addEventListener("visibilitychange", () => {
595
+ if (document.visibilityState === "visible") void refresh();
596
+ });
597
+
598
+ void refresh({ first: true });
599
+ setInterval(() => {
600
+ if (document.visibilityState === "visible") void refresh();
601
+ }, 5_000);
@@ -0,0 +1,48 @@
1
+ import { escapeHtml } from "./format.js";
2
+
3
+ export const renderArtifactPreview = (preview) => `
4
+ <header class="detail-header compact-header">
5
+ <button class="back-button" type="button" data-back-detail>← Evidence</button>
6
+ <div class="detail-title-row"><div><h2>${escapeHtml(
7
+ preview.path,
8
+ )}</h2><p class="detail-subtitle">${escapeHtml(
9
+ [preview.contentType, preview.size === null ? null : `${preview.size} bytes`]
10
+ .filter(Boolean)
11
+ .join(" · "),
12
+ )}</p></div></div>
13
+ </header>
14
+ <div class="detail-body">
15
+ ${
16
+ preview.viewable
17
+ ? `<pre class="artifact-preview">${escapeHtml(preview.text)}</pre>`
18
+ : `<div class="attention-banner">${escapeHtml(
19
+ preview.reason ?? "Artifact cannot be previewed.",
20
+ )}</div>`
21
+ }
22
+ </div>`;
23
+
24
+ export const renderArtifactComparison = (comparison) => `
25
+ <header class="detail-header compact-header">
26
+ <button class="back-button" type="button" data-back-comparison>← Comparison</button>
27
+ <div class="detail-title-row"><div><h2>${escapeHtml(
28
+ comparison.path,
29
+ )}</h2><p class="detail-subtitle">Unified artifact diff · A → B</p></div></div>
30
+ </header>
31
+ <div class="detail-body">
32
+ ${
33
+ comparison.operations
34
+ ? `<pre class="artifact-diff">${comparison.operations
35
+ .flatMap((operation) =>
36
+ operation.lines.map(
37
+ (line) =>
38
+ `<span class="diff-${escapeHtml(operation.kind)}"><b>${
39
+ { added: "+", removed: "−", same: " " }[operation.kind]
40
+ }</b>${escapeHtml(line)}</span>`,
41
+ ),
42
+ )
43
+ .join("\n")}</pre>`
44
+ : `<div class="attention-banner">${escapeHtml(
45
+ comparison.reason ?? "Artifact diff is unavailable.",
46
+ )}</div>`
47
+ }
48
+ </div>`;