@agent-delivery-harness/cli 0.1.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 +10 -5
- package/src/boundary.ts +302 -9
- 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 +419 -0
- package/src/commands/gate.ts +127 -18
- package/src/commands/maintain.ts +203 -0
- package/src/commands/managed.ts +478 -0
- package/src/commands/prepare.ts +123 -15
- package/src/commands/record.ts +100 -8
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +640 -0
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +173 -5
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +40 -4
- package/src/main.ts +38 -42
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +24 -0
- 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 +396 -0
- package/src/run-server.ts +905 -0
- package/src/run-surface.ts +334 -0
- 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
|
@@ -0,0 +1,905 @@
|
|
|
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";
|
|
12
|
+
/**
|
|
13
|
+
* `runs serve` — the operator's bird's-eye view of the run store.
|
|
14
|
+
*
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* WHAT "LIVE" MEANS, AND WHAT IT DELIBERATELY DOES NOT. A run is live when the
|
|
20
|
+
* pointer of a worktree the operator NAMED points at it and it carries no
|
|
21
|
+
* `run.ended`. The server never enumerates the store's `current/` directory:
|
|
22
|
+
* it computes each named path's own pointer key from the same `rev-parse` that
|
|
23
|
+
* resolves that path's store, and reads that one pointer. So a run executing
|
|
24
|
+
* in a worktree the operator did not name renders as open but not live — an
|
|
25
|
+
* understatement by construction, which is the direction that cannot mislead.
|
|
26
|
+
*
|
|
27
|
+
* WHY THE GIT ENVIRONMENT IS CLEARED. Twice, for the same reason the store is
|
|
28
|
+
* resolved that way everywhere else: an inherited `GIT_DIR` in the operator's
|
|
29
|
+
* shell must not be able to point one `--repo` path's store at another
|
|
30
|
+
* repository. Both queries — the store resolution and the toplevel the
|
|
31
|
+
* config-presence note names — run in the path itself with the `GIT_`
|
|
32
|
+
* namespace dropped.
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
createServer,
|
|
36
|
+
type IncomingMessage,
|
|
37
|
+
type Server,
|
|
38
|
+
type ServerResponse,
|
|
39
|
+
} from "node:http";
|
|
40
|
+
import {
|
|
41
|
+
evaluateRunJournal,
|
|
42
|
+
sha256Hex,
|
|
43
|
+
readRunArtifact,
|
|
44
|
+
RUN_STORE_ID,
|
|
45
|
+
type RunEvent,
|
|
46
|
+
type RunArtifactMetadata,
|
|
47
|
+
type RunStore,
|
|
48
|
+
} from "@agent-delivery-harness/kernel";
|
|
49
|
+
import {
|
|
50
|
+
READOUT_LABELS,
|
|
51
|
+
costLabel,
|
|
52
|
+
detailOf,
|
|
53
|
+
payloadOf,
|
|
54
|
+
readoutOf,
|
|
55
|
+
roundEntries,
|
|
56
|
+
roundLenses,
|
|
57
|
+
summarize,
|
|
58
|
+
type Readout,
|
|
59
|
+
type RunSummary,
|
|
60
|
+
} from "./run-projection.ts";
|
|
61
|
+
import {
|
|
62
|
+
oneLine,
|
|
63
|
+
oneLineOf,
|
|
64
|
+
resolveRunSurface,
|
|
65
|
+
resolveWorktreeRoot,
|
|
66
|
+
} from "./run-surface.ts";
|
|
67
|
+
|
|
68
|
+
/** Loopback, always. The page is the operator's, and only the operator's. */
|
|
69
|
+
export const RUN_SERVER_HOST = "127.0.0.1";
|
|
70
|
+
|
|
71
|
+
/** How often a page with a live run refreshes itself, in whole seconds. */
|
|
72
|
+
export const DEFAULT_POLL_SECONDS = 2;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The policy header every response carries.
|
|
76
|
+
*
|
|
77
|
+
* `default-src 'none'` is the base, and each directive that would otherwise
|
|
78
|
+
* fall back to it is restated rather than left implicit, because a reader
|
|
79
|
+
* auditing this line should not have to know the fallback table. Styles are
|
|
80
|
+
* inline and nothing else loads: no script, no image, no font, no frame, and
|
|
81
|
+
* no form target. `frame-ancestors 'none'` is what keeps the page out of
|
|
82
|
+
* someone else's frame, which the `Host` check alone would not prevent.
|
|
83
|
+
*/
|
|
84
|
+
export const RUN_SERVER_CSP = [
|
|
85
|
+
"default-src 'none'",
|
|
86
|
+
"script-src 'none'",
|
|
87
|
+
"style-src 'unsafe-inline'",
|
|
88
|
+
"img-src 'none'",
|
|
89
|
+
"connect-src 'none'",
|
|
90
|
+
"form-action 'none'",
|
|
91
|
+
"frame-ancestors 'none'",
|
|
92
|
+
"base-uri 'none'",
|
|
93
|
+
].join("; ");
|
|
94
|
+
|
|
95
|
+
// ── Resolution ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** One `--repo` path, resolved to the store it addresses and the root it names. */
|
|
98
|
+
interface ResolvedRepo {
|
|
99
|
+
readonly root: string;
|
|
100
|
+
readonly commonDir: string;
|
|
101
|
+
readonly runsDir: string;
|
|
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;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One store, and every named worktree that addresses it.
|
|
113
|
+
*
|
|
114
|
+
* Two worktrees of one repository share a common directory and therefore share
|
|
115
|
+
* every run. Grouping is what keeps each run on the page ONCE while still
|
|
116
|
+
* reading every pointer the operator named, which is what makes a run live
|
|
117
|
+
* when any named worktree is executing it.
|
|
118
|
+
*/
|
|
119
|
+
interface RepoGroup {
|
|
120
|
+
readonly root: string;
|
|
121
|
+
readonly commonDir: string;
|
|
122
|
+
readonly runsDir: string;
|
|
123
|
+
readonly worktreeKeys: readonly string[];
|
|
124
|
+
readonly store: RunStore;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type RunServerStart =
|
|
128
|
+
| { readonly ok: true; readonly server: RunServerHandle }
|
|
129
|
+
| { readonly ok: false; readonly reason: string };
|
|
130
|
+
|
|
131
|
+
export interface RunServerHandle {
|
|
132
|
+
readonly host: string;
|
|
133
|
+
readonly port: number;
|
|
134
|
+
/** `http://<host>:<port>` — what the command prints and what a client dials. */
|
|
135
|
+
readonly url: string;
|
|
136
|
+
close(): Promise<void>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface RunServerInput {
|
|
140
|
+
/** One or more paths, each a worktree of a repository whose runs to serve. */
|
|
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;
|
|
146
|
+
/** Zero, the default, asks the operating system for an ephemeral port. */
|
|
147
|
+
readonly port?: number;
|
|
148
|
+
readonly pollSeconds?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resolveRepo(
|
|
152
|
+
repoPath: string,
|
|
153
|
+
): Promise<ResolvedRepo | { readonly reason: string }> {
|
|
154
|
+
const surface = await resolveRunSurface(repoPath);
|
|
155
|
+
if (!surface.ok) return { reason: `${repoPath}: ${surface.reason}` };
|
|
156
|
+
const root = await resolveWorktreeRoot(repoPath);
|
|
157
|
+
if (!root.ok) return { reason: `${repoPath}: ${root.reason}` };
|
|
158
|
+
return {
|
|
159
|
+
root: root.root,
|
|
160
|
+
commonDir: surface.surface.commonDir,
|
|
161
|
+
runsDir: surface.surface.runsDir,
|
|
162
|
+
worktreeKey: surface.surface.worktreeKey,
|
|
163
|
+
store: surface.surface.store,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Groups resolved paths by store, preserving the order the operator gave. */
|
|
168
|
+
function groupByStore(resolved: readonly ResolvedRepo[]): readonly RepoGroup[] {
|
|
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
|
+
>();
|
|
179
|
+
for (const repo of resolved) {
|
|
180
|
+
const existing = groups.get(repo.commonDir);
|
|
181
|
+
if (existing === undefined) {
|
|
182
|
+
// The FIRST named path wins the group's root. The root is what the
|
|
183
|
+
// config-presence note names and what the repository column shows, and
|
|
184
|
+
// two worktrees of one repository can disagree about both; naming the
|
|
185
|
+
// path the operator listed first is the answer an operator can predict.
|
|
186
|
+
groups.set(repo.commonDir, {
|
|
187
|
+
root: repo.root,
|
|
188
|
+
commonDir: repo.commonDir,
|
|
189
|
+
runsDir: repo.runsDir,
|
|
190
|
+
worktreeKeys: [repo.worktreeKey],
|
|
191
|
+
store: repo.store,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!existing.worktreeKeys.includes(repo.worktreeKey))
|
|
196
|
+
existing.worktreeKeys.push(repo.worktreeKey);
|
|
197
|
+
}
|
|
198
|
+
return [...groups.values()];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── The served state ─────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
interface ServedTimelineEntry {
|
|
204
|
+
readonly seq: number;
|
|
205
|
+
readonly at: string;
|
|
206
|
+
readonly kind: string;
|
|
207
|
+
readonly writer: "cli" | "executor";
|
|
208
|
+
readonly detail: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface ServedRound {
|
|
212
|
+
readonly round: string;
|
|
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;
|
|
222
|
+
readonly lenses: string;
|
|
223
|
+
readonly outcome: string;
|
|
224
|
+
readonly findings: string;
|
|
225
|
+
readonly cost: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface ServedNote {
|
|
229
|
+
readonly at: string;
|
|
230
|
+
readonly kind: string;
|
|
231
|
+
readonly code: string;
|
|
232
|
+
readonly pattern: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface ServedRun {
|
|
236
|
+
readonly view?: RunView;
|
|
237
|
+
readonly href?: string;
|
|
238
|
+
readonly runId: string;
|
|
239
|
+
readonly repository: string;
|
|
240
|
+
/** False when the journal refused the read discipline; every other field is then empty. */
|
|
241
|
+
readonly readable: boolean;
|
|
242
|
+
readonly live: boolean;
|
|
243
|
+
readonly open: boolean;
|
|
244
|
+
readonly ticket: string;
|
|
245
|
+
readonly startedAt: string;
|
|
246
|
+
readonly lastAt: string;
|
|
247
|
+
readonly durationSeconds: number;
|
|
248
|
+
readonly rounds: { readonly opened: number; readonly closed: number };
|
|
249
|
+
readonly findings: RunSummary["findings"];
|
|
250
|
+
readonly gate?: RunSummary["gate"];
|
|
251
|
+
readonly record?: RunSummary["record"];
|
|
252
|
+
readonly result?: RunSummary["result"];
|
|
253
|
+
readonly readout: Readout;
|
|
254
|
+
readonly timeline: readonly ServedTimelineEntry[];
|
|
255
|
+
readonly roundDetail: readonly ServedRound[];
|
|
256
|
+
readonly notes: readonly ServedNote[];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The projection, wearing the names the JSON endpoint publishes. Written out
|
|
261
|
+
* member by member rather than spread: the served shape is a contract a reader
|
|
262
|
+
* can rely on, and a spread would let a future member of the summary appear on
|
|
263
|
+
* the wire without anyone deciding it should.
|
|
264
|
+
*/
|
|
265
|
+
function servedRun(input: {
|
|
266
|
+
readonly runId: string;
|
|
267
|
+
readonly repository: string;
|
|
268
|
+
readonly live: boolean;
|
|
269
|
+
readonly summary: RunSummary;
|
|
270
|
+
readonly readout: Readout;
|
|
271
|
+
readonly timeline: readonly ServedTimelineEntry[];
|
|
272
|
+
readonly roundDetail: readonly ServedRound[];
|
|
273
|
+
readonly notes: readonly ServedNote[];
|
|
274
|
+
readonly readable: boolean;
|
|
275
|
+
readonly view?: RunView;
|
|
276
|
+
readonly href?: string;
|
|
277
|
+
}): ServedRun {
|
|
278
|
+
const { summary } = input;
|
|
279
|
+
return {
|
|
280
|
+
runId: input.runId,
|
|
281
|
+
...(input.view === undefined ? {} : { view: input.view }),
|
|
282
|
+
...(input.href === undefined ? {} : { href: input.href }),
|
|
283
|
+
repository: input.repository,
|
|
284
|
+
readable: input.readable,
|
|
285
|
+
live: input.live,
|
|
286
|
+
open: summary.open,
|
|
287
|
+
ticket: summary.ticket,
|
|
288
|
+
startedAt: summary.startedAt,
|
|
289
|
+
lastAt: summary.lastAt,
|
|
290
|
+
durationSeconds: summary.durationSeconds,
|
|
291
|
+
rounds: { opened: summary.roundsOpened, closed: summary.roundsClosed },
|
|
292
|
+
findings: summary.findings,
|
|
293
|
+
...(summary.gate === undefined ? {} : { gate: summary.gate }),
|
|
294
|
+
...(summary.record === undefined ? {} : { record: summary.record }),
|
|
295
|
+
...(summary.result === undefined ? {} : { result: summary.result }),
|
|
296
|
+
readout: input.readout,
|
|
297
|
+
timeline: input.timeline,
|
|
298
|
+
roundDetail: input.roundDetail,
|
|
299
|
+
notes: input.notes,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface ServedState {
|
|
304
|
+
readonly selected?: boolean;
|
|
305
|
+
readonly labels: string;
|
|
306
|
+
readonly pollSeconds: number;
|
|
307
|
+
readonly repositories: readonly {
|
|
308
|
+
readonly root: string;
|
|
309
|
+
readonly commonDir: string;
|
|
310
|
+
readonly runsDir: string;
|
|
311
|
+
readonly worktreeKeys: readonly string[];
|
|
312
|
+
}[];
|
|
313
|
+
readonly runs: readonly ServedRun[];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The summary an unreadable journal gets: everything empty, nothing inferred. */
|
|
317
|
+
const EMPTY_SUMMARY: RunSummary = {
|
|
318
|
+
ticket: "",
|
|
319
|
+
open: true,
|
|
320
|
+
startedAt: "",
|
|
321
|
+
lastAt: "",
|
|
322
|
+
durationSeconds: 0,
|
|
323
|
+
roundsOpened: 0,
|
|
324
|
+
roundsClosed: 0,
|
|
325
|
+
findings: { P0: 0, P1: 0, P2: 0, P3: 0 },
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
function timelineOf(
|
|
329
|
+
events: readonly RunEvent[],
|
|
330
|
+
): readonly ServedTimelineEntry[] {
|
|
331
|
+
return events.map((event) => ({
|
|
332
|
+
seq: event.seq,
|
|
333
|
+
at: event.at,
|
|
334
|
+
kind: event.kind,
|
|
335
|
+
writer: event.actor.role,
|
|
336
|
+
detail: detailOf(event),
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function roundsOf(events: readonly RunEvent[]): readonly ServedRound[] {
|
|
341
|
+
return roundEntries(events).map((entry) => {
|
|
342
|
+
const closed =
|
|
343
|
+
entry.closed === undefined ? undefined : payloadOf(entry.closed);
|
|
344
|
+
return {
|
|
345
|
+
round: entry.round,
|
|
346
|
+
candidateTreeSha: entry.candidateTreeSha,
|
|
347
|
+
opened: entry.opened !== undefined,
|
|
348
|
+
lenses: roundLenses(entry),
|
|
349
|
+
outcome: closed === undefined ? "open" : oneLineOf(closed["outcome"], 64),
|
|
350
|
+
findings: closed === undefined ? "" : oneLineOf(closed["findings"]),
|
|
351
|
+
cost: closed === undefined ? "" : costLabel(closed["cost"]),
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function notesOf(entries: readonly unknown[]): readonly ServedNote[] {
|
|
357
|
+
return entries.map((entry) => {
|
|
358
|
+
const note = (
|
|
359
|
+
typeof entry === "object" && entry !== null ? entry : {}
|
|
360
|
+
) as Record<string, unknown>;
|
|
361
|
+
return {
|
|
362
|
+
at: oneLineOf(note["at"], 32),
|
|
363
|
+
kind: oneLineOf(note["kind"], 128),
|
|
364
|
+
code: oneLineOf(note["code"], 64),
|
|
365
|
+
pattern: oneLineOf(note["pattern"], 64),
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Reads every store once and projects it.
|
|
372
|
+
*
|
|
373
|
+
* A read that fails is a row, not an error. `runs list` already renders an
|
|
374
|
+
* unreadable journal as a row rather than refusing the whole listing, and the
|
|
375
|
+
* page has one more reason to: a poll can land in the middle of an append's
|
|
376
|
+
* torn-tail repair, and a viewer that 500s on that would be unusable exactly
|
|
377
|
+
* while a run is most interesting.
|
|
378
|
+
*/
|
|
379
|
+
async function readState(
|
|
380
|
+
groups: readonly RepoGroup[],
|
|
381
|
+
pollSeconds: number,
|
|
382
|
+
now: string,
|
|
383
|
+
freshnessWindowMs?: number,
|
|
384
|
+
recordPath?: string,
|
|
385
|
+
): Promise<ServedState> {
|
|
386
|
+
const runs: ServedRun[] = [];
|
|
387
|
+
for (const group of groups) {
|
|
388
|
+
const live = new Set<string>();
|
|
389
|
+
for (const worktreeKey of group.worktreeKeys) {
|
|
390
|
+
const current = await group.store.current(worktreeKey);
|
|
391
|
+
if (current.ok && current.runId !== undefined) live.add(current.runId);
|
|
392
|
+
}
|
|
393
|
+
for (const runId of await group.store.list()) {
|
|
394
|
+
const read = await group.store.read(runId);
|
|
395
|
+
const notes = notesOf(await group.store.readNotes(runId));
|
|
396
|
+
if (!read.ok) {
|
|
397
|
+
runs.push(
|
|
398
|
+
servedRun({
|
|
399
|
+
runId,
|
|
400
|
+
repository: group.root,
|
|
401
|
+
readable: false,
|
|
402
|
+
href: `/runs/${sha256Hex(group.commonDir)}/${runId}`,
|
|
403
|
+
live: false,
|
|
404
|
+
summary: EMPTY_SUMMARY,
|
|
405
|
+
readout: {
|
|
406
|
+
status: "absent",
|
|
407
|
+
present: [],
|
|
408
|
+
missing: [],
|
|
409
|
+
violations: [],
|
|
410
|
+
},
|
|
411
|
+
timeline: [],
|
|
412
|
+
roundDetail: [],
|
|
413
|
+
notes,
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const events = read.events;
|
|
419
|
+
const summary = summarize(events);
|
|
420
|
+
runs.push(
|
|
421
|
+
servedRun({
|
|
422
|
+
runId,
|
|
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}`,
|
|
433
|
+
readable: true,
|
|
434
|
+
// Liveness is the pointer AND the absence of an end, never one alone:
|
|
435
|
+
// a pointer left behind by a run that ended without clearing it must
|
|
436
|
+
// not read as a run still in flight.
|
|
437
|
+
live: summary.open && live.has(runId),
|
|
438
|
+
summary,
|
|
439
|
+
// No record tree sha and no mandated pair: the viewer has neither, and
|
|
440
|
+
// pretending otherwise would turn an observation into a claim.
|
|
441
|
+
readout: readoutOf(events, evaluateRunJournal(events), group.root),
|
|
442
|
+
timeline: timelineOf(events),
|
|
443
|
+
roundDetail: roundsOf(events),
|
|
444
|
+
notes,
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
labels: READOUT_LABELS,
|
|
451
|
+
pollSeconds,
|
|
452
|
+
repositories: groups.map((group) => ({
|
|
453
|
+
root: group.root,
|
|
454
|
+
commonDir: group.commonDir,
|
|
455
|
+
runsDir: group.runsDir,
|
|
456
|
+
worktreeKeys: group.worktreeKeys,
|
|
457
|
+
})),
|
|
458
|
+
runs,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Every string that reaches the page goes through here, store-derived or not.
|
|
466
|
+
*
|
|
467
|
+
* The exemption nobody gets is the point: a path the operator typed, a run id
|
|
468
|
+
* the store allocated, and a rationale an executor wrote are all escaped by
|
|
469
|
+
* the same function, because "this one is ours" is how the one that was not
|
|
470
|
+
* ours gets through. The five characters are the full set that can end an
|
|
471
|
+
* attribute or open a tag in HTML text and attribute contexts.
|
|
472
|
+
*/
|
|
473
|
+
export function escapeHtml(value: string): string {
|
|
474
|
+
return value
|
|
475
|
+
.replace(/&/g, "&")
|
|
476
|
+
.replace(/</g, "<")
|
|
477
|
+
.replace(/>/g, ">")
|
|
478
|
+
.replace(/"/g, """)
|
|
479
|
+
.replace(/'/g, "'");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Neutralized to one line, then escaped as markup: the terminal's rule, plus the browser's. */
|
|
483
|
+
const cell = (value: string, maximum = 240): string =>
|
|
484
|
+
escapeHtml(oneLine(value, maximum));
|
|
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
|
+
}
|
|
491
|
+
function runsTable(state: ServedState): string {
|
|
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>` : ""}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function timelineTable(run: ServedRun): string {
|
|
501
|
+
const rows = run.timeline.map((entry) =>
|
|
502
|
+
[
|
|
503
|
+
"<tr>",
|
|
504
|
+
`<td>${entry.seq}</td>`,
|
|
505
|
+
`<td>${cell(entry.at, 32)}</td>`,
|
|
506
|
+
`<td>${cell(entry.kind, 64)}</td>`,
|
|
507
|
+
`<td>${cell(entry.writer, 16)}-written</td>`,
|
|
508
|
+
`<td>${cell(entry.detail, 400)}</td>`,
|
|
509
|
+
"</tr>",
|
|
510
|
+
].join(""),
|
|
511
|
+
);
|
|
512
|
+
return [
|
|
513
|
+
"<h3>timeline</h3><table><tr><th>seq</th><th>at</th><th>kind</th><th>writer</th><th>detail</th></tr>",
|
|
514
|
+
rows.length === 0
|
|
515
|
+
? '<tr><td colspan="5">no readable events</td></tr>'
|
|
516
|
+
: rows.join(""),
|
|
517
|
+
"</table>",
|
|
518
|
+
].join("");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function roundsTable(run: ServedRun): string {
|
|
522
|
+
if (run.roundDetail.length === 0) return "";
|
|
523
|
+
const rows = run.roundDetail.map((round) =>
|
|
524
|
+
[
|
|
525
|
+
"<tr>",
|
|
526
|
+
`<td>${cell(round.round, 32)}</td>`,
|
|
527
|
+
`<td>${cell(round.candidateTreeSha, 128) || "(none)"}</td>`,
|
|
528
|
+
`<td>${cell(round.lenses)}</td>`,
|
|
529
|
+
`<td>${cell(round.outcome, 64)}</td>`,
|
|
530
|
+
`<td>${cell(round.findings)}</td>`,
|
|
531
|
+
`<td>${cell(round.cost, 64)}</td>`,
|
|
532
|
+
"</tr>",
|
|
533
|
+
].join(""),
|
|
534
|
+
);
|
|
535
|
+
return [
|
|
536
|
+
"<h3>rounds</h3><table><tr><th>round</th><th>candidate</th><th>lenses</th><th>outcome</th><th>findings</th><th>cost</th></tr>",
|
|
537
|
+
rows.join(""),
|
|
538
|
+
"</table>",
|
|
539
|
+
].join("");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function notesTable(run: ServedRun): string {
|
|
543
|
+
if (run.notes.length === 0) return "";
|
|
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>`,
|
|
547
|
+
);
|
|
548
|
+
return [
|
|
549
|
+
"<h3>refused appends</h3><table><tr><th>at</th><th>kind</th><th>code</th><th>pattern</th></tr>",
|
|
550
|
+
rows.join(""),
|
|
551
|
+
"</table>",
|
|
552
|
+
].join("");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function readoutBlock(run: ServedRun): string {
|
|
556
|
+
const list = (entries: readonly string[]): string =>
|
|
557
|
+
entries.length === 0 ? "(none)" : cell(entries.join(", "), 800);
|
|
558
|
+
return [
|
|
559
|
+
"<h3>completeness</h3>",
|
|
560
|
+
`<p class="labels">${cell(run.readout.status, 64)} — ${escapeHtml(READOUT_LABELS)}</p>`,
|
|
561
|
+
`<p class="meta">present: ${list(run.readout.present)}</p>`,
|
|
562
|
+
`<p class="meta">missing: ${list(run.readout.missing)}</p>`,
|
|
563
|
+
run.readout.violations.length === 0
|
|
564
|
+
? ""
|
|
565
|
+
: `<p class="meta">violations: ${list(run.readout.violations)}</p>`,
|
|
566
|
+
run.readout.note === undefined
|
|
567
|
+
? ""
|
|
568
|
+
: `<p class="meta">note: ${cell(run.readout.note, 500)}</p>`,
|
|
569
|
+
].join("");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function renderPage(state: ServedState): string {
|
|
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
|
+
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── The server ───────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
const SECURITY_HEADERS: Readonly<Record<string, string>> = {
|
|
609
|
+
"X-Content-Type-Options": "nosniff",
|
|
610
|
+
"Content-Security-Policy": RUN_SERVER_CSP,
|
|
611
|
+
"Referrer-Policy": "no-referrer",
|
|
612
|
+
// A viewer of a live store must never be shown a cached run, and no proxy
|
|
613
|
+
// between loopback and loopback has any business holding one.
|
|
614
|
+
"Cache-Control": "no-store",
|
|
615
|
+
};
|
|
616
|
+
|
|
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
|
+
});
|
|
631
|
+
response.end(body);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Whether this request is addressed to the socket it arrived on.
|
|
636
|
+
*
|
|
637
|
+
* Compared by exact equality against the address and port actually bound —
|
|
638
|
+
* not parsed, not normalized, not matched against a list of names that "mean"
|
|
639
|
+
* loopback. A request naming anything else reached this server through
|
|
640
|
+
* something that rewrote its destination (a DNS rebind from a page the
|
|
641
|
+
* operator was reading, a proxy), and this page renders text an executor
|
|
642
|
+
* wrote. `localhost` is refused with everything else: the server prints the
|
|
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.
|
|
650
|
+
*/
|
|
651
|
+
export function hostIsBound(
|
|
652
|
+
header: string | undefined,
|
|
653
|
+
host: string,
|
|
654
|
+
port: number,
|
|
655
|
+
): boolean {
|
|
656
|
+
return header === `${host}:${port}`;
|
|
657
|
+
}
|
|
658
|
+
|
|
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" };
|
|
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
|
+
};
|
|
675
|
+
|
|
676
|
+
const resolved: ResolvedRepo[] = [];
|
|
677
|
+
for (const repoPath of input.repos) {
|
|
678
|
+
const outcome = await resolveRepo(repoPath);
|
|
679
|
+
if ("reason" in outcome) return { ok: false, reason: outcome.reason };
|
|
680
|
+
resolved.push(outcome);
|
|
681
|
+
}
|
|
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
|
+
};
|
|
733
|
+
|
|
734
|
+
let bound: { readonly host: string; readonly port: number } | undefined;
|
|
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();
|
|
867
|
+
}
|
|
868
|
+
})();
|
|
869
|
+
},
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
const listening = await new Promise<string | undefined>((resolve) => {
|
|
873
|
+
server.once("error", (error: Error) => resolve(error.message));
|
|
874
|
+
server.listen(input.port ?? 0, RUN_SERVER_HOST, () => resolve(undefined));
|
|
875
|
+
});
|
|
876
|
+
if (listening !== undefined)
|
|
877
|
+
return { ok: false, reason: oneLine(listening, 200) };
|
|
878
|
+
|
|
879
|
+
// THE BOUND ADDRESS IS READ BACK OFF THE SOCKET, never echoed from the
|
|
880
|
+
// constant that was passed to `listen`. Reporting the constant would make
|
|
881
|
+
// "this server is loopback-only" unfalsifiable: dropping the interface
|
|
882
|
+
// argument would bind every interface, and the handle, the printed URL, and
|
|
883
|
+
// the `Host` check would all still say `127.0.0.1`. Asking the socket is what
|
|
884
|
+
// leaves the claim answerable by something other than the claim itself.
|
|
885
|
+
const address = server.address();
|
|
886
|
+
if (address === null || typeof address === "string") {
|
|
887
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
888
|
+
return { ok: false, reason: "the server bound no inspectable address" };
|
|
889
|
+
}
|
|
890
|
+
bound = { host: address.address, port: address.port };
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
ok: true,
|
|
894
|
+
server: {
|
|
895
|
+
host: bound.host,
|
|
896
|
+
port: bound.port,
|
|
897
|
+
url: `http://${bound.host}:${bound.port}`,
|
|
898
|
+
close: () =>
|
|
899
|
+
new Promise<void>((resolve) => {
|
|
900
|
+
server.closeAllConnections();
|
|
901
|
+
server.close(() => resolve());
|
|
902
|
+
}),
|
|
903
|
+
},
|
|
904
|
+
};
|
|
905
|
+
}
|