@agent-delivery-harness/cli 0.2.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +5 -5
- package/src/boundary.ts +84 -63
- package/src/check-snapshot.ts +167 -0
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +64 -13
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +105 -34
- package/src/commands/prepare.ts +148 -22
- package/src/commands/record.ts +107 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +461 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +156 -10
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +17 -1
- package/src/main.ts +56 -65
- package/src/ordinary-context.ts +83 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +202 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +327 -12
- package/src/run-server.ts +403 -149
- package/src/run-surface.ts +196 -9
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
package/src/commands/gate.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { ScopedChecks } from "../scoped-checks.ts";
|
|
2
|
+
import { CheckSnapshotError } from "../check-snapshot.ts";
|
|
3
|
+
import { commandBlocker } from "../boundary.ts";
|
|
4
|
+
import { runDeclaredCheck } from "../declared-checks.ts";
|
|
1
5
|
/**
|
|
2
6
|
* `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
|
|
3
7
|
*
|
|
@@ -9,8 +13,9 @@
|
|
|
9
13
|
* only ever offers a waiver to a `human` context, all-or-nothing over waivable
|
|
10
14
|
* findings; the CLI adds no waiver logic of its own.
|
|
11
15
|
*/
|
|
12
|
-
import { runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
16
|
+
import { computeDeliverableIdentity, runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
13
17
|
import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
18
|
+
import { oneLine } from "../run-surface.ts";
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Runs the ordinary admission first, invokes only configured providers that can
|
|
@@ -18,10 +23,15 @@ import { CliInterruption, type CommandContext, type CommandDescriptor, type Comm
|
|
|
18
23
|
* through the same admission adapter. Configs without provider commands take
|
|
19
24
|
* the pre-existing path unchanged.
|
|
20
25
|
*/
|
|
21
|
-
|
|
26
|
+
async function providerAdmission(
|
|
22
27
|
context: CommandContext,
|
|
23
28
|
options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
|
|
24
|
-
|
|
29
|
+
session?: ScopedChecks,
|
|
30
|
+
): Promise<AdmissionResult & { readonly observedLiveResults?: readonly LiveProviderResult[] }> {
|
|
31
|
+
const admit = async (input: Parameters<typeof runAdmission>[0], options: Parameters<typeof runAdmission>[1]) => runAdmission(input, { ...options, ...(session ? { scopedPlan: await session.plan(), readOutput: session.readOutput } : {}) });
|
|
32
|
+
if (session) {
|
|
33
|
+
await session.fenceNonReusable(context.config.providers.filter(p => p.check?.scope).map(p => p.id));
|
|
34
|
+
}
|
|
25
35
|
const wiring = await context.wire();
|
|
26
36
|
const admissionOptions = {
|
|
27
37
|
captureCandidate: wiring.captureCandidate,
|
|
@@ -39,14 +49,15 @@ export async function runProviderBackedAdmission(
|
|
|
39
49
|
...(options.allowPrompt && context.promptForWaiver !== undefined ? { promptForWaiver: context.promptForWaiver } : {}),
|
|
40
50
|
};
|
|
41
51
|
|
|
42
|
-
if (!context.config.providers.some((provider) => provider.command !== undefined)) {
|
|
43
|
-
return
|
|
52
|
+
if (!context.config.providers.some((provider) => provider.command !== undefined || provider.check !== undefined)) {
|
|
53
|
+
return admit(input, finalAdmissionOptions);
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
|
|
47
57
|
const attempted = new Set<string>();
|
|
48
58
|
const attemptBlockers: Blocker[] = [];
|
|
49
|
-
let admission = await
|
|
59
|
+
let admission = await admit(input, admissionOptions);
|
|
60
|
+
await session?.explainReuse((admission.decision?.resolutions ?? []).flatMap(r => r.kind === "satisfied_evidence" ? [r.providerId] : []));
|
|
50
61
|
|
|
51
62
|
while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
|
|
52
63
|
const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
|
|
@@ -58,7 +69,8 @@ export async function runProviderBackedAdmission(
|
|
|
58
69
|
for (const finding of resolution.providerFindings ?? []) {
|
|
59
70
|
if (finding.code !== missingCode || finding.providerId === undefined || attempted.has(finding.providerId)) continue;
|
|
60
71
|
const registration = context.config.providers.find((provider) => provider.id === finding.providerId);
|
|
61
|
-
if (registration
|
|
72
|
+
if (registration === undefined || (registration.command === undefined && registration.check === undefined) || !obligation.providers.includes(registration.id)) continue;
|
|
73
|
+
if (registration.check !== undefined && (obligation.freshness !== "exact_candidate" || !obligation.acceptedPayloadSpecs.includes("checks.passed/1") || admission.decision.resolutions.some(resolution => resolution.kind !== "satisfied_evidence" && resolution.kind !== "not_applicable" && resolution.kind !== "waived" && context.config.obligations.find(entry => entry.id === resolution.obligationId)?.acceptedPayloadSpecs.includes("review.green/1")))) continue;
|
|
62
74
|
const entry = requested.get(registration.id) ?? { obligationIds: [], requiresEvidence: false, needsLiveResult: false };
|
|
63
75
|
entry.obligationIds.push(obligation.id);
|
|
64
76
|
entry.requiresEvidence ||= obligation.freshness === "exact_candidate";
|
|
@@ -73,6 +85,18 @@ export async function runProviderBackedAdmission(
|
|
|
73
85
|
if (next === undefined) break;
|
|
74
86
|
const [providerId, request] = next;
|
|
75
87
|
attempted.add(providerId);
|
|
88
|
+
const registration = context.config.providers.find(provider => provider.id === providerId)!;
|
|
89
|
+
if (registration.check !== undefined) {
|
|
90
|
+
if (registration.check.scope && session) {
|
|
91
|
+
try { await session.execute(registration, request.obligationIds); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (!(error instanceof CheckSnapshotError)) throw error;
|
|
94
|
+
attemptBlockers.push(commandBlocker({ code: error.code, sourceId: "delivery-harness.cli.gate", summary: error.message, remediations: [{ id: "repair-scoped-check", kind: "manual_action", summary: "Repair the declared scoped check or its execution profile and run the gate again." }] }));
|
|
95
|
+
}
|
|
96
|
+
} else attemptBlockers.push(...await runDeclaredCheck(context, registration, request.obligationIds, admission.candidate));
|
|
97
|
+
admission = await admit({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
76
100
|
const result = await context.invokeProvider?.({
|
|
77
101
|
providerId,
|
|
78
102
|
requiresEvidence: request.requiresEvidence,
|
|
@@ -94,27 +118,45 @@ export async function runProviderBackedAdmission(
|
|
|
94
118
|
liveResults.push(result.liveResult);
|
|
95
119
|
}
|
|
96
120
|
|
|
97
|
-
admission = await
|
|
121
|
+
admission = await admit(
|
|
98
122
|
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
99
123
|
admissionOptions,
|
|
100
124
|
);
|
|
101
125
|
}
|
|
102
126
|
|
|
103
|
-
if (admission.admitted) return admission;
|
|
104
|
-
const final = await
|
|
127
|
+
if (admission.admitted) return { ...admission, observedLiveResults: liveResults };
|
|
128
|
+
const final = await admit(
|
|
105
129
|
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
106
130
|
finalAdmissionOptions,
|
|
107
131
|
);
|
|
108
132
|
return attemptBlockers.length === 0 || final.admitted
|
|
109
|
-
? final
|
|
110
|
-
: { ...final, blockers: [...attemptBlockers, ...final.blockers] };
|
|
133
|
+
? { ...final, observedLiveResults: liveResults }
|
|
134
|
+
: { ...final, observedLiveResults: liveResults, blockers: [...attemptBlockers, ...final.blockers] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function runProviderBackedAdmission(context: CommandContext, options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean }): Promise<AdmissionResult & { readonly observedLiveResults?: readonly LiveProviderResult[] }> {
|
|
138
|
+
let session: ScopedChecks | undefined;
|
|
139
|
+
try {
|
|
140
|
+
if (context.config.providers.some(p => p.check?.scope)) {
|
|
141
|
+
const capture = await (await context.wire()).captureCandidate();
|
|
142
|
+
if (capture.ok) session = await ScopedChecks.create(context, capture.candidate);
|
|
143
|
+
}
|
|
144
|
+
return await providerAdmission(context, options, session);
|
|
145
|
+
} finally { await session?.cleanup(); }
|
|
111
146
|
}
|
|
112
147
|
|
|
113
148
|
export const gateCommand: CommandDescriptor = {
|
|
114
149
|
name: "gate",
|
|
115
150
|
sourceId: "delivery-harness.cli.gate",
|
|
116
151
|
summary: "Evaluate the delivery gate for the current candidate.",
|
|
152
|
+
usage: "Usage: delivery-harness gate\nTakes no arguments; a waiver is offered only under a real TTY.",
|
|
117
153
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
154
|
+
// Arguments before admission: `gate` accepts none, and a call it cannot
|
|
155
|
+
// honour is answered about the call rather than performed approximately.
|
|
156
|
+
const unexpected = context.args[0];
|
|
157
|
+
if (unexpected !== undefined) {
|
|
158
|
+
return { kind: "usage", message: `gate takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${gateCommand.usage}` };
|
|
159
|
+
}
|
|
118
160
|
const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
|
|
119
161
|
|
|
120
162
|
if (result.admitted) {
|
|
@@ -123,7 +165,16 @@ export const gateCommand: CommandDescriptor = {
|
|
|
123
165
|
? ` (waived: ${result.waivedObligationIds.join(", ")})`
|
|
124
166
|
: "";
|
|
125
167
|
const kinds = (result.decision?.resolutions ?? []).map((resolution) => `${resolution.obligationId}=${resolution.kind}`);
|
|
126
|
-
|
|
168
|
+
// This observation describes the admitted immutable tree. Failure to
|
|
169
|
+
// observe it cannot change admission; the run journal is not evidence.
|
|
170
|
+
let digest: string | undefined;
|
|
171
|
+
if (result.candidate !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
digest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: result.candidate.treeSha,
|
|
174
|
+
config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
|
|
175
|
+
} catch { /* Best-effort observability, like the completion append. */ }
|
|
176
|
+
}
|
|
177
|
+
return { kind: "ok", summary: `admitted${waiverNote}: ${kinds.join(", ")}`, ...(digest === undefined ? {} : { digest }) };
|
|
127
178
|
}
|
|
128
179
|
return { kind: "blocked", blockers: [...result.blockers] };
|
|
129
180
|
},
|
package/src/commands/maintain.ts
CHANGED
|
@@ -129,6 +129,7 @@ export const maintainCommand: CommandDescriptor = {
|
|
|
129
129
|
name: "maintain",
|
|
130
130
|
sourceId: SOURCE_ID,
|
|
131
131
|
summary: "Maintain the product installation (update, rollback, trust-state pin/revoke/unrevoke/high-water-mark).",
|
|
132
|
+
usage: `Usage: delivery-harness maintain <operation> [options]\nOperations: ${MAINTAIN_OPERATIONS.join(" | ")}`,
|
|
132
133
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
133
134
|
const [operation, ...rest] = context.args;
|
|
134
135
|
if (operation === undefined) {
|
package/src/commands/managed.ts
CHANGED
|
@@ -35,6 +35,7 @@ const SOURCE_ID = "delivery-harness.cli.managed";
|
|
|
35
35
|
/** Every operation this command answers, in the order its usage lists them. */
|
|
36
36
|
const MANAGED_OPERATIONS: readonly string[] = Object.freeze([
|
|
37
37
|
"status",
|
|
38
|
+
"deliveries",
|
|
38
39
|
"next",
|
|
39
40
|
"operations",
|
|
40
41
|
"blockers",
|
|
@@ -98,21 +99,28 @@ interface ResolvedManaged {
|
|
|
98
99
|
readonly fence: number | undefined;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
interface ResolvedInstallation {
|
|
103
|
+
readonly facade: ManagedDeliveryFacade;
|
|
104
|
+
readonly namespace: string;
|
|
105
|
+
/** Every delivery this installation registered, sorted. */
|
|
106
|
+
readonly deliveries: readonly string[];
|
|
107
|
+
/** Those whose journal has not committed a transition into a terminal state. */
|
|
108
|
+
readonly active: readonly string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
/**
|
|
102
|
-
* Resolves the
|
|
112
|
+
* Resolves the INSTALLATION this invocation runs against, without resolving a
|
|
113
|
+
* delivery.
|
|
103
114
|
*
|
|
104
|
-
*
|
|
105
|
-
* one
|
|
106
|
-
* operation,
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* NOT offered to the checkpoint operations: those bind an invocation fence
|
|
112
|
-
* derived from the worktree, and a delivery named by flag would not be the one
|
|
113
|
-
* that worktree is bound to.
|
|
115
|
+
* WHY THE SPLIT EXISTS. Delivery resolution refuses when a repository has zero
|
|
116
|
+
* registered deliveries or more than one in flight — correct for every
|
|
117
|
+
* checkpoint operation, which drives exactly one. But those are precisely the
|
|
118
|
+
* situations the installation-scoped listing exists to report, so a listing
|
|
119
|
+
* built on top of delivery resolution would refuse in the two cases it is for.
|
|
120
|
+
* Everything above delivery selection is shared and lives here; selection and
|
|
121
|
+
* the fence stay below.
|
|
114
122
|
*/
|
|
115
|
-
async function
|
|
123
|
+
async function resolveInstallation(context: CommandContext): Promise<ResolvedInstallation | CommandResult> {
|
|
116
124
|
const common = await gitCommonDir(context.rootDir);
|
|
117
125
|
if (common === undefined) {
|
|
118
126
|
return blocked("not_a_repository", "The working directory is not a git repository.", "Run from the delivery worktree.");
|
|
@@ -130,7 +138,15 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
|
|
|
130
138
|
}
|
|
131
139
|
let deliveries: string[];
|
|
132
140
|
try {
|
|
133
|
-
|
|
141
|
+
// Directories only, the same rule the facade's listing applies. A stray
|
|
142
|
+
// FILE in the namespace is not a delivery, and counting one as in flight is
|
|
143
|
+
// the two-surfaces-contradict state the listing exists to prevent: `status`
|
|
144
|
+
// would refuse with "several deliveries are in flight" while `deliveries`
|
|
145
|
+
// named it in neither list, leaving no id to reconcile the two counts by.
|
|
146
|
+
deliveries = (await readdir(path.join(namespace, "deliveries"), { withFileTypes: true }))
|
|
147
|
+
.filter((candidate) => candidate.isDirectory())
|
|
148
|
+
.map((candidate) => candidate.name)
|
|
149
|
+
.sort();
|
|
134
150
|
} catch {
|
|
135
151
|
deliveries = [];
|
|
136
152
|
}
|
|
@@ -161,26 +177,6 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
|
|
|
161
177
|
active.push(candidate);
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
|
-
let deliveryId: string | undefined;
|
|
165
|
-
if (requested !== undefined) {
|
|
166
|
-
if (!deliveries.includes(requested)) {
|
|
167
|
-
return blocked(
|
|
168
|
-
"delivery_unresolved",
|
|
169
|
-
`No delivery ${requested} is registered for this repository.`,
|
|
170
|
-
"Name a delivery this repository registered; `managed status` reports the current one.",
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
deliveryId = requested;
|
|
174
|
-
} else {
|
|
175
|
-
deliveryId = active[0] ?? deliveries[deliveries.length - 1];
|
|
176
|
-
if (deliveryId === undefined || active.length > 1) {
|
|
177
|
-
return blocked(
|
|
178
|
-
"delivery_unresolved",
|
|
179
|
-
active.length > 1 ? "Several deliveries are in flight; the skeleton drives one." : "No registered delivery exists.",
|
|
180
|
-
"Register exactly one delivery for this repository, or name one with --delivery for export and delete.",
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
180
|
let policyBinding: CompiledAdopterPolicyBinding | undefined = context.policyBinding;
|
|
185
181
|
if (policyBinding === undefined) {
|
|
186
182
|
try {
|
|
@@ -206,6 +202,48 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
|
|
|
206
202
|
installation: { installationPath: pointer.installationPath, receiptDir: pointer.receiptDir },
|
|
207
203
|
hostVersion: pointer.hostVersion,
|
|
208
204
|
});
|
|
205
|
+
return { facade, namespace, deliveries, active };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolves the delivery this invocation addresses.
|
|
210
|
+
*
|
|
211
|
+
* `requested` exists for the retention operations alone. The skeleton's rule —
|
|
212
|
+
* one delivery in flight per repository — is right for every checkpoint
|
|
213
|
+
* operation, but it makes the retention lane unusable the moment a repository
|
|
214
|
+
* has finished more than one delivery: `active[0] ?? deliveries.at(-1)` then
|
|
215
|
+
* addresses only the newest, and every earlier terminal delivery's durable
|
|
216
|
+
* detail becomes unreachable from the CLI even though the facade can export and
|
|
217
|
+
* delete it. Naming one explicitly is the whole remedy, and it is deliberately
|
|
218
|
+
* NOT offered to the checkpoint operations: those bind an invocation fence
|
|
219
|
+
* derived from the worktree, and a delivery named by flag would not be the one
|
|
220
|
+
* that worktree is bound to.
|
|
221
|
+
*/
|
|
222
|
+
async function resolveManaged(context: CommandContext, requested?: string): Promise<ResolvedManaged | CommandResult> {
|
|
223
|
+
const installation = await resolveInstallation(context);
|
|
224
|
+
if (isCommandResult(installation)) return installation;
|
|
225
|
+
const { facade, namespace, deliveries, active } = installation;
|
|
226
|
+
|
|
227
|
+
let deliveryId: string | undefined;
|
|
228
|
+
if (requested !== undefined) {
|
|
229
|
+
if (!deliveries.includes(requested)) {
|
|
230
|
+
return blocked(
|
|
231
|
+
"delivery_unresolved",
|
|
232
|
+
`No delivery ${requested} is registered for this repository.`,
|
|
233
|
+
"Name a delivery this repository registered; `managed status` reports the current one.",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
deliveryId = requested;
|
|
237
|
+
} else {
|
|
238
|
+
deliveryId = active[0] ?? deliveries[deliveries.length - 1];
|
|
239
|
+
if (deliveryId === undefined || active.length > 1) {
|
|
240
|
+
return blocked(
|
|
241
|
+
"delivery_unresolved",
|
|
242
|
+
active.length > 1 ? "Several deliveries are in flight; the skeleton drives one." : "No registered delivery exists.",
|
|
243
|
+
"Register exactly one delivery for this repository, or name one with --delivery for export and delete, or list them all with `managed deliveries`.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
209
247
|
|
|
210
248
|
let fence: number | undefined;
|
|
211
249
|
try {
|
|
@@ -222,12 +260,13 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
|
|
|
222
260
|
return { facade, deliveryId, fence };
|
|
223
261
|
}
|
|
224
262
|
|
|
225
|
-
const isCommandResult = (value: ResolvedManaged | CommandResult): value is CommandResult => "kind" in value;
|
|
263
|
+
const isCommandResult = (value: ResolvedManaged | ResolvedInstallation | CommandResult): value is CommandResult => "kind" in value;
|
|
226
264
|
|
|
227
265
|
export const managedCommand: CommandDescriptor = {
|
|
228
266
|
name: "managed",
|
|
229
267
|
sourceId: SOURCE_ID,
|
|
230
268
|
summary: "Drive the managed delivery's next checkpoint (status, stages, sensor, review, admission, record, finish).",
|
|
269
|
+
usage: `Usage: delivery-harness managed <operation> [options]\nOperations: ${MANAGED_OPERATIONS.join(" | ")}`,
|
|
231
270
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
232
271
|
const [operation, ...rest] = context.args;
|
|
233
272
|
if (operation === undefined) {
|
|
@@ -254,6 +293,38 @@ export const managedCommand: CommandDescriptor = {
|
|
|
254
293
|
return { kind: "ok", summary: `${FACADE_OPERATIONS.length} facade operations` };
|
|
255
294
|
}
|
|
256
295
|
|
|
296
|
+
// The installation-scoped listing mode of the status surface. It answers
|
|
297
|
+
// HERE, above delivery resolution, for the same reason `operations` does:
|
|
298
|
+
// the two situations it exists to report — no delivery registered, and
|
|
299
|
+
// several in flight — are exactly the two that delivery resolution
|
|
300
|
+
// refuses, so a listing resolved through it would be unavailable whenever
|
|
301
|
+
// it had something to say. It names no delivery and binds no fence.
|
|
302
|
+
//
|
|
303
|
+
// It still requires an INSTALLATION. A repository with no product
|
|
304
|
+
// namespace pointer has registered nothing at all, and there is then no
|
|
305
|
+
// installation to scope a listing to — which is a different answer from an
|
|
306
|
+
// installation whose registered deliveries happen to number zero, and the
|
|
307
|
+
// facade reports that one as an empty listing.
|
|
308
|
+
if (operation === "deliveries") {
|
|
309
|
+
const installation = await resolveInstallation(context);
|
|
310
|
+
if (isCommandResult(installation)) return installation;
|
|
311
|
+
const listing = await installation.facade.listDeliveries({ observedAt: nowInstant() });
|
|
312
|
+
if (!listing.ok) return { kind: "blocked", blockers: [...listing.blockers] };
|
|
313
|
+
context.write(`${JSON.stringify(listing.deliveries, null, 2)}\n`);
|
|
314
|
+
// An unreadable delivery is NAMED, not dropped. Delivery resolution below
|
|
315
|
+
// counts the same directory as registered, so a listing that quietly
|
|
316
|
+
// omitted it would leave `managed status` saying several deliveries are in
|
|
317
|
+
// flight while the surface it points at shows fewer — with no id to
|
|
318
|
+
// reconcile the two by. The id here is enough to ask `managed status
|
|
319
|
+
// --delivery <id>` and get the refusal that says what is wrong.
|
|
320
|
+
const unreadable =
|
|
321
|
+
listing.unreadable.length === 0 ? "" : `; ${listing.unreadable.length} unreadable (${listing.unreadable.join(", ")})`;
|
|
322
|
+
return {
|
|
323
|
+
kind: "ok",
|
|
324
|
+
summary: `${listing.deliveries.length} delivery(ies) registered for this installation${unreadable}`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
257
328
|
// Only the retention operations may name a delivery; everything else binds
|
|
258
329
|
// the fence of the worktree it runs in.
|
|
259
330
|
const RETENTION_OPERATIONS = ["export", "delete"];
|
package/src/commands/prepare.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ScopedChecks } from "../scoped-checks.ts";
|
|
2
|
+
import { CheckSnapshotError } from "../check-snapshot.ts";
|
|
1
3
|
/**
|
|
2
4
|
* `prepare` — capture the candidate and publish its preparation receipt.
|
|
3
5
|
*
|
|
@@ -5,34 +7,158 @@
|
|
|
5
7
|
* admission. Capturing an unprepared tree (dirty or with untracked files) is a
|
|
6
8
|
* typed block, not a silent skip.
|
|
7
9
|
*/
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
+
import {
|
|
11
|
+
classifyCandidateDrift,
|
|
12
|
+
computePreparationFingerprint,
|
|
13
|
+
computeDeliverableIdentity,
|
|
14
|
+
evaluatePreparationReceipt,
|
|
15
|
+
createBlocker,
|
|
16
|
+
createExecPort,
|
|
17
|
+
invalidatePreparationReceipt,
|
|
18
|
+
revokePreparationAttempt,
|
|
19
|
+
publishPreparationReceipt,
|
|
20
|
+
type RunPreparationObservation,
|
|
21
|
+
} from "@agent-delivery-harness/kernel";
|
|
22
|
+
import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
23
|
+
|
|
24
|
+
const USAGE = "Usage: delivery-harness prepare [--refresh-record-neutral]";
|
|
10
25
|
|
|
11
26
|
export const prepareCommand: CommandDescriptor = {
|
|
12
27
|
name: "prepare",
|
|
13
28
|
sourceId: "delivery-harness.cli.prepare",
|
|
14
|
-
summary: "
|
|
29
|
+
summary: "Run preparation checks; --refresh-record-neutral permits proven artifact-only receipt refresh.",
|
|
30
|
+
// The text prepare has always answered `--help` with, unchanged; the
|
|
31
|
+
// boundary is what prints it now, for every command rather than this one.
|
|
32
|
+
usage: `${USAGE}\nOrdinary prepare always runs mechanical checks. The refresh flag reuses prior success only when strict validation, policy, wiring and base are unchanged; otherwise it runs the checks.`,
|
|
15
33
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
34
|
+
if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
|
|
35
|
+
return { kind: "usage", message: USAGE };
|
|
36
|
+
}
|
|
37
|
+
if (context.config.providers.some(p => p.check?.scope) && !context.config.scopedExecution) throw new CheckSnapshotError("scoped_executor_required", "Scoped execution profiles are required before preparation commands may run.");
|
|
38
|
+
let scoped: ScopedChecks | undefined;
|
|
39
|
+
const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
|
|
16
40
|
const wiring = await context.wire();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
41
|
+
let retainedAttemptId: string | undefined;
|
|
42
|
+
let ownedAttemptId: string | undefined;
|
|
43
|
+
let refreshed = false;
|
|
44
|
+
try {
|
|
45
|
+
let capture: Awaited<ReturnType<typeof wiring.captureCandidate>>;
|
|
46
|
+
// A refresh continues the successful attempt it evaluated. It cannot
|
|
47
|
+
// transfer that success to new ownership after another attempt revokes it.
|
|
48
|
+
let reusableFingerprint: string | undefined;
|
|
49
|
+
let attemptId: string;
|
|
50
|
+
try {
|
|
51
|
+
if (context.config.scopedExecution?.repairCommands?.length) {
|
|
52
|
+
ownedAttemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
53
|
+
const beforeRepair = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
|
|
54
|
+
for (const repair of context.config.scopedExecution.repairCommands) {
|
|
55
|
+
context.write(`repairing ${repair.id}`);
|
|
56
|
+
const result = await createExecPort().run({ command: repair.command[0], args: repair.command.slice(1), cwd: context.rootDir,
|
|
57
|
+
env: Object.fromEntries(Object.entries(context.env).filter((e): e is [string, string] => e[1] !== undefined)), timeoutMs: repair.timeoutMs, maxBuffer: 1024 * 1024, ...(context.signal ? { signal: context.signal } : {}) });
|
|
58
|
+
if (context.signal?.aborted) throw new CliInterruption();
|
|
59
|
+
if (result.code !== 0) throw new CheckSnapshotError("preparation_repair_failed", `Source repair ${repair.id} failed; fix it and prepare again.`);
|
|
60
|
+
}
|
|
61
|
+
if (beforeRepair !== await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions)) throw new CheckSnapshotError("preparation_candidate_changed", "Repair changed preparation wiring; reload the configuration and prepare again.");
|
|
62
|
+
}
|
|
63
|
+
capture = await wiring.captureCandidate();
|
|
64
|
+
if (capture.ok && refreshRecordNeutral) {
|
|
65
|
+
const previous = await evaluatePreparationReceipt(context.rootDir,
|
|
66
|
+
{ config: context.config, candidate: capture.candidate }, { ...wiring.storageOptions, allowValidationEquivalent: true });
|
|
67
|
+
if (previous.prepared && previous.receipt.attemptId !== undefined) {
|
|
68
|
+
reusableFingerprint = previous.receipt.preparationFingerprint;
|
|
69
|
+
retainedAttemptId = previous.receipt.attemptId;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
attemptId = retainedAttemptId ?? ownedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
74
|
+
ownedAttemptId = attemptId;
|
|
75
|
+
}
|
|
76
|
+
if (!capture.ok) {
|
|
77
|
+
return { kind: "blocked", blockers: [...capture.blockers] };
|
|
78
|
+
}
|
|
79
|
+
const fingerprint = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
|
|
80
|
+
const reusable = reusableFingerprint === fingerprint;
|
|
81
|
+
const preparation: RunPreparationObservation = reusable
|
|
82
|
+
? { checks: "reused", reason: "validation-equivalent" }
|
|
83
|
+
: { checks: "executed", reason: !refreshRecordNeutral ? "ordinary"
|
|
84
|
+
: retainedAttemptId === undefined ? "receipt-not-reusable" : "preparation-fingerprint-changed" };
|
|
85
|
+
if (!reusable && retainedAttemptId !== undefined) {
|
|
86
|
+
attemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
87
|
+
ownedAttemptId = attemptId;
|
|
88
|
+
retainedAttemptId = undefined;
|
|
89
|
+
}
|
|
90
|
+
let validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
|
|
91
|
+
config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
|
|
92
|
+
if (reusable) context.write("reusing preparation checks: strict validation projection, base, policy and wiring unchanged");
|
|
93
|
+
const exec = createExecPort();
|
|
94
|
+
const env = Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined));
|
|
95
|
+
for (const check of reusable ? [] : context.config.preparationCommands ?? []) {
|
|
96
|
+
context.write(`preparing ${check.id}`);
|
|
97
|
+
const result = await exec.run({
|
|
98
|
+
command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
|
|
99
|
+
env, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024,
|
|
100
|
+
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
101
|
+
});
|
|
102
|
+
if (result.code !== 0 || context.signal?.aborted) {
|
|
103
|
+
return {
|
|
104
|
+
kind: "blocked",
|
|
105
|
+
blockers: [createBlocker({
|
|
106
|
+
code: "preparation_command_failed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
|
|
107
|
+
summary: `Preparation check ${check.id} failed; no receipt was published.`,
|
|
108
|
+
details: `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`,
|
|
109
|
+
remediations: [{ id: "repair-preparation-check", kind: "manual_action", summary: `Fix ${check.id}, then run prepare again.` }],
|
|
110
|
+
})],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (context.signal?.aborted) throw new CliInterruption();
|
|
115
|
+
const finalCapture = await wiring.captureCandidate();
|
|
116
|
+
if (!finalCapture.ok) return { kind: "blocked", blockers: [...finalCapture.blockers] };
|
|
117
|
+
const after = finalCapture.candidate;
|
|
118
|
+
if (classifyCandidateDrift(capture.candidate, after).length > 0 ||
|
|
119
|
+
capture.candidate.headSha !== after.headSha || capture.candidate.mode !== after.mode ||
|
|
120
|
+
fingerprint !== await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions)) {
|
|
121
|
+
return {
|
|
122
|
+
kind: "blocked",
|
|
123
|
+
blockers: [createBlocker({
|
|
124
|
+
code: "preparation_candidate_changed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
|
|
125
|
+
summary: "The candidate, base, or preparation wiring changed while checks ran; no receipt was published.",
|
|
126
|
+
remediations: [{ id: "prepare-stable-candidate", kind: "manual_action", summary: "Commit any intended changes and prepare the stable candidate again." }],
|
|
127
|
+
})],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (context.config.scopedExecution) {
|
|
131
|
+
capture = finalCapture;
|
|
132
|
+
validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha, config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
|
|
133
|
+
scoped = await ScopedChecks.create(context, capture.candidate);
|
|
134
|
+
await scoped?.satisfyMechanical();
|
|
135
|
+
}
|
|
136
|
+
const published = await publishPreparationReceipt(
|
|
137
|
+
context.rootDir,
|
|
138
|
+
{ config: context.config, candidate: capture.candidate, attemptId, validationDigest },
|
|
139
|
+
wiring.storageOptions,
|
|
140
|
+
);
|
|
141
|
+
if (context.signal?.aborted) throw new CliInterruption();
|
|
142
|
+
await scoped?.submitMechanical();
|
|
143
|
+
await scoped?.cleanup();
|
|
144
|
+
refreshed = true;
|
|
145
|
+
// The labelled line exists so a reader — an operator or a review round
|
|
146
|
+
// about to bind itself to this candidate — has one unambiguous token to
|
|
147
|
+
// copy. It is the same value the record carries as `treeSha` and the same
|
|
148
|
+
// value `review-context` reports as `candidate tree`.
|
|
149
|
+
return {
|
|
150
|
+
kind: "ok",
|
|
151
|
+
preparation,
|
|
152
|
+
summary: [
|
|
153
|
+
`prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
|
|
154
|
+
` treeSha ${capture.candidate.treeSha}`,
|
|
155
|
+
].join("\n"),
|
|
156
|
+
};
|
|
157
|
+
} finally {
|
|
158
|
+
try { await scoped?.cleanup(); }
|
|
159
|
+
finally {
|
|
160
|
+
if (ownedAttemptId !== undefined && !refreshed) await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
|
|
161
|
+
}
|
|
20
162
|
}
|
|
21
|
-
const published = await publishPreparationReceipt(
|
|
22
|
-
context.rootDir,
|
|
23
|
-
{ config: context.config, candidate: capture.candidate },
|
|
24
|
-
wiring.storageOptions,
|
|
25
|
-
);
|
|
26
|
-
// The labelled line exists so a reader — an operator or a review round
|
|
27
|
-
// about to bind itself to this candidate — has one unambiguous token to
|
|
28
|
-
// copy. It is the same value the record carries as `treeSha` and the same
|
|
29
|
-
// value `review-context` reports as `candidate tree`.
|
|
30
|
-
return {
|
|
31
|
-
kind: "ok",
|
|
32
|
-
summary: [
|
|
33
|
-
`prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
|
|
34
|
-
` treeSha ${capture.candidate.treeSha}`,
|
|
35
|
-
].join("\n"),
|
|
36
|
-
};
|
|
37
163
|
},
|
|
38
164
|
};
|