@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.
- package/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +5 -5
- package/src/boundary.ts +84 -63
- package/src/check-snapshot.ts +167 -0
- 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 +64 -13
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +105 -34
- package/src/commands/prepare.ts +148 -22
- package/src/commands/record.ts +107 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +461 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +156 -10
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +17 -1
- package/src/main.ts +56 -65
- package/src/ordinary-context.ts +83 -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 +202 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +327 -12
- package/src/run-server.ts +403 -149
- package/src/run-surface.ts +196 -9
- 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/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- 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
|
|
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 {
|
|
@@ -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(
|
|
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 =
|
|
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
|
-
|
|
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 ? "" :
|
|
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 = (
|
|
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(
|
|
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: {
|
|
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 =>
|
|
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
|
-
|
|
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("");
|
|
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
|
|
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(
|
|
497
|
-
|
|
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 =>
|
|
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
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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(
|
|
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(
|
|
584
|
-
|
|
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(
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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)
|
|
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
|