@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
|
@@ -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
|
+
};
|
|
@@ -9,21 +9,60 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { submitManifest } from "@agent-delivery-harness/kernel";
|
|
11
11
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
12
|
+
import { oneLine } from "../run-surface.ts";
|
|
12
13
|
|
|
13
|
-
function
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
function manifestPathsFrom(args: readonly string[]): string[] | undefined {
|
|
15
|
+
const paths: string[] = [];
|
|
16
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
17
|
+
const token = args[index]!;
|
|
18
|
+
if (token === "--manifest") {
|
|
19
|
+
const value = args[++index];
|
|
20
|
+
if (value === undefined || value === "") return undefined;
|
|
21
|
+
paths.push(value);
|
|
22
|
+
} else if (!token.startsWith("-")) paths.push(token);
|
|
23
|
+
}
|
|
24
|
+
return paths;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The one flag this command has, and everything else that looks like a flag.
|
|
29
|
+
* `--manifest`'s VALUE is skipped rather than scanned, so a path that happens
|
|
30
|
+
* to begin with a hyphen is still that flag's value and not an unknown flag —
|
|
31
|
+
* rejecting legitimate arguments generically is the failure mode this guard
|
|
32
|
+
* has to avoid while it closes the silently-ignored one.
|
|
33
|
+
*/
|
|
34
|
+
function unknownFlagIn(args: readonly string[]): string | undefined {
|
|
35
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
36
|
+
const token = args[index]!;
|
|
37
|
+
if (token === "--manifest") {
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (token.startsWith("-")) return token;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
export const submitEvidenceCommand: CommandDescriptor = {
|
|
22
47
|
name: "submit-evidence",
|
|
23
48
|
sourceId: "delivery-harness.cli.submit-evidence",
|
|
24
49
|
summary: "Validate a provider manifest and publish its evidence records.",
|
|
50
|
+
usage: "Usage: delivery-harness submit-evidence --manifest <path>",
|
|
25
51
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
26
|
-
|
|
52
|
+
// An unrecognized flag is refused before the manifest is looked for, so a
|
|
53
|
+
// mistyped flag can never be ignored into a positional read of the argv.
|
|
54
|
+
const unknown = unknownFlagIn(context.args);
|
|
55
|
+
if (unknown !== undefined) {
|
|
56
|
+
return { kind: "usage", message: `Unknown flag ${oneLine(unknown, 64)}.\n${submitEvidenceCommand.usage}` };
|
|
57
|
+
}
|
|
58
|
+
const paths = manifestPathsFrom(context.args);
|
|
59
|
+
if (paths === undefined) {
|
|
60
|
+
return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
|
|
61
|
+
}
|
|
62
|
+
if (paths.length > 1) {
|
|
63
|
+
return { kind: "usage", message: `submit-evidence requires one manifest path; received ${paths.map(value => oneLine(value, 256)).join(", ")}.` };
|
|
64
|
+
}
|
|
65
|
+
const manifestPath = paths[0];
|
|
27
66
|
if (manifestPath === undefined || manifestPath === "") {
|
|
28
67
|
return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
|
|
29
68
|
}
|
package/src/commands/verify.ts
CHANGED
|
@@ -37,12 +37,15 @@ import {
|
|
|
37
37
|
parseDeliveryRecord,
|
|
38
38
|
runGitCommand,
|
|
39
39
|
verifyDeliveryRecord,
|
|
40
|
+
capturePortableVerificationInputs,
|
|
41
|
+
collectLiveProviderResults,
|
|
40
42
|
type CandidateTreeEntry,
|
|
41
43
|
type RunJournalRow,
|
|
42
44
|
} from "@agent-delivery-harness/kernel";
|
|
43
45
|
import { commandBlocker } from "../boundary.ts";
|
|
44
46
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
45
|
-
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";
|
|
46
49
|
|
|
47
50
|
const USAGE = "Usage: delivery-harness verify [--require-run-journal] [--mandated-lens <id>]...";
|
|
48
51
|
|
|
@@ -96,11 +99,61 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
96
99
|
function runJournalBlocker(row: RunJournalRow) {
|
|
97
100
|
const missing = row.missing.length === 0 ? "(none)" : row.missing.join(", ");
|
|
98
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("");
|
|
99
152
|
return commandBlocker({
|
|
100
153
|
code: "run_journal_incomplete",
|
|
101
154
|
sourceId: "delivery-harness.cli.verify",
|
|
102
155
|
summary: "The run journal for this candidate is not complete, and --require-run-journal was given.",
|
|
103
|
-
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}`,
|
|
104
157
|
remediations: [
|
|
105
158
|
{
|
|
106
159
|
id: "emit-the-missing-run-events",
|
|
@@ -111,11 +164,81 @@ function runJournalBlocker(row: RunJournalRow) {
|
|
|
111
164
|
});
|
|
112
165
|
}
|
|
113
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
|
+
|
|
114
235
|
export const verifyCommand: CommandDescriptor = {
|
|
115
236
|
name: "verify",
|
|
116
237
|
sourceId: "delivery-harness.cli.verify",
|
|
117
238
|
summary: "Verify the tracked delivery record against the current candidate.",
|
|
239
|
+
usage: USAGE,
|
|
118
240
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
241
|
+
const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
|
|
119
242
|
// Arguments first: a malformed invocation is a usage error and captures
|
|
120
243
|
// nothing, exactly as `emit` and `submit-evidence` order it.
|
|
121
244
|
const parsedArgs = parseArgs(context.args);
|
|
@@ -196,19 +319,29 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
196
319
|
}
|
|
197
320
|
}
|
|
198
321
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
322
|
+
const inputs = await capturePortableVerificationInputs(context.rootDir, context.config, capture.candidate, parsed.record);
|
|
323
|
+
const live = await collectLiveProviderResults({rootDir:context.rootDir,config:context.config,candidate:capture.candidate,
|
|
324
|
+
projection:inputs.projection,evidenceContext:inputs.evidenceContext,env:context.env,...(context.signal===undefined?{}:{signal:context.signal})});
|
|
325
|
+
// Verify the portable record before letting any retained projection widen
|
|
326
|
+
// the observational tree coordinates. Corrupt or invented projection bytes
|
|
327
|
+
// therefore fail as record evidence and never influence journal matching.
|
|
328
|
+
const verified = verifyDeliveryRecord(context.config, parsed.record, identity, base,
|
|
329
|
+
{ candidateTreePaths, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
|
|
330
|
+
if (!verified.ok) {
|
|
331
|
+
return { kind: "blocked", blockers: [...live.blockers, ...verified.blockers] };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The record tree remains the primary coordinate. A product-validated
|
|
335
|
+
// review-neutral projection may additionally name the earlier raw tree the
|
|
336
|
+
// reviewers actually read; the journal stays observational either way.
|
|
202
337
|
const runJournal = await resolveRunJournalRow({
|
|
203
338
|
cwd: context.rootDir,
|
|
204
339
|
treeSha: parsed.record.candidateBinding.treeSha,
|
|
340
|
+
reviewedCandidateTreeShas: verified.reviewedCandidateTreeShas,
|
|
205
341
|
...(parsedArgs.args.mandatedLensIds.length === 0 ? {} : { mandatedLensIds: parsedArgs.args.mandatedLensIds }),
|
|
206
342
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!check.ok) {
|
|
210
|
-
return { kind: "blocked", blockers: [...check.blockers] };
|
|
211
|
-
}
|
|
343
|
+
const check = verifyDeliveryRecord(context.config, parsed.record, identity, base,
|
|
344
|
+
{ candidateTreePaths, runJournal, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
|
|
212
345
|
|
|
213
346
|
// The opt-in is judged AFTER the record's own verification, so a delivery
|
|
214
347
|
// whose record is bad is never told its journal is the problem.
|
|
@@ -216,13 +349,26 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
216
349
|
return { kind: "blocked", blockers: [runJournalBlocker(runJournal)] };
|
|
217
350
|
}
|
|
218
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
|
+
|
|
219
357
|
const relaxation = check.baseMovementRelaxed
|
|
220
358
|
? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
|
|
221
359
|
: "";
|
|
360
|
+
const exemption = check.hostedChecks.exemption;
|
|
361
|
+
const hostedCheckRow = exemption === undefined ? [] : [
|
|
362
|
+
`hosted checks: exempted for ${oneLine(exemption.scope.repositoryId, 128)} at ${oneLine(exemption.scope.baseRef, 256)}; granted by ${oneLine(exemption.grantedBy, 256)}; until ${exemption.until}; reason: ${oneLine(exemption.reason, 512)}`,
|
|
363
|
+
];
|
|
222
364
|
return {
|
|
223
365
|
kind: "ok",
|
|
224
366
|
summary: [
|
|
225
367
|
`verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
|
|
368
|
+
`recorded base: ${oneLine(parsed.record.candidateBinding.baseRef, 256)} at ${parsed.record.candidateBinding.baseTipSha}`,
|
|
369
|
+
`observed base: ${oneLine(base.ref, 256)} at ${base.tipSha}`,
|
|
370
|
+
...span.rows,
|
|
371
|
+
...hostedCheckRow,
|
|
226
372
|
...runJournalRows(runJournal),
|
|
227
373
|
].join("\n"),
|
|
228
374
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { captureCheckBindings, captureCheckOutputSnapshots, digestCanonical, classifyCandidateDrift, computeCheckWiringFingerprint, createBlocker, createExecPort, sha256Hex, submitManifest, type Blocker, type CandidateBinding, type ProviderRegistration } from "@agent-delivery-harness/kernel";
|
|
4
|
+
import type { CommandContext } from "./boundary.ts";
|
|
5
|
+
|
|
6
|
+
/** Runs one declared argv check and submits its product-constructed terminal evidence. */
|
|
7
|
+
export async function runDeclaredCheck(context: CommandContext, provider: ProviderRegistration, obligationIds: readonly string[], before: CandidateBinding): Promise<readonly Blocker[]> {
|
|
8
|
+
const check = provider.check!;
|
|
9
|
+
const wiring = await context.wire();
|
|
10
|
+
const fail = (code: string, summary: string, details?: string): readonly Blocker[] => [createBlocker({ code, source: { kind: "command", id: "delivery-harness.cli.gate" }, summary,
|
|
11
|
+
...(details === undefined ? {} : { details }), remediations: [{ id: "repair-declared-check", kind: "manual_action", summary: "Fix the declared check or its inputs, prepare the stable candidate and run gate again." }] })];
|
|
12
|
+
const start = await wiring.captureCandidate();
|
|
13
|
+
if (!start.ok) return start.blockers;
|
|
14
|
+
if (classifyCandidateDrift(before, start.candidate).length) return fail("check_candidate_changed", "The candidate changed after review admission and before the check started.");
|
|
15
|
+
const fingerprint = await computeCheckWiringFingerprint(context.rootDir, context.config, wiring.storageOptions);
|
|
16
|
+
context.write(`checking ${provider.id}`);
|
|
17
|
+
const result = await createExecPort().run({ command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
|
|
18
|
+
env: Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined)),
|
|
19
|
+
timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024, ...(context.signal === undefined ? {} : { signal: context.signal }) });
|
|
20
|
+
if (result.code !== 0 || context.signal?.aborted) return fail("check_command_failed", `Declared check ${provider.id} did not complete successfully.`, `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`);
|
|
21
|
+
const captured = await wiring.captureCandidate();
|
|
22
|
+
if (!captured.ok) return captured.blockers;
|
|
23
|
+
if (classifyCandidateDrift(before, captured.candidate).length || start.candidate.headSha !== captured.candidate.headSha || fingerprint !== await computeCheckWiringFingerprint(context.rootDir, context.config, wiring.storageOptions)) return fail("check_candidate_changed", "The candidate, base or wiring changed while the check ran.");
|
|
24
|
+
const binding = (await captureCheckBindings(context.rootDir, context.config, captured.candidate, wiring.storageOptions))[provider.id];
|
|
25
|
+
if (binding === undefined) return fail("check_output_missing", `Declared check ${provider.id} has a missing, unreadable, oversized or escaped output.`);
|
|
26
|
+
const runId = randomUUID(), finalPassId = "pass-1";
|
|
27
|
+
const allocation = await context.artifacts.allocateRunRoot({ providerId: provider.id, runId });
|
|
28
|
+
if (!allocation.ok) return fail("check_artifact_unavailable", "Cannot allocate the declared check evidence root.");
|
|
29
|
+
const snapshots = await captureCheckOutputSnapshots(context.rootDir, check.outputs ?? []);
|
|
30
|
+
if (snapshots === undefined || digestCanonical(snapshots.map(({ path, sha256 }) => ({ path, sha256 }))) !== binding.outputsDigest) return fail("check_output_missing", "Declared outputs changed before evidence retention.");
|
|
31
|
+
const outputArtifacts: { path: string; sha256: string; role: string }[] = [];
|
|
32
|
+
for (const [index, output] of snapshots.entries()) {
|
|
33
|
+
const contents = JSON.stringify({ path: output.path, base64: output.base64 });
|
|
34
|
+
const artifactPath = `check-output-${index}.json`;
|
|
35
|
+
await context.artifacts.writeTextFile(path.join(allocation.runRoot.path, artifactPath), contents);
|
|
36
|
+
outputArtifacts.push({ path: artifactPath, sha256: sha256Hex(contents), role: "check-output" });
|
|
37
|
+
}
|
|
38
|
+
const payload = { verdict: "green", exitCode: 0, binding };
|
|
39
|
+
const terminal = JSON.stringify({ providerId: provider.id, runId, finalPassId, ...payload });
|
|
40
|
+
await context.artifacts.writeTextFile(path.join(allocation.runRoot.path, "check-result.json"), terminal);
|
|
41
|
+
const candidate = captured.candidate;
|
|
42
|
+
const manifest = { spec: "delivery-evidence/1", provider: { id: provider.id, runId, finalPassId },
|
|
43
|
+
candidate: { vcs: "git", treeSha: candidate.treeSha, headSha: candidate.headSha, deliverable: candidate.deliverable, base: candidate.base, workspaceId: candidate.workspaceId },
|
|
44
|
+
runHistory: [{ preparedTreeSha: candidate.treeSha, evaluatedInPassId: finalPassId }], artifacts: [{ path: "check-result.json", sha256: sha256Hex(terminal), role: "check-result" }, ...outputArtifacts],
|
|
45
|
+
attestation: { level: "self", signatures: [] }, recordedAt: new Date().toISOString(), claims: obligationIds.map(obligation => ({ obligation, payloadSpec: "checks.passed/1", payload })) };
|
|
46
|
+
const manifestPath = path.join(allocation.runRoot.path, "manifest.json");
|
|
47
|
+
await context.artifacts.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
48
|
+
const outcome = await submitManifest({ rootDir: context.rootDir, config: context.config, manifestPath }, { captureCandidate: wiring.captureCandidate, artifacts: context.artifacts, ...wiring.storageOptions });
|
|
49
|
+
return outcome.status === "accepted" ? [] : outcome.blockers;
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Delivery harness CLI: the
|
|
2
|
+
* Delivery harness CLI: the fifteen-command operator surface.
|
|
3
3
|
*
|
|
4
4
|
* THE COMMAND REGISTRY. `COMMANDS` is the single source of truth for which
|
|
5
5
|
* commands exist. Every command module under `commands/` must appear here, and
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* in `boundary.ts`; each command is a thin, testable unit behind it.
|
|
13
13
|
*/
|
|
14
14
|
import { checkCommand } from "./commands/check.ts";
|
|
15
|
+
import { admitCommand } from "./commands/admit.ts";
|
|
15
16
|
import { emitCommand } from "./commands/emit.ts";
|
|
16
17
|
import { gateCommand } from "./commands/gate.ts";
|
|
17
18
|
import { maintainCommand } from "./commands/maintain.ts";
|
|
@@ -19,12 +20,16 @@ import { managedCommand } from "./commands/managed.ts";
|
|
|
19
20
|
import { prepareCommand } from "./commands/prepare.ts";
|
|
20
21
|
import { recordCommand } from "./commands/record.ts";
|
|
21
22
|
import { reviewContextCommand } from "./commands/review-context.ts";
|
|
23
|
+
import { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
|
|
22
24
|
import { runsCommand } from "./commands/runs.ts";
|
|
23
25
|
import { submitEvidenceCommand } from "./commands/submit-evidence.ts";
|
|
24
26
|
import { verifyCommand } from "./commands/verify.ts";
|
|
27
|
+
import { saveContextCommand } from "./commands/save-context.ts";
|
|
28
|
+
import { resumeCommand } from "./commands/resume.ts";
|
|
25
29
|
import { runCliBoundary, type AnyCommandDescriptor, type CliRuntime } from "./boundary.ts";
|
|
26
30
|
|
|
27
31
|
export const PACKAGE_NAME = "@agent-delivery-harness/cli";
|
|
32
|
+
export { buildRunExport, parseRunExport, type DeliveryRunExport, type RunExportParseResult } from "./run-export.ts";
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
35
|
* The command registry. The order here is the order `--help` lists them, and it
|
|
@@ -38,8 +43,10 @@ export const PACKAGE_NAME = "@agent-delivery-harness/cli";
|
|
|
38
43
|
* write the run store rather than anything a delivery decision depends on.
|
|
39
44
|
*/
|
|
40
45
|
export const COMMANDS: readonly AnyCommandDescriptor[] = [
|
|
46
|
+
admitCommand,
|
|
41
47
|
prepareCommand,
|
|
42
48
|
reviewContextCommand,
|
|
49
|
+
emitReviewEvidenceCommand,
|
|
43
50
|
submitEvidenceCommand,
|
|
44
51
|
gateCommand,
|
|
45
52
|
recordCommand,
|
|
@@ -49,6 +56,8 @@ export const COMMANDS: readonly AnyCommandDescriptor[] = [
|
|
|
49
56
|
maintainCommand,
|
|
50
57
|
emitCommand,
|
|
51
58
|
runsCommand,
|
|
59
|
+
saveContextCommand,
|
|
60
|
+
resumeCommand,
|
|
52
61
|
];
|
|
53
62
|
|
|
54
63
|
export {
|
|
@@ -74,7 +83,9 @@ export {
|
|
|
74
83
|
} from "./boundary.ts";
|
|
75
84
|
|
|
76
85
|
export { prepareCommand } from "./commands/prepare.ts";
|
|
86
|
+
export { admitCommand } from "./commands/admit.ts";
|
|
77
87
|
export { reviewContextCommand } from "./commands/review-context.ts";
|
|
88
|
+
export { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
|
|
78
89
|
export { submitEvidenceCommand } from "./commands/submit-evidence.ts";
|
|
79
90
|
export { gateCommand } from "./commands/gate.ts";
|
|
80
91
|
export { recordCommand } from "./commands/record.ts";
|
|
@@ -84,8 +95,13 @@ export { managedCommand } from "./commands/managed.ts";
|
|
|
84
95
|
export { maintainCommand } from "./commands/maintain.ts";
|
|
85
96
|
export { emitCommand } from "./commands/emit.ts";
|
|
86
97
|
export { runsCommand } from "./commands/runs.ts";
|
|
98
|
+
export { saveContextCommand } from "./commands/save-context.ts";
|
|
99
|
+
export { resumeCommand } from "./commands/resume.ts";
|
|
87
100
|
|
|
88
101
|
/** Runs the CLI against a runtime and returns the process exit code. */
|
|
89
102
|
export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
|
|
90
103
|
return runCliBoundary(argv, COMMANDS, runtime);
|
|
91
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";
|