@agent-delivery-harness/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,660 @@
1
+ /**
2
+ * `runs serve` — the operator's bird's-eye view of the run store.
3
+ *
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.
19
+ *
20
+ * WHAT "LIVE" MEANS, AND WHAT IT DELIBERATELY DOES NOT. A run is live when the
21
+ * pointer of a worktree the operator NAMED points at it and it carries no
22
+ * `run.ended`. The server never enumerates the store's `current/` directory:
23
+ * it computes each named path's own pointer key from the same `rev-parse` that
24
+ * resolves that path's store, and reads that one pointer. So a run executing
25
+ * in a worktree the operator did not name renders as open but not live — an
26
+ * understatement by construction, which is the direction that cannot mislead.
27
+ *
28
+ * WHY THE GIT ENVIRONMENT IS CLEARED. Twice, for the same reason the store is
29
+ * resolved that way everywhere else: an inherited `GIT_DIR` in the operator's
30
+ * shell must not be able to point one `--repo` path's store at another
31
+ * repository. Both queries — the store resolution and the toplevel the
32
+ * config-presence note names — run in the path itself with the `GIT_`
33
+ * namespace dropped.
34
+ */
35
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
36
+ import {
37
+ createRunStore,
38
+ evaluateRunJournal,
39
+ type RunEvent,
40
+ type RunStore,
41
+ } from "@agent-delivery-harness/kernel";
42
+ import {
43
+ READOUT_LABELS,
44
+ detailOf,
45
+ payloadOf,
46
+ readoutOf,
47
+ roundEntries,
48
+ summarize,
49
+ type Readout,
50
+ type RunSummary,
51
+ } from "./run-projection.ts";
52
+ import { oneLine, oneLineOf, resolveRunSurface, resolveWorktreeRoot } from "./run-surface.ts";
53
+
54
+ /** Loopback, always. The page is the operator's, and only the operator's. */
55
+ export const RUN_SERVER_HOST = "127.0.0.1";
56
+
57
+ /** How often a page with a live run refreshes itself, in whole seconds. */
58
+ export const DEFAULT_POLL_SECONDS = 2;
59
+
60
+ /**
61
+ * The policy header every response carries.
62
+ *
63
+ * `default-src 'none'` is the base, and each directive that would otherwise
64
+ * fall back to it is restated rather than left implicit, because a reader
65
+ * auditing this line should not have to know the fallback table. Styles are
66
+ * inline and nothing else loads: no script, no image, no font, no frame, and
67
+ * no form target. `frame-ancestors 'none'` is what keeps the page out of
68
+ * someone else's frame, which the `Host` check alone would not prevent.
69
+ */
70
+ export const RUN_SERVER_CSP = [
71
+ "default-src 'none'",
72
+ "script-src 'none'",
73
+ "style-src 'unsafe-inline'",
74
+ "img-src 'none'",
75
+ "connect-src 'none'",
76
+ "form-action 'none'",
77
+ "frame-ancestors 'none'",
78
+ "base-uri 'none'",
79
+ ].join("; ");
80
+
81
+ // ── Resolution ───────────────────────────────────────────────────────────────
82
+
83
+ /** One `--repo` path, resolved to the store it addresses and the root it names. */
84
+ interface ResolvedRepo {
85
+ readonly root: string;
86
+ readonly commonDir: string;
87
+ readonly runsDir: string;
88
+ readonly worktreeKey: string;
89
+ }
90
+
91
+ /**
92
+ * One store, and every named worktree that addresses it.
93
+ *
94
+ * Two worktrees of one repository share a common directory and therefore share
95
+ * every run. Grouping is what keeps each run on the page ONCE while still
96
+ * reading every pointer the operator named, which is what makes a run live
97
+ * when any named worktree is executing it.
98
+ */
99
+ interface RepoGroup {
100
+ readonly root: string;
101
+ readonly commonDir: string;
102
+ readonly runsDir: string;
103
+ readonly worktreeKeys: readonly string[];
104
+ readonly store: RunStore;
105
+ }
106
+
107
+ export type RunServerStart =
108
+ | { readonly ok: true; readonly server: RunServerHandle }
109
+ | { readonly ok: false; readonly reason: string };
110
+
111
+ export interface RunServerHandle {
112
+ readonly host: string;
113
+ readonly port: number;
114
+ /** `http://<host>:<port>` — what the command prints and what a client dials. */
115
+ readonly url: string;
116
+ close(): Promise<void>;
117
+ }
118
+
119
+ export interface RunServerInput {
120
+ /** One or more paths, each a worktree of a repository whose runs to serve. */
121
+ readonly repos: readonly string[];
122
+ /** Zero, the default, asks the operating system for an ephemeral port. */
123
+ readonly port?: number;
124
+ readonly pollSeconds?: number;
125
+ }
126
+
127
+ async function resolveRepo(repoPath: string): Promise<ResolvedRepo | { readonly reason: string }> {
128
+ const surface = await resolveRunSurface(repoPath);
129
+ if (!surface.ok) return { reason: `${repoPath}: ${surface.reason}` };
130
+ const root = await resolveWorktreeRoot(repoPath);
131
+ if (!root.ok) return { reason: `${repoPath}: ${root.reason}` };
132
+ return {
133
+ root: root.root,
134
+ commonDir: surface.surface.commonDir,
135
+ runsDir: surface.surface.runsDir,
136
+ worktreeKey: surface.surface.worktreeKey,
137
+ };
138
+ }
139
+
140
+ /** Groups resolved paths by store, preserving the order the operator gave. */
141
+ function groupByStore(resolved: readonly ResolvedRepo[]): readonly RepoGroup[] {
142
+ const groups = new Map<string, { root: string; commonDir: string; runsDir: string; worktreeKeys: string[] }>();
143
+ for (const repo of resolved) {
144
+ const existing = groups.get(repo.commonDir);
145
+ if (existing === undefined) {
146
+ // The FIRST named path wins the group's root. The root is what the
147
+ // config-presence note names and what the repository column shows, and
148
+ // two worktrees of one repository can disagree about both; naming the
149
+ // path the operator listed first is the answer an operator can predict.
150
+ groups.set(repo.commonDir, {
151
+ root: repo.root,
152
+ commonDir: repo.commonDir,
153
+ runsDir: repo.runsDir,
154
+ worktreeKeys: [repo.worktreeKey],
155
+ });
156
+ continue;
157
+ }
158
+ if (!existing.worktreeKeys.includes(repo.worktreeKey)) existing.worktreeKeys.push(repo.worktreeKey);
159
+ }
160
+ return [...groups.values()].map((group) => ({ ...group, store: createRunStore(group.commonDir) }));
161
+ }
162
+
163
+ // ── The served state ─────────────────────────────────────────────────────────
164
+
165
+ interface ServedTimelineEntry {
166
+ readonly seq: number;
167
+ readonly at: string;
168
+ readonly kind: string;
169
+ readonly writer: "cli" | "executor";
170
+ readonly detail: string;
171
+ }
172
+
173
+ interface ServedRound {
174
+ readonly round: string;
175
+ readonly candidateTreeSha: string;
176
+ readonly lenses: string;
177
+ readonly outcome: string;
178
+ readonly findings: string;
179
+ readonly cost: string;
180
+ }
181
+
182
+ interface ServedNote {
183
+ readonly at: string;
184
+ readonly kind: string;
185
+ readonly code: string;
186
+ readonly pattern: string;
187
+ }
188
+
189
+ interface ServedRun {
190
+ readonly runId: string;
191
+ readonly repository: string;
192
+ /** False when the journal refused the read discipline; every other field is then empty. */
193
+ readonly readable: boolean;
194
+ readonly live: boolean;
195
+ readonly open: boolean;
196
+ readonly ticket: string;
197
+ readonly startedAt: string;
198
+ readonly lastAt: string;
199
+ readonly durationSeconds: number;
200
+ readonly rounds: { readonly opened: number; readonly closed: number };
201
+ readonly findings: RunSummary["findings"];
202
+ readonly gate?: RunSummary["gate"];
203
+ readonly record?: RunSummary["record"];
204
+ readonly result?: RunSummary["result"];
205
+ readonly readout: Readout;
206
+ readonly timeline: readonly ServedTimelineEntry[];
207
+ readonly roundDetail: readonly ServedRound[];
208
+ readonly notes: readonly ServedNote[];
209
+ }
210
+
211
+ /**
212
+ * The projection, wearing the names the JSON endpoint publishes. Written out
213
+ * member by member rather than spread: the served shape is a contract a reader
214
+ * can rely on, and a spread would let a future member of the summary appear on
215
+ * the wire without anyone deciding it should.
216
+ */
217
+ function servedRun(input: {
218
+ readonly runId: string;
219
+ readonly repository: string;
220
+ readonly live: boolean;
221
+ readonly summary: RunSummary;
222
+ readonly readout: Readout;
223
+ readonly timeline: readonly ServedTimelineEntry[];
224
+ readonly roundDetail: readonly ServedRound[];
225
+ readonly notes: readonly ServedNote[];
226
+ readonly readable: boolean;
227
+ }): ServedRun {
228
+ const { summary } = input;
229
+ return {
230
+ runId: input.runId,
231
+ repository: input.repository,
232
+ readable: input.readable,
233
+ live: input.live,
234
+ open: summary.open,
235
+ ticket: summary.ticket,
236
+ startedAt: summary.startedAt,
237
+ lastAt: summary.lastAt,
238
+ durationSeconds: summary.durationSeconds,
239
+ rounds: { opened: summary.roundsOpened, closed: summary.roundsClosed },
240
+ findings: summary.findings,
241
+ ...(summary.gate === undefined ? {} : { gate: summary.gate }),
242
+ ...(summary.record === undefined ? {} : { record: summary.record }),
243
+ ...(summary.result === undefined ? {} : { result: summary.result }),
244
+ readout: input.readout,
245
+ timeline: input.timeline,
246
+ roundDetail: input.roundDetail,
247
+ notes: input.notes,
248
+ };
249
+ }
250
+
251
+ interface ServedState {
252
+ readonly labels: string;
253
+ readonly pollSeconds: number;
254
+ readonly repositories: readonly {
255
+ readonly root: string;
256
+ readonly commonDir: string;
257
+ readonly runsDir: string;
258
+ readonly worktreeKeys: readonly string[];
259
+ }[];
260
+ readonly runs: readonly ServedRun[];
261
+ }
262
+
263
+ /** The summary an unreadable journal gets: everything empty, nothing inferred. */
264
+ const EMPTY_SUMMARY: RunSummary = {
265
+ ticket: "",
266
+ open: true,
267
+ startedAt: "",
268
+ lastAt: "",
269
+ durationSeconds: 0,
270
+ roundsOpened: 0,
271
+ roundsClosed: 0,
272
+ findings: { P0: 0, P1: 0, P2: 0, P3: 0 },
273
+ };
274
+
275
+ function timelineOf(events: readonly RunEvent[]): readonly ServedTimelineEntry[] {
276
+ return events.map((event) => ({
277
+ seq: event.seq,
278
+ at: event.at,
279
+ kind: event.kind,
280
+ writer: event.actor.role,
281
+ detail: detailOf(event),
282
+ }));
283
+ }
284
+
285
+ function roundsOf(events: readonly RunEvent[]): readonly ServedRound[] {
286
+ return roundEntries(events).map((entry) => {
287
+ const closed = entry.closed === undefined ? undefined : payloadOf(entry.closed);
288
+ return {
289
+ round: entry.round,
290
+ candidateTreeSha: entry.candidateTreeSha,
291
+ lenses: entry.opened === undefined ? "" : oneLineOf(payloadOf(entry.opened)["lenses"]),
292
+ outcome: closed === undefined ? "open" : oneLineOf(closed["outcome"], 64),
293
+ findings: closed === undefined ? "" : oneLineOf(closed["findings"]),
294
+ cost: closed === undefined ? "" : oneLineOf((closed["cost"] as { total?: unknown } | undefined)?.total, 64),
295
+ };
296
+ });
297
+ }
298
+
299
+ function notesOf(entries: readonly unknown[]): readonly ServedNote[] {
300
+ return entries.map((entry) => {
301
+ const note = (typeof entry === "object" && entry !== null ? entry : {}) as Record<string, unknown>;
302
+ return {
303
+ at: oneLineOf(note["at"], 32),
304
+ kind: oneLineOf(note["kind"], 128),
305
+ code: oneLineOf(note["code"], 64),
306
+ pattern: oneLineOf(note["pattern"], 64),
307
+ };
308
+ });
309
+ }
310
+
311
+ /**
312
+ * Reads every store once and projects it.
313
+ *
314
+ * A read that fails is a row, not an error. `runs list` already renders an
315
+ * unreadable journal as a row rather than refusing the whole listing, and the
316
+ * page has one more reason to: a poll can land in the middle of an append's
317
+ * torn-tail repair, and a viewer that 500s on that would be unusable exactly
318
+ * while a run is most interesting.
319
+ */
320
+ async function readState(groups: readonly RepoGroup[], pollSeconds: number): Promise<ServedState> {
321
+ const runs: ServedRun[] = [];
322
+ for (const group of groups) {
323
+ const live = new Set<string>();
324
+ for (const worktreeKey of group.worktreeKeys) {
325
+ const current = await group.store.current(worktreeKey);
326
+ if (current.ok && current.runId !== undefined) live.add(current.runId);
327
+ }
328
+ for (const runId of await group.store.list()) {
329
+ const read = await group.store.read(runId);
330
+ const notes = notesOf(await group.store.readNotes(runId));
331
+ if (!read.ok) {
332
+ runs.push(
333
+ servedRun({
334
+ runId,
335
+ repository: group.root,
336
+ readable: false,
337
+ live: false,
338
+ summary: EMPTY_SUMMARY,
339
+ readout: { status: "absent", present: [], missing: [], violations: [] },
340
+ timeline: [],
341
+ roundDetail: [],
342
+ notes,
343
+ }),
344
+ );
345
+ continue;
346
+ }
347
+ const events = read.events;
348
+ const summary = summarize(events);
349
+ runs.push(
350
+ servedRun({
351
+ runId,
352
+ repository: group.root,
353
+ readable: true,
354
+ // Liveness is the pointer AND the absence of an end, never one alone:
355
+ // a pointer left behind by a run that ended without clearing it must
356
+ // not read as a run still in flight.
357
+ live: summary.open && live.has(runId),
358
+ summary,
359
+ // No record tree sha and no mandated pair: the viewer has neither, and
360
+ // pretending otherwise would turn an observation into a claim.
361
+ readout: readoutOf(events, evaluateRunJournal(events), group.root),
362
+ timeline: timelineOf(events),
363
+ roundDetail: roundsOf(events),
364
+ notes,
365
+ }),
366
+ );
367
+ }
368
+ }
369
+ return {
370
+ labels: READOUT_LABELS,
371
+ pollSeconds,
372
+ repositories: groups.map((group) => ({
373
+ root: group.root,
374
+ commonDir: group.commonDir,
375
+ runsDir: group.runsDir,
376
+ worktreeKeys: group.worktreeKeys,
377
+ })),
378
+ runs,
379
+ };
380
+ }
381
+
382
+ // ── Rendering ────────────────────────────────────────────────────────────────
383
+
384
+ /**
385
+ * Every string that reaches the page goes through here, store-derived or not.
386
+ *
387
+ * The exemption nobody gets is the point: a path the operator typed, a run id
388
+ * the store allocated, and a rationale an executor wrote are all escaped by
389
+ * the same function, because "this one is ours" is how the one that was not
390
+ * ours gets through. The five characters are the full set that can end an
391
+ * attribute or open a tag in HTML text and attribute contexts.
392
+ */
393
+ export function escapeHtml(value: string): string {
394
+ return value
395
+ .replace(/&/g, "&amp;")
396
+ .replace(/</g, "&lt;")
397
+ .replace(/>/g, "&gt;")
398
+ .replace(/"/g, "&quot;")
399
+ .replace(/'/g, "&#39;");
400
+ }
401
+
402
+ /** 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>`;
428
+
429
+ 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("");
452
+ }
453
+
454
+ function timelineTable(run: ServedRun): string {
455
+ const rows = run.timeline.map((entry) =>
456
+ [
457
+ "<tr>",
458
+ `<td>${entry.seq}</td>`,
459
+ `<td>${cell(entry.at, 32)}</td>`,
460
+ `<td>${cell(entry.kind, 64)}</td>`,
461
+ `<td>${cell(entry.writer, 16)}-written</td>`,
462
+ `<td>${cell(entry.detail, 400)}</td>`,
463
+ "</tr>",
464
+ ].join(""),
465
+ );
466
+ return [
467
+ "<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(""),
469
+ "</table>",
470
+ ].join("");
471
+ }
472
+
473
+ function roundsTable(run: ServedRun): string {
474
+ if (run.roundDetail.length === 0) return "";
475
+ const rows = run.roundDetail.map((round) =>
476
+ [
477
+ "<tr>",
478
+ `<td>${cell(round.round, 32)}</td>`,
479
+ `<td>${cell(round.candidateTreeSha, 128) || "(none)"}</td>`,
480
+ `<td>${cell(round.lenses)}</td>`,
481
+ `<td>${cell(round.outcome, 64)}</td>`,
482
+ `<td>${cell(round.findings)}</td>`,
483
+ `<td>${cell(round.cost, 64)}</td>`,
484
+ "</tr>",
485
+ ].join(""),
486
+ );
487
+ return [
488
+ "<h3>rounds</h3><table><tr><th>round</th><th>candidate</th><th>lenses</th><th>outcome</th><th>findings</th><th>cost</th></tr>",
489
+ rows.join(""),
490
+ "</table>",
491
+ ].join("");
492
+ }
493
+
494
+ function notesTable(run: ServedRun): string {
495
+ 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>`,
498
+ );
499
+ return [
500
+ "<h3>refused appends</h3><table><tr><th>at</th><th>kind</th><th>code</th><th>pattern</th></tr>",
501
+ rows.join(""),
502
+ "</table>",
503
+ ].join("");
504
+ }
505
+
506
+ function readoutBlock(run: ServedRun): string {
507
+ const list = (entries: readonly string[]): string => (entries.length === 0 ? "(none)" : cell(entries.join(", "), 800));
508
+ return [
509
+ "<h3>completeness</h3>",
510
+ `<p class="labels">${cell(run.readout.status, 64)} — ${escapeHtml(READOUT_LABELS)}</p>`,
511
+ `<p class="meta">present: ${list(run.readout.present)}</p>`,
512
+ `<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>`,
515
+ ].join("");
516
+ }
517
+
518
+ 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("");
550
+ }
551
+
552
+ // ── The server ───────────────────────────────────────────────────────────────
553
+
554
+ const SECURITY_HEADERS: Readonly<Record<string, string>> = {
555
+ "X-Content-Type-Options": "nosniff",
556
+ "Content-Security-Policy": RUN_SERVER_CSP,
557
+ "Referrer-Policy": "no-referrer",
558
+ // A viewer of a live store must never be shown a cached run, and no proxy
559
+ // between loopback and loopback has any business holding one.
560
+ "Cache-Control": "no-store",
561
+ };
562
+
563
+ function send(response: ServerResponse, status: number, contentType: string, body: string): void {
564
+ response.writeHead(status, { ...SECURITY_HEADERS, "Content-Type": contentType });
565
+ response.end(body);
566
+ }
567
+
568
+ /**
569
+ * Whether this request is addressed to the socket it arrived on.
570
+ *
571
+ * Compared by exact equality against the address and port actually bound —
572
+ * not parsed, not normalized, not matched against a list of names that "mean"
573
+ * loopback. A request naming anything else reached this server through
574
+ * something that rewrote its destination (a DNS rebind from a page the
575
+ * operator was reading, a proxy), and this page renders text an executor
576
+ * wrote. `localhost` is refused with everything else: the server prints the
577
+ * URL it bound, and that URL is the one that works.
578
+ */
579
+ export function hostIsBound(header: string | undefined, host: string, port: number): boolean {
580
+ return header === `${host}:${port}`;
581
+ }
582
+
583
+ export async function startRunServer(input: RunServerInput): Promise<RunServerStart> {
584
+ if (input.repos.length === 0) return { ok: false, reason: "no repository path to serve" };
585
+ const pollSeconds = input.pollSeconds ?? DEFAULT_POLL_SECONDS;
586
+
587
+ const resolved: ResolvedRepo[] = [];
588
+ for (const repoPath of input.repos) {
589
+ const outcome = await resolveRepo(repoPath);
590
+ if ("reason" in outcome) return { ok: false, reason: outcome.reason };
591
+ resolved.push(outcome);
592
+ }
593
+ const groups = groupByStore(resolved);
594
+
595
+ 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;
615
+ }
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
+ });
627
+
628
+ const listening = await new Promise<string | undefined>((resolve) => {
629
+ server.once("error", (error: Error) => resolve(error.message));
630
+ server.listen(input.port ?? 0, RUN_SERVER_HOST, () => resolve(undefined));
631
+ });
632
+ if (listening !== undefined) return { ok: false, reason: oneLine(listening, 200) };
633
+
634
+ // THE BOUND ADDRESS IS READ BACK OFF THE SOCKET, never echoed from the
635
+ // constant that was passed to `listen`. Reporting the constant would make
636
+ // "this server is loopback-only" unfalsifiable: dropping the interface
637
+ // argument would bind every interface, and the handle, the printed URL, and
638
+ // the `Host` check would all still say `127.0.0.1`. Asking the socket is what
639
+ // leaves the claim answerable by something other than the claim itself.
640
+ const address = server.address();
641
+ if (address === null || typeof address === "string") {
642
+ await new Promise<void>((resolve) => server.close(() => resolve()));
643
+ return { ok: false, reason: "the server bound no inspectable address" };
644
+ }
645
+ bound = { host: address.address, port: address.port };
646
+
647
+ return {
648
+ ok: true,
649
+ server: {
650
+ host: bound.host,
651
+ port: bound.port,
652
+ url: `http://${bound.host}:${bound.port}`,
653
+ close: () =>
654
+ new Promise<void>((resolve) => {
655
+ server.closeAllConnections();
656
+ server.close(() => resolve());
657
+ }),
658
+ },
659
+ };
660
+ }