@prismatic-io/lux 0.0.2-preview.3 → 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 (169) hide show
  1. package/lib/answerers/terminal/index.js +1 -1
  2. package/lib/answerers/terminal/index.js.map +1 -1
  3. package/lib/assertions/authoring.d.ts +2 -2
  4. package/lib/assertions/authoring.d.ts.map +1 -1
  5. package/lib/assertions/authoring.js.map +1 -1
  6. package/lib/assertions/core/helpers.d.ts +2 -7
  7. package/lib/assertions/core/helpers.d.ts.map +1 -1
  8. package/lib/assertions/core/helpers.js +2 -6
  9. package/lib/assertions/core/helpers.js.map +1 -1
  10. package/lib/assertions/core/index.d.ts.map +1 -1
  11. package/lib/assertions/core/index.js +2 -1
  12. package/lib/assertions/core/index.js.map +1 -1
  13. package/lib/assertions/core/resource-checks.d.ts +19 -0
  14. package/lib/assertions/core/resource-checks.d.ts.map +1 -1
  15. package/lib/assertions/core/resource-checks.js +32 -0
  16. package/lib/assertions/core/resource-checks.js.map +1 -1
  17. package/lib/cli/program.d.ts.map +1 -1
  18. package/lib/cli/program.js +12 -67
  19. package/lib/cli/program.js.map +1 -1
  20. package/lib/cli/render/reporter.d.ts.map +1 -1
  21. package/lib/cli/render/reporter.js +16 -3
  22. package/lib/cli/render/reporter.js.map +1 -1
  23. package/lib/cli/run-options.d.ts +1 -0
  24. package/lib/cli/run-options.d.ts.map +1 -1
  25. package/lib/cli/run-options.js.map +1 -1
  26. package/lib/cli/view.d.ts +13 -0
  27. package/lib/cli/view.d.ts.map +1 -0
  28. package/lib/cli/view.js +126 -0
  29. package/lib/cli/view.js.map +1 -0
  30. package/lib/core/action-events.d.ts +29 -0
  31. package/lib/core/action-events.d.ts.map +1 -0
  32. package/lib/core/action-events.js +51 -0
  33. package/lib/core/action-events.js.map +1 -0
  34. package/lib/core/artifact-evidence.d.ts +8 -0
  35. package/lib/core/artifact-evidence.d.ts.map +1 -1
  36. package/lib/core/artifact-evidence.js +36 -4
  37. package/lib/core/artifact-evidence.js.map +1 -1
  38. package/lib/core/assertion.d.ts +12 -0
  39. package/lib/core/assertion.d.ts.map +1 -1
  40. package/lib/core/assertion.js +13 -0
  41. package/lib/core/assertion.js.map +1 -1
  42. package/lib/core/driver-metrics.d.ts +98 -0
  43. package/lib/core/driver-metrics.d.ts.map +1 -0
  44. package/lib/core/driver-metrics.js +36 -0
  45. package/lib/core/driver-metrics.js.map +1 -0
  46. package/lib/core/events.d.ts +2 -0
  47. package/lib/core/events.d.ts.map +1 -1
  48. package/lib/core/events.js +3 -2
  49. package/lib/core/events.js.map +1 -1
  50. package/lib/core/experiment.d.ts +19 -0
  51. package/lib/core/experiment.d.ts.map +1 -1
  52. package/lib/core/experiment.js +2 -0
  53. package/lib/core/experiment.js.map +1 -1
  54. package/lib/core/index.d.ts +2 -0
  55. package/lib/core/index.d.ts.map +1 -1
  56. package/lib/core/index.js +2 -0
  57. package/lib/core/index.js.map +1 -1
  58. package/lib/core/run.d.ts +51 -0
  59. package/lib/core/run.d.ts.map +1 -1
  60. package/lib/core/run.js +6 -0
  61. package/lib/core/run.js.map +1 -1
  62. package/lib/core/wire.d.ts +1 -0
  63. package/lib/core/wire.d.ts.map +1 -1
  64. package/lib/core/wire.js +5 -1
  65. package/lib/core/wire.js.map +1 -1
  66. package/lib/index.d.ts +3 -2
  67. package/lib/index.d.ts.map +1 -1
  68. package/lib/index.js +3 -2
  69. package/lib/index.js.map +1 -1
  70. package/lib/orchestrator/compare.d.ts +9 -1
  71. package/lib/orchestrator/compare.d.ts.map +1 -1
  72. package/lib/orchestrator/compare.js +8 -0
  73. package/lib/orchestrator/compare.js.map +1 -1
  74. package/lib/orchestrator/config.d.ts.map +1 -1
  75. package/lib/orchestrator/config.js +0 -2
  76. package/lib/orchestrator/config.js.map +1 -1
  77. package/lib/orchestrator/experiment-contracts.d.ts +19 -0
  78. package/lib/orchestrator/experiment-contracts.d.ts.map +1 -1
  79. package/lib/orchestrator/experiment-evaluation.d.ts.map +1 -1
  80. package/lib/orchestrator/experiment-evaluation.js +17 -0
  81. package/lib/orchestrator/experiment-evaluation.js.map +1 -1
  82. package/lib/orchestrator/experiment-report.d.ts +19 -0
  83. package/lib/orchestrator/experiment-report.d.ts.map +1 -1
  84. package/lib/orchestrator/experiment-runs.d.ts +38 -0
  85. package/lib/orchestrator/experiment-runs.d.ts.map +1 -1
  86. package/lib/orchestrator/grader-experiment.d.ts +12 -0
  87. package/lib/orchestrator/grader-experiment.d.ts.map +1 -1
  88. package/lib/orchestrator/grading.d.ts +2 -1
  89. package/lib/orchestrator/grading.d.ts.map +1 -1
  90. package/lib/orchestrator/grading.js +16 -4
  91. package/lib/orchestrator/grading.js.map +1 -1
  92. package/lib/orchestrator/index.d.ts +1 -1
  93. package/lib/orchestrator/index.d.ts.map +1 -1
  94. package/lib/orchestrator/index.js +1 -1
  95. package/lib/orchestrator/index.js.map +1 -1
  96. package/lib/orchestrator/list-runs.d.ts +6 -1
  97. package/lib/orchestrator/list-runs.d.ts.map +1 -1
  98. package/lib/orchestrator/list-runs.js +15 -1
  99. package/lib/orchestrator/list-runs.js.map +1 -1
  100. package/lib/orchestrator/orchestrator.d.ts +3 -0
  101. package/lib/orchestrator/orchestrator.d.ts.map +1 -1
  102. package/lib/orchestrator/orchestrator.js +3 -0
  103. package/lib/orchestrator/orchestrator.js.map +1 -1
  104. package/lib/orchestrator/regrade.d.ts +6 -0
  105. package/lib/orchestrator/regrade.d.ts.map +1 -1
  106. package/lib/orchestrator/run-execution.d.ts.map +1 -1
  107. package/lib/orchestrator/run-execution.js +13 -3
  108. package/lib/orchestrator/run-execution.js.map +1 -1
  109. package/lib/orchestrator/suite-compare.d.ts +34 -0
  110. package/lib/orchestrator/suite-compare.d.ts.map +1 -1
  111. package/lib/orchestrator/suite-summary.d.ts +68 -0
  112. package/lib/orchestrator/suite-summary.d.ts.map +1 -1
  113. package/lib/orchestrator/suite-summary.js +22 -7
  114. package/lib/orchestrator/suite-summary.js.map +1 -1
  115. package/lib/previewer/artifacts.d.ts +25 -0
  116. package/lib/previewer/artifacts.d.ts.map +1 -0
  117. package/lib/previewer/artifacts.js +180 -0
  118. package/lib/previewer/artifacts.js.map +1 -0
  119. package/lib/previewer/server.d.ts +104 -0
  120. package/lib/previewer/server.d.ts.map +1 -0
  121. package/lib/previewer/server.js +541 -0
  122. package/lib/previewer/server.js.map +1 -0
  123. package/package.json +2 -2
  124. package/skills/lux/SKILL.md +7 -3
  125. package/skills/lux/references/cli.md +25 -2
  126. package/skills/lux/references/eval-authoring.md +158 -4
  127. package/skills/lux-answerer/SKILL.md +1 -1
  128. package/src/answerers/terminal/index.ts +1 -1
  129. package/src/assertions/authoring.ts +2 -0
  130. package/src/assertions/core/helpers.ts +3 -6
  131. package/src/assertions/core/index.ts +2 -0
  132. package/src/assertions/core/resource-checks.ts +35 -0
  133. package/src/cli/program.ts +14 -93
  134. package/src/cli/render/reporter.ts +18 -3
  135. package/src/cli/run-options.ts +1 -0
  136. package/src/cli/view.ts +168 -0
  137. package/src/core/action-events.ts +59 -0
  138. package/src/core/artifact-evidence.ts +47 -4
  139. package/src/core/assertion.ts +15 -0
  140. package/src/core/driver-metrics.ts +51 -0
  141. package/src/core/events.ts +3 -2
  142. package/src/core/experiment.ts +2 -0
  143. package/src/core/index.ts +2 -0
  144. package/src/core/run.ts +6 -0
  145. package/src/core/wire.ts +5 -1
  146. package/src/index.ts +25 -5
  147. package/src/orchestrator/compare.ts +12 -0
  148. package/src/orchestrator/config.ts +0 -2
  149. package/src/orchestrator/experiment-evaluation.ts +23 -0
  150. package/src/orchestrator/grading.ts +21 -2
  151. package/src/orchestrator/index.ts +1 -0
  152. package/src/orchestrator/list-runs.ts +25 -0
  153. package/src/orchestrator/orchestrator.ts +6 -0
  154. package/src/orchestrator/run-execution.ts +14 -1
  155. package/src/orchestrator/suite-summary.ts +36 -8
  156. package/src/previewer/artifacts.ts +221 -0
  157. package/src/previewer/server.ts +763 -0
  158. package/viewer/app.css +2022 -0
  159. package/viewer/app.js +601 -0
  160. package/viewer/artifact-view.js +48 -0
  161. package/viewer/charts.js +86 -0
  162. package/viewer/detail.js +1108 -0
  163. package/viewer/format.js +129 -0
  164. package/viewer/index.html +151 -0
  165. package/lib/answerers/scripted/index.d.ts +0 -8
  166. package/lib/answerers/scripted/index.d.ts.map +0 -1
  167. package/lib/answerers/scripted/index.js +0 -163
  168. package/lib/answerers/scripted/index.js.map +0 -1
  169. package/src/answerers/scripted/index.ts +0 -186
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>`;