@agent-delivery-harness/cli 0.5.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/package.json +3 -3
- package/src/boundary.ts +7 -2
- package/src/check-snapshot.ts +167 -0
- package/src/commands/gate.ts +33 -7
- package/src/commands/managed.ts +104 -34
- package/src/commands/prepare.ts +29 -4
- package/src/commands/record.ts +10 -1
- package/src/commands/runs.ts +53 -7
- package/src/commands/verify.ts +127 -2
- package/src/index.ts +3 -0
- package/src/main.ts +36 -18
- package/src/ordinary-context.ts +2 -18
- package/src/run-export.ts +40 -2
- package/src/run-projection.ts +198 -1
- package/src/run-server.ts +9 -0
- package/src/run-surface.ts +115 -1
- 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
|
@@ -31,13 +31,18 @@ import {
|
|
|
31
31
|
describeRunEventPayload,
|
|
32
32
|
evaluateRunJournal,
|
|
33
33
|
type RunEventPayloadGrammar,
|
|
34
|
+
type RunEventPayloadMemberGrammar,
|
|
35
|
+
type RunEventValueGrammar,
|
|
34
36
|
type RunEventVersion,
|
|
35
37
|
} from "@agent-delivery-harness/kernel";
|
|
36
38
|
import {
|
|
37
39
|
READOUT_LABELS,
|
|
38
40
|
detailOf,
|
|
41
|
+
durationLabel,
|
|
42
|
+
phaseRows,
|
|
39
43
|
readoutRows,
|
|
40
44
|
roundRows,
|
|
45
|
+
summarize,
|
|
41
46
|
} from "../run-projection.ts";
|
|
42
47
|
import { buildRunExport } from "../run-export.ts";
|
|
43
48
|
import { startRunServer, type RunServerHandle } from "../run-server.ts";
|
|
@@ -195,11 +200,31 @@ function parseGrammarArgs(args: readonly string[]): GrammarParse {
|
|
|
195
200
|
return { ok: true, args: { kind, version, json } };
|
|
196
201
|
}
|
|
197
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
|
+
|
|
198
223
|
function renderGrammar(grammar: RunEventPayloadGrammar, context: ConfigFreeCommandContext): void {
|
|
199
224
|
context.write(`${grammar.kind} (${grammar.version})`);
|
|
200
|
-
for (const member of grammar.members)
|
|
201
|
-
|
|
202
|
-
|
|
225
|
+
for (const member of grammar.members) renderMember(member, " ", context);
|
|
226
|
+
context.write(" example:");
|
|
227
|
+
context.write(JSON.stringify(grammar.example, null, 2));
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
function showGrammar(context: ConfigFreeCommandContext, args: readonly string[]): CommandResult {
|
|
@@ -330,12 +355,23 @@ async function sizeOf(runsDir: string, runId: string): Promise<number> {
|
|
|
330
355
|
}
|
|
331
356
|
}
|
|
332
357
|
|
|
333
|
-
/** One run as both surfaces see it: the same
|
|
358
|
+
/** One run as both surfaces see it: the same six facts, rendered or serialized. */
|
|
334
359
|
interface InventoryRow {
|
|
335
360
|
readonly runId: string;
|
|
336
361
|
readonly status: string;
|
|
337
362
|
readonly open: boolean;
|
|
338
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;
|
|
339
375
|
readonly bytes: number;
|
|
340
376
|
}
|
|
341
377
|
|
|
@@ -367,11 +403,12 @@ async function inventoryOf(
|
|
|
367
403
|
status: evaluateRunJournal(read.events).status,
|
|
368
404
|
open: !read.events.some((event) => event.kind === "run.ended"),
|
|
369
405
|
current: runId === currentRunId,
|
|
406
|
+
durationSeconds: summarize(read.events).durationSeconds,
|
|
370
407
|
bytes,
|
|
371
408
|
}
|
|
372
409
|
: // Nothing was read, so no lifecycle or completeness state is claimed.
|
|
373
410
|
// The worktree pointer and on-disk size are independent readable facts.
|
|
374
|
-
{ runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, bytes },
|
|
411
|
+
{ runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, durationSeconds: null, bytes },
|
|
375
412
|
);
|
|
376
413
|
}
|
|
377
414
|
return { rows, currentRunId };
|
|
@@ -426,11 +463,16 @@ async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext,
|
|
|
426
463
|
// for first, before it knows which id to show; an unlabeled `complete` here
|
|
427
464
|
// is the misreading the labels exist to prevent.
|
|
428
465
|
const lines: string[] = [`runs in ${oneLine(surface.runsDir, 400)}`, ` (${READOUT_LABELS})`];
|
|
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.
|
|
429
471
|
for (const row of shown) {
|
|
430
472
|
lines.push(
|
|
431
|
-
row.status === RUN_LIST_UNREADABLE
|
|
473
|
+
row.status === RUN_LIST_UNREADABLE || row.durationSeconds === null
|
|
432
474
|
? ` ${row.runId} ${RUN_LIST_UNREADABLE} ${row.bytes} bytes`
|
|
433
|
-
: ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${row.bytes} bytes`,
|
|
475
|
+
: ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${durationLabel(row.durationSeconds)} ${row.bytes} bytes`,
|
|
434
476
|
);
|
|
435
477
|
}
|
|
436
478
|
lines.push(`total ${totalBytes} bytes across ${selected.length} run(s)`);
|
|
@@ -481,6 +523,10 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
|
|
|
481
523
|
context.write(` ${event.seq} ${event.at} ${event.kind.padEnd(20)} ${event.actor.role.padEnd(8)} ${detailOf(event)}`);
|
|
482
524
|
}
|
|
483
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
|
+
|
|
484
530
|
const rounds = roundRows(events);
|
|
485
531
|
if (rounds.length > 0) {
|
|
486
532
|
context.write(" rounds:");
|
package/src/commands/verify.ts
CHANGED
|
@@ -44,7 +44,8 @@ import {
|
|
|
44
44
|
} from "@agent-delivery-harness/kernel";
|
|
45
45
|
import { commandBlocker } from "../boundary.ts";
|
|
46
46
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
47
|
-
import { oneLine, resolveRunJournalRow, runJournalRows } from "../run-surface.ts";
|
|
47
|
+
import { RUN_JOURNAL_ADMISSION_ROW, oneLine, resolveRunJournalRow, resolveRunSpan, runJournalRows } from "../run-surface.ts";
|
|
48
|
+
import { durationLabel } from "../run-projection.ts";
|
|
48
49
|
|
|
49
50
|
const USAGE = "Usage: delivery-harness verify [--require-run-journal] [--mandated-lens <id>]...";
|
|
50
51
|
|
|
@@ -98,11 +99,61 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
98
99
|
function runJournalBlocker(row: RunJournalRow) {
|
|
99
100
|
const missing = row.missing.length === 0 ? "(none)" : row.missing.join(", ");
|
|
100
101
|
const violations = row.violations === undefined || row.violations.length === 0 ? "(none)" : row.violations.join(", ");
|
|
102
|
+
// The same per-violation reasons the row prints. A refusal that named only
|
|
103
|
+
// the identifiers would make the operator run the command again with the
|
|
104
|
+
// flag dropped just to read why.
|
|
105
|
+
//
|
|
106
|
+
// EVERYTHING THE REMEDIATION NAMES COMES FIRST, BECAUSE A DETAIL IS CUT FROM
|
|
107
|
+
// THE END. A blocker's detail reaches a terminal through `renderBlockers`,
|
|
108
|
+
// which bounds it at 600 characters, so a journal with several warnings
|
|
109
|
+
// pushes whatever is last off the end. Two things must never be the
|
|
110
|
+
// casualty: the line saying none of this blocks admission, because a
|
|
111
|
+
// truncated list of violated constraints with no such line reads as a
|
|
112
|
+
// verdict; and the `missing:` list, because the remediation tells the
|
|
113
|
+
// operator to emit the run events this delivery did not journal and that
|
|
114
|
+
// list is the only place this refusal says which ones. So the identifiers,
|
|
115
|
+
// the missing list and the admission sentence all precede the per-warning
|
|
116
|
+
// reasons, which are the right thing to lose to the cap — the row on stdout,
|
|
117
|
+
// which is written line by line and is not bounded this way, always carries
|
|
118
|
+
// all of them. Inside a warning's own segment the `(a consequence of …)`
|
|
119
|
+
// clause goes before the reason for the same arithmetic: the reason is the
|
|
120
|
+
// long part, so a clause written after it is the first thing the cap
|
|
121
|
+
// destroys. With the clause after the reason it vanished from the
|
|
122
|
+
// reproduction shape once the run id reached 36 characters, and `runId` is
|
|
123
|
+
// accepted up to 128; with it before, it survives at every run id length
|
|
124
|
+
// through 128, as do the missing list and the admission sentence. Two
|
|
125
|
+
// warnings that restate one fact, printed as peers with nothing saying so,
|
|
126
|
+
// are the exact misreading this row exists to prevent.
|
|
127
|
+
//
|
|
128
|
+
// Do not read the 120 as making the whole detail fit. It never does on a
|
|
129
|
+
// journal with more than one warning: the reproduction's two render 733
|
|
130
|
+
// characters at this bound (753 at 130, 813 at 160, and 1045 at 400 and at
|
|
131
|
+
// every larger bound — the longer of its two reasons is 315 characters, so
|
|
132
|
+
// nothing above that is binding) against a 600-character cut, and the later
|
|
133
|
+
// warnings' reasons are lost at every one of them — that is what the
|
|
134
|
+
// unbounded stdout row is for.
|
|
135
|
+
//
|
|
136
|
+
// What 120 buys is narrower than it looks, and the narrowness is the point,
|
|
137
|
+
// because two earlier rounds of this delivery each shipped a confident and
|
|
138
|
+
// wrong statement about this budget. Measured on the reproduction's two
|
|
139
|
+
// warnings at the run id this store actually mints (`run-` + 16 hex = 20
|
|
140
|
+
// characters), the second warning's identifier begins inside the cut — as
|
|
141
|
+
// `round-not-bou…`, 13 of its 25 characters — and at 121 and above it does
|
|
142
|
+
// not. That is the whole of it. The margin is one character: at a 21-
|
|
143
|
+
// character run id, which `isLegalRunId` admits up to 128, it is already
|
|
144
|
+
// gone, and a journal carrying a third warning loses the later segments
|
|
145
|
+
// whatever the bound is. What a larger bound costs is a later warning's own
|
|
146
|
+
// SEGMENT, never its name: the `violations:` list and any `(a consequence
|
|
147
|
+
// of …)` clause naming it are ahead of the cut and survive regardless.
|
|
148
|
+
const why = (row.explanations ?? []).map(
|
|
149
|
+
(explanation) =>
|
|
150
|
+
`; ${oneLine(explanation.violation, 64)}${explanation.consequenceOf === undefined ? "" : ` (a consequence of ${oneLine(explanation.consequenceOf, 64)})`}: ${oneLine(explanation.because, 120)}`,
|
|
151
|
+
).join("");
|
|
101
152
|
return commandBlocker({
|
|
102
153
|
code: "run_journal_incomplete",
|
|
103
154
|
sourceId: "delivery-harness.cli.verify",
|
|
104
155
|
summary: "The run journal for this candidate is not complete, and --require-run-journal was given.",
|
|
105
|
-
details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`};
|
|
156
|
+
details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`}; violations: ${violations}; missing: ${missing}; ${RUN_JOURNAL_ADMISSION_ROW}${why}`,
|
|
106
157
|
remediations: [
|
|
107
158
|
{
|
|
108
159
|
id: "emit-the-missing-run-events",
|
|
@@ -113,6 +164,74 @@ function runJournalBlocker(row: RunJournalRow) {
|
|
|
113
164
|
});
|
|
114
165
|
}
|
|
115
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The record's own span, checked against the journal that binds its candidate.
|
|
169
|
+
*
|
|
170
|
+
* WHAT IS CHECKED, AND WHY THOSE TWO THINGS. `startedAt` must EQUAL the
|
|
171
|
+
* journal's first instant: a journal is append-only, so its first event never
|
|
172
|
+
* moves and a record that disagrees about when the delivery began is describing
|
|
173
|
+
* some other run. `endedAt` must lie inside the journal's span, because the
|
|
174
|
+
* journal keeps growing after the record is written — `verify`, `pr.opened` and
|
|
175
|
+
* `run.ended` all land later — so equality there would fail on every honest
|
|
176
|
+
* record, while an `endedAt` after the journal's last instant or before its
|
|
177
|
+
* first could not have been read off it at all.
|
|
178
|
+
*
|
|
179
|
+
* WHY A MISS IS NOT A REFUSAL. A journal is self-attested observability that
|
|
180
|
+
* anything the owner executes can append to, and this command runs in CI over
|
|
181
|
+
* checkouts that carry no run store whatever. So the refusal is available only
|
|
182
|
+
* where a journal BINDS this record's candidate — never on whichever run is
|
|
183
|
+
* current in the checkout someone verified from, and never as a demand that a
|
|
184
|
+
* journal exist. Where none binds it the span is reported unchecked, which is
|
|
185
|
+
* what `verify` could honestly say before this member existed too.
|
|
186
|
+
*/
|
|
187
|
+
async function runSpanRows(
|
|
188
|
+
rootDir: string,
|
|
189
|
+
record: { readonly runSpan?: { readonly startedAt: string; readonly endedAt: string }; readonly candidateBinding: { readonly treeSha: string } },
|
|
190
|
+
): Promise<{ readonly rows: readonly string[]; readonly blocker?: ReturnType<typeof commandBlocker> }> {
|
|
191
|
+
const span = record.runSpan;
|
|
192
|
+
if (span === undefined) return { rows: [] };
|
|
193
|
+
const spent = (Date.parse(span.endedAt) - Date.parse(span.startedAt)) / 1000;
|
|
194
|
+
const recorded = `recorded run span: ${span.startedAt} to ${span.endedAt} (${durationLabel(spent)})`;
|
|
195
|
+
// `preferStartedAt` is what keeps a SECOND run in the same worktree — the one
|
|
196
|
+
// this verify is itself running under, say — from refusing a record whose own
|
|
197
|
+
// run reports exactly this span. A refusal stands only where no journal
|
|
198
|
+
// binding this candidate starts where the record says it did.
|
|
199
|
+
const journal = await resolveRunSpan({
|
|
200
|
+
cwd: rootDir,
|
|
201
|
+
treeSha: record.candidateBinding.treeSha,
|
|
202
|
+
preferStartedAt: span.startedAt,
|
|
203
|
+
});
|
|
204
|
+
if (journal === undefined) {
|
|
205
|
+
return { rows: [`${recorded}; unchecked: no run journal in this repository binds this candidate`] };
|
|
206
|
+
}
|
|
207
|
+
const disagreement =
|
|
208
|
+
span.startedAt !== journal.startedAt
|
|
209
|
+
? `the record starts at ${span.startedAt} but run ${oneLine(journal.runId, 128)} starts at ${journal.startedAt}`
|
|
210
|
+
: span.endedAt > journal.endedAt
|
|
211
|
+
? `the record ends at ${span.endedAt}, after the last instant run ${oneLine(journal.runId, 128)} reached (${journal.endedAt})`
|
|
212
|
+
: undefined;
|
|
213
|
+
if (disagreement === undefined) {
|
|
214
|
+
return { rows: [`${recorded}; checked against run ${oneLine(journal.runId, 128)}`] };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
rows: [],
|
|
218
|
+
blocker: commandBlocker({
|
|
219
|
+
code: "record_run_span_mismatch",
|
|
220
|
+
sourceId: "delivery-harness.cli.verify",
|
|
221
|
+
summary: "The delivery record's run span disagrees with the run journal that binds its candidate.",
|
|
222
|
+
details: disagreement,
|
|
223
|
+
remediations: [
|
|
224
|
+
{
|
|
225
|
+
id: "re-record-the-span",
|
|
226
|
+
kind: "command",
|
|
227
|
+
command: ["delivery-harness", "record"],
|
|
228
|
+
summary: "Re-record this candidate so its span is the one its own run journal reports.",
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
}),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
116
235
|
export const verifyCommand: CommandDescriptor = {
|
|
117
236
|
name: "verify",
|
|
118
237
|
sourceId: "delivery-harness.cli.verify",
|
|
@@ -230,6 +349,11 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
230
349
|
return { kind: "blocked", blockers: [runJournalBlocker(runJournal)] };
|
|
231
350
|
}
|
|
232
351
|
|
|
352
|
+
// Judged in the same place and for the same reason: a record that fails its
|
|
353
|
+
// own verification is never told its span is the problem.
|
|
354
|
+
const span = await runSpanRows(context.rootDir, parsed.record);
|
|
355
|
+
if (span.blocker !== undefined) return { kind: "blocked", blockers: [span.blocker] };
|
|
356
|
+
|
|
233
357
|
const relaxation = check.baseMovementRelaxed
|
|
234
358
|
? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
|
|
235
359
|
: "";
|
|
@@ -243,6 +367,7 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
243
367
|
`verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
|
|
244
368
|
`recorded base: ${oneLine(parsed.record.candidateBinding.baseRef, 256)} at ${parsed.record.candidateBinding.baseTipSha}`,
|
|
245
369
|
`observed base: ${oneLine(base.ref, 256)} at ${base.tipSha}`,
|
|
370
|
+
...span.rows,
|
|
246
371
|
...hostedCheckRow,
|
|
247
372
|
...runJournalRows(runJournal),
|
|
248
373
|
].join("\n"),
|
package/src/index.ts
CHANGED
|
@@ -102,3 +102,6 @@ export { resumeCommand } from "./commands/resume.ts";
|
|
|
102
102
|
export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
|
|
103
103
|
return runCliBoundary(argv, COMMANDS, runtime);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
export { readScopedCheckObservations, type ScopedCheckObservations } from "./scoped-observations.ts";
|
|
107
|
+
export { readScopedCheckDiagnostics, type ScopedCheckDiagnostics, type ScopedAttemptDiagnostic, type RecordedScopedAttemptDiagnostic, type ScopedDiagnosticPhase, type ScopedDiagnosticFailureCode, type ScopedDiagnosticExecutionCode, type ScopedDiagnosticCommand, type ScopedDependencyDiagnostic } from "./scoped-diagnostics.ts";
|
package/src/main.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* a Ctrl-C into the typed {@link CliInterruption} the boundary maps to exit 130.
|
|
10
10
|
*/
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
|
-
import { CliInterruption, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
|
|
12
|
+
import { CliInterruption, EXIT_INTERRUPTED, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
|
|
13
13
|
import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -35,23 +35,25 @@ import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kern
|
|
|
35
35
|
* would reject and then resolve, and a settled promise silently ignoring its
|
|
36
36
|
* second settlement is exactly how this class of bug hides.
|
|
37
37
|
*/
|
|
38
|
-
export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
|
|
38
|
+
export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, signal?: AbortSignal): WaiverPrompt {
|
|
39
39
|
return (decision, obligationIds) =>
|
|
40
40
|
new Promise<Awaited<ReturnType<WaiverPrompt>>>((resolve, reject) => {
|
|
41
|
+
if (signal?.aborted) { reject(new CliInterruption()); return; }
|
|
41
42
|
const rl = createInterface({ input, output });
|
|
42
43
|
let settled = false;
|
|
43
44
|
const settle = (action: () => void): void => {
|
|
44
45
|
if (settled) return;
|
|
45
46
|
settled = true;
|
|
47
|
+
signal?.removeEventListener("abort", interrupt);
|
|
46
48
|
action();
|
|
47
49
|
};
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
reject(new CliInterruption("Waiver prompt interrupted."));
|
|
53
|
-
});
|
|
51
|
+
const interrupt = () => settle(() => {
|
|
52
|
+
rl.close();
|
|
53
|
+
reject(new CliInterruption("Waiver prompt interrupted."));
|
|
54
54
|
});
|
|
55
|
+
rl.on("SIGINT", interrupt);
|
|
56
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
55
57
|
// Ctrl-D, a closed pipe, or any other end of input.
|
|
56
58
|
rl.on("close", () => {
|
|
57
59
|
settle(() => resolve(false));
|
|
@@ -93,20 +95,29 @@ export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
|
|
|
93
95
|
* reads as empty, and the store refuses the empty payload with a diagnostic,
|
|
94
96
|
* which is a far better answer than silence.
|
|
95
97
|
*/
|
|
96
|
-
export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }): Promise<string> {
|
|
98
|
+
export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }, signal?: AbortSignal): Promise<string> {
|
|
99
|
+
if (signal?.aborted) return Promise.reject(new CliInterruption());
|
|
97
100
|
if (input.isTTY === true) return Promise.resolve("");
|
|
98
|
-
return new Promise((resolve) => {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
99
102
|
let text = "";
|
|
103
|
+
const cleanup = () => {
|
|
104
|
+
input.removeListener("data", data);
|
|
105
|
+
input.removeListener("error", finish);
|
|
106
|
+
input.removeListener("end", finish);
|
|
107
|
+
signal?.removeEventListener("abort", interrupt);
|
|
108
|
+
};
|
|
109
|
+
const data = (chunk: string) => { text += chunk; };
|
|
110
|
+
const finish = () => { cleanup(); resolve(text); };
|
|
111
|
+
const interrupt = () => { cleanup(); input.pause(); reject(new CliInterruption()); };
|
|
100
112
|
input.setEncoding("utf8");
|
|
101
|
-
input.on("data",
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
input.once("end", () => resolve(text));
|
|
113
|
+
input.on("data", data);
|
|
114
|
+
input.once("error", finish);
|
|
115
|
+
input.once("end", finish);
|
|
116
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
106
117
|
});
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
export function defaultRuntime(): CliRuntime {
|
|
120
|
+
export function defaultRuntime(signal?: AbortSignal): CliRuntime {
|
|
110
121
|
return {
|
|
111
122
|
cwd: process.cwd(),
|
|
112
123
|
env: process.env,
|
|
@@ -114,13 +125,20 @@ export function defaultRuntime(): CliRuntime {
|
|
|
114
125
|
stdoutIsTTY: process.stdout.isTTY === true,
|
|
115
126
|
stdout: (text) => process.stdout.write(text),
|
|
116
127
|
stderr: (text) => process.stderr.write(text),
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
...(signal ? { signal } : {}),
|
|
129
|
+
promptForWaiver: createWaiverPrompt(process.stdin, process.stderr, signal),
|
|
130
|
+
readStdin: () => readStdinText(process.stdin, signal),
|
|
119
131
|
};
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
export async function main(argv: readonly string[]): Promise<number> {
|
|
123
|
-
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
const interrupt = () => controller.abort();
|
|
137
|
+
process.on("SIGINT", interrupt);
|
|
138
|
+
try {
|
|
139
|
+
const code = await runCli(argv, defaultRuntime(controller.signal));
|
|
140
|
+
return controller.signal.aborted ? EXIT_INTERRUPTED : code;
|
|
141
|
+
} finally { process.removeListener("SIGINT", interrupt); }
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
package/src/ordinary-context.ts
CHANGED
|
@@ -79,21 +79,5 @@ export async function installedRelease(rootDir: string) {
|
|
|
79
79
|
|
|
80
80
|
export const policyDigest = (context: CommandContext) => digestCanonical({ config: context.config, policyBinding: context.policyBinding ?? null });
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
export
|
|
84
|
-
const actions = new Map<string, { actionId: string; operation?: unknown; reference: unknown; outcome: string; inconsistent?: boolean }>();
|
|
85
|
-
for (const event of events) {
|
|
86
|
-
const p = event.payload;
|
|
87
|
-
if (event.kind === "action.intent") {
|
|
88
|
-
const actionId = p["actionId"] as string;
|
|
89
|
-
const prior = actions.get(actionId);
|
|
90
|
-
actions.set(actionId, { actionId, operation: p["operation"], reference: p["reference"], outcome: "unknown", ...(prior ? { inconsistent: true } : {}) });
|
|
91
|
-
} else if (event.kind === "action.observed") {
|
|
92
|
-
const actionId = p["actionId"] as string;
|
|
93
|
-
const prior = actions.get(actionId);
|
|
94
|
-
actions.set(actionId, { ...prior, actionId, reference: p["reference"], outcome: p["outcome"] as string,
|
|
95
|
-
...(!prior || prior.inconsistent || (prior.outcome !== "unknown" && prior.outcome !== p["outcome"]) ? { inconsistent: true } : {}) });
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return [...actions.values()];
|
|
99
|
-
}
|
|
82
|
+
// One reduction is shared with the atomic journal append validator.
|
|
83
|
+
export { reconciliationActions } from "@agent-delivery-harness/kernel";
|
package/src/run-export.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
evaluateRunJournal,
|
|
11
11
|
RUN_STORE_ID,
|
|
12
12
|
validateRunEvent,
|
|
13
|
+
runActionReconciliationError,
|
|
13
14
|
type RunEvent,
|
|
14
15
|
} from "@agent-delivery-harness/kernel";
|
|
15
16
|
import {
|
|
@@ -55,6 +56,37 @@ export function buildRunExport(input: {
|
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The readout members a stored export is CHECKED against.
|
|
61
|
+
*
|
|
62
|
+
* The per-violation sentences are derived from the same events by the same
|
|
63
|
+
* pass on every read, so an export never has to carry them and is never
|
|
64
|
+
* refused for lacking them: an archive written before the readout explained
|
|
65
|
+
* itself stays valid, and one carrying invented sentences gains nothing,
|
|
66
|
+
* because the value returned below is rebuilt from the recomputation either
|
|
67
|
+
* way. Compare what the events fix; recompute what they derive.
|
|
68
|
+
*/
|
|
69
|
+
function comparableReadout(readout: object): Record<string, unknown> {
|
|
70
|
+
const comparable: Record<string, unknown> = { ...readout };
|
|
71
|
+
delete comparable["explanations"];
|
|
72
|
+
return comparable;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The summary members a stored export is CHECKED against.
|
|
77
|
+
*
|
|
78
|
+
* `phases` is derived from the same events by the same pass on every read, for
|
|
79
|
+
* exactly the reason the readout's sentences are: an archive written before the
|
|
80
|
+
* phase breakdown existed stays valid, and one carrying invented phases gains
|
|
81
|
+
* nothing, because the value returned below is rebuilt from the recomputation
|
|
82
|
+
* either way. Compare what the events fix; recompute what they derive.
|
|
83
|
+
*/
|
|
84
|
+
function comparableSummary(summary: object): Record<string, unknown> {
|
|
85
|
+
const comparable: Record<string, unknown> = { ...summary };
|
|
86
|
+
delete comparable["phases"];
|
|
87
|
+
return comparable;
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
export type DeliveryRunExport = ReturnType<typeof buildRunExport> & {
|
|
59
91
|
readonly attachments?: RunAttachments;
|
|
60
92
|
};
|
|
@@ -103,6 +135,7 @@ export function parseRunExport(text: string): RunExportParseResult {
|
|
|
103
135
|
: "costs,events,labels,readout,refusedAppends,runId,spec,summary")
|
|
104
136
|
)
|
|
105
137
|
return invalid;
|
|
138
|
+
const prefix: RunEvent[] = [];
|
|
106
139
|
for (const [index, event] of value["events"].entries()) {
|
|
107
140
|
if (
|
|
108
141
|
!validateRunEvent(event).ok ||
|
|
@@ -112,6 +145,9 @@ export function parseRunExport(text: string): RunExportParseResult {
|
|
|
112
145
|
event["version"] !== (v2 ? "run-event/2" : "run-event/1")
|
|
113
146
|
)
|
|
114
147
|
return invalid;
|
|
148
|
+
const validated = event as unknown as RunEvent;
|
|
149
|
+
if (runActionReconciliationError(prefix, validated)) return invalid;
|
|
150
|
+
prefix.push(validated);
|
|
115
151
|
}
|
|
116
152
|
if (
|
|
117
153
|
hasAttachments &&
|
|
@@ -132,7 +168,8 @@ export function parseRunExport(text: string): RunExportParseResult {
|
|
|
132
168
|
)
|
|
133
169
|
return invalid;
|
|
134
170
|
if (
|
|
135
|
-
|
|
171
|
+
!isRecord(value["summary"]) ||
|
|
172
|
+
canonicalize(comparableSummary(value["summary"])) !== canonicalize(comparableSummary(expected.summary)) ||
|
|
136
173
|
canonicalize(value["costs"]) !== canonicalize(expected.costs)
|
|
137
174
|
)
|
|
138
175
|
return invalid;
|
|
@@ -141,7 +178,8 @@ export function parseRunExport(text: string): RunExportParseResult {
|
|
|
141
178
|
const { note, ...readout } = value["readout"];
|
|
142
179
|
if (
|
|
143
180
|
(note !== undefined && typeof note !== "string") ||
|
|
144
|
-
canonicalize(readout) !==
|
|
181
|
+
canonicalize(comparableReadout(readout)) !==
|
|
182
|
+
canonicalize(comparableReadout(expected.readout))
|
|
145
183
|
)
|
|
146
184
|
return invalid;
|
|
147
185
|
return {
|