@agent-delivery-harness/cli 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +4 -4
- package/src/boundary.ts +77 -61
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +33 -8
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +1 -0
- package/src/commands/prepare.ts +123 -22
- package/src/commands/record.ts +98 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +415 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +29 -8
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +14 -1
- package/src/main.ts +20 -47
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +129 -11
- package/src/run-server.ts +394 -149
- package/src/run-surface.ts +81 -8
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
package/src/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,46 @@
|
|
|
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 RunEventVersion,
|
|
35
|
+
} from "@agent-delivery-harness/kernel";
|
|
22
36
|
import {
|
|
23
37
|
READOUT_LABELS,
|
|
24
38
|
detailOf,
|
|
25
39
|
readoutRows,
|
|
26
40
|
roundRows,
|
|
27
41
|
} from "../run-projection.ts";
|
|
42
|
+
import { buildRunExport } from "../run-export.ts";
|
|
28
43
|
import { startRunServer, type RunServerHandle } from "../run-server.ts";
|
|
29
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
oneLine,
|
|
46
|
+
oneLineOf,
|
|
47
|
+
resolveRunSurface,
|
|
48
|
+
resolveWorktreeRoot,
|
|
49
|
+
runSurfaceBlocker,
|
|
50
|
+
type RunSurface,
|
|
51
|
+
} from "../run-surface.ts";
|
|
30
52
|
import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
|
|
31
53
|
|
|
32
54
|
const USAGE = [
|
|
33
|
-
"Usage: delivery-harness runs
|
|
34
|
-
" delivery-harness runs
|
|
35
|
-
" delivery-harness runs
|
|
55
|
+
"Usage: delivery-harness runs capabilities --json",
|
|
56
|
+
" delivery-harness runs list [--json] [--limit <n>] [--status <status>] [--open|--ended]",
|
|
57
|
+
" delivery-harness runs show [<run-id>] [--json]",
|
|
58
|
+
" delivery-harness runs grammar <kind> [--version 1|2] [--json]",
|
|
59
|
+
" delivery-harness runs view <run-id> [--json] [--record <repository-relative-path>]",
|
|
60
|
+
" delivery-harness runs export <run-id> --output <file>",
|
|
61
|
+
" delivery-harness runs archive <file> [--artifact <id>]",
|
|
62
|
+
" delivery-harness runs capture <run-id> --json <request>",
|
|
63
|
+
" delivery-harness runs artifact <run-id> <artifact-id> [--json]",
|
|
64
|
+
" delivery-harness runs serve [--repo <path>]... [--archive <file>]... [--port <n>] [--freshness-seconds <n>] [--record <repository-relative-path>]",
|
|
36
65
|
].join("\n");
|
|
37
66
|
|
|
38
67
|
const unresolvable = (reason: string): CommandResult => ({
|
|
@@ -54,32 +83,244 @@ export const runsCommand: ConfigFreeCommandDescriptor = {
|
|
|
54
83
|
name: "runs",
|
|
55
84
|
sourceId: "delivery-harness.cli.runs",
|
|
56
85
|
summary: "List, show, and serve the delivery runs this repository has recorded.",
|
|
86
|
+
usage: USAGE,
|
|
57
87
|
configFree: true,
|
|
58
88
|
async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
59
89
|
const [subcommand, ...rest] = context.args;
|
|
90
|
+
if (subcommand === "capabilities") {
|
|
91
|
+
if (rest.length !== 1 || rest[0] !== "--json") return { kind: "usage", message: "Usage: runs capabilities --json" };
|
|
92
|
+
context.write(`${JSON.stringify({ spec: "run-capabilities/1", writerVersions: ["run-event/1", "run-event/2"], artifactCapture: true })}\n`);
|
|
93
|
+
return { kind: "ok" };
|
|
94
|
+
}
|
|
95
|
+
if (subcommand === "grammar") return showGrammar(context, rest);
|
|
96
|
+
if (subcommand === "view") return runViewCommand(context, rest);
|
|
97
|
+
if (subcommand === "export" || subcommand === "archive") return runArchiveCommand(context, subcommand, rest);
|
|
98
|
+
if (subcommand === "capture" || subcommand === "artifact") return runArtifactCommand(context, subcommand, rest);
|
|
60
99
|
if (subcommand === undefined) return { kind: "usage", message: `runs needs a subcommand.\n${USAGE}` };
|
|
61
100
|
if (subcommand !== "list" && subcommand !== "show" && subcommand !== "serve") {
|
|
62
101
|
return { kind: "usage", message: `Unknown runs subcommand ${oneLine(subcommand, 64)}.\n${USAGE}` };
|
|
63
102
|
}
|
|
64
|
-
if (subcommand === "show" && rest[0] === undefined) {
|
|
65
|
-
return { kind: "usage", message: `runs show needs a run id.\n${USAGE}` };
|
|
66
|
-
}
|
|
67
103
|
// `serve` resolves its OWN repositories — one per `--repo`, none of them
|
|
68
104
|
// necessarily the invoking worktree — so it never asks the invoking
|
|
69
105
|
// worktree's store to resolve first.
|
|
70
106
|
if (subcommand === "serve") return serveRuns(context, rest);
|
|
71
107
|
|
|
108
|
+
// `list`'s argument surface is decided BEFORE the store is resolved. A
|
|
109
|
+
// mistyped bound is a usage error wherever it was typed, and an operator
|
|
110
|
+
// standing outside a repository should not have to fix the repository to
|
|
111
|
+
// find out the flag was wrong.
|
|
112
|
+
const listArgs = subcommand === "list" ? parseListArgs(rest) : undefined;
|
|
113
|
+
if (listArgs !== undefined && !listArgs.ok) return { kind: "usage", message: listArgs.message };
|
|
114
|
+
const showArgs = subcommand === "show" ? parseShowArgs(rest) : undefined;
|
|
115
|
+
if (showArgs !== undefined && !showArgs.ok) return { kind: "usage", message: showArgs.message };
|
|
116
|
+
|
|
72
117
|
const resolved = await resolveRunSurface(context.rootDir);
|
|
73
118
|
if (!resolved.ok) return unresolvable(resolved.reason);
|
|
74
119
|
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
120
|
+
if (listArgs !== undefined && listArgs.ok) return listRuns(resolved.surface, context, listArgs.args);
|
|
121
|
+
if (showArgs === undefined || !showArgs.ok) throw new Error("runs show arguments were not parsed");
|
|
122
|
+
let runId = showArgs.args.runId;
|
|
123
|
+
if (runId === undefined) {
|
|
124
|
+
const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
|
|
125
|
+
if (!current.ok || current.runId === undefined) {
|
|
126
|
+
return {
|
|
127
|
+
kind: "blocked",
|
|
128
|
+
blockers: [runSurfaceBlocker({
|
|
129
|
+
code: "run_unresolvable",
|
|
130
|
+
summary: "There is no current run that can be resolved in this worktree.",
|
|
131
|
+
details: current.ok
|
|
132
|
+
? "no run id was supplied and the worktree has no readable current run"
|
|
133
|
+
: `no run id was supplied; current-run lookup was refused: ${oneLine(current.rejections[0]?.message ?? "unreadable", 200)}`,
|
|
134
|
+
remediation: {
|
|
135
|
+
id: "start-or-name-a-run",
|
|
136
|
+
summary: "Start a run with `delivery-harness emit run.started`, or pass a run id from `delivery-harness runs list`.",
|
|
137
|
+
},
|
|
138
|
+
})],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
runId = current.runId;
|
|
142
|
+
}
|
|
143
|
+
return showRun(resolved.surface, context, runId, showArgs.args.json);
|
|
78
144
|
},
|
|
79
145
|
};
|
|
80
146
|
|
|
147
|
+
interface ShowArgs { readonly runId?: string; readonly json: boolean }
|
|
148
|
+
type ShowParse = { readonly ok: true; readonly args: ShowArgs } | { readonly ok: false; readonly message: string };
|
|
149
|
+
|
|
150
|
+
function parseShowArgs(args: readonly string[]): ShowParse {
|
|
151
|
+
let runId: string | undefined;
|
|
152
|
+
let json = false;
|
|
153
|
+
for (const token of args) {
|
|
154
|
+
if (token === "--json") {
|
|
155
|
+
if (json) return { ok: false, message: `runs show: --json was given twice.\n${USAGE}` };
|
|
156
|
+
json = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (token.startsWith("-")) return { ok: false, message: `runs show: unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
160
|
+
if (runId !== undefined) return { ok: false, message: `runs show accepts at most one run id.\n${USAGE}` };
|
|
161
|
+
runId = token;
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, args: { json, ...(runId === undefined ? {} : { runId }) } };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface GrammarArgs { readonly kind: string; readonly version: RunEventVersion; readonly json: boolean }
|
|
167
|
+
type GrammarParse = { readonly ok: true; readonly args: GrammarArgs } | { readonly ok: false; readonly message: string };
|
|
168
|
+
|
|
169
|
+
function parseGrammarArgs(args: readonly string[]): GrammarParse {
|
|
170
|
+
let kind: string | undefined;
|
|
171
|
+
let version: RunEventVersion = RUN_EVENT_SPEC_V2;
|
|
172
|
+
let versionSeen = false;
|
|
173
|
+
let json = false;
|
|
174
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
175
|
+
const token = args[index]!;
|
|
176
|
+
if (token === "--json") {
|
|
177
|
+
if (json) return { ok: false, message: `runs grammar: --json was given twice.\n${USAGE}` };
|
|
178
|
+
json = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (token === "--version") {
|
|
182
|
+
if (versionSeen) return { ok: false, message: `runs grammar: --version was given twice.\n${USAGE}` };
|
|
183
|
+
const value = args[index + 1];
|
|
184
|
+
if (value !== "1" && value !== "2") return { ok: false, message: `runs grammar: --version needs 1 or 2.\n${USAGE}` };
|
|
185
|
+
version = value === "1" ? RUN_EVENT_SPEC : RUN_EVENT_SPEC_V2;
|
|
186
|
+
versionSeen = true;
|
|
187
|
+
index += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (token.startsWith("-")) return { ok: false, message: `runs grammar: unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
191
|
+
if (kind !== undefined) return { ok: false, message: `runs grammar accepts exactly one event kind.\n${USAGE}` };
|
|
192
|
+
kind = token;
|
|
193
|
+
}
|
|
194
|
+
if (kind === undefined) return { ok: false, message: `runs grammar needs an event kind.\n${USAGE}` };
|
|
195
|
+
return { ok: true, args: { kind, version, json } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderGrammar(grammar: RunEventPayloadGrammar, context: ConfigFreeCommandContext): void {
|
|
199
|
+
context.write(`${grammar.kind} (${grammar.version})`);
|
|
200
|
+
for (const member of grammar.members) {
|
|
201
|
+
context.write(` ${member.name} ${member.required ? "required" : "optional"}${member.values === undefined ? "" : ` values: ${member.values.join(", ")}`}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function showGrammar(context: ConfigFreeCommandContext, args: readonly string[]): CommandResult {
|
|
206
|
+
const parsed = parseGrammarArgs(args);
|
|
207
|
+
if (!parsed.ok) return { kind: "usage", message: parsed.message };
|
|
208
|
+
const grammar = describeRunEventPayload(parsed.args.kind, parsed.args.version);
|
|
209
|
+
if (grammar === undefined) {
|
|
210
|
+
const kinds = parsed.args.version === RUN_EVENT_SPEC ? RUN_EVENT_KINDS_V1 : RUN_EVENT_KINDS;
|
|
211
|
+
return {
|
|
212
|
+
kind: "usage",
|
|
213
|
+
message: `runs grammar: ${oneLine(parsed.args.kind, 128)} is not a ${parsed.args.version} event kind; accepted kinds: ${kinds.join(", ")}.\n${USAGE}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (parsed.args.json) context.write(JSON.stringify(grammar, null, 2));
|
|
217
|
+
else renderGrammar(grammar, context);
|
|
218
|
+
return { kind: "ok" };
|
|
219
|
+
}
|
|
220
|
+
|
|
81
221
|
// ── list ─────────────────────────────────────────────────────────────────────
|
|
82
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The one label this listing prints for a journal it could not read at all.
|
|
225
|
+
* It is not a completeness verdict — nothing was evaluated — so it is not one
|
|
226
|
+
* of the kernel's statuses, and it is spelled once, here.
|
|
227
|
+
*/
|
|
228
|
+
export const RUN_LIST_UNREADABLE = "unreadable";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `absent` is the completeness vocabulary's answer to "no journal bound this
|
|
232
|
+
* candidate", which an inventory of the journals that exist can never be. It
|
|
233
|
+
* is excluded rather than accepted-and-never-matched, because a selector that
|
|
234
|
+
* cannot select anything whatever the store holds is a false affordance: an
|
|
235
|
+
* agent filtering on it would read the empty result as "no such runs" rather
|
|
236
|
+
* than as "this question cannot be asked here".
|
|
237
|
+
*/
|
|
238
|
+
const STATUS_NEVER_LISTED = "absent";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Exactly the statuses a row of this listing can carry, which is what
|
|
242
|
+
* `--status` accepts. Derived from the kernel's closed vocabulary so a status
|
|
243
|
+
* renamed there is a compile-and-test problem here rather than a filter that
|
|
244
|
+
* silently stops matching.
|
|
245
|
+
*/
|
|
246
|
+
export const RUN_LIST_STATUSES: readonly string[] = Object.freeze([
|
|
247
|
+
...RUN_JOURNAL_STATUSES.filter((status) => status !== STATUS_NEVER_LISTED),
|
|
248
|
+
RUN_LIST_UNREADABLE,
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
/** The spec every machine-readable inventory carries, beside `runs show --json`'s export spec. */
|
|
252
|
+
export const RUN_INVENTORY_SPEC = "run-inventory/1";
|
|
253
|
+
|
|
254
|
+
interface ListArgs {
|
|
255
|
+
readonly json: boolean;
|
|
256
|
+
readonly limit?: number;
|
|
257
|
+
readonly status?: string;
|
|
258
|
+
/** True selects the runs with no `run.ended`; false selects the ended ones. */
|
|
259
|
+
readonly open?: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type ListParse = { readonly ok: true; readonly args: ListArgs } | { readonly ok: false; readonly message: string };
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The separate-argument form the rest of this command uses, and the same
|
|
266
|
+
* refusal discipline: an unknown flag, a repeated one, a positional, and a
|
|
267
|
+
* value that is not a bound are all usage errors rather than conveniences.
|
|
268
|
+
*
|
|
269
|
+
* A LIMIT IS A COUNT OF ROWS, so it is a positive integer and nothing else.
|
|
270
|
+
* `0` is refused rather than read as "no rows" or as "no bound": both readings
|
|
271
|
+
* are plausible, an operator cannot tell which one they got from the output,
|
|
272
|
+
* and neither is a thing anyone means to ask for.
|
|
273
|
+
*/
|
|
274
|
+
function parseListArgs(args: readonly string[]): ListParse {
|
|
275
|
+
const refuse = (reason: string): ListParse => ({ ok: false, message: `runs list: ${reason}.\n${USAGE}` });
|
|
276
|
+
let json = false;
|
|
277
|
+
let limit: number | undefined;
|
|
278
|
+
let status: string | undefined;
|
|
279
|
+
let open: boolean | undefined;
|
|
280
|
+
|
|
281
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
282
|
+
const token = args[index]!;
|
|
283
|
+
if (token === "--json") {
|
|
284
|
+
if (json) return refuse("--json was given twice");
|
|
285
|
+
json = true;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (token === "--open" || token === "--ended") {
|
|
289
|
+
// Two names for one question, so asking it twice — however spelled —
|
|
290
|
+
// has no answer to give.
|
|
291
|
+
if (open !== undefined) return refuse("use at most one of --open and --ended");
|
|
292
|
+
open = token === "--open";
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (token === "--limit" || token === "--status") {
|
|
296
|
+
const value = args[index + 1];
|
|
297
|
+
if (value === undefined) return refuse(`${token} needs a value`);
|
|
298
|
+
index += 1;
|
|
299
|
+
if (token === "--limit") {
|
|
300
|
+
if (limit !== undefined) return refuse("--limit was given twice");
|
|
301
|
+
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
|
|
302
|
+
return refuse(`--limit needs a positive whole number, not ${oneLine(value, 64)}`);
|
|
303
|
+
}
|
|
304
|
+
limit = Number(value);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (status !== undefined) return refuse("--status was given twice");
|
|
308
|
+
if (!RUN_LIST_STATUSES.includes(value)) {
|
|
309
|
+
return refuse(`--status accepts ${RUN_LIST_STATUSES.join(", ")}, not ${oneLine(value, 64)}`);
|
|
310
|
+
}
|
|
311
|
+
status = value;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (token.startsWith("--")) return refuse(`unknown flag ${oneLine(token, 64)}`);
|
|
315
|
+
return refuse("it takes no positional arguments");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
ok: true,
|
|
320
|
+
args: { json, ...(limit === undefined ? {} : { limit }), ...(status === undefined ? {} : { status }), ...(open === undefined ? {} : { open }) },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
83
324
|
/** The journal's size on disk, or zero where it cannot be measured. */
|
|
84
325
|
async function sizeOf(runsDir: string, runId: string): Promise<number> {
|
|
85
326
|
try {
|
|
@@ -89,39 +330,120 @@ async function sizeOf(runsDir: string, runId: string): Promise<number> {
|
|
|
89
330
|
}
|
|
90
331
|
}
|
|
91
332
|
|
|
92
|
-
|
|
333
|
+
/** One run as both surfaces see it: the same five facts, rendered or serialized. */
|
|
334
|
+
interface InventoryRow {
|
|
335
|
+
readonly runId: string;
|
|
336
|
+
readonly status: string;
|
|
337
|
+
readonly open: boolean;
|
|
338
|
+
readonly current: boolean;
|
|
339
|
+
readonly bytes: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Every run the store holds, in the store's own order.
|
|
344
|
+
*
|
|
345
|
+
* THE ORDER IS THE STORE'S, and the store sorts run ids ascending. That is
|
|
346
|
+
* what makes a bounded listing reproducible: the same store bounded the same
|
|
347
|
+
* way returns the same rows, and the row a `--limit 1` returns is the one an
|
|
348
|
+
* unbounded listing printed first. Nothing here re-orders it, so the order
|
|
349
|
+
* this surface documents is the order the store defines and not a second one
|
|
350
|
+
* that happens to agree today.
|
|
351
|
+
*/
|
|
352
|
+
async function inventoryOf(
|
|
353
|
+
surface: RunSurface,
|
|
354
|
+
): Promise<{ readonly rows: readonly InventoryRow[]; readonly currentRunId: string | undefined }> {
|
|
93
355
|
const runIds = await surface.store.list();
|
|
94
356
|
const current = await surface.store.current(surface.worktreeKey);
|
|
95
357
|
const currentRunId = current.ok ? current.runId : undefined;
|
|
96
358
|
|
|
359
|
+
const rows: InventoryRow[] = [];
|
|
360
|
+
for (const runId of runIds) {
|
|
361
|
+
const bytes = await sizeOf(surface.runsDir, runId);
|
|
362
|
+
const read = await surface.store.read(runId);
|
|
363
|
+
rows.push(
|
|
364
|
+
read.ok
|
|
365
|
+
? {
|
|
366
|
+
runId,
|
|
367
|
+
status: evaluateRunJournal(read.events).status,
|
|
368
|
+
open: !read.events.some((event) => event.kind === "run.ended"),
|
|
369
|
+
current: runId === currentRunId,
|
|
370
|
+
bytes,
|
|
371
|
+
}
|
|
372
|
+
: // Nothing was read, so no lifecycle or completeness state is claimed.
|
|
373
|
+
// The worktree pointer and on-disk size are independent readable facts.
|
|
374
|
+
{ runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, bytes },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
return { rows, currentRunId };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `runs list`, human and machine.
|
|
382
|
+
*
|
|
383
|
+
* THE FILTER RUNS BEFORE THE BOUND. A bound applied first would spend its rows
|
|
384
|
+
* on runs the filter then discards, so `--status x --limit 1` could answer
|
|
385
|
+
* "none" for a store that holds one — the bound is a bound on the ANSWER, not
|
|
386
|
+
* on how far the store was read.
|
|
387
|
+
*
|
|
388
|
+
* WHAT `total` COUNTS is the set the filters selected, before the bound. That
|
|
389
|
+
* is the number a reader needs to know it was bounded (`returned` below it
|
|
390
|
+
* means rows were cut), and with no filter it is the whole store — which is
|
|
391
|
+
* why the unfiltered human listing's total line is unchanged.
|
|
392
|
+
*/
|
|
393
|
+
async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext, args: ListArgs): Promise<CommandResult> {
|
|
394
|
+
const { rows, currentRunId } = await inventoryOf(surface);
|
|
395
|
+
const selected = rows.filter(
|
|
396
|
+
(row) =>
|
|
397
|
+
(args.status === undefined || row.status === args.status) &&
|
|
398
|
+
(args.open === undefined || (row.status !== RUN_LIST_UNREADABLE && row.open === args.open)),
|
|
399
|
+
);
|
|
400
|
+
const totalBytes = selected.reduce((sum, row) => sum + row.bytes, 0);
|
|
401
|
+
const shown = args.limit === undefined ? selected : selected.slice(0, args.limit);
|
|
402
|
+
const truncated = shown.length < selected.length;
|
|
403
|
+
|
|
404
|
+
if (args.json) {
|
|
405
|
+
context.write(
|
|
406
|
+
JSON.stringify(
|
|
407
|
+
{
|
|
408
|
+
spec: RUN_INVENTORY_SPEC,
|
|
409
|
+
labels: READOUT_LABELS,
|
|
410
|
+
runsDir: surface.runsDir,
|
|
411
|
+
current: currentRunId ?? null,
|
|
412
|
+
runs: shown,
|
|
413
|
+
total: { count: selected.length, bytes: totalBytes },
|
|
414
|
+
returned: shown.length,
|
|
415
|
+
truncated,
|
|
416
|
+
},
|
|
417
|
+
null,
|
|
418
|
+
2,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
return { kind: "ok" };
|
|
422
|
+
}
|
|
423
|
+
|
|
97
424
|
// The status column carries a completeness verdict, so this readout is
|
|
98
425
|
// labeled exactly like `show`'s. `list` is the command an operator reaches
|
|
99
426
|
// for first, before it knows which id to show; an unlabeled `complete` here
|
|
100
427
|
// is the misreading the labels exist to prevent.
|
|
101
428
|
const lines: string[] = [`runs in ${oneLine(surface.runsDir, 400)}`, ` (${READOUT_LABELS})`];
|
|
102
|
-
|
|
103
|
-
for (const runId of runIds) {
|
|
104
|
-
const size = await sizeOf(surface.runsDir, runId);
|
|
105
|
-
total += size;
|
|
106
|
-
const read = await surface.store.read(runId);
|
|
107
|
-
if (!read.ok) {
|
|
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");
|
|
429
|
+
for (const row of shown) {
|
|
113
430
|
lines.push(
|
|
114
|
-
|
|
431
|
+
row.status === RUN_LIST_UNREADABLE
|
|
432
|
+
? ` ${row.runId} ${RUN_LIST_UNREADABLE} ${row.bytes} bytes`
|
|
433
|
+
: ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${row.bytes} bytes`,
|
|
115
434
|
);
|
|
116
435
|
}
|
|
117
|
-
lines.push(`total ${
|
|
436
|
+
lines.push(`total ${totalBytes} bytes across ${selected.length} run(s)`);
|
|
437
|
+
// Said only when something was actually cut, so the unbounded listing keeps
|
|
438
|
+
// the bytes it always had.
|
|
439
|
+
if (truncated) lines.push(`showing ${shown.length} of ${selected.length} run(s) (--limit ${args.limit})`);
|
|
118
440
|
for (const line of lines) context.write(line);
|
|
119
441
|
return { kind: "ok" };
|
|
120
442
|
}
|
|
121
443
|
|
|
122
444
|
// ── show ─────────────────────────────────────────────────────────────────────
|
|
123
445
|
|
|
124
|
-
async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string): Promise<CommandResult> {
|
|
446
|
+
async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string, json: boolean): Promise<CommandResult> {
|
|
125
447
|
const read = await surface.store.read(runId);
|
|
126
448
|
if (!read.ok) {
|
|
127
449
|
return {
|
|
@@ -138,6 +460,17 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
|
|
|
138
460
|
}
|
|
139
461
|
|
|
140
462
|
const events = read.events;
|
|
463
|
+
if (json) {
|
|
464
|
+
const worktreeRoot = await resolveWorktreeRoot(context.rootDir);
|
|
465
|
+
const rootDir = worktreeRoot.ok ? worktreeRoot.root : context.rootDir;
|
|
466
|
+
context.write(JSON.stringify(buildRunExport({
|
|
467
|
+
runId,
|
|
468
|
+
events,
|
|
469
|
+
rootDir,
|
|
470
|
+
refusedAppends: await surface.store.readNotes(runId),
|
|
471
|
+
}), null, 2));
|
|
472
|
+
return { kind: "ok" };
|
|
473
|
+
}
|
|
141
474
|
const open = !events.some((event) => event.kind === "run.ended");
|
|
142
475
|
const current = await surface.store.current(surface.worktreeKey);
|
|
143
476
|
const isCurrent = current.ok && current.runId === runId;
|
|
@@ -173,19 +506,56 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
|
|
|
173
506
|
|
|
174
507
|
// No record tree sha and no mandated pair: the viewer has neither, and
|
|
175
508
|
// pretending otherwise would turn an observation into a claim.
|
|
176
|
-
|
|
509
|
+
//
|
|
510
|
+
// THE ROOT IS THE WORKTREE'S, NOT THE INVOCATION'S. The config-presence note
|
|
511
|
+
// is a question about the repository, and `runs serve` answers it at the
|
|
512
|
+
// toplevel of the path it was given. Answering it here at the cwd would tell
|
|
513
|
+
// an operator standing in `packages/cli` that this repository carries no
|
|
514
|
+
// config while the page, over the same journal, says it carries one — two
|
|
515
|
+
// answers to one question, which is the thing the shared projection exists
|
|
516
|
+
// to prevent. The cwd stands in only where git can name no root at all, and
|
|
517
|
+
// then it is the only root there is.
|
|
518
|
+
const worktreeRoot = await resolveWorktreeRoot(context.rootDir);
|
|
519
|
+
const rootDir = worktreeRoot.ok ? worktreeRoot.root : context.rootDir;
|
|
520
|
+
for (const row of readoutRows(events, evaluateRunJournal(events), rootDir)) context.write(row);
|
|
177
521
|
return { kind: "ok" };
|
|
178
522
|
}
|
|
179
523
|
|
|
180
524
|
// ── serve ────────────────────────────────────────────────────────────────────
|
|
181
525
|
|
|
182
526
|
interface ServeArgs {
|
|
527
|
+
readonly archives:readonly string[];
|
|
528
|
+
readonly recordPath?:string;
|
|
529
|
+
readonly freshnessWindowMs?:number;
|
|
183
530
|
readonly repos: readonly string[];
|
|
184
531
|
readonly port?: number;
|
|
185
532
|
}
|
|
186
533
|
|
|
187
534
|
type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly ok: false; readonly message: string };
|
|
188
535
|
|
|
536
|
+
/**
|
|
537
|
+
* The ports a browser leaves out of the `Host` header, being the defaults of
|
|
538
|
+
* the schemes it would dial them under.
|
|
539
|
+
*
|
|
540
|
+
* `run-server.ts` decides whether a request is addressed to the socket it
|
|
541
|
+
* arrived on by comparing `Host` against the bound `host:port` for EXACT
|
|
542
|
+
* equality — it parses nothing, normalizes nothing, and matches no list of
|
|
543
|
+
* names that "mean" loopback. That exactness is the property; it is what makes
|
|
544
|
+
* the page's answer to a DNS rebind checkable rather than a matter of parsing
|
|
545
|
+
* taste. A page served on one of these ports is one no browser can ever reach:
|
|
546
|
+
* the request arrives carrying `Host: 127.0.0.1`, with no port at all, and is
|
|
547
|
+
* answered 403 — the same 403 a rebind gets, with nothing to tell them apart.
|
|
548
|
+
*
|
|
549
|
+
* So the choice is between widening the check and refusing the port, and it is
|
|
550
|
+
* settled by what each costs. Widening costs the property. Refusing costs an
|
|
551
|
+
* operator nothing they wanted: a loopback viewer of a run store has no reason
|
|
552
|
+
* to sit on a privileged port, and the ephemeral default is one flag away.
|
|
553
|
+
* The refusal lives HERE, at the one surface an operator types a port at,
|
|
554
|
+
* rather than beside the check it protects — a second guard inside the server
|
|
555
|
+
* would make neither one provable.
|
|
556
|
+
*/
|
|
557
|
+
const BROWSER_ELIDED_PORTS: readonly number[] = [80, 443];
|
|
558
|
+
|
|
189
559
|
/**
|
|
190
560
|
* The separate-argument form every other command uses. `--flag=value` is
|
|
191
561
|
* REFUSED rather than accepted as a convenience: one spelling means an
|
|
@@ -194,14 +564,20 @@ type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly o
|
|
|
194
564
|
*/
|
|
195
565
|
function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
196
566
|
const repos: string[] = [];
|
|
567
|
+
const archives:string[]=[];
|
|
568
|
+
let recordPath:string|undefined;
|
|
569
|
+
let freshnessWindowMs:number|undefined;
|
|
197
570
|
let port: number | undefined;
|
|
198
571
|
|
|
199
572
|
for (let index = 0; index < args.length; index += 1) {
|
|
200
573
|
const token = args[index]!;
|
|
201
|
-
if (token === "--repo" || token === "--port") {
|
|
574
|
+
if (token === "--repo" || token === "--port" || token === "--archive" || token === "--freshness-seconds" || token === "--record") {
|
|
202
575
|
const value = args[index + 1];
|
|
203
576
|
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
204
577
|
index += 1;
|
|
578
|
+
if(token==="--record"){if(recordPath!==undefined)return {ok:false,message:"Use one explicit record path"};recordPath=value;continue;}
|
|
579
|
+
if(token==="--archive"){archives.push(path.resolve(rootDir,value));continue;}
|
|
580
|
+
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
581
|
if (token === "--repo") {
|
|
206
582
|
repos.push(path.resolve(rootDir, value));
|
|
207
583
|
continue;
|
|
@@ -209,6 +585,14 @@ function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
|
209
585
|
if (!/^\d{1,5}$/.test(value)) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
|
|
210
586
|
const parsed = Number(value);
|
|
211
587
|
if (parsed > 65535) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
|
|
588
|
+
if (BROWSER_ELIDED_PORTS.includes(parsed)) {
|
|
589
|
+
return {
|
|
590
|
+
ok: false,
|
|
591
|
+
message:
|
|
592
|
+
`--port ${parsed} cannot be served: a browser omits a scheme's default port from the Host header, ` +
|
|
593
|
+
`and this page answers only to the exact host:port it bound.\n${USAGE}`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
212
596
|
port = parsed;
|
|
213
597
|
continue;
|
|
214
598
|
}
|
|
@@ -218,7 +602,7 @@ function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
|
|
|
218
602
|
|
|
219
603
|
// No `--repo` means the worktree the operator is standing in, which is the
|
|
220
604
|
// only repository they can have meant.
|
|
221
|
-
return { ok: true, args: { repos: repos.length === 0 ? [rootDir] : repos, ...(port === undefined ? {} : { port }) } };
|
|
605
|
+
return { ok: true, args: { repos: repos.length === 0 && archives.length===0 ? [rootDir] : repos, archives,...(recordPath===undefined?{}:{recordPath}),...(freshnessWindowMs===undefined?{}:{freshnessWindowMs}), ...(port === undefined ? {} : { port }) } };
|
|
222
606
|
}
|
|
223
607
|
|
|
224
608
|
/**
|
|
@@ -233,7 +617,8 @@ async function serveRuns(context: ConfigFreeCommandContext, args: readonly strin
|
|
|
233
617
|
const parsed = parseServeArgs(args, context.rootDir);
|
|
234
618
|
if (!parsed.ok) return { kind: "usage", message: parsed.message };
|
|
235
619
|
|
|
236
|
-
|
|
620
|
+
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.");}
|
|
621
|
+
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
622
|
if (!started.ok) return unresolvable(started.reason);
|
|
238
623
|
|
|
239
624
|
const server: RunServerHandle = started.server;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { buildRunEvent } from "../run-surface.ts";
|
|
2
|
+
import { installedRelease, ordinaryEventWriter, policyDigest, recoveryBlocker, recoveryRun, rejectionDetails } from "../ordinary-context.ts";
|
|
3
|
+
import type { CommandDescriptor } from "../boundary.ts";
|
|
4
|
+
|
|
5
|
+
const USAGE = "Usage: delivery-harness save-context --json '{\"contract\":{\"objective\":\"...\",\"acceptanceCriteria\":[\"...\"],\"finishLine\":\"merge-ready\"},\"stage\":\"work\"}'";
|
|
6
|
+
|
|
7
|
+
export const saveContextCommand: CommandDescriptor = {
|
|
8
|
+
name: "save-context", sourceId: "delivery-harness.cli.save-context",
|
|
9
|
+
summary: "Save a bounded delivery contract and stage observation in the current run.",
|
|
10
|
+
usage: USAGE,
|
|
11
|
+
async run(context) {
|
|
12
|
+
if (context.args.length !== 2 || context.args[0] !== "--json") return { kind: "usage", message: USAGE };
|
|
13
|
+
let input: { contract?: unknown; stage?: unknown };
|
|
14
|
+
try {
|
|
15
|
+
const value: unknown = JSON.parse(context.args[1]!);
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => key !== "contract" && key !== "stage")) throw new Error();
|
|
17
|
+
input = value;
|
|
18
|
+
} catch { return { kind: "usage", message: "Context must be a JSON object with only contract and stage." }; }
|
|
19
|
+
const run = await recoveryRun(context.rootDir);
|
|
20
|
+
if (!run.ok) return { kind: "blocked", blockers: [run.blocker] };
|
|
21
|
+
const wiring = await context.wire();
|
|
22
|
+
const capture = await wiring.captureCandidate();
|
|
23
|
+
if (!capture.ok) return { kind: "blocked", blockers: capture.blockers };
|
|
24
|
+
let release;
|
|
25
|
+
try { release = await installedRelease(context.rootDir); }
|
|
26
|
+
catch { return { kind: "blocked", blockers: [recoveryBlocker("resume_release_unreadable", "The installed workflow release identity cannot be read; restore a valid installation before saving context.")] }; }
|
|
27
|
+
const candidate = capture.candidate;
|
|
28
|
+
// An omitted member is absent, never present-and-undefined: the contract
|
|
29
|
+
// reports it as a missing member, and the v2 retry key stays derivable.
|
|
30
|
+
const payload = { spec: "ordinary-run-context/1",
|
|
31
|
+
...(input.contract === undefined ? {} : { contract: input.contract }),
|
|
32
|
+
...(input.stage === undefined ? {} : { stage: input.stage }),
|
|
33
|
+
candidateTreeSha: candidate.treeSha,
|
|
34
|
+
candidateBinding: { deliverableDigest: candidate.deliverable.digest, identity: candidate.deliverable.identity,
|
|
35
|
+
baseRef: candidate.base.ref, baseTipSha: candidate.base.tipSha, mergeBaseSha: candidate.base.mergeBaseSha, workspaceId: candidate.workspaceId },
|
|
36
|
+
policyDigest: policyDigest(context), release };
|
|
37
|
+
// The run's own writer version, never this command's: a v2 journal admits
|
|
38
|
+
// no v1 event, and a v2 event needs the retry key the writer derives.
|
|
39
|
+
const writer = ordinaryEventWriter(run.version, "context.saved", payload);
|
|
40
|
+
const appended = await run.surface.store.append(
|
|
41
|
+
run.runId,
|
|
42
|
+
buildRunEvent({ runId: run.runId, commonDir: run.surface.commonDir, kind: "context.saved", role: "executor", payload, ...writer }),
|
|
43
|
+
// An exact retry of one save is one observation, at its first instant.
|
|
44
|
+
{ reuseExistingTimestamp: true },
|
|
45
|
+
);
|
|
46
|
+
if (!appended.ok) return { kind: "blocked", blockers: [recoveryBlocker("resume_context_invalid",
|
|
47
|
+
"Context was refused by the bounded run-event contract; inspect contract, stage, and secret-free inputs.",
|
|
48
|
+
`run ${run.runId}: ${rejectionDetails(appended.rejections)}`)] };
|
|
49
|
+
return { kind: "ok", summary: `saved ordinary context for ${run.runId}; stage is an observation, not evidence` };
|
|
50
|
+
},
|
|
51
|
+
};
|