@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/commands/runs.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { runViewCommand } from "../run-view-command.ts";
|
|
2
|
+
import { readArchiveFile } from "../run-archive-commands.ts";
|
|
3
|
+
import { runArchiveCommand } from "../run-archive-commands.ts";
|
|
4
|
+
import { runArtifactCommand } from "../run-artifact-commands.ts";
|
|
1
5
|
/**
|
|
2
6
|
* `runs` — read the run store back: `runs list`, `runs show <id>`, and
|
|
3
7
|
* `runs serve`, the local page over the same files.
|
|
@@ -18,21 +22,51 @@
|
|
|
18
22
|
*/
|
|
19
23
|
import { stat } from "node:fs/promises";
|
|
20
24
|
import path from "node:path";
|
|
21
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
RUN_EVENT_KINDS,
|
|
27
|
+
RUN_EVENT_KINDS_V1,
|
|
28
|
+
RUN_EVENT_SPEC,
|
|
29
|
+
RUN_EVENT_SPEC_V2,
|
|
30
|
+
RUN_JOURNAL_STATUSES,
|
|
31
|
+
describeRunEventPayload,
|
|
32
|
+
evaluateRunJournal,
|
|
33
|
+
type RunEventPayloadGrammar,
|
|
34
|
+
type RunEventPayloadMemberGrammar,
|
|
35
|
+
type RunEventValueGrammar,
|
|
36
|
+
type RunEventVersion,
|
|
37
|
+
} from "@agent-delivery-harness/kernel";
|
|
22
38
|
import {
|
|
23
39
|
READOUT_LABELS,
|
|
24
40
|
detailOf,
|
|
41
|
+
durationLabel,
|
|
42
|
+
phaseRows,
|
|
25
43
|
readoutRows,
|
|
26
44
|
roundRows,
|
|
45
|
+
summarize,
|
|
27
46
|
} from "../run-projection.ts";
|
|
47
|
+
import { buildRunExport } from "../run-export.ts";
|
|
28
48
|
import { startRunServer, type RunServerHandle } from "../run-server.ts";
|
|
29
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
oneLine,
|
|
51
|
+
oneLineOf,
|
|
52
|
+
resolveRunSurface,
|
|
53
|
+
resolveWorktreeRoot,
|
|
54
|
+
runSurfaceBlocker,
|
|
55
|
+
type RunSurface,
|
|
56
|
+
} from "../run-surface.ts";
|
|
30
57
|
import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
|
|
31
58
|
|
|
32
59
|
const USAGE = [
|
|
33
|
-
"Usage: delivery-harness runs
|
|
34
|
-
" delivery-harness runs
|
|
35
|
-
" delivery-harness runs
|
|
60
|
+
"Usage: delivery-harness runs capabilities --json",
|
|
61
|
+
" delivery-harness runs list [--json] [--limit <n>] [--status <status>] [--open|--ended]",
|
|
62
|
+
" delivery-harness runs show [<run-id>] [--json]",
|
|
63
|
+
" delivery-harness runs grammar <kind> [--version 1|2] [--json]",
|
|
64
|
+
" delivery-harness runs view <run-id> [--json] [--record <repository-relative-path>]",
|
|
65
|
+
" delivery-harness runs export <run-id> --output <file>",
|
|
66
|
+
" delivery-harness runs archive <file> [--artifact <id>]",
|
|
67
|
+
" delivery-harness runs capture <run-id> --json <request>",
|
|
68
|
+
" delivery-harness runs artifact <run-id> <artifact-id> [--json]",
|
|
69
|
+
" delivery-harness runs serve [--repo <path>]... [--archive <file>]... [--port <n>] [--freshness-seconds <n>] [--record <repository-relative-path>]",
|
|
36
70
|
].join("\n");
|
|
37
71
|
|
|
38
72
|
const unresolvable = (reason: string): CommandResult => ({
|
|
@@ -54,32 +88,264 @@ export const runsCommand: ConfigFreeCommandDescriptor = {
|
|
|
54
88
|
name: "runs",
|
|
55
89
|
sourceId: "delivery-harness.cli.runs",
|
|
56
90
|
summary: "List, show, and serve the delivery runs this repository has recorded.",
|
|
91
|
+
usage: USAGE,
|
|
57
92
|
configFree: true,
|
|
58
93
|
async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
59
94
|
const [subcommand, ...rest] = context.args;
|
|
95
|
+
if (subcommand === "capabilities") {
|
|
96
|
+
if (rest.length !== 1 || rest[0] !== "--json") return { kind: "usage", message: "Usage: runs capabilities --json" };
|
|
97
|
+
context.write(`${JSON.stringify({ spec: "run-capabilities/1", writerVersions: ["run-event/1", "run-event/2"], artifactCapture: true })}\n`);
|
|
98
|
+
return { kind: "ok" };
|
|
99
|
+
}
|
|
100
|
+
if (subcommand === "grammar") return showGrammar(context, rest);
|
|
101
|
+
if (subcommand === "view") return runViewCommand(context, rest);
|
|
102
|
+
if (subcommand === "export" || subcommand === "archive") return runArchiveCommand(context, subcommand, rest);
|
|
103
|
+
if (subcommand === "capture" || subcommand === "artifact") return runArtifactCommand(context, subcommand, rest);
|
|
60
104
|
if (subcommand === undefined) return { kind: "usage", message: `runs needs a subcommand.\n${USAGE}` };
|
|
61
105
|
if (subcommand !== "list" && subcommand !== "show" && subcommand !== "serve") {
|
|
62
106
|
return { kind: "usage", message: `Unknown runs subcommand ${oneLine(subcommand, 64)}.\n${USAGE}` };
|
|
63
107
|
}
|
|
64
|
-
if (subcommand === "show" && rest[0] === undefined) {
|
|
65
|
-
return { kind: "usage", message: `runs show needs a run id.\n${USAGE}` };
|
|
66
|
-
}
|
|
67
108
|
// `serve` resolves its OWN repositories — one per `--repo`, none of them
|
|
68
109
|
// necessarily the invoking worktree — so it never asks the invoking
|
|
69
110
|
// worktree's store to resolve first.
|
|
70
111
|
if (subcommand === "serve") return serveRuns(context, rest);
|
|
71
112
|
|
|
113
|
+
// `list`'s argument surface is decided BEFORE the store is resolved. A
|
|
114
|
+
// mistyped bound is a usage error wherever it was typed, and an operator
|
|
115
|
+
// standing outside a repository should not have to fix the repository to
|
|
116
|
+
// find out the flag was wrong.
|
|
117
|
+
const listArgs = subcommand === "list" ? parseListArgs(rest) : undefined;
|
|
118
|
+
if (listArgs !== undefined && !listArgs.ok) return { kind: "usage", message: listArgs.message };
|
|
119
|
+
const showArgs = subcommand === "show" ? parseShowArgs(rest) : undefined;
|
|
120
|
+
if (showArgs !== undefined && !showArgs.ok) return { kind: "usage", message: showArgs.message };
|
|
121
|
+
|
|
72
122
|
const resolved = await resolveRunSurface(context.rootDir);
|
|
73
123
|
if (!resolved.ok) return unresolvable(resolved.reason);
|
|
74
124
|
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
if (listArgs !== undefined && listArgs.ok) return listRuns(resolved.surface, context, listArgs.args);
|
|
126
|
+
if (showArgs === undefined || !showArgs.ok) throw new Error("runs show arguments were not parsed");
|
|
127
|
+
let runId = showArgs.args.runId;
|
|
128
|
+
if (runId === undefined) {
|
|
129
|
+
const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
|
|
130
|
+
if (!current.ok || current.runId === undefined) {
|
|
131
|
+
return {
|
|
132
|
+
kind: "blocked",
|
|
133
|
+
blockers: [runSurfaceBlocker({
|
|
134
|
+
code: "run_unresolvable",
|
|
135
|
+
summary: "There is no current run that can be resolved in this worktree.",
|
|
136
|
+
details: current.ok
|
|
137
|
+
? "no run id was supplied and the worktree has no readable current run"
|
|
138
|
+
: `no run id was supplied; current-run lookup was refused: ${oneLine(current.rejections[0]?.message ?? "unreadable", 200)}`,
|
|
139
|
+
remediation: {
|
|
140
|
+
id: "start-or-name-a-run",
|
|
141
|
+
summary: "Start a run with `delivery-harness emit run.started`, or pass a run id from `delivery-harness runs list`.",
|
|
142
|
+
},
|
|
143
|
+
})],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
runId = current.runId;
|
|
147
|
+
}
|
|
148
|
+
return showRun(resolved.surface, context, runId, showArgs.args.json);
|
|
78
149
|
},
|
|
79
150
|
};
|
|
80
151
|
|
|
152
|
+
interface ShowArgs { readonly runId?: string; readonly json: boolean }
|
|
153
|
+
type ShowParse = { readonly ok: true; readonly args: ShowArgs } | { readonly ok: false; readonly message: string };
|
|
154
|
+
|
|
155
|
+
function parseShowArgs(args: readonly string[]): ShowParse {
|
|
156
|
+
let runId: string | undefined;
|
|
157
|
+
let json = false;
|
|
158
|
+
for (const token of args) {
|
|
159
|
+
if (token === "--json") {
|
|
160
|
+
if (json) return { ok: false, message: `runs show: --json was given twice.\n${USAGE}` };
|
|
161
|
+
json = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (token.startsWith("-")) return { ok: false, message: `runs show: unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
165
|
+
if (runId !== undefined) return { ok: false, message: `runs show accepts at most one run id.\n${USAGE}` };
|
|
166
|
+
runId = token;
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, args: { json, ...(runId === undefined ? {} : { runId }) } };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface GrammarArgs { readonly kind: string; readonly version: RunEventVersion; readonly json: boolean }
|
|
172
|
+
type GrammarParse = { readonly ok: true; readonly args: GrammarArgs } | { readonly ok: false; readonly message: string };
|
|
173
|
+
|
|
174
|
+
function parseGrammarArgs(args: readonly string[]): GrammarParse {
|
|
175
|
+
let kind: string | undefined;
|
|
176
|
+
let version: RunEventVersion = RUN_EVENT_SPEC_V2;
|
|
177
|
+
let versionSeen = false;
|
|
178
|
+
let json = false;
|
|
179
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
180
|
+
const token = args[index]!;
|
|
181
|
+
if (token === "--json") {
|
|
182
|
+
if (json) return { ok: false, message: `runs grammar: --json was given twice.\n${USAGE}` };
|
|
183
|
+
json = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (token === "--version") {
|
|
187
|
+
if (versionSeen) return { ok: false, message: `runs grammar: --version was given twice.\n${USAGE}` };
|
|
188
|
+
const value = args[index + 1];
|
|
189
|
+
if (value !== "1" && value !== "2") return { ok: false, message: `runs grammar: --version needs 1 or 2.\n${USAGE}` };
|
|
190
|
+
version = value === "1" ? RUN_EVENT_SPEC : RUN_EVENT_SPEC_V2;
|
|
191
|
+
versionSeen = true;
|
|
192
|
+
index += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (token.startsWith("-")) return { ok: false, message: `runs grammar: unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
196
|
+
if (kind !== undefined) return { ok: false, message: `runs grammar accepts exactly one event kind.\n${USAGE}` };
|
|
197
|
+
kind = token;
|
|
198
|
+
}
|
|
199
|
+
if (kind === undefined) return { ok: false, message: `runs grammar needs an event kind.\n${USAGE}` };
|
|
200
|
+
return { ok: true, args: { kind, version, json } };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One described value, and everything beneath it. Types, nested tables, array
|
|
205
|
+
* items and variants are printed rather than summarised: the whole point of
|
|
206
|
+
* this view is that a caller never has to open the validator to find out what
|
|
207
|
+
* a member holds.
|
|
208
|
+
*/
|
|
209
|
+
function renderValue(value: RunEventValueGrammar, indent: string, lead: string, context: ConfigFreeCommandContext): void {
|
|
210
|
+
context.write(`${indent}${lead}${value.type} ${value.constraint}${value.values === undefined ? "" : ` values: ${value.values.join(", ")}`}`);
|
|
211
|
+
context.write(`${indent} example: ${JSON.stringify(value.example)}`);
|
|
212
|
+
for (const member of value.members ?? []) renderMember(member, `${indent} `, context);
|
|
213
|
+
if (value.items !== undefined) renderValue(value.items, `${indent} `, "items ", context);
|
|
214
|
+
for (const [index, variant] of (value.variants ?? []).entries()) {
|
|
215
|
+
renderValue(variant, `${indent} `, `variant ${index + 1} `, context);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderMember(member: RunEventPayloadMemberGrammar, indent: string, context: ConfigFreeCommandContext): void {
|
|
220
|
+
renderValue(member, indent, `${member.name} ${member.required ? "required" : "optional"} `, context);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderGrammar(grammar: RunEventPayloadGrammar, context: ConfigFreeCommandContext): void {
|
|
224
|
+
context.write(`${grammar.kind} (${grammar.version})`);
|
|
225
|
+
for (const member of grammar.members) renderMember(member, " ", context);
|
|
226
|
+
context.write(" example:");
|
|
227
|
+
context.write(JSON.stringify(grammar.example, null, 2));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function showGrammar(context: ConfigFreeCommandContext, args: readonly string[]): CommandResult {
|
|
231
|
+
const parsed = parseGrammarArgs(args);
|
|
232
|
+
if (!parsed.ok) return { kind: "usage", message: parsed.message };
|
|
233
|
+
const grammar = describeRunEventPayload(parsed.args.kind, parsed.args.version);
|
|
234
|
+
if (grammar === undefined) {
|
|
235
|
+
const kinds = parsed.args.version === RUN_EVENT_SPEC ? RUN_EVENT_KINDS_V1 : RUN_EVENT_KINDS;
|
|
236
|
+
return {
|
|
237
|
+
kind: "usage",
|
|
238
|
+
message: `runs grammar: ${oneLine(parsed.args.kind, 128)} is not a ${parsed.args.version} event kind; accepted kinds: ${kinds.join(", ")}.\n${USAGE}`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (parsed.args.json) context.write(JSON.stringify(grammar, null, 2));
|
|
242
|
+
else renderGrammar(grammar, context);
|
|
243
|
+
return { kind: "ok" };
|
|
244
|
+
}
|
|
245
|
+
|
|
81
246
|
// ── list ─────────────────────────────────────────────────────────────────────
|
|
82
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The one label this listing prints for a journal it could not read at all.
|
|
250
|
+
* It is not a completeness verdict — nothing was evaluated — so it is not one
|
|
251
|
+
* of the kernel's statuses, and it is spelled once, here.
|
|
252
|
+
*/
|
|
253
|
+
export const RUN_LIST_UNREADABLE = "unreadable";
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* `absent` is the completeness vocabulary's answer to "no journal bound this
|
|
257
|
+
* candidate", which an inventory of the journals that exist can never be. It
|
|
258
|
+
* is excluded rather than accepted-and-never-matched, because a selector that
|
|
259
|
+
* cannot select anything whatever the store holds is a false affordance: an
|
|
260
|
+
* agent filtering on it would read the empty result as "no such runs" rather
|
|
261
|
+
* than as "this question cannot be asked here".
|
|
262
|
+
*/
|
|
263
|
+
const STATUS_NEVER_LISTED = "absent";
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Exactly the statuses a row of this listing can carry, which is what
|
|
267
|
+
* `--status` accepts. Derived from the kernel's closed vocabulary so a status
|
|
268
|
+
* renamed there is a compile-and-test problem here rather than a filter that
|
|
269
|
+
* silently stops matching.
|
|
270
|
+
*/
|
|
271
|
+
export const RUN_LIST_STATUSES: readonly string[] = Object.freeze([
|
|
272
|
+
...RUN_JOURNAL_STATUSES.filter((status) => status !== STATUS_NEVER_LISTED),
|
|
273
|
+
RUN_LIST_UNREADABLE,
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
/** The spec every machine-readable inventory carries, beside `runs show --json`'s export spec. */
|
|
277
|
+
export const RUN_INVENTORY_SPEC = "run-inventory/1";
|
|
278
|
+
|
|
279
|
+
interface ListArgs {
|
|
280
|
+
readonly json: boolean;
|
|
281
|
+
readonly limit?: number;
|
|
282
|
+
readonly status?: string;
|
|
283
|
+
/** True selects the runs with no `run.ended`; false selects the ended ones. */
|
|
284
|
+
readonly open?: boolean;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type ListParse = { readonly ok: true; readonly args: ListArgs } | { readonly ok: false; readonly message: string };
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The separate-argument form the rest of this command uses, and the same
|
|
291
|
+
* refusal discipline: an unknown flag, a repeated one, a positional, and a
|
|
292
|
+
* value that is not a bound are all usage errors rather than conveniences.
|
|
293
|
+
*
|
|
294
|
+
* A LIMIT IS A COUNT OF ROWS, so it is a positive integer and nothing else.
|
|
295
|
+
* `0` is refused rather than read as "no rows" or as "no bound": both readings
|
|
296
|
+
* are plausible, an operator cannot tell which one they got from the output,
|
|
297
|
+
* and neither is a thing anyone means to ask for.
|
|
298
|
+
*/
|
|
299
|
+
function parseListArgs(args: readonly string[]): ListParse {
|
|
300
|
+
const refuse = (reason: string): ListParse => ({ ok: false, message: `runs list: ${reason}.\n${USAGE}` });
|
|
301
|
+
let json = false;
|
|
302
|
+
let limit: number | undefined;
|
|
303
|
+
let status: string | undefined;
|
|
304
|
+
let open: boolean | undefined;
|
|
305
|
+
|
|
306
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
307
|
+
const token = args[index]!;
|
|
308
|
+
if (token === "--json") {
|
|
309
|
+
if (json) return refuse("--json was given twice");
|
|
310
|
+
json = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (token === "--open" || token === "--ended") {
|
|
314
|
+
// Two names for one question, so asking it twice — however spelled —
|
|
315
|
+
// has no answer to give.
|
|
316
|
+
if (open !== undefined) return refuse("use at most one of --open and --ended");
|
|
317
|
+
open = token === "--open";
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (token === "--limit" || token === "--status") {
|
|
321
|
+
const value = args[index + 1];
|
|
322
|
+
if (value === undefined) return refuse(`${token} needs a value`);
|
|
323
|
+
index += 1;
|
|
324
|
+
if (token === "--limit") {
|
|
325
|
+
if (limit !== undefined) return refuse("--limit was given twice");
|
|
326
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
|
|
327
|
+
return refuse(`--limit needs a positive whole number, not ${oneLine(value, 64)}`);
|
|
328
|
+
}
|
|
329
|
+
limit = Number(value);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (status !== undefined) return refuse("--status was given twice");
|
|
333
|
+
if (!RUN_LIST_STATUSES.includes(value)) {
|
|
334
|
+
return refuse(`--status accepts ${RUN_LIST_STATUSES.join(", ")}, not ${oneLine(value, 64)}`);
|
|
335
|
+
}
|
|
336
|
+
status = value;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (token.startsWith("--")) return refuse(`unknown flag ${oneLine(token, 64)}`);
|
|
340
|
+
return refuse("it takes no positional arguments");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
args: { json, ...(limit === undefined ? {} : { limit }), ...(status === undefined ? {} : { status }), ...(open === undefined ? {} : { open }) },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
83
349
|
/** The journal's size on disk, or zero where it cannot be measured. */
|
|
84
350
|
async function sizeOf(runsDir: string, runId: string): Promise<number> {
|
|
85
351
|
try {
|
|
@@ -89,39 +355,137 @@ async function sizeOf(runsDir: string, runId: string): Promise<number> {
|
|
|
89
355
|
}
|
|
90
356
|
}
|
|
91
357
|
|
|
92
|
-
|
|
358
|
+
/** One run as both surfaces see it: the same six facts, rendered or serialized. */
|
|
359
|
+
interface InventoryRow {
|
|
360
|
+
readonly runId: string;
|
|
361
|
+
readonly status: string;
|
|
362
|
+
readonly open: boolean;
|
|
363
|
+
readonly current: boolean;
|
|
364
|
+
/**
|
|
365
|
+
* The journal's own span in whole seconds, and `null` where there is no
|
|
366
|
+
* journal to take a span from.
|
|
367
|
+
*
|
|
368
|
+
* NULL, NEVER ZERO. An unreadable journal has no duration; a run that began
|
|
369
|
+
* and ended inside one second has a duration of zero. Spelling both `0` would
|
|
370
|
+
* make the listing's one cycle-time figure unreadable exactly where the
|
|
371
|
+
* status column already says something is wrong — and an agent summing the
|
|
372
|
+
* column would count the unreadable ones as instantaneous.
|
|
373
|
+
*/
|
|
374
|
+
readonly durationSeconds: number | null;
|
|
375
|
+
readonly bytes: number;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Every run the store holds, in the store's own order.
|
|
380
|
+
*
|
|
381
|
+
* THE ORDER IS THE STORE'S, and the store sorts run ids ascending. That is
|
|
382
|
+
* what makes a bounded listing reproducible: the same store bounded the same
|
|
383
|
+
* way returns the same rows, and the row a `--limit 1` returns is the one an
|
|
384
|
+
* unbounded listing printed first. Nothing here re-orders it, so the order
|
|
385
|
+
* this surface documents is the order the store defines and not a second one
|
|
386
|
+
* that happens to agree today.
|
|
387
|
+
*/
|
|
388
|
+
async function inventoryOf(
|
|
389
|
+
surface: RunSurface,
|
|
390
|
+
): Promise<{ readonly rows: readonly InventoryRow[]; readonly currentRunId: string | undefined }> {
|
|
93
391
|
const runIds = await surface.store.list();
|
|
94
392
|
const current = await surface.store.current(surface.worktreeKey);
|
|
95
393
|
const currentRunId = current.ok ? current.runId : undefined;
|
|
96
394
|
|
|
395
|
+
const rows: InventoryRow[] = [];
|
|
396
|
+
for (const runId of runIds) {
|
|
397
|
+
const bytes = await sizeOf(surface.runsDir, runId);
|
|
398
|
+
const read = await surface.store.read(runId);
|
|
399
|
+
rows.push(
|
|
400
|
+
read.ok
|
|
401
|
+
? {
|
|
402
|
+
runId,
|
|
403
|
+
status: evaluateRunJournal(read.events).status,
|
|
404
|
+
open: !read.events.some((event) => event.kind === "run.ended"),
|
|
405
|
+
current: runId === currentRunId,
|
|
406
|
+
durationSeconds: summarize(read.events).durationSeconds,
|
|
407
|
+
bytes,
|
|
408
|
+
}
|
|
409
|
+
: // Nothing was read, so no lifecycle or completeness state is claimed.
|
|
410
|
+
// The worktree pointer and on-disk size are independent readable facts.
|
|
411
|
+
{ runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, durationSeconds: null, bytes },
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return { rows, currentRunId };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* `runs list`, human and machine.
|
|
419
|
+
*
|
|
420
|
+
* THE FILTER RUNS BEFORE THE BOUND. A bound applied first would spend its rows
|
|
421
|
+
* on runs the filter then discards, so `--status x --limit 1` could answer
|
|
422
|
+
* "none" for a store that holds one — the bound is a bound on the ANSWER, not
|
|
423
|
+
* on how far the store was read.
|
|
424
|
+
*
|
|
425
|
+
* WHAT `total` COUNTS is the set the filters selected, before the bound. That
|
|
426
|
+
* is the number a reader needs to know it was bounded (`returned` below it
|
|
427
|
+
* means rows were cut), and with no filter it is the whole store — which is
|
|
428
|
+
* why the unfiltered human listing's total line is unchanged.
|
|
429
|
+
*/
|
|
430
|
+
async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext, args: ListArgs): Promise<CommandResult> {
|
|
431
|
+
const { rows, currentRunId } = await inventoryOf(surface);
|
|
432
|
+
const selected = rows.filter(
|
|
433
|
+
(row) =>
|
|
434
|
+
(args.status === undefined || row.status === args.status) &&
|
|
435
|
+
(args.open === undefined || (row.status !== RUN_LIST_UNREADABLE && row.open === args.open)),
|
|
436
|
+
);
|
|
437
|
+
const totalBytes = selected.reduce((sum, row) => sum + row.bytes, 0);
|
|
438
|
+
const shown = args.limit === undefined ? selected : selected.slice(0, args.limit);
|
|
439
|
+
const truncated = shown.length < selected.length;
|
|
440
|
+
|
|
441
|
+
if (args.json) {
|
|
442
|
+
context.write(
|
|
443
|
+
JSON.stringify(
|
|
444
|
+
{
|
|
445
|
+
spec: RUN_INVENTORY_SPEC,
|
|
446
|
+
labels: READOUT_LABELS,
|
|
447
|
+
runsDir: surface.runsDir,
|
|
448
|
+
current: currentRunId ?? null,
|
|
449
|
+
runs: shown,
|
|
450
|
+
total: { count: selected.length, bytes: totalBytes },
|
|
451
|
+
returned: shown.length,
|
|
452
|
+
truncated,
|
|
453
|
+
},
|
|
454
|
+
null,
|
|
455
|
+
2,
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
return { kind: "ok" };
|
|
459
|
+
}
|
|
460
|
+
|
|
97
461
|
// The status column carries a completeness verdict, so this readout is
|
|
98
462
|
// labeled exactly like `show`'s. `list` is the command an operator reaches
|
|
99
463
|
// for first, before it knows which id to show; an unlabeled `complete` here
|
|
100
464
|
// is the misreading the labels exist to prevent.
|
|
101
465
|
const lines: string[] = [`runs in ${oneLine(surface.runsDir, 400)}`, ` (${READOUT_LABELS})`];
|
|
102
|
-
|
|
103
|
-
for
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
lines.push(` ${runId} unreadable ${size} bytes`);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
const evaluation = evaluateRunJournal(read.events);
|
|
112
|
-
const open = !read.events.some((event) => event.kind === "run.ended");
|
|
466
|
+
// The duration goes AHEAD of the size, which is the demotion this column was
|
|
467
|
+
// added for: an operator scanning this listing is asking which delivery took
|
|
468
|
+
// a day, and the journal's size on disk answers a question nobody has except
|
|
469
|
+
// when something is wrong with the file. The size stays, because it is the
|
|
470
|
+
// only readable fact about an unreadable journal.
|
|
471
|
+
for (const row of shown) {
|
|
113
472
|
lines.push(
|
|
114
|
-
|
|
473
|
+
row.status === RUN_LIST_UNREADABLE || row.durationSeconds === null
|
|
474
|
+
? ` ${row.runId} ${RUN_LIST_UNREADABLE} ${row.bytes} bytes`
|
|
475
|
+
: ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${durationLabel(row.durationSeconds)} ${row.bytes} bytes`,
|
|
115
476
|
);
|
|
116
477
|
}
|
|
117
|
-
lines.push(`total ${
|
|
478
|
+
lines.push(`total ${totalBytes} bytes across ${selected.length} run(s)`);
|
|
479
|
+
// Said only when something was actually cut, so the unbounded listing keeps
|
|
480
|
+
// the bytes it always had.
|
|
481
|
+
if (truncated) lines.push(`showing ${shown.length} of ${selected.length} run(s) (--limit ${args.limit})`);
|
|
118
482
|
for (const line of lines) context.write(line);
|
|
119
483
|
return { kind: "ok" };
|
|
120
484
|
}
|
|
121
485
|
|
|
122
486
|
// ── show ─────────────────────────────────────────────────────────────────────
|
|
123
487
|
|
|
124
|
-
async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string): Promise<CommandResult> {
|
|
488
|
+
async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string, json: boolean): Promise<CommandResult> {
|
|
125
489
|
const read = await surface.store.read(runId);
|
|
126
490
|
if (!read.ok) {
|
|
127
491
|
return {
|
|
@@ -138,6 +502,17 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
|
|
|
138
502
|
}
|
|
139
503
|
|
|
140
504
|
const events = read.events;
|
|
505
|
+
if (json) {
|
|
506
|
+
const worktreeRoot = await resolveWorktreeRoot(context.rootDir);
|
|
507
|
+
const rootDir = worktreeRoot.ok ? worktreeRoot.root : context.rootDir;
|
|
508
|
+
context.write(JSON.stringify(buildRunExport({
|
|
509
|
+
runId,
|
|
510
|
+
events,
|
|
511
|
+
rootDir,
|
|
512
|
+
refusedAppends: await surface.store.readNotes(runId),
|
|
513
|
+
}), null, 2));
|
|
514
|
+
return { kind: "ok" };
|
|
515
|
+
}
|
|
141
516
|
const open = !events.some((event) => event.kind === "run.ended");
|
|
142
517
|
const current = await surface.store.current(surface.worktreeKey);
|
|
143
518
|
const isCurrent = current.ok && current.runId === runId;
|
|
@@ -148,6 +523,10 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
|
|
|
148
523
|
context.write(` ${event.seq} ${event.at} ${event.kind.padEnd(20)} ${event.actor.role.padEnd(8)} ${detailOf(event)}`);
|
|
149
524
|
}
|
|
150
525
|
|
|
526
|
+
// The phase breakdown sits directly under the timeline, before the rounds it
|
|
527
|
+
// divides the run by, because it is the summary those rows are the detail of.
|
|
528
|
+
for (const row of phaseRows(events)) context.write(row);
|
|
529
|
+
|
|
151
530
|
const rounds = roundRows(events);
|
|
152
531
|
if (rounds.length > 0) {
|
|
153
532
|
context.write(" rounds:");
|
|
@@ -173,19 +552,56 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
|
|
|
173
552
|
|
|
174
553
|
// No record tree sha and no mandated pair: the viewer has neither, and
|
|
175
554
|
// pretending otherwise would turn an observation into a claim.
|
|
176
|
-
|
|
555
|
+
//
|
|
556
|
+
// THE ROOT IS THE WORKTREE'S, NOT THE INVOCATION'S. The config-presence note
|
|
557
|
+
// is a question about the repository, and `runs serve` answers it at the
|
|
558
|
+
// toplevel of the path it was given. Answering it here at the cwd would tell
|
|
559
|
+
// an operator standing in `packages/cli` that this repository carries no
|
|
560
|
+
// config while the page, over the same journal, says it carries one — two
|
|
561
|
+
// answers to one question, which is the thing the shared projection exists
|
|
562
|
+
// to prevent. The cwd stands in only where git can name no root at all, and
|
|
563
|
+
// then it is the only root there is.
|
|
564
|
+
const worktreeRoot = await resolveWorktreeRoot(context.rootDir);
|
|
565
|
+
const rootDir = worktreeRoot.ok ? worktreeRoot.root : context.rootDir;
|
|
566
|
+
for (const row of readoutRows(events, evaluateRunJournal(events), rootDir)) context.write(row);
|
|
177
567
|
return { kind: "ok" };
|
|
178
568
|
}
|
|
179
569
|
|
|
180
570
|
// ── serve ────────────────────────────────────────────────────────────────────
|
|
181
571
|
|
|
182
572
|
interface ServeArgs {
|
|
573
|
+
readonly archives:readonly string[];
|
|
574
|
+
readonly recordPath?:string;
|
|
575
|
+
readonly freshnessWindowMs?:number;
|
|
183
576
|
readonly repos: readonly string[];
|
|
184
577
|
readonly port?: number;
|
|
185
578
|
}
|
|
186
579
|
|
|
187
580
|
type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly ok: false; readonly message: string };
|
|
188
581
|
|
|
582
|
+
/**
|
|
583
|
+
* The ports a browser leaves out of the `Host` header, being the defaults of
|
|
584
|
+
* the schemes it would dial them under.
|
|
585
|
+
*
|
|
586
|
+
* `run-server.ts` decides whether a request is addressed to the socket it
|
|
587
|
+
* arrived on by comparing `Host` against the bound `host:port` for EXACT
|
|
588
|
+
* equality — it parses nothing, normalizes nothing, and matches no list of
|
|
589
|
+
* names that "mean" loopback. That exactness is the property; it is what makes
|
|
590
|
+
* the page's answer to a DNS rebind checkable rather than a matter of parsing
|
|
591
|
+
* taste. A page served on one of these ports is one no browser can ever reach:
|
|
592
|
+
* the request arrives carrying `Host: 127.0.0.1`, with no port at all, and is
|
|
593
|
+
* answered 403 — the same 403 a rebind gets, with nothing to tell them apart.
|
|
594
|
+
*
|
|
595
|
+
* So the choice is between widening the check and refusing the port, and it is
|
|
596
|
+
* settled by what each costs. Widening costs the property. Refusing costs an
|
|
597
|
+
* operator nothing they wanted: a loopback viewer of a run store has no reason
|
|
598
|
+
* to sit on a privileged port, and the ephemeral default is one flag away.
|
|
599
|
+
* The refusal lives HERE, at the one surface an operator types a port at,
|
|
600
|
+
* rather than beside the check it protects — a second guard inside the server
|
|
601
|
+
* would make neither one provable.
|
|
602
|
+
*/
|
|
603
|
+
const BROWSER_ELIDED_PORTS: readonly number[] = [80, 443];
|
|
604
|
+
|
|
189
605
|
/**
|
|
190
606
|
* The separate-argument form every other command uses. `--flag=value` is
|
|
191
607
|
* REFUSED rather than accepted as a convenience: one spelling means an
|
|
@@ -194,14 +610,20 @@ type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly o
|
|
|
194
610
|
*/
|
|
195
611
|
function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
196
612
|
const repos: string[] = [];
|
|
613
|
+
const archives:string[]=[];
|
|
614
|
+
let recordPath:string|undefined;
|
|
615
|
+
let freshnessWindowMs:number|undefined;
|
|
197
616
|
let port: number | undefined;
|
|
198
617
|
|
|
199
618
|
for (let index = 0; index < args.length; index += 1) {
|
|
200
619
|
const token = args[index]!;
|
|
201
|
-
if (token === "--repo" || token === "--port") {
|
|
620
|
+
if (token === "--repo" || token === "--port" || token === "--archive" || token === "--freshness-seconds" || token === "--record") {
|
|
202
621
|
const value = args[index + 1];
|
|
203
622
|
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
204
623
|
index += 1;
|
|
624
|
+
if(token==="--record"){if(recordPath!==undefined)return {ok:false,message:"Use one explicit record path"};recordPath=value;continue;}
|
|
625
|
+
if(token==="--archive"){archives.push(path.resolve(rootDir,value));continue;}
|
|
626
|
+
if(token==="--freshness-seconds"){if(!/^\d+$/.test(value)||!Number.isSafeInteger(Number(value))||Number(value)>86400)return {ok:false,message:"freshness seconds must be between 0 and 86400"};freshnessWindowMs=Number(value)*1000;continue;}
|
|
205
627
|
if (token === "--repo") {
|
|
206
628
|
repos.push(path.resolve(rootDir, value));
|
|
207
629
|
continue;
|
|
@@ -209,6 +631,14 @@ function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
|
209
631
|
if (!/^\d{1,5}$/.test(value)) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
|
|
210
632
|
const parsed = Number(value);
|
|
211
633
|
if (parsed > 65535) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
|
|
634
|
+
if (BROWSER_ELIDED_PORTS.includes(parsed)) {
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
message:
|
|
638
|
+
`--port ${parsed} cannot be served: a browser omits a scheme's default port from the Host header, ` +
|
|
639
|
+
`and this page answers only to the exact host:port it bound.\n${USAGE}`,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
212
642
|
port = parsed;
|
|
213
643
|
continue;
|
|
214
644
|
}
|
|
@@ -218,7 +648,7 @@ function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
|
218
648
|
|
|
219
649
|
// No `--repo` means the worktree the operator is standing in, which is the
|
|
220
650
|
// only repository they can have meant.
|
|
221
|
-
return { ok: true, args: { repos: repos.length === 0 ? [rootDir] : repos, ...(port === undefined ? {} : { port }) } };
|
|
651
|
+
return { ok: true, args: { repos: repos.length === 0 && archives.length===0 ? [rootDir] : repos, archives,...(recordPath===undefined?{}:{recordPath}),...(freshnessWindowMs===undefined?{}:{freshnessWindowMs}), ...(port === undefined ? {} : { port }) } };
|
|
222
652
|
}
|
|
223
653
|
|
|
224
654
|
/**
|
|
@@ -233,7 +663,8 @@ async function serveRuns(context: ConfigFreeCommandContext, args: readonly strin
|
|
|
233
663
|
const parsed = parseServeArgs(args, context.rootDir);
|
|
234
664
|
if (!parsed.ok) return { kind: "usage", message: parsed.message };
|
|
235
665
|
|
|
236
|
-
|
|
666
|
+
let archives:{label:string;text:string}[];try{archives=await Promise.all(parsed.args.archives.map(async file=>({label:path.basename(file),text:await readArchiveFile(file)})));}catch{return unresolvable("An explicitly selected archive could not be read within its size limit.");}
|
|
667
|
+
const started = await startRunServer({ repos: parsed.args.repos,archives,...(parsed.args.recordPath===undefined?{}:{recordPath:parsed.args.recordPath}),...(parsed.args.freshnessWindowMs===undefined?{}:{freshnessWindowMs:parsed.args.freshnessWindowMs}), ...(parsed.args.port === undefined ? {} : { port: parsed.args.port }) });
|
|
237
668
|
if (!started.ok) return unresolvable(started.reason);
|
|
238
669
|
|
|
239
670
|
const server: RunServerHandle = started.server;
|