@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
|
@@ -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,6 +37,8 @@ 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";
|
|
@@ -115,7 +117,9 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
115
117
|
name: "verify",
|
|
116
118
|
sourceId: "delivery-harness.cli.verify",
|
|
117
119
|
summary: "Verify the tracked delivery record against the current candidate.",
|
|
120
|
+
usage: USAGE,
|
|
118
121
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
122
|
+
const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
|
|
119
123
|
// Arguments first: a malformed invocation is a usage error and captures
|
|
120
124
|
// nothing, exactly as `emit` and `submit-evidence` order it.
|
|
121
125
|
const parsedArgs = parseArgs(context.args);
|
|
@@ -196,19 +200,29 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
196
200
|
}
|
|
197
201
|
}
|
|
198
202
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
const inputs = await capturePortableVerificationInputs(context.rootDir, context.config, capture.candidate, parsed.record);
|
|
204
|
+
const live = await collectLiveProviderResults({rootDir:context.rootDir,config:context.config,candidate:capture.candidate,
|
|
205
|
+
projection:inputs.projection,evidenceContext:inputs.evidenceContext,env:context.env,...(context.signal===undefined?{}:{signal:context.signal})});
|
|
206
|
+
// Verify the portable record before letting any retained projection widen
|
|
207
|
+
// the observational tree coordinates. Corrupt or invented projection bytes
|
|
208
|
+
// therefore fail as record evidence and never influence journal matching.
|
|
209
|
+
const verified = verifyDeliveryRecord(context.config, parsed.record, identity, base,
|
|
210
|
+
{ candidateTreePaths, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
|
|
211
|
+
if (!verified.ok) {
|
|
212
|
+
return { kind: "blocked", blockers: [...live.blockers, ...verified.blockers] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The record tree remains the primary coordinate. A product-validated
|
|
216
|
+
// review-neutral projection may additionally name the earlier raw tree the
|
|
217
|
+
// reviewers actually read; the journal stays observational either way.
|
|
202
218
|
const runJournal = await resolveRunJournalRow({
|
|
203
219
|
cwd: context.rootDir,
|
|
204
220
|
treeSha: parsed.record.candidateBinding.treeSha,
|
|
221
|
+
reviewedCandidateTreeShas: verified.reviewedCandidateTreeShas,
|
|
205
222
|
...(parsedArgs.args.mandatedLensIds.length === 0 ? {} : { mandatedLensIds: parsedArgs.args.mandatedLensIds }),
|
|
206
223
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!check.ok) {
|
|
210
|
-
return { kind: "blocked", blockers: [...check.blockers] };
|
|
211
|
-
}
|
|
224
|
+
const check = verifyDeliveryRecord(context.config, parsed.record, identity, base,
|
|
225
|
+
{ candidateTreePaths, runJournal, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
|
|
212
226
|
|
|
213
227
|
// The opt-in is judged AFTER the record's own verification, so a delivery
|
|
214
228
|
// whose record is bad is never told its journal is the problem.
|
|
@@ -219,10 +233,17 @@ export const verifyCommand: CommandDescriptor = {
|
|
|
219
233
|
const relaxation = check.baseMovementRelaxed
|
|
220
234
|
? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
|
|
221
235
|
: "";
|
|
236
|
+
const exemption = check.hostedChecks.exemption;
|
|
237
|
+
const hostedCheckRow = exemption === undefined ? [] : [
|
|
238
|
+
`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)}`,
|
|
239
|
+
];
|
|
222
240
|
return {
|
|
223
241
|
kind: "ok",
|
|
224
242
|
summary: [
|
|
225
243
|
`verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
|
|
244
|
+
`recorded base: ${oneLine(parsed.record.candidateBinding.baseRef, 256)} at ${parsed.record.candidateBinding.baseTipSha}`,
|
|
245
|
+
`observed base: ${oneLine(base.ref, 256)} at ${base.tipSha}`,
|
|
246
|
+
...hostedCheckRow,
|
|
226
247
|
...runJournalRows(runJournal),
|
|
227
248
|
].join("\n"),
|
|
228
249
|
};
|
|
@@ -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,6 +95,8 @@ 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> {
|
package/src/main.ts
CHANGED
|
@@ -8,11 +8,9 @@
|
|
|
8
8
|
* prints every obligation one "yes" would cover, reads a single line, and turns
|
|
9
9
|
* a Ctrl-C into the typed {@link CliInterruption} the boundary maps to exit 130.
|
|
10
10
|
*/
|
|
11
|
-
import { realpathSync } from "node:fs";
|
|
12
11
|
import { createInterface } from "node:readline";
|
|
13
|
-
import { fileURLToPath } from "node:url";
|
|
14
12
|
import { CliInterruption, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
|
|
15
|
-
import type
|
|
13
|
+
import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
16
14
|
|
|
17
15
|
/**
|
|
18
16
|
* Reads a yes/no answer after naming every obligation the waiver would cover.
|
|
@@ -38,8 +36,8 @@ import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
|
38
36
|
* second settlement is exactly how this class of bug hides.
|
|
39
37
|
*/
|
|
40
38
|
export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
|
|
41
|
-
return (
|
|
42
|
-
new Promise<
|
|
39
|
+
return (decision, obligationIds) =>
|
|
40
|
+
new Promise<Awaited<ReturnType<WaiverPrompt>>>((resolve, reject) => {
|
|
43
41
|
const rl = createInterface({ input, output });
|
|
44
42
|
let settled = false;
|
|
45
43
|
const settle = (action: () => void): void => {
|
|
@@ -60,10 +58,24 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
|
|
|
60
58
|
});
|
|
61
59
|
|
|
62
60
|
output.write(`Waiving covers ${obligationIds.length} obligation(s): ${obligationIds.join(", ")}.\n`);
|
|
61
|
+
output.write(`Candidate: ${decision.candidate.treeSha}. Approval covers only these findings under the current policy; live obligations require new approval each invocation.\n`);
|
|
62
|
+
for (const resolution of decision.resolutions) {
|
|
63
|
+
if (resolution.kind === "blocked" && obligationIds.includes(resolution.obligationId)) {
|
|
64
|
+
for (const blocker of resolution.blockers) output.write(`${resolution.obligationId}: [${blocker.code}] ${blocker.summary}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const finish = (value: Awaited<ReturnType<WaiverPrompt>>) => settle(() => {
|
|
68
|
+
rl.close();
|
|
69
|
+
resolve(value);
|
|
70
|
+
});
|
|
63
71
|
rl.question("Waive all of them? [y/N] ", (answer) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
if (!/^\s*y(es)?\s*$/i.test(answer)) return finish(false);
|
|
73
|
+
rl.question("Author: ", (author) => {
|
|
74
|
+
if (!author.trim() || author.length > 256) return finish(false);
|
|
75
|
+
rl.question("Reason: ", (reason) => {
|
|
76
|
+
if (!reason.trim() || reason.length > 4096) return finish(false);
|
|
77
|
+
finish({ author: author.trim(), reason: reason.trim() });
|
|
78
|
+
});
|
|
67
79
|
});
|
|
68
80
|
});
|
|
69
81
|
});
|
|
@@ -72,45 +84,6 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
|
|
|
72
84
|
export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
|
|
73
85
|
createWaiverPrompt(process.stdin, process.stderr)(decision, obligationIds);
|
|
74
86
|
|
|
75
|
-
/** The spelling the filesystem can vouch for: the realpath where it can answer, the spelling itself where it cannot. */
|
|
76
|
-
function canonicalEntryPath(entryPath: string): string {
|
|
77
|
-
try {
|
|
78
|
-
return realpathSync(entryPath);
|
|
79
|
-
} catch {
|
|
80
|
-
return entryPath;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Whether this module is the entry the process was started with.
|
|
86
|
-
*
|
|
87
|
-
* argv and `import.meta.url` may spell the same file differently: argv is the
|
|
88
|
-
* caller's spelling, and Node builds the module URL from the realpath by
|
|
89
|
-
* default but from the caller's spelling under `--preserve-symlinks-main`. So
|
|
90
|
-
* each side is canonicalized independently and the canonical forms compared:
|
|
91
|
-
* a symlinked spelling matches its realpath whenever the link can be read
|
|
92
|
-
* (`/tmp` → `/private/tmp` on macOS, a wrapper script's stored path, a pnpm
|
|
93
|
-
* workspace link), and equal spellings still match when neither side resolves.
|
|
94
|
-
*
|
|
95
|
-
* What is NOT claimed: a symlink the filesystem cannot resolve cannot be seen
|
|
96
|
-
* through, and the failing-exit-code floor below sits inside this guard, so an
|
|
97
|
-
* under-match exits 0 in silence — the CLI reporting success having verified
|
|
98
|
-
* nothing. The floor cannot be hoisted above the guard: that would stamp a
|
|
99
|
-
* failing exit code on every process that merely *imports* this module. And a
|
|
100
|
-
* non-`file:` module href (a bundled or single-executable build) never
|
|
101
|
-
* matches — such a build must invoke `main` explicitly.
|
|
102
|
-
*/
|
|
103
|
-
export function invokedDirectly(argvEntry: string | undefined, moduleHref: string): boolean {
|
|
104
|
-
if (argvEntry === undefined) return false;
|
|
105
|
-
let modulePath: string;
|
|
106
|
-
try {
|
|
107
|
-
modulePath = fileURLToPath(moduleHref);
|
|
108
|
-
} catch {
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
111
|
-
return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
87
|
/**
|
|
115
88
|
* Reads the whole of stdin as UTF-8.
|
|
116
89
|
*
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { HARNESS_VERSION, digestCanonical, type Blocker, type RunEvent, type RunEventVersion } from "@agent-delivery-harness/kernel";
|
|
4
|
+
import { commandBlocker, type CommandContext } from "./boundary.ts";
|
|
5
|
+
import { oneLine, resolveRunSurface, type RunSurface } from "./run-surface.ts";
|
|
6
|
+
|
|
7
|
+
export function recoveryBlocker(code: string, summary: string, details?: string): Blocker {
|
|
8
|
+
return commandBlocker({ code, sourceId: "delivery-harness.cli.resume", summary,
|
|
9
|
+
...(details === undefined ? {} : { details }),
|
|
10
|
+
remediations: [{ id: "reconcile-delivery-context", kind: "manual_action", summary: "Inspect the saved context and actual workspace/external state with host tools; rerun preparation and gates where evidence is not current. Never replay an uncertain action automatically." }],
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a store rejection may say to an operator: the store's own code, the
|
|
16
|
+
* JSON pointer at the offending member, and the store's own message. All three
|
|
17
|
+
* are written by the store rather than taken from the refused payload, so the
|
|
18
|
+
* diagnostic names the field without ever echoing its value — a contract a
|
|
19
|
+
* caller must keep, because the payload it refused is the one place a
|
|
20
|
+
* credential could have been.
|
|
21
|
+
*/
|
|
22
|
+
export function rejectionDetails(rejections: readonly { readonly code: string; readonly pointer: string; readonly message: string }[]): string {
|
|
23
|
+
const first = rejections[0];
|
|
24
|
+
if (first === undefined) return "the store refused the append";
|
|
25
|
+
return `${oneLine(first.code, 64)} at ${oneLine(first.pointer, 128) || "/"}: ${oneLine(first.message, 200)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The writer members an ordinary-context event carries, for the version the
|
|
30
|
+
* selected run was started at.
|
|
31
|
+
*
|
|
32
|
+
* WHY THE ID IS DERIVED FROM THE OBSERVATION. A v2 event id is a retry key:
|
|
33
|
+
* the store admits a repeat of an id only when the whole event matches, and
|
|
34
|
+
* refuses the id outright for different content. An id derived from the
|
|
35
|
+
* payload's canonical digest is therefore exactly the key the contract wants —
|
|
36
|
+
* the same save repeated is the same event and appends nothing new, while a
|
|
37
|
+
* changed observation is a different id and a new entry, so neither an
|
|
38
|
+
* interrupted retry nor a genuine second save can produce a refusal an
|
|
39
|
+
* operator has to reason about. It is also stable across processes and hosts,
|
|
40
|
+
* which a random id is not.
|
|
41
|
+
*/
|
|
42
|
+
export function ordinaryEventWriter(version: RunEventVersion, kind: "context.saved", payload: unknown): {
|
|
43
|
+
readonly version: RunEventVersion;
|
|
44
|
+
readonly eventId?: string;
|
|
45
|
+
} {
|
|
46
|
+
if (version !== "run-event/2") return { version };
|
|
47
|
+
// `kind` is narrowed to the one kind this writer serves, so the prefix is a
|
|
48
|
+
// constant that keeps the id readable and inside the store's 128-character
|
|
49
|
+
// run-id charset. It is deliberately not a general scoping claim: dots are
|
|
50
|
+
// folded to hyphens, which is not injective over arbitrary kinds, so a
|
|
51
|
+
// second kind must not be added here without an injective encoding.
|
|
52
|
+
return { version, eventId: `${kind.replaceAll(".", "-")}-${digestCanonical(payload)}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function recoveryRun(rootDir: string, named?: string): Promise<
|
|
56
|
+
{ ok: true; surface: RunSurface; runId: string; events: readonly RunEvent[]; version: RunEventVersion } | { ok: false; blocker: Blocker }
|
|
57
|
+
> {
|
|
58
|
+
const resolved = await resolveRunSurface(rootDir);
|
|
59
|
+
if (!resolved.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The repository run store cannot be read.") };
|
|
60
|
+
const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
|
|
61
|
+
const runId = named ?? (current.ok ? current.runId : undefined);
|
|
62
|
+
if (!runId) return { ok: false, blocker: recoveryBlocker("resume_context_missing", "No current delivery run was found; start a run or name its existing id with --run.") };
|
|
63
|
+
const read = await resolved.surface.store.read(runId);
|
|
64
|
+
if (!read.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The delivery journal is missing, corrupt, or inaccessible.", rejectionDetails(read.rejections)) };
|
|
65
|
+
// The writer version is selected when the run starts and never upgraded, so
|
|
66
|
+
// every writer into this journal reads it from the journal's first event.
|
|
67
|
+
return { ok: true, surface: resolved.surface, runId, events: read.events, version: read.events[0]?.version ?? "run-event/1" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function installedRelease(rootDir: string) {
|
|
71
|
+
const document: unknown = JSON.parse(await readFile(path.join(rootDir, ".agent-skills/active.json"), "utf8"));
|
|
72
|
+
const release = (document as { release?: { releaseId?: unknown; profile?: unknown; archiveSha256?: unknown } } | null)?.release;
|
|
73
|
+
if (!release || typeof release.releaseId !== "string" || typeof release.profile !== "string" ||
|
|
74
|
+
typeof release.archiveSha256 !== "string" || !/^[0-9a-f]{64}$/.test(release.archiveSha256)) {
|
|
75
|
+
throw new Error("Installed workflow release identity is missing or malformed.");
|
|
76
|
+
}
|
|
77
|
+
return { runtimeVersion: HARNESS_VERSION, releaseId: release.releaseId, profile: release.profile, archiveSha256: release.archiveSha256 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const policyDigest = (context: CommandContext) => digestCanonical({ config: context.config, policyBinding: context.policyBinding ?? null });
|
|
81
|
+
|
|
82
|
+
/** Observed action outcomes are a host handoff, never permission to invoke an action. */
|
|
83
|
+
export function reconciliationActions(events: readonly RunEvent[]) {
|
|
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
|
+
}
|