@agent-delivery-harness/cli 0.2.0 → 0.5.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.
Files changed (40) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +4 -4
  4. package/src/boundary.ts +77 -61
  5. package/src/command-activity.ts +75 -0
  6. package/src/commands/admit.ts +106 -0
  7. package/src/commands/check.ts +13 -2
  8. package/src/commands/emit-review-evidence.ts +38 -0
  9. package/src/commands/emit.ts +80 -12
  10. package/src/commands/gate.ts +33 -8
  11. package/src/commands/maintain.ts +1 -0
  12. package/src/commands/managed.ts +1 -0
  13. package/src/commands/prepare.ts +123 -22
  14. package/src/commands/record.ts +98 -3
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +415 -30
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +29 -8
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +14 -1
  23. package/src/main.ts +20 -47
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +23 -704
  26. package/src/record-retention.ts +325 -0
  27. package/src/review-evidence.ts +284 -0
  28. package/src/run-archive-commands.ts +164 -0
  29. package/src/run-archive.ts +127 -0
  30. package/src/run-artifact-commands.ts +163 -0
  31. package/src/run-attachments.ts +112 -0
  32. package/src/run-export.ts +164 -0
  33. package/src/run-live.ts +102 -0
  34. package/src/run-projection.ts +129 -11
  35. package/src/run-server.ts +394 -149
  36. package/src/run-surface.ts +81 -8
  37. package/src/run-view-command.ts +77 -0
  38. package/src/run-view-html.ts +138 -0
  39. package/src/run-view-record.ts +119 -0
  40. package/src/run-view.ts +343 -0
package/src/run-server.ts CHANGED
@@ -1,21 +1,20 @@
1
+ import { createHash } from "node:crypto";
2
+ import { RUN_LIVE_SCRIPT } from "./run-live.ts";
3
+ import { withRetainedRecord } from "./run-view-record.ts";
4
+ import { projectRunView, type RunView } from "./run-view.ts";
5
+ import {
6
+ renderOperationalView,
7
+ renderArtifactDetail,
8
+ OPERATIONAL_STYLE,
9
+ } from "./run-view-html.ts";
10
+ import { parseRunExport, type DeliveryRunExport } from "./run-export.ts";
11
+ import { readArchiveArtifact } from "./run-archive.ts";
1
12
  /**
2
13
  * `runs serve` — the operator's bird's-eye view of the run store.
3
14
  *
4
- * ONE PAGE, NO SCRIPT. The page carries executor-written free text: rationales,
5
- * decisions, blocker summaries, a gate label an adopter chose. Every one of
6
- * those is attacker-controlled the moment a candidate script can run in the
7
- * repository, which is exactly the threat the store's own design admits it
8
- * cannot exclude. So the page is served with `script-src 'none'` and contains
9
- * no script of its own — not an inline one, not a nonce'd one, not a fetch
10
- * loop. Refresh is a `<meta http-equiv="refresh">`, which is the whole of the
11
- * polling mechanism. That leaves nothing for an escaped string to escape INTO:
12
- * even a rendering bug can only produce inert markup on a page where scripts
13
- * are refused by policy.
14
- *
15
- * WHY THE JSON ENDPOINT EXISTS ANYWAY. The plan asks for one, and it is what
16
- * makes the surface programmable — a test, a script, a future viewer. The page
17
- * does not consume it; the page is rendered server-side from the same
18
- * projection, so there is one renderer per surface and never a second answer.
15
+ * ONE SERVER RENDERER. A fixed hash-authorized enhancement updates live pages
16
+ * in place. Executor text remains escaped and cannot supply executable code.
17
+ * Reports keep the no-script policy. The page and JSON share one projection.
19
18
  *
20
19
  * WHAT "LIVE" MEANS, AND WHAT IT DELIBERATELY DOES NOT. A run is live when the
21
20
  * pointer of a worktree the operator NAMED points at it and it carries no
@@ -32,24 +31,39 @@
32
31
  * config-presence note names — run in the path itself with the `GIT_`
33
32
  * namespace dropped.
34
33
  */
35
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
36
34
  import {
37
- createRunStore,
35
+ createServer,
36
+ type IncomingMessage,
37
+ type Server,
38
+ type ServerResponse,
39
+ } from "node:http";
40
+ import {
38
41
  evaluateRunJournal,
42
+ sha256Hex,
43
+ readRunArtifact,
44
+ RUN_STORE_ID,
39
45
  type RunEvent,
46
+ type RunArtifactMetadata,
40
47
  type RunStore,
41
48
  } from "@agent-delivery-harness/kernel";
42
49
  import {
43
50
  READOUT_LABELS,
51
+ costLabel,
44
52
  detailOf,
45
53
  payloadOf,
46
54
  readoutOf,
47
55
  roundEntries,
56
+ roundLenses,
48
57
  summarize,
49
58
  type Readout,
50
59
  type RunSummary,
51
60
  } from "./run-projection.ts";
52
- import { oneLine, oneLineOf, resolveRunSurface, resolveWorktreeRoot } from "./run-surface.ts";
61
+ import {
62
+ oneLine,
63
+ oneLineOf,
64
+ resolveRunSurface,
65
+ resolveWorktreeRoot,
66
+ } from "./run-surface.ts";
53
67
 
54
68
  /** Loopback, always. The page is the operator's, and only the operator's. */
55
69
  export const RUN_SERVER_HOST = "127.0.0.1";
@@ -86,6 +100,12 @@ interface ResolvedRepo {
86
100
  readonly commonDir: string;
87
101
  readonly runsDir: string;
88
102
  readonly worktreeKey: string;
103
+ /**
104
+ * The store the surface resolved, carried rather than rebuilt: the store's
105
+ * root is not always the repository's common directory, and a second
106
+ * construction here would read a different store than every writer wrote to.
107
+ */
108
+ readonly store: RunStore;
89
109
  }
90
110
 
91
111
  /**
@@ -119,12 +139,18 @@ export interface RunServerHandle {
119
139
  export interface RunServerInput {
120
140
  /** One or more paths, each a worktree of a repository whose runs to serve. */
121
141
  readonly repos: readonly string[];
142
+ readonly archives?: readonly { label: string; text: string }[];
143
+ readonly now?: () => string;
144
+ readonly freshnessWindowMs?: number;
145
+ readonly recordPath?: string;
122
146
  /** Zero, the default, asks the operating system for an ephemeral port. */
123
147
  readonly port?: number;
124
148
  readonly pollSeconds?: number;
125
149
  }
126
150
 
127
- async function resolveRepo(repoPath: string): Promise<ResolvedRepo | { readonly reason: string }> {
151
+ async function resolveRepo(
152
+ repoPath: string,
153
+ ): Promise<ResolvedRepo | { readonly reason: string }> {
128
154
  const surface = await resolveRunSurface(repoPath);
129
155
  if (!surface.ok) return { reason: `${repoPath}: ${surface.reason}` };
130
156
  const root = await resolveWorktreeRoot(repoPath);
@@ -134,12 +160,22 @@ async function resolveRepo(repoPath: string): Promise<ResolvedRepo | { readonly
134
160
  commonDir: surface.surface.commonDir,
135
161
  runsDir: surface.surface.runsDir,
136
162
  worktreeKey: surface.surface.worktreeKey,
163
+ store: surface.surface.store,
137
164
  };
138
165
  }
139
166
 
140
167
  /** Groups resolved paths by store, preserving the order the operator gave. */
141
168
  function groupByStore(resolved: readonly ResolvedRepo[]): readonly RepoGroup[] {
142
- const groups = new Map<string, { root: string; commonDir: string; runsDir: string; worktreeKeys: string[] }>();
169
+ const groups = new Map<
170
+ string,
171
+ {
172
+ root: string;
173
+ commonDir: string;
174
+ runsDir: string;
175
+ worktreeKeys: string[];
176
+ store: RunStore;
177
+ }
178
+ >();
143
179
  for (const repo of resolved) {
144
180
  const existing = groups.get(repo.commonDir);
145
181
  if (existing === undefined) {
@@ -152,12 +188,14 @@ function groupByStore(resolved: readonly ResolvedRepo[]): readonly RepoGroup[] {
152
188
  commonDir: repo.commonDir,
153
189
  runsDir: repo.runsDir,
154
190
  worktreeKeys: [repo.worktreeKey],
191
+ store: repo.store,
155
192
  });
156
193
  continue;
157
194
  }
158
- if (!existing.worktreeKeys.includes(repo.worktreeKey)) existing.worktreeKeys.push(repo.worktreeKey);
195
+ if (!existing.worktreeKeys.includes(repo.worktreeKey))
196
+ existing.worktreeKeys.push(repo.worktreeKey);
159
197
  }
160
- return [...groups.values()].map((group) => ({ ...group, store: createRunStore(group.commonDir) }));
198
+ return [...groups.values()];
161
199
  }
162
200
 
163
201
  // ── The served state ─────────────────────────────────────────────────────────
@@ -173,6 +211,14 @@ interface ServedTimelineEntry {
173
211
  interface ServedRound {
174
212
  readonly round: string;
175
213
  readonly candidateTreeSha: string;
214
+ /**
215
+ * Whether this round was ever opened. The projection's `lenses` says so in
216
+ * words for a reader of the page, and a reader of the JSON should not have
217
+ * to match those words to learn it: a round that never opened and a round
218
+ * whose lens set happens to spell the same thing are one value apart on the
219
+ * wire otherwise.
220
+ */
221
+ readonly opened: boolean;
176
222
  readonly lenses: string;
177
223
  readonly outcome: string;
178
224
  readonly findings: string;
@@ -187,6 +233,8 @@ interface ServedNote {
187
233
  }
188
234
 
189
235
  interface ServedRun {
236
+ readonly view?: RunView;
237
+ readonly href?: string;
190
238
  readonly runId: string;
191
239
  readonly repository: string;
192
240
  /** False when the journal refused the read discipline; every other field is then empty. */
@@ -224,10 +272,14 @@ function servedRun(input: {
224
272
  readonly roundDetail: readonly ServedRound[];
225
273
  readonly notes: readonly ServedNote[];
226
274
  readonly readable: boolean;
275
+ readonly view?: RunView;
276
+ readonly href?: string;
227
277
  }): ServedRun {
228
278
  const { summary } = input;
229
279
  return {
230
280
  runId: input.runId,
281
+ ...(input.view === undefined ? {} : { view: input.view }),
282
+ ...(input.href === undefined ? {} : { href: input.href }),
231
283
  repository: input.repository,
232
284
  readable: input.readable,
233
285
  live: input.live,
@@ -249,6 +301,7 @@ function servedRun(input: {
249
301
  }
250
302
 
251
303
  interface ServedState {
304
+ readonly selected?: boolean;
252
305
  readonly labels: string;
253
306
  readonly pollSeconds: number;
254
307
  readonly repositories: readonly {
@@ -272,7 +325,9 @@ const EMPTY_SUMMARY: RunSummary = {
272
325
  findings: { P0: 0, P1: 0, P2: 0, P3: 0 },
273
326
  };
274
327
 
275
- function timelineOf(events: readonly RunEvent[]): readonly ServedTimelineEntry[] {
328
+ function timelineOf(
329
+ events: readonly RunEvent[],
330
+ ): readonly ServedTimelineEntry[] {
276
331
  return events.map((event) => ({
277
332
  seq: event.seq,
278
333
  at: event.at,
@@ -284,21 +339,25 @@ function timelineOf(events: readonly RunEvent[]): readonly ServedTimelineEntry[]
284
339
 
285
340
  function roundsOf(events: readonly RunEvent[]): readonly ServedRound[] {
286
341
  return roundEntries(events).map((entry) => {
287
- const closed = entry.closed === undefined ? undefined : payloadOf(entry.closed);
342
+ const closed =
343
+ entry.closed === undefined ? undefined : payloadOf(entry.closed);
288
344
  return {
289
345
  round: entry.round,
290
346
  candidateTreeSha: entry.candidateTreeSha,
291
- lenses: entry.opened === undefined ? "" : oneLineOf(payloadOf(entry.opened)["lenses"]),
347
+ opened: entry.opened !== undefined,
348
+ lenses: roundLenses(entry),
292
349
  outcome: closed === undefined ? "open" : oneLineOf(closed["outcome"], 64),
293
350
  findings: closed === undefined ? "" : oneLineOf(closed["findings"]),
294
- cost: closed === undefined ? "" : oneLineOf((closed["cost"] as { total?: unknown } | undefined)?.total, 64),
351
+ cost: closed === undefined ? "" : costLabel(closed["cost"]),
295
352
  };
296
353
  });
297
354
  }
298
355
 
299
356
  function notesOf(entries: readonly unknown[]): readonly ServedNote[] {
300
357
  return entries.map((entry) => {
301
- const note = (typeof entry === "object" && entry !== null ? entry : {}) as Record<string, unknown>;
358
+ const note = (
359
+ typeof entry === "object" && entry !== null ? entry : {}
360
+ ) as Record<string, unknown>;
302
361
  return {
303
362
  at: oneLineOf(note["at"], 32),
304
363
  kind: oneLineOf(note["kind"], 128),
@@ -317,7 +376,13 @@ function notesOf(entries: readonly unknown[]): readonly ServedNote[] {
317
376
  * torn-tail repair, and a viewer that 500s on that would be unusable exactly
318
377
  * while a run is most interesting.
319
378
  */
320
- async function readState(groups: readonly RepoGroup[], pollSeconds: number): Promise<ServedState> {
379
+ async function readState(
380
+ groups: readonly RepoGroup[],
381
+ pollSeconds: number,
382
+ now: string,
383
+ freshnessWindowMs?: number,
384
+ recordPath?: string,
385
+ ): Promise<ServedState> {
321
386
  const runs: ServedRun[] = [];
322
387
  for (const group of groups) {
323
388
  const live = new Set<string>();
@@ -334,9 +399,15 @@ async function readState(groups: readonly RepoGroup[], pollSeconds: number): Pro
334
399
  runId,
335
400
  repository: group.root,
336
401
  readable: false,
402
+ href: `/runs/${sha256Hex(group.commonDir)}/${runId}`,
337
403
  live: false,
338
404
  summary: EMPTY_SUMMARY,
339
- readout: { status: "absent", present: [], missing: [], violations: [] },
405
+ readout: {
406
+ status: "absent",
407
+ present: [],
408
+ missing: [],
409
+ violations: [],
410
+ },
340
411
  timeline: [],
341
412
  roundDetail: [],
342
413
  notes,
@@ -350,6 +421,15 @@ async function readState(groups: readonly RepoGroup[], pollSeconds: number): Pro
350
421
  servedRun({
351
422
  runId,
352
423
  repository: group.root,
424
+ view: await withRetainedRecord(
425
+ projectRunView(events, {
426
+ now,
427
+ ...(freshnessWindowMs === undefined ? {} : { freshnessWindowMs }),
428
+ }),
429
+ group.root,
430
+ recordPath,
431
+ ),
432
+ href: `/runs/${sha256Hex(group.commonDir)}/${runId}`,
353
433
  readable: true,
354
434
  // Liveness is the pointer AND the absence of an end, never one alone:
355
435
  // a pointer left behind by a run that ended without clearing it must
@@ -400,55 +480,21 @@ export function escapeHtml(value: string): string {
400
480
  }
401
481
 
402
482
  /** Neutralized to one line, then escaped as markup: the terminal's rule, plus the browser's. */
403
- const cell = (value: string, maximum = 240): string => escapeHtml(oneLine(value, maximum));
404
-
405
- const STYLE = [
406
- "body{font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:1.5rem;color:#1a1a1a;background:#fbfbfa}",
407
- "h1{font-size:1.1rem;margin:0 0 .25rem}h2{font-size:.95rem;margin:1.5rem 0 .4rem}h3{font-size:.85rem;margin:.9rem 0 .3rem;color:#555}",
408
- ".labels{color:#7a6a00;background:#fffbe6;border:1px solid #e8dca0;padding:.35rem .5rem;margin:.5rem 0 1rem}",
409
- "table{border-collapse:collapse;width:100%;margin:.3rem 0 .6rem}",
410
- "th,td{border:1px solid #ddd;padding:.22rem .45rem;text-align:left;vertical-align:top;word-break:break-word}",
411
- "th{background:#f0f0ee;font-weight:600}",
412
- ".live{color:#0a6b2e;font-weight:700}.ended{color:#666}.open{color:#7a4b00}",
413
- ".meta{color:#666;margin:.2rem 0}",
414
- "@media(prefers-color-scheme:dark){body{background:#16181a;color:#e6e6e6}th{background:#24272a}th,td{border-color:#3a3f44}",
415
- ".labels{color:#e8d98a;background:#2a2718;border-color:#4d4526}.meta,.ended{color:#9aa0a6}h3{color:#9aa0a6}}",
416
- ].join("");
417
-
418
- const RUNS_HEADER = ["run", "ticket", "repository", "duration", "rounds", "findings", "gate", "record", "result", "state"];
419
-
420
- function stateCell(run: ServedRun): string {
421
- if (!run.readable) return `<td class="open">unreadable</td>`;
422
- if (run.live) return `<td class="live">live</td>`;
423
- return run.open ? `<td class="open">open</td>` : `<td class="ended">ended</td>`;
424
- }
425
-
426
- const written = (outcome: { readonly outcome: string; readonly writer: string } | undefined): string =>
427
- outcome === undefined ? "—" : `${cell(outcome.outcome, 64)} <span class="meta">(${cell(outcome.writer, 16)}-written)</span>`;
483
+ const cell = (value: string, maximum = 240): string =>
484
+ escapeHtml(oneLine(value, maximum));
428
485
 
486
+ function runStatus(run: ServedRun): string {
487
+ if (!run.readable) return "Unable to read";
488
+ if (run.result !== undefined) return `Reported ${run.result}`;
489
+ return run.open ? "Open" : "Ended";
490
+ }
429
491
  function runsTable(state: ServedState): string {
430
- const rows = state.runs.map((run) =>
431
- [
432
- "<tr>",
433
- `<td>${cell(run.runId, 128)}</td>`,
434
- `<td>${cell(run.ticket, 128) || "—"}</td>`,
435
- `<td>${cell(run.repository, 400)}</td>`,
436
- `<td>${run.durationSeconds}s</td>`,
437
- `<td>${run.rounds.closed}/${run.rounds.opened}</td>`,
438
- `<td>P0 ${run.findings.P0} · P1 ${run.findings.P1} · P2 ${run.findings.P2} · P3 ${run.findings.P3}</td>`,
439
- `<td>${written(run.gate)}</td>`,
440
- `<td>${written(run.record)}</td>`,
441
- `<td>${run.result === undefined ? "—" : cell(run.result, 64)}</td>`,
442
- stateCell(run),
443
- "</tr>",
444
- ].join(""),
445
- );
446
- return [
447
- "<table>",
448
- `<tr>${RUNS_HEADER.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr>`,
449
- rows.length === 0 ? `<tr><td colspan="${RUNS_HEADER.length}">no runs in this store</td></tr>` : rows.join(""),
450
- "</table>",
451
- ].join("");
492
+ if (!state.runs.length) return '<section class="notice"><h2>No deliveries yet</h2><p>Runs will appear here when a delivery is recorded in a connected repository.</p></section>';
493
+ const rows = (runs: readonly ServedRun[]) => `<div class="run-list">${runs.map(run => `<article data-key="${escapeHtml(run.href ?? run.runId)}" class="run-row"><div><h3><a href="${escapeHtml(run.href ?? "#")}">${cell(run.ticket || run.runId, 128)}</a></h3><p class="meta">${cell(run.repository.split("/").filter(Boolean).at(-1) ?? run.repository, 128)}</p></div><div><span class="status">${cell(runStatus(run), 128)}</span></div><p class="run-caption">${run.readable ? `${run.rounds.closed} of ${run.rounds.opened} review rounds closed · Last reported ${cell(run.lastAt || "unknown", 32)}` : "Activity and results are unavailable."}</p></article>`).join("")}</div>`;
494
+ const ordered = [...state.runs].sort((a, b) => b.lastAt.localeCompare(a.lastAt));
495
+ const open = ordered.filter(run => run.open);
496
+ const ended = ordered.filter(run => !run.open);
497
+ return `${open.length ? `<section id="open-runs"><h2>Open deliveries <span class="count">${open.length}</span></h2>${rows(open.slice(0, 10))}${open.length > 10 ? `<details id="older-open-runs"><summary>Earlier open deliveries <span>${open.length - 10} runs</span></summary>${rows(open.slice(10))}</details>` : ""}</section>` : ""}${ended.length ? `<section id="completed-runs"><h2>Recent deliveries</h2>${rows(ended.slice(0, 6))}${ended.length > 6 ? `<details id="older-ended-runs"><summary>Earlier deliveries <span>${ended.length - 6} runs</span></summary>${rows(ended.slice(6))}</details>` : ""}</section>` : ""}`;
452
498
  }
453
499
 
454
500
  function timelineTable(run: ServedRun): string {
@@ -465,7 +511,9 @@ function timelineTable(run: ServedRun): string {
465
511
  );
466
512
  return [
467
513
  "<h3>timeline</h3><table><tr><th>seq</th><th>at</th><th>kind</th><th>writer</th><th>detail</th></tr>",
468
- rows.length === 0 ? '<tr><td colspan="5">no readable events</td></tr>' : rows.join(""),
514
+ rows.length === 0
515
+ ? '<tr><td colspan="5">no readable events</td></tr>'
516
+ : rows.join(""),
469
517
  "</table>",
470
518
  ].join("");
471
519
  }
@@ -493,8 +541,9 @@ function roundsTable(run: ServedRun): string {
493
541
 
494
542
  function notesTable(run: ServedRun): string {
495
543
  if (run.notes.length === 0) return "";
496
- const rows = run.notes.map((note) =>
497
- `<tr><td>${cell(note.at, 32)}</td><td>${cell(note.kind, 128)}</td><td>${cell(note.code, 64)}</td><td>${cell(note.pattern, 64)}</td></tr>`,
544
+ const rows = run.notes.map(
545
+ (note) =>
546
+ `<tr><td>${cell(note.at, 32)}</td><td>${cell(note.kind, 128)}</td><td>${cell(note.code, 64)}</td><td>${cell(note.pattern, 64)}</td></tr>`,
498
547
  );
499
548
  return [
500
549
  "<h3>refused appends</h3><table><tr><th>at</th><th>kind</th><th>code</th><th>pattern</th></tr>",
@@ -504,49 +553,54 @@ function notesTable(run: ServedRun): string {
504
553
  }
505
554
 
506
555
  function readoutBlock(run: ServedRun): string {
507
- const list = (entries: readonly string[]): string => (entries.length === 0 ? "(none)" : cell(entries.join(", "), 800));
556
+ const list = (entries: readonly string[]): string =>
557
+ entries.length === 0 ? "(none)" : cell(entries.join(", "), 800);
508
558
  return [
509
559
  "<h3>completeness</h3>",
510
560
  `<p class="labels">${cell(run.readout.status, 64)} — ${escapeHtml(READOUT_LABELS)}</p>`,
511
561
  `<p class="meta">present: ${list(run.readout.present)}</p>`,
512
562
  `<p class="meta">missing: ${list(run.readout.missing)}</p>`,
513
- run.readout.violations.length === 0 ? "" : `<p class="meta">violations: ${list(run.readout.violations)}</p>`,
514
- run.readout.note === undefined ? "" : `<p class="meta">note: ${cell(run.readout.note, 500)}</p>`,
563
+ run.readout.violations.length === 0
564
+ ? ""
565
+ : `<p class="meta">violations: ${list(run.readout.violations)}</p>`,
566
+ run.readout.note === undefined
567
+ ? ""
568
+ : `<p class="meta">note: ${cell(run.readout.note, 500)}</p>`,
515
569
  ].join("");
516
570
  }
517
571
 
518
572
  export function renderPage(state: ServedState): string {
519
- // The refresh is declared ONLY while something is live. A store of finished
520
- // runs must cost an open browser tab nothing, and a page that kept polling
521
- // after `run.ended` would be claiming the run might still move.
522
- const anyLive = state.runs.some((run) => run.live);
523
- return [
524
- "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">",
525
- '<meta name="viewport" content="width=device-width,initial-scale=1">',
526
- anyLive ? `<meta http-equiv="refresh" content="${state.pollSeconds}">` : "",
527
- "<title>delivery runs</title>",
528
- `<style>${STYLE}</style></head><body>`,
529
- "<h1>delivery runs</h1>",
530
- `<p class="labels">${escapeHtml(READOUT_LABELS)}. Nothing here is read by admission, the gate, or the recorder.</p>`,
531
- ...state.repositories.map(
532
- (repository) => `<p class="meta">${cell(repository.root, 400)} — ${cell(repository.runsDir, 400)}</p>`,
533
- ),
534
- anyLive
535
- ? `<p class="meta">refreshing every ${state.pollSeconds}s while a run is live</p>`
536
- : '<p class="meta">no live run; this page does not refresh itself</p>',
537
- runsTable(state),
538
- ...state.runs.map((run) =>
539
- [
540
- `<h2>${cell(run.runId, 128)}</h2>`,
541
- `<p class="meta">${cell(run.repository, 400)}</p>`,
542
- timelineTable(run),
543
- roundsTable(run),
544
- notesTable(run),
545
- readoutBlock(run),
546
- ].join(""),
547
- ),
548
- "</body></html>",
549
- ].join("");
573
+ const anyLive = state.runs.some(run => run.live);
574
+ const refresh = anyLive;
575
+ const selected = state.selected ? state.runs[0] : undefined;
576
+ const base = selected?.href ?? "/";
577
+ const html = [
578
+ '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">',
579
+
580
+ `<title>${selected ? cell(selected.ticket || "Delivery run", 128) : "Delivery runs"}</title><style>${OPERATIONAL_STYLE}</style></head><body><main data-live="${anyLive}" data-poll-seconds="${state.pollSeconds}">`,
581
+ selected ? '<a class="back" href="/">All runs</a>' : "",
582
+ `<header class="page-header"><p class="eyebrow">Delivery workspace</p><h1>${selected ? cell(selected.ticket || "Delivery run", 128) : "Delivery runs"}</h1>`,
583
+ selected ? `<span class="status">${cell(runStatus(selected), 128)}</span>` : '<p class="meta">Follow delivery progress and read the latest reviews.</p>',
584
+ '</header><div class="toolbar" data-live-controls>',
585
+ `<span class="meta" data-live-status>${refresh ? "Live" : "Saved observations"}</span>`,
586
+ `<a href="${escapeHtml(base)}">Refresh</a>`,
587
+ anyLive ? `<a data-live-toggle href="${escapeHtml(base)}">Pause updates</a>` : "",
588
+ '</div>',
589
+ selected
590
+ ? !selected.readable
591
+ ? `<section class="notice" aria-label="Run read error"><h2>Run journal unreadable</h2><p>The journal for ${cell(selected.runId, 128)} could not be read. Activity, evidence and completeness are unavailable.</p></section>`
592
+ : selected.view ? renderOperationalView(selected.view, selected.href ?? "") : ""
593
+ : runsTable(state),
594
+ selected?.readable ? `<details id="journal-details" class="supporting"><summary>Journal details<span>Recorded events, round accounting and completeness</span></summary>${roundsTable(selected)}${timelineTable(selected)}${notesTable(selected)}${readoutBlock(selected)}</details>` : "",
595
+ '<details id="view-provenance" class="view-provenance"><summary>About these observations</summary>',
596
+ `<p class="meta">Reported observations only; not approval evidence. ${escapeHtml(READOUT_LABELS)}. Nothing here is read by admission, the gate, or the recorder.</p>`,
597
+ ...state.repositories.map(repository => `<p class="meta">${cell(repository.root, 400)} — ${cell(repository.runsDir, 400)}</p>`),
598
+ selected ? `<p class="meta">Run ${cell(selected.runId, 128)} · Last reported ${cell(selected.lastAt || "unknown", 32)}</p>` : "",
599
+ anyLive ? `<p class="meta">Live mode refreshes every ${state.pollSeconds}s while a run is selected and open; execution is not inferred.</p>` : '<p class="meta">No selected open run; this page does not refresh itself.</p>',
600
+ `</details></main>${refresh ? `<script>${RUN_LIVE_SCRIPT}</script>` : ""}</body></html>`,
601
+ ].join("").replace(/<table>/g, '<div class="table-scroll"><table>').replace(/<\/table>/g, "</table></div>");
602
+ return html;
603
+
550
604
  }
551
605
 
552
606
  // ── The server ───────────────────────────────────────────────────────────────
@@ -560,8 +614,20 @@ const SECURITY_HEADERS: Readonly<Record<string, string>> = {
560
614
  "Cache-Control": "no-store",
561
615
  };
562
616
 
563
- function send(response: ServerResponse, status: number, contentType: string, body: string): void {
564
- response.writeHead(status, { ...SECURITY_HEADERS, "Content-Type": contentType });
617
+ const LIVE_CSP = RUN_SERVER_CSP.replace("script-src 'none'", `script-src 'sha256-${createHash("sha256").update(RUN_LIVE_SCRIPT).digest("base64")}'`).replace("connect-src 'none'", "connect-src 'self'");
618
+
619
+ function send(
620
+ response: ServerResponse,
621
+ status: number,
622
+ contentType: string,
623
+ body: string,
624
+ livePage = false,
625
+ ): void {
626
+ response.writeHead(status, {
627
+ ...SECURITY_HEADERS,
628
+ "Content-Type": contentType,
629
+ ...(livePage ? { "Content-Security-Policy": LIVE_CSP } : {}),
630
+ });
565
631
  response.end(body);
566
632
  }
567
633
 
@@ -575,14 +641,37 @@ function send(response: ServerResponse, status: number, contentType: string, bod
575
641
  * operator was reading, a proxy), and this page renders text an executor
576
642
  * wrote. `localhost` is refused with everything else: the server prints the
577
643
  * URL it bound, and that URL is the one that works.
644
+ *
645
+ * The exactness has one consequence a caller has to honour rather than this
646
+ * function: a browser omits a scheme's default port from `Host`, so a page
647
+ * bound to 80 or 443 could never satisfy this comparison. `runs serve` refuses
648
+ * those ports where the operator types them. Nothing is re-checked here — a
649
+ * second guard would leave neither one answerable.
578
650
  */
579
- export function hostIsBound(header: string | undefined, host: string, port: number): boolean {
651
+ export function hostIsBound(
652
+ header: string | undefined,
653
+ host: string,
654
+ port: number,
655
+ ): boolean {
580
656
  return header === `${host}:${port}`;
581
657
  }
582
658
 
583
- export async function startRunServer(input: RunServerInput): Promise<RunServerStart> {
584
- if (input.repos.length === 0) return { ok: false, reason: "no repository path to serve" };
659
+ export async function startRunServer(
660
+ input: RunServerInput,
661
+ ): Promise<RunServerStart> {
662
+ if (input.repos.length === 0 && (input.archives?.length ?? 0) === 0)
663
+ return { ok: false, reason: "no repository path to serve" };
585
664
  const pollSeconds = input.pollSeconds ?? DEFAULT_POLL_SECONDS;
665
+ if (
666
+ !Number.isFinite(pollSeconds) ||
667
+ pollSeconds <= 0 ||
668
+ !Number.isFinite(input.freshnessWindowMs ?? 0) ||
669
+ (input.freshnessWindowMs ?? 0) < 0
670
+ )
671
+ return {
672
+ ok: false,
673
+ reason: "invalid observation refresh or freshness window",
674
+ };
586
675
 
587
676
  const resolved: ResolvedRepo[] = [];
588
677
  for (const repoPath of input.repos) {
@@ -591,45 +680,201 @@ export async function startRunServer(input: RunServerInput): Promise<RunServerSt
591
680
  resolved.push(outcome);
592
681
  }
593
682
  const groups = groupByStore(resolved);
683
+ const archives = new Map<
684
+ string,
685
+ { label: string; text: string; archive: DeliveryRunExport }
686
+ >();
687
+ for (const supplied of input.archives ?? []) {
688
+ const parsed = parseRunExport(supplied.text);
689
+ if (!parsed.ok)
690
+ return { ok: false, reason: "archive invalid or unsupported" };
691
+ archives.set(sha256Hex(supplied.text), {
692
+ ...supplied,
693
+ archive: parsed.value,
694
+ });
695
+ }
696
+ const now = input.now ?? (() => new Date().toISOString());
697
+ const archiveRun = (
698
+ id: string,
699
+ entry: { label: string; archive: DeliveryRunExport },
700
+ ): ServedRun =>
701
+ servedRun({
702
+ runId: entry.archive.runId,
703
+ repository: `Archive: ${entry.label}`,
704
+ readable: true,
705
+ live: false,
706
+ summary: summarize(entry.archive.events),
707
+ readout: entry.archive.readout,
708
+ timeline: timelineOf(entry.archive.events),
709
+ roundDetail: roundsOf(entry.archive.events),
710
+ notes: [],
711
+ href: `/archives/${id}`,
712
+ view: projectRunView(entry.archive.events, {
713
+ now: entry.archive.events.at(-1)?.at ?? "",
714
+ historical: true,
715
+ }),
716
+ });
717
+ const state = async () => {
718
+ const live = await readState(
719
+ groups,
720
+ pollSeconds,
721
+ now(),
722
+ input.freshnessWindowMs,
723
+ input.recordPath,
724
+ );
725
+ return {
726
+ ...live,
727
+ runs: [
728
+ ...live.runs,
729
+ ...[...archives.entries()].map(([id, entry]) => archiveRun(id, entry)),
730
+ ],
731
+ };
732
+ };
594
733
 
595
734
  let bound: { readonly host: string; readonly port: number } | undefined;
596
- const server: Server = createServer((request: IncomingMessage, response: ServerResponse) => {
597
- void (async () => {
598
- try {
599
- if (bound === undefined || !hostIsBound(request.headers.host, bound.host, bound.port)) {
600
- send(response, 403, "text/plain; charset=utf-8", "forbidden host\n");
601
- return;
602
- }
603
- if (request.method !== "GET" && request.method !== "HEAD") {
604
- send(response, 405, "text/plain; charset=utf-8", "method not allowed\n");
605
- return;
606
- }
607
- const route = (request.url ?? "/").split("?")[0];
608
- if (route === "/") {
609
- send(response, 200, "text/html; charset=utf-8", renderPage(await readState(groups, pollSeconds)));
610
- return;
611
- }
612
- if (route === "/api/runs") {
613
- send(response, 200, "application/json; charset=utf-8", `${JSON.stringify(await readState(groups, pollSeconds), null, 2)}\n`);
614
- return;
735
+ const server: Server = createServer(
736
+ (request: IncomingMessage, response: ServerResponse) => {
737
+ void (async () => {
738
+ try {
739
+ if (
740
+ bound === undefined ||
741
+ !hostIsBound(request.headers.host, bound.host, bound.port)
742
+ ) {
743
+ send(
744
+ response,
745
+ 403,
746
+ "text/plain; charset=utf-8",
747
+ "forbidden host\n",
748
+ );
749
+ return;
750
+ }
751
+ if (request.method !== "GET" && request.method !== "HEAD") {
752
+ send(
753
+ response,
754
+ 405,
755
+ "text/plain; charset=utf-8",
756
+ "method not allowed\n",
757
+ );
758
+ return;
759
+ }
760
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
761
+ const route = requestUrl.pathname;
762
+ if (route === "/") {
763
+ send(
764
+ response,
765
+ 200,
766
+ "text/html; charset=utf-8",
767
+ renderPage(await state()),
768
+ true,
769
+ );
770
+ return;
771
+ }
772
+ if (route === "/api/runs") {
773
+ send(
774
+ response,
775
+ 200,
776
+ "application/json; charset=utf-8",
777
+ `${JSON.stringify(await state(), null, 2)}\n`,
778
+ );
779
+ return;
780
+ }
781
+ const match = route?.match(
782
+ /^\/(runs\/([a-f0-9]{64})\/([A-Za-z0-9_-]+)|archives\/([a-f0-9]{64}))(?:\/artifacts\/([A-Za-z0-9_-]+)(\/download)?)?$/,
783
+ );
784
+ if (match) {
785
+ const base = `/${match[1]}`;
786
+ const archive = match[4] ? archives.get(match[4]) : undefined;
787
+ const group = match[2]
788
+ ? groups.find((g) => sha256Hex(g.commonDir) === match[2])
789
+ : undefined;
790
+ if (!archive && !group) {
791
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
792
+ return;
793
+ }
794
+ const runId = archive?.archive.runId ?? match[3]!;
795
+ const artifactId = match[5];
796
+ if (artifactId !== undefined) {
797
+ if (!RUN_STORE_ID.test(artifactId) || artifactId.length > 128) {
798
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
799
+ return;
800
+ }
801
+ const result = archive
802
+ ? readArchiveArtifact(archive.text, artifactId)
803
+ : await readRunArtifact(group!.store, runId, artifactId);
804
+ if (match[6] && result.ok) {
805
+ response.writeHead(200, {
806
+ ...SECURITY_HEADERS,
807
+ "Content-Type": "application/octet-stream",
808
+ "Content-Disposition": `attachment; filename="${artifactId}.bin"`,
809
+ });
810
+ response.end(Buffer.from(result.base64, "base64"));
811
+ return;
812
+ }
813
+ const liveRead =
814
+ group === undefined ? undefined : await group.store.read(runId);
815
+ const referenceList =
816
+ archive?.archive.events ??
817
+ (liveRead?.ok ? liveRead.events : []);
818
+ const reference = referenceList.find(
819
+ (e) =>
820
+ e.kind === "artifact.referenced" &&
821
+ e.payload["artifactId"] === artifactId,
822
+ )?.payload as RunArtifactMetadata | undefined;
823
+ send(
824
+ response,
825
+ result.ok ? 200 : 404,
826
+ "text/html; charset=utf-8",
827
+ renderArtifactDetail({
828
+ runId,
829
+ artifactId,
830
+ backHref: base,
831
+ ...(reference === undefined ? {} : { metadata: reference }),
832
+ result,
833
+ historical: archive !== undefined,
834
+ }),
835
+ );
836
+ return;
837
+ }
838
+ const full = await state();
839
+ const selected = full.runs.filter((r) => r.href === base);
840
+ if (!selected.length) {
841
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
842
+ return;
843
+ }
844
+ send(
845
+ response,
846
+ 200,
847
+ "text/html; charset=utf-8",
848
+ renderPage({ ...full, runs: selected, selected: true }),
849
+ true,
850
+ );
851
+ return;
852
+ }
853
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
854
+ } catch {
855
+ // The store is on disk and the disk can say no. A viewer that leaked
856
+ // the reason would be printing a path, and a viewer that crashed would
857
+ // take the operator's window with it. Guarded on `headersSent` because
858
+ // a failure after the status line cannot be answered twice.
859
+ if (!response.headersSent)
860
+ send(
861
+ response,
862
+ 500,
863
+ "text/plain; charset=utf-8",
864
+ "the run store could not be read\n",
865
+ );
866
+ response.end();
615
867
  }
616
- send(response, 404, "text/plain; charset=utf-8", "not found\n");
617
- } catch {
618
- // The store is on disk and the disk can say no. A viewer that leaked
619
- // the reason would be printing a path, and a viewer that crashed would
620
- // take the operator's window with it. Guarded on `headersSent` because
621
- // a failure after the status line cannot be answered twice.
622
- if (!response.headersSent) send(response, 500, "text/plain; charset=utf-8", "the run store could not be read\n");
623
- response.end();
624
- }
625
- })();
626
- });
868
+ })();
869
+ },
870
+ );
627
871
 
628
872
  const listening = await new Promise<string | undefined>((resolve) => {
629
873
  server.once("error", (error: Error) => resolve(error.message));
630
874
  server.listen(input.port ?? 0, RUN_SERVER_HOST, () => resolve(undefined));
631
875
  });
632
- if (listening !== undefined) return { ok: false, reason: oneLine(listening, 200) };
876
+ if (listening !== undefined)
877
+ return { ok: false, reason: oneLine(listening, 200) };
633
878
 
634
879
  // THE BOUND ADDRESS IS READ BACK OFF THE SOCKET, never echoed from the
635
880
  // constant that was passed to `listen`. Reporting the constant would make