@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.
- package/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +4 -4
- package/src/boundary.ts +77 -61
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +33 -8
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +1 -0
- package/src/commands/prepare.ts +123 -22
- package/src/commands/record.ts +98 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +415 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +29 -8
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +14 -1
- package/src/main.ts +20 -47
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +129 -11
- package/src/run-server.ts +394 -149
- package/src/run-surface.ts +81 -8
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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<
|
|
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))
|
|
195
|
+
if (!existing.worktreeKeys.includes(repo.worktreeKey))
|
|
196
|
+
existing.worktreeKeys.push(repo.worktreeKey);
|
|
159
197
|
}
|
|
160
|
-
return [...groups.values()]
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 ? "" :
|
|
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 = (
|
|
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(
|
|
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: {
|
|
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 =>
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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(
|
|
497
|
-
|
|
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 =>
|
|
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
|
|
514
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
"
|
|
528
|
-
`<
|
|
529
|
-
"<
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
),
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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(
|
|
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(
|
|
584
|
-
|
|
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(
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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)
|
|
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
|