@agent-delivery-harness/cli 0.2.0 → 0.7.3

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 (47) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +5 -5
  4. package/src/boundary.ts +84 -63
  5. package/src/check-snapshot.ts +167 -0
  6. package/src/command-activity.ts +75 -0
  7. package/src/commands/admit.ts +106 -0
  8. package/src/commands/check.ts +13 -2
  9. package/src/commands/emit-review-evidence.ts +38 -0
  10. package/src/commands/emit.ts +80 -12
  11. package/src/commands/gate.ts +64 -13
  12. package/src/commands/maintain.ts +1 -0
  13. package/src/commands/managed.ts +105 -34
  14. package/src/commands/prepare.ts +148 -22
  15. package/src/commands/record.ts +107 -3
  16. package/src/commands/resume.ts +53 -0
  17. package/src/commands/review-context.ts +19 -0
  18. package/src/commands/runs.ts +461 -30
  19. package/src/commands/save-context.ts +51 -0
  20. package/src/commands/submit-evidence.ts +46 -7
  21. package/src/commands/verify.ts +156 -10
  22. package/src/declared-checks.ts +50 -0
  23. package/src/index.ts +17 -1
  24. package/src/main.ts +56 -65
  25. package/src/ordinary-context.ts +83 -0
  26. package/src/provider-rails.ts +23 -704
  27. package/src/record-retention.ts +325 -0
  28. package/src/review-evidence.ts +284 -0
  29. package/src/run-archive-commands.ts +164 -0
  30. package/src/run-archive.ts +127 -0
  31. package/src/run-artifact-commands.ts +163 -0
  32. package/src/run-attachments.ts +112 -0
  33. package/src/run-export.ts +202 -0
  34. package/src/run-live.ts +102 -0
  35. package/src/run-projection.ts +327 -12
  36. package/src/run-server.ts +403 -149
  37. package/src/run-surface.ts +196 -9
  38. package/src/run-view-command.ts +77 -0
  39. package/src/run-view-html.ts +138 -0
  40. package/src/run-view-record.ts +119 -0
  41. package/src/run-view.ts +343 -0
  42. package/src/scoped-attempts.ts +86 -0
  43. package/src/scoped-candidate.ts +28 -0
  44. package/src/scoped-checks.ts +196 -0
  45. package/src/scoped-diagnostics.ts +124 -0
  46. package/src/scoped-observations.ts +51 -0
  47. package/src/snapshot-inventory-worker.ts +40 -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 {
@@ -267,12 +320,23 @@ const EMPTY_SUMMARY: RunSummary = {
267
320
  startedAt: "",
268
321
  lastAt: "",
269
322
  durationSeconds: 0,
323
+ // Nothing was read, so every phase is empty and the gate is unseen rather
324
+ // than summed to zero: an unreadable journal reports no gate, not a free one.
325
+ phases: {
326
+ implementationSeconds: 0,
327
+ reviewSeconds: 0,
328
+ tailSeconds: 0,
329
+ rounds: 0,
330
+ gate: { totalMs: 0, counted: 0, unreadable: 0, unseen: true },
331
+ },
270
332
  roundsOpened: 0,
271
333
  roundsClosed: 0,
272
334
  findings: { P0: 0, P1: 0, P2: 0, P3: 0 },
273
335
  };
274
336
 
275
- function timelineOf(events: readonly RunEvent[]): readonly ServedTimelineEntry[] {
337
+ function timelineOf(
338
+ events: readonly RunEvent[],
339
+ ): readonly ServedTimelineEntry[] {
276
340
  return events.map((event) => ({
277
341
  seq: event.seq,
278
342
  at: event.at,
@@ -284,21 +348,25 @@ function timelineOf(events: readonly RunEvent[]): readonly ServedTimelineEntry[]
284
348
 
285
349
  function roundsOf(events: readonly RunEvent[]): readonly ServedRound[] {
286
350
  return roundEntries(events).map((entry) => {
287
- const closed = entry.closed === undefined ? undefined : payloadOf(entry.closed);
351
+ const closed =
352
+ entry.closed === undefined ? undefined : payloadOf(entry.closed);
288
353
  return {
289
354
  round: entry.round,
290
355
  candidateTreeSha: entry.candidateTreeSha,
291
- lenses: entry.opened === undefined ? "" : oneLineOf(payloadOf(entry.opened)["lenses"]),
356
+ opened: entry.opened !== undefined,
357
+ lenses: roundLenses(entry),
292
358
  outcome: closed === undefined ? "open" : oneLineOf(closed["outcome"], 64),
293
359
  findings: closed === undefined ? "" : oneLineOf(closed["findings"]),
294
- cost: closed === undefined ? "" : oneLineOf((closed["cost"] as { total?: unknown } | undefined)?.total, 64),
360
+ cost: closed === undefined ? "" : costLabel(closed["cost"]),
295
361
  };
296
362
  });
297
363
  }
298
364
 
299
365
  function notesOf(entries: readonly unknown[]): readonly ServedNote[] {
300
366
  return entries.map((entry) => {
301
- const note = (typeof entry === "object" && entry !== null ? entry : {}) as Record<string, unknown>;
367
+ const note = (
368
+ typeof entry === "object" && entry !== null ? entry : {}
369
+ ) as Record<string, unknown>;
302
370
  return {
303
371
  at: oneLineOf(note["at"], 32),
304
372
  kind: oneLineOf(note["kind"], 128),
@@ -317,7 +385,13 @@ function notesOf(entries: readonly unknown[]): readonly ServedNote[] {
317
385
  * torn-tail repair, and a viewer that 500s on that would be unusable exactly
318
386
  * while a run is most interesting.
319
387
  */
320
- async function readState(groups: readonly RepoGroup[], pollSeconds: number): Promise<ServedState> {
388
+ async function readState(
389
+ groups: readonly RepoGroup[],
390
+ pollSeconds: number,
391
+ now: string,
392
+ freshnessWindowMs?: number,
393
+ recordPath?: string,
394
+ ): Promise<ServedState> {
321
395
  const runs: ServedRun[] = [];
322
396
  for (const group of groups) {
323
397
  const live = new Set<string>();
@@ -334,9 +408,15 @@ async function readState(groups: readonly RepoGroup[], pollSeconds: number): Pro
334
408
  runId,
335
409
  repository: group.root,
336
410
  readable: false,
411
+ href: `/runs/${sha256Hex(group.commonDir)}/${runId}`,
337
412
  live: false,
338
413
  summary: EMPTY_SUMMARY,
339
- readout: { status: "absent", present: [], missing: [], violations: [] },
414
+ readout: {
415
+ status: "absent",
416
+ present: [],
417
+ missing: [],
418
+ violations: [],
419
+ },
340
420
  timeline: [],
341
421
  roundDetail: [],
342
422
  notes,
@@ -350,6 +430,15 @@ async function readState(groups: readonly RepoGroup[], pollSeconds: number): Pro
350
430
  servedRun({
351
431
  runId,
352
432
  repository: group.root,
433
+ view: await withRetainedRecord(
434
+ projectRunView(events, {
435
+ now,
436
+ ...(freshnessWindowMs === undefined ? {} : { freshnessWindowMs }),
437
+ }),
438
+ group.root,
439
+ recordPath,
440
+ ),
441
+ href: `/runs/${sha256Hex(group.commonDir)}/${runId}`,
353
442
  readable: true,
354
443
  // Liveness is the pointer AND the absence of an end, never one alone:
355
444
  // a pointer left behind by a run that ended without clearing it must
@@ -400,55 +489,21 @@ export function escapeHtml(value: string): string {
400
489
  }
401
490
 
402
491
  /** 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>`;
492
+ const cell = (value: string, maximum = 240): string =>
493
+ escapeHtml(oneLine(value, maximum));
428
494
 
495
+ function runStatus(run: ServedRun): string {
496
+ if (!run.readable) return "Unable to read";
497
+ if (run.result !== undefined) return `Reported ${run.result}`;
498
+ return run.open ? "Open" : "Ended";
499
+ }
429
500
  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("");
501
+ 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>';
502
+ 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>`;
503
+ const ordered = [...state.runs].sort((a, b) => b.lastAt.localeCompare(a.lastAt));
504
+ const open = ordered.filter(run => run.open);
505
+ const ended = ordered.filter(run => !run.open);
506
+ 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
507
  }
453
508
 
454
509
  function timelineTable(run: ServedRun): string {
@@ -465,7 +520,9 @@ function timelineTable(run: ServedRun): string {
465
520
  );
466
521
  return [
467
522
  "<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(""),
523
+ rows.length === 0
524
+ ? '<tr><td colspan="5">no readable events</td></tr>'
525
+ : rows.join(""),
469
526
  "</table>",
470
527
  ].join("");
471
528
  }
@@ -493,8 +550,9 @@ function roundsTable(run: ServedRun): string {
493
550
 
494
551
  function notesTable(run: ServedRun): string {
495
552
  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>`,
553
+ const rows = run.notes.map(
554
+ (note) =>
555
+ `<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
556
  );
499
557
  return [
500
558
  "<h3>refused appends</h3><table><tr><th>at</th><th>kind</th><th>code</th><th>pattern</th></tr>",
@@ -504,49 +562,54 @@ function notesTable(run: ServedRun): string {
504
562
  }
505
563
 
506
564
  function readoutBlock(run: ServedRun): string {
507
- const list = (entries: readonly string[]): string => (entries.length === 0 ? "(none)" : cell(entries.join(", "), 800));
565
+ const list = (entries: readonly string[]): string =>
566
+ entries.length === 0 ? "(none)" : cell(entries.join(", "), 800);
508
567
  return [
509
568
  "<h3>completeness</h3>",
510
569
  `<p class="labels">${cell(run.readout.status, 64)} — ${escapeHtml(READOUT_LABELS)}</p>`,
511
570
  `<p class="meta">present: ${list(run.readout.present)}</p>`,
512
571
  `<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>`,
572
+ run.readout.violations.length === 0
573
+ ? ""
574
+ : `<p class="meta">violations: ${list(run.readout.violations)}</p>`,
575
+ run.readout.note === undefined
576
+ ? ""
577
+ : `<p class="meta">note: ${cell(run.readout.note, 500)}</p>`,
515
578
  ].join("");
516
579
  }
517
580
 
518
581
  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("");
582
+ const anyLive = state.runs.some(run => run.live);
583
+ const refresh = anyLive;
584
+ const selected = state.selected ? state.runs[0] : undefined;
585
+ const base = selected?.href ?? "/";
586
+ const html = [
587
+ '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">',
588
+
589
+ `<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}">`,
590
+ selected ? '<a class="back" href="/">All runs</a>' : "",
591
+ `<header class="page-header"><p class="eyebrow">Delivery workspace</p><h1>${selected ? cell(selected.ticket || "Delivery run", 128) : "Delivery runs"}</h1>`,
592
+ selected ? `<span class="status">${cell(runStatus(selected), 128)}</span>` : '<p class="meta">Follow delivery progress and read the latest reviews.</p>',
593
+ '</header><div class="toolbar" data-live-controls>',
594
+ `<span class="meta" data-live-status>${refresh ? "Live" : "Saved observations"}</span>`,
595
+ `<a href="${escapeHtml(base)}">Refresh</a>`,
596
+ anyLive ? `<a data-live-toggle href="${escapeHtml(base)}">Pause updates</a>` : "",
597
+ '</div>',
598
+ selected
599
+ ? !selected.readable
600
+ ? `<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>`
601
+ : selected.view ? renderOperationalView(selected.view, selected.href ?? "") : ""
602
+ : runsTable(state),
603
+ 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>` : "",
604
+ '<details id="view-provenance" class="view-provenance"><summary>About these observations</summary>',
605
+ `<p class="meta">Reported observations only; not approval evidence. ${escapeHtml(READOUT_LABELS)}. Nothing here is read by admission, the gate, or the recorder.</p>`,
606
+ ...state.repositories.map(repository => `<p class="meta">${cell(repository.root, 400)} — ${cell(repository.runsDir, 400)}</p>`),
607
+ selected ? `<p class="meta">Run ${cell(selected.runId, 128)} · Last reported ${cell(selected.lastAt || "unknown", 32)}</p>` : "",
608
+ 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>',
609
+ `</details></main>${refresh ? `<script>${RUN_LIVE_SCRIPT}</script>` : ""}</body></html>`,
610
+ ].join("").replace(/<table>/g, '<div class="table-scroll"><table>').replace(/<\/table>/g, "</table></div>");
611
+ return html;
612
+
550
613
  }
551
614
 
552
615
  // ── The server ───────────────────────────────────────────────────────────────
@@ -560,8 +623,20 @@ const SECURITY_HEADERS: Readonly<Record<string, string>> = {
560
623
  "Cache-Control": "no-store",
561
624
  };
562
625
 
563
- function send(response: ServerResponse, status: number, contentType: string, body: string): void {
564
- response.writeHead(status, { ...SECURITY_HEADERS, "Content-Type": contentType });
626
+ 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'");
627
+
628
+ function send(
629
+ response: ServerResponse,
630
+ status: number,
631
+ contentType: string,
632
+ body: string,
633
+ livePage = false,
634
+ ): void {
635
+ response.writeHead(status, {
636
+ ...SECURITY_HEADERS,
637
+ "Content-Type": contentType,
638
+ ...(livePage ? { "Content-Security-Policy": LIVE_CSP } : {}),
639
+ });
565
640
  response.end(body);
566
641
  }
567
642
 
@@ -575,14 +650,37 @@ function send(response: ServerResponse, status: number, contentType: string, bod
575
650
  * operator was reading, a proxy), and this page renders text an executor
576
651
  * wrote. `localhost` is refused with everything else: the server prints the
577
652
  * URL it bound, and that URL is the one that works.
653
+ *
654
+ * The exactness has one consequence a caller has to honour rather than this
655
+ * function: a browser omits a scheme's default port from `Host`, so a page
656
+ * bound to 80 or 443 could never satisfy this comparison. `runs serve` refuses
657
+ * those ports where the operator types them. Nothing is re-checked here — a
658
+ * second guard would leave neither one answerable.
578
659
  */
579
- export function hostIsBound(header: string | undefined, host: string, port: number): boolean {
660
+ export function hostIsBound(
661
+ header: string | undefined,
662
+ host: string,
663
+ port: number,
664
+ ): boolean {
580
665
  return header === `${host}:${port}`;
581
666
  }
582
667
 
583
- export async function startRunServer(input: RunServerInput): Promise<RunServerStart> {
584
- if (input.repos.length === 0) return { ok: false, reason: "no repository path to serve" };
668
+ export async function startRunServer(
669
+ input: RunServerInput,
670
+ ): Promise<RunServerStart> {
671
+ if (input.repos.length === 0 && (input.archives?.length ?? 0) === 0)
672
+ return { ok: false, reason: "no repository path to serve" };
585
673
  const pollSeconds = input.pollSeconds ?? DEFAULT_POLL_SECONDS;
674
+ if (
675
+ !Number.isFinite(pollSeconds) ||
676
+ pollSeconds <= 0 ||
677
+ !Number.isFinite(input.freshnessWindowMs ?? 0) ||
678
+ (input.freshnessWindowMs ?? 0) < 0
679
+ )
680
+ return {
681
+ ok: false,
682
+ reason: "invalid observation refresh or freshness window",
683
+ };
586
684
 
587
685
  const resolved: ResolvedRepo[] = [];
588
686
  for (const repoPath of input.repos) {
@@ -591,45 +689,201 @@ export async function startRunServer(input: RunServerInput): Promise<RunServerSt
591
689
  resolved.push(outcome);
592
690
  }
593
691
  const groups = groupByStore(resolved);
692
+ const archives = new Map<
693
+ string,
694
+ { label: string; text: string; archive: DeliveryRunExport }
695
+ >();
696
+ for (const supplied of input.archives ?? []) {
697
+ const parsed = parseRunExport(supplied.text);
698
+ if (!parsed.ok)
699
+ return { ok: false, reason: "archive invalid or unsupported" };
700
+ archives.set(sha256Hex(supplied.text), {
701
+ ...supplied,
702
+ archive: parsed.value,
703
+ });
704
+ }
705
+ const now = input.now ?? (() => new Date().toISOString());
706
+ const archiveRun = (
707
+ id: string,
708
+ entry: { label: string; archive: DeliveryRunExport },
709
+ ): ServedRun =>
710
+ servedRun({
711
+ runId: entry.archive.runId,
712
+ repository: `Archive: ${entry.label}`,
713
+ readable: true,
714
+ live: false,
715
+ summary: summarize(entry.archive.events),
716
+ readout: entry.archive.readout,
717
+ timeline: timelineOf(entry.archive.events),
718
+ roundDetail: roundsOf(entry.archive.events),
719
+ notes: [],
720
+ href: `/archives/${id}`,
721
+ view: projectRunView(entry.archive.events, {
722
+ now: entry.archive.events.at(-1)?.at ?? "",
723
+ historical: true,
724
+ }),
725
+ });
726
+ const state = async () => {
727
+ const live = await readState(
728
+ groups,
729
+ pollSeconds,
730
+ now(),
731
+ input.freshnessWindowMs,
732
+ input.recordPath,
733
+ );
734
+ return {
735
+ ...live,
736
+ runs: [
737
+ ...live.runs,
738
+ ...[...archives.entries()].map(([id, entry]) => archiveRun(id, entry)),
739
+ ],
740
+ };
741
+ };
594
742
 
595
743
  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;
744
+ const server: Server = createServer(
745
+ (request: IncomingMessage, response: ServerResponse) => {
746
+ void (async () => {
747
+ try {
748
+ if (
749
+ bound === undefined ||
750
+ !hostIsBound(request.headers.host, bound.host, bound.port)
751
+ ) {
752
+ send(
753
+ response,
754
+ 403,
755
+ "text/plain; charset=utf-8",
756
+ "forbidden host\n",
757
+ );
758
+ return;
759
+ }
760
+ if (request.method !== "GET" && request.method !== "HEAD") {
761
+ send(
762
+ response,
763
+ 405,
764
+ "text/plain; charset=utf-8",
765
+ "method not allowed\n",
766
+ );
767
+ return;
768
+ }
769
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
770
+ const route = requestUrl.pathname;
771
+ if (route === "/") {
772
+ send(
773
+ response,
774
+ 200,
775
+ "text/html; charset=utf-8",
776
+ renderPage(await state()),
777
+ true,
778
+ );
779
+ return;
780
+ }
781
+ if (route === "/api/runs") {
782
+ send(
783
+ response,
784
+ 200,
785
+ "application/json; charset=utf-8",
786
+ `${JSON.stringify(await state(), null, 2)}\n`,
787
+ );
788
+ return;
789
+ }
790
+ const match = route?.match(
791
+ /^\/(runs\/([a-f0-9]{64})\/([A-Za-z0-9_-]+)|archives\/([a-f0-9]{64}))(?:\/artifacts\/([A-Za-z0-9_-]+)(\/download)?)?$/,
792
+ );
793
+ if (match) {
794
+ const base = `/${match[1]}`;
795
+ const archive = match[4] ? archives.get(match[4]) : undefined;
796
+ const group = match[2]
797
+ ? groups.find((g) => sha256Hex(g.commonDir) === match[2])
798
+ : undefined;
799
+ if (!archive && !group) {
800
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
801
+ return;
802
+ }
803
+ const runId = archive?.archive.runId ?? match[3]!;
804
+ const artifactId = match[5];
805
+ if (artifactId !== undefined) {
806
+ if (!RUN_STORE_ID.test(artifactId) || artifactId.length > 128) {
807
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
808
+ return;
809
+ }
810
+ const result = archive
811
+ ? readArchiveArtifact(archive.text, artifactId)
812
+ : await readRunArtifact(group!.store, runId, artifactId);
813
+ if (match[6] && result.ok) {
814
+ response.writeHead(200, {
815
+ ...SECURITY_HEADERS,
816
+ "Content-Type": "application/octet-stream",
817
+ "Content-Disposition": `attachment; filename="${artifactId}.bin"`,
818
+ });
819
+ response.end(Buffer.from(result.base64, "base64"));
820
+ return;
821
+ }
822
+ const liveRead =
823
+ group === undefined ? undefined : await group.store.read(runId);
824
+ const referenceList =
825
+ archive?.archive.events ??
826
+ (liveRead?.ok ? liveRead.events : []);
827
+ const reference = referenceList.find(
828
+ (e) =>
829
+ e.kind === "artifact.referenced" &&
830
+ e.payload["artifactId"] === artifactId,
831
+ )?.payload as RunArtifactMetadata | undefined;
832
+ send(
833
+ response,
834
+ result.ok ? 200 : 404,
835
+ "text/html; charset=utf-8",
836
+ renderArtifactDetail({
837
+ runId,
838
+ artifactId,
839
+ backHref: base,
840
+ ...(reference === undefined ? {} : { metadata: reference }),
841
+ result,
842
+ historical: archive !== undefined,
843
+ }),
844
+ );
845
+ return;
846
+ }
847
+ const full = await state();
848
+ const selected = full.runs.filter((r) => r.href === base);
849
+ if (!selected.length) {
850
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
851
+ return;
852
+ }
853
+ send(
854
+ response,
855
+ 200,
856
+ "text/html; charset=utf-8",
857
+ renderPage({ ...full, runs: selected, selected: true }),
858
+ true,
859
+ );
860
+ return;
861
+ }
862
+ send(response, 404, "text/plain; charset=utf-8", "not found\n");
863
+ } catch {
864
+ // The store is on disk and the disk can say no. A viewer that leaked
865
+ // the reason would be printing a path, and a viewer that crashed would
866
+ // take the operator's window with it. Guarded on `headersSent` because
867
+ // a failure after the status line cannot be answered twice.
868
+ if (!response.headersSent)
869
+ send(
870
+ response,
871
+ 500,
872
+ "text/plain; charset=utf-8",
873
+ "the run store could not be read\n",
874
+ );
875
+ response.end();
615
876
  }
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
- });
877
+ })();
878
+ },
879
+ );
627
880
 
628
881
  const listening = await new Promise<string | undefined>((resolve) => {
629
882
  server.once("error", (error: Error) => resolve(error.message));
630
883
  server.listen(input.port ?? 0, RUN_SERVER_HOST, () => resolve(undefined));
631
884
  });
632
- if (listening !== undefined) return { ok: false, reason: oneLine(listening, 200) };
885
+ if (listening !== undefined)
886
+ return { ok: false, reason: oneLine(listening, 200) };
633
887
 
634
888
  // THE BOUND ADDRESS IS READ BACK OFF THE SOCKET, never echoed from the
635
889
  // constant that was passed to `listen`. Reporting the constant would make