@agent-delivery-harness/cli 0.1.0 → 0.2.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/package.json +8 -3
- package/src/boundary.ts +282 -5
- package/src/commands/emit.ts +351 -0
- package/src/commands/gate.ts +101 -17
- package/src/commands/maintain.ts +202 -0
- package/src/commands/managed.ts +477 -0
- package/src/commands/prepare.ts +8 -1
- package/src/commands/record.ts +2 -5
- package/src/commands/runs.ts +255 -0
- package/src/commands/verify.ts +150 -3
- package/src/index.ts +27 -4
- package/src/main.ts +23 -0
- package/src/provider-rails.ts +705 -0
- package/src/run-projection.ts +278 -0
- package/src/run-server.ts +660 -0
- package/src/run-surface.ts +261 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `emit` — append one run event to the current delivery run's journal.
|
|
3
|
+
*
|
|
4
|
+
* THE EXECUTOR'S ONE WRITE PATH. Everything an executor knows that the product
|
|
5
|
+
* cannot observe — which lenses it chose and why, what it decided at a fork,
|
|
6
|
+
* what a review round cost — arrives here or is lost to a transcript. The
|
|
7
|
+
* command is deliberately small: a kind, a JSON payload, and the store.
|
|
8
|
+
*
|
|
9
|
+
* WHAT `emit` MAY NOT SET. Not `seq`, which is the store's, taken inside its
|
|
10
|
+
* critical section. Not `at`, which is this process's own instant. Not
|
|
11
|
+
* `actor`, which is always `executor` here — the boundary wrap is the only
|
|
12
|
+
* writer of `cli`, and the store refuses `cli` on any other kind. And not
|
|
13
|
+
* `command.completed` at all: a completion is a claim that the product ran a
|
|
14
|
+
* command, and only the product may make it. That refusal is noted in the
|
|
15
|
+
* run's note exactly like any other refused append, so an executor that tries
|
|
16
|
+
* leaves a trace rather than nothing.
|
|
17
|
+
*
|
|
18
|
+
* ORDER OF OPERATIONS, AND WHY IT IS THIS ORDER.
|
|
19
|
+
* 1. Arguments. A malformed invocation is a usage error and touches nothing.
|
|
20
|
+
* 2. The repository. Outside one there is no store, so there is nowhere to
|
|
21
|
+
* write and nowhere to note.
|
|
22
|
+
* 3. The run. Resolution precedes kind validation, because a refused append
|
|
23
|
+
* is recorded against a run — with no run there is nothing to note
|
|
24
|
+
* against, and inventing one would create a journal for a typo.
|
|
25
|
+
* `run.started` is the single exception: it allocates the run it needs.
|
|
26
|
+
* 4. The store. Kind and payload validation, the secret discipline, and the
|
|
27
|
+
* note on refusal are all the store's, so there is exactly one
|
|
28
|
+
* implementation of each.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
RUN_STORE_ID,
|
|
32
|
+
reduceToProviderId,
|
|
33
|
+
type RunStore,
|
|
34
|
+
} from "@agent-delivery-harness/kernel";
|
|
35
|
+
import {
|
|
36
|
+
buildRunEvent,
|
|
37
|
+
oneLine,
|
|
38
|
+
resolveRunSurface,
|
|
39
|
+
runSurfaceBlocker,
|
|
40
|
+
type RunSurface,
|
|
41
|
+
} from "../run-surface.ts";
|
|
42
|
+
import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
|
|
43
|
+
|
|
44
|
+
const USAGE = "Usage: delivery-harness emit <kind> [--run <id>] [--json <payload>] [--force]";
|
|
45
|
+
|
|
46
|
+
interface ParsedArgs {
|
|
47
|
+
readonly kind: string;
|
|
48
|
+
readonly run?: string;
|
|
49
|
+
readonly json?: string;
|
|
50
|
+
readonly force: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
|
|
54
|
+
|
|
55
|
+
function parseArgs(args: readonly string[]): ArgParse {
|
|
56
|
+
let kind: string | undefined;
|
|
57
|
+
let run: string | undefined;
|
|
58
|
+
let json: string | undefined;
|
|
59
|
+
let force = false;
|
|
60
|
+
|
|
61
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
62
|
+
const token = args[index]!;
|
|
63
|
+
if (token === "--force") {
|
|
64
|
+
force = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (token === "--run" || token === "--json") {
|
|
68
|
+
const value = args[index + 1];
|
|
69
|
+
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
70
|
+
if (token === "--run") run = value;
|
|
71
|
+
else json = value;
|
|
72
|
+
index += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (token.startsWith("--")) return { ok: false, message: `Unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
|
|
76
|
+
if (kind !== undefined) return { ok: false, message: `emit takes one kind.\n${USAGE}` };
|
|
77
|
+
kind = token;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (kind === undefined) return { ok: false, message: `emit needs a kind.\n${USAGE}` };
|
|
81
|
+
return { ok: true, args: { kind, force, ...(run === undefined ? {} : { run }), ...(json === undefined ? {} : { json }) } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The payload as the caller supplied it. Text that is not JSON is passed
|
|
86
|
+
* through as-is rather than rejected here: the store owns payload validation,
|
|
87
|
+
* and routing it there is what puts the refusal in the run's note.
|
|
88
|
+
*/
|
|
89
|
+
function parsePayload(text: string): unknown {
|
|
90
|
+
const trimmed = text.trim();
|
|
91
|
+
if (trimmed.length === 0) return {};
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(trimmed) as unknown;
|
|
94
|
+
} catch {
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const noRun = (details: string) =>
|
|
100
|
+
runSurfaceBlocker({
|
|
101
|
+
code: "run_unresolvable",
|
|
102
|
+
summary: "There is no current run to emit against.",
|
|
103
|
+
details,
|
|
104
|
+
remediation: {
|
|
105
|
+
id: "start-a-run",
|
|
106
|
+
summary: "Run `delivery-harness emit run.started` in this worktree, or name an existing run with --run.",
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolves the run this event belongs to: `--run` when given, else the
|
|
112
|
+
* invoking worktree's pointer. An explicit `--run` resolves only when the id
|
|
113
|
+
* passes the charset check AND its journal exists, so no arbitrary id ever
|
|
114
|
+
* causes a journal or a note to be created.
|
|
115
|
+
*/
|
|
116
|
+
async function resolveRun(store: RunStore, surface: RunSurface, named: string | undefined): Promise<string | undefined> {
|
|
117
|
+
if (named !== undefined) {
|
|
118
|
+
if (named.length > 128 || !RUN_STORE_ID.test(named)) return undefined;
|
|
119
|
+
const read = await store.read(named);
|
|
120
|
+
return read.ok ? named : undefined;
|
|
121
|
+
}
|
|
122
|
+
const current = await store.current(surface.worktreeKey);
|
|
123
|
+
return current.ok ? current.runId : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
127
|
+
name: "emit",
|
|
128
|
+
sourceId: "delivery-harness.cli.emit",
|
|
129
|
+
summary: "Append one run event to the current delivery run's journal.",
|
|
130
|
+
configFree: true,
|
|
131
|
+
async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
132
|
+
const parsed = parseArgs(context.args);
|
|
133
|
+
if (!parsed.ok) return { kind: "usage", message: parsed.message };
|
|
134
|
+
const { kind, run: named, force } = parsed.args;
|
|
135
|
+
|
|
136
|
+
const resolved = await resolveRunSurface(context.rootDir);
|
|
137
|
+
if (!resolved.ok) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "blocked",
|
|
140
|
+
blockers: [
|
|
141
|
+
runSurfaceBlocker({
|
|
142
|
+
code: "run_store_unresolvable",
|
|
143
|
+
summary: "The run store could not be resolved.",
|
|
144
|
+
details: oneLine(resolved.reason, 200),
|
|
145
|
+
remediation: {
|
|
146
|
+
id: "run-inside-a-repository",
|
|
147
|
+
summary: "Run this command inside a git repository; the run store lives under its common directory.",
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const surface = resolved.surface;
|
|
154
|
+
const store = surface.store;
|
|
155
|
+
|
|
156
|
+
if (kind === "run.started") {
|
|
157
|
+
return startRun(surface, force, parsePayload(parsed.args.json ?? (await context.readStdin())));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const runId = await resolveRun(store, surface, named);
|
|
161
|
+
if (runId === undefined) {
|
|
162
|
+
return {
|
|
163
|
+
kind: "blocked",
|
|
164
|
+
blockers: [
|
|
165
|
+
noRun(
|
|
166
|
+
named === undefined
|
|
167
|
+
? "no current run is pointed at from this worktree, and no --run was given"
|
|
168
|
+
: `--run ${oneLine(named, 128)} names no readable journal in this store`,
|
|
169
|
+
),
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const payload = parsePayload(parsed.args.json ?? (await context.readStdin()));
|
|
175
|
+
const event = buildRunEvent({ runId, commonDir: surface.commonDir, kind, role: "executor", payload });
|
|
176
|
+
|
|
177
|
+
// `command.completed` is the CLI's to write. The refusal is recorded in the
|
|
178
|
+
// run's note through the store's one bounded note writer, so an attempt
|
|
179
|
+
// leaves the same trace as any other refused append.
|
|
180
|
+
if (kind === "command.completed") {
|
|
181
|
+
await store.noteRefusal(runId, event, {
|
|
182
|
+
code: "unsupported_combination",
|
|
183
|
+
pointer: "/kind",
|
|
184
|
+
message: "command.completed is written by the CLI; emit may not write it",
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
kind: "blocked",
|
|
188
|
+
blockers: [
|
|
189
|
+
runSurfaceBlocker({
|
|
190
|
+
code: "run_event_refused",
|
|
191
|
+
summary: "command.completed is written by the CLI, never by emit.",
|
|
192
|
+
details: `run ${runId}: the product's own commands append their completions; an executor reports a non-product gate with gate.reported`,
|
|
193
|
+
remediation: {
|
|
194
|
+
id: "use-gate-reported",
|
|
195
|
+
summary: "Emit gate.reported when the repository's gate is not a product command.",
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const appended = await store.append(runId, event);
|
|
203
|
+
if (!appended.ok) {
|
|
204
|
+
const first = appended.rejections[0];
|
|
205
|
+
return {
|
|
206
|
+
kind: "blocked",
|
|
207
|
+
blockers: [
|
|
208
|
+
runSurfaceBlocker({
|
|
209
|
+
code: "run_event_refused",
|
|
210
|
+
// The rejected kind is echoed reduced to the bound the note records
|
|
211
|
+
// it at, so the diagnostic and the durable line say the same thing
|
|
212
|
+
// and neither can carry an escape sequence to the terminal.
|
|
213
|
+
summary: `The run event was refused: ${reduceToProviderId(kind)}`,
|
|
214
|
+
details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
|
|
215
|
+
remediation: {
|
|
216
|
+
id: "correct-the-event",
|
|
217
|
+
summary: "Correct the kind or the payload against the run-event/1 contract and emit again.",
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (kind === "run.ended") await store.clearCurrent(surface.worktreeKey, runId);
|
|
225
|
+
return { kind: "ok", summary: `emitted ${appended.event.kind} seq ${appended.event.seq} to run ${runId}` };
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `run.started` is the one kind that allocates rather than resolves.
|
|
231
|
+
*
|
|
232
|
+
* The pointer is read BEFORE the journal is allocated, so the ordinary refusal
|
|
233
|
+
* — a run is already current and no `--force` was given — leaves no orphan
|
|
234
|
+
* journal behind. `--force` carries the displaced run's id into the new run's
|
|
235
|
+
* own `run.started` payload, which is what makes a restarted delivery legible
|
|
236
|
+
* afterwards.
|
|
237
|
+
*/
|
|
238
|
+
async function startRun(surface: RunSurface, force: boolean, supplied: unknown): Promise<CommandResult> {
|
|
239
|
+
const store = surface.store;
|
|
240
|
+
const existing = await store.current(surface.worktreeKey);
|
|
241
|
+
const displaced = existing.ok ? existing.runId : undefined;
|
|
242
|
+
if (displaced !== undefined && !force) {
|
|
243
|
+
return {
|
|
244
|
+
kind: "blocked",
|
|
245
|
+
blockers: [
|
|
246
|
+
runSurfaceBlocker({
|
|
247
|
+
code: "run_already_current",
|
|
248
|
+
summary: "A run is already current for this worktree.",
|
|
249
|
+
details: `run ${displaced} is current; --force displaces it and records it as displacedRunId`,
|
|
250
|
+
remediation: {
|
|
251
|
+
id: "end-or-force",
|
|
252
|
+
summary: "End the current run with `emit run.ended`, or start this one with --force.",
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const allocated = await store.allocate();
|
|
260
|
+
if (!allocated.ok) {
|
|
261
|
+
return {
|
|
262
|
+
kind: "blocked",
|
|
263
|
+
blockers: [
|
|
264
|
+
runSurfaceBlocker({
|
|
265
|
+
code: "run_not_allocated",
|
|
266
|
+
summary: "A run journal could not be allocated.",
|
|
267
|
+
details: oneLine(allocated.rejections[0]?.message ?? "the store refused to allocate", 200),
|
|
268
|
+
remediation: { id: "check-the-store", summary: "Check that the repository's git common directory is writable." },
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const runId = allocated.runId;
|
|
274
|
+
|
|
275
|
+
const payload =
|
|
276
|
+
typeof supplied === "object" && supplied !== null && displaced !== undefined
|
|
277
|
+
? { ...(supplied as Record<string, unknown>), displacedRunId: displaced }
|
|
278
|
+
: supplied;
|
|
279
|
+
|
|
280
|
+
const appended = await store.append(
|
|
281
|
+
runId,
|
|
282
|
+
buildRunEvent({ runId, commonDir: surface.commonDir, kind: "run.started", role: "executor", payload }),
|
|
283
|
+
);
|
|
284
|
+
if (!appended.ok) {
|
|
285
|
+
const first = appended.rejections[0];
|
|
286
|
+
return {
|
|
287
|
+
kind: "blocked",
|
|
288
|
+
blockers: [
|
|
289
|
+
runSurfaceBlocker({
|
|
290
|
+
code: "run_event_refused",
|
|
291
|
+
summary: "The run event was refused: run.started",
|
|
292
|
+
details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
|
|
293
|
+
remediation: {
|
|
294
|
+
id: "correct-the-event",
|
|
295
|
+
summary: "Correct the payload against the run-event/1 contract and emit again.",
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let pointed = await store.setCurrent(surface.worktreeKey, runId, { force });
|
|
303
|
+
let displacedStale = false;
|
|
304
|
+
// A STALE POINTER IS NOT A CONFLICT. `current` answers `undefined` both when
|
|
305
|
+
// no pointer exists and when one exists naming a journal this store can no
|
|
306
|
+
// longer read — a pruned store, a restored `.git`. In the second case the
|
|
307
|
+
// exclusive create below refuses, and without this the caller's only recourse
|
|
308
|
+
// is to retry, allocating one more orphan journal per attempt. Re-reading the
|
|
309
|
+
// pointer separates the two: a run that is now current is a genuine race and
|
|
310
|
+
// is still refused; a pointer that still names nothing is stale and is
|
|
311
|
+
// displaced, which is what the operator meant.
|
|
312
|
+
if (!pointed.ok && !force) {
|
|
313
|
+
const recheck = await store.current(surface.worktreeKey);
|
|
314
|
+
if (recheck.ok && recheck.runId === undefined) {
|
|
315
|
+
pointed = await store.setCurrent(surface.worktreeKey, runId, { force: true });
|
|
316
|
+
displacedStale = pointed.ok;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!pointed.ok) {
|
|
320
|
+
return {
|
|
321
|
+
kind: "blocked",
|
|
322
|
+
blockers: [
|
|
323
|
+
runSurfaceBlocker({
|
|
324
|
+
code: "run_pointer_refused",
|
|
325
|
+
summary: "The worktree pointer could not be written.",
|
|
326
|
+
details: `run ${runId}: ${oneLine(pointed.rejections[0]?.message ?? "the pointer was refused", 200)}`,
|
|
327
|
+
// `--run` cannot help here: `run.started` allocates rather than
|
|
328
|
+
// resolves, so it never reads that flag. `--force` is the one thing
|
|
329
|
+
// that displaces a pointer this command refused to overwrite.
|
|
330
|
+
remediation: { id: "force-the-start", summary: "Start the run with --force to displace the pointer that is already current." },
|
|
331
|
+
}),
|
|
332
|
+
],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A stale displacement has no id to record — the pointer named nothing this
|
|
337
|
+
// store could read, which is why it was displaceable at all — so the summary
|
|
338
|
+
// is the only place the operator learns it happened. It is also the only
|
|
339
|
+
// signal that the store itself needs attention: a pointer reaches this state
|
|
340
|
+
// through a pruned journal, a truncated write, or a permissions fault.
|
|
341
|
+
return {
|
|
342
|
+
kind: "ok",
|
|
343
|
+
summary: `started run ${runId}${
|
|
344
|
+
displaced === undefined
|
|
345
|
+
? displacedStale
|
|
346
|
+
? " (displaced a stale pointer naming no readable run; the run store may need attention)"
|
|
347
|
+
: ""
|
|
348
|
+
: ` (displaced ${displaced})`
|
|
349
|
+
}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
package/src/commands/gate.ts
CHANGED
|
@@ -9,29 +9,113 @@
|
|
|
9
9
|
* only ever offers a waiver to a `human` context, all-or-nothing over waivable
|
|
10
10
|
* findings; the CLI adds no waiver logic of its own.
|
|
11
11
|
*/
|
|
12
|
-
import { runAdmission } from "@agent-delivery-harness/kernel";
|
|
13
|
-
import type
|
|
12
|
+
import { runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
|
|
13
|
+
import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runs the ordinary admission first, invokes only configured providers that can
|
|
17
|
+
* answer the resulting missing-evidence/live-result blocks, then re-evaluates
|
|
18
|
+
* through the same admission adapter. Configs without provider commands take
|
|
19
|
+
* the pre-existing path unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export async function runProviderBackedAdmission(
|
|
22
|
+
context: CommandContext,
|
|
23
|
+
options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
|
|
24
|
+
): Promise<AdmissionResult> {
|
|
25
|
+
const wiring = await context.wire();
|
|
26
|
+
const admissionOptions = {
|
|
27
|
+
captureCandidate: wiring.captureCandidate,
|
|
28
|
+
projectActivation: wiring.projectActivation,
|
|
29
|
+
...wiring.storageOptions,
|
|
30
|
+
};
|
|
31
|
+
const input = {
|
|
32
|
+
rootDir: context.rootDir,
|
|
33
|
+
config: context.config,
|
|
34
|
+
context: context.classifyContext(),
|
|
35
|
+
...(options.includeInjectedLiveResults && context.liveResults !== undefined ? { liveResults: context.liveResults } : {}),
|
|
36
|
+
};
|
|
37
|
+
const finalAdmissionOptions = {
|
|
38
|
+
...admissionOptions,
|
|
39
|
+
...(options.allowPrompt && context.promptForWaiver !== undefined ? { promptForWaiver: context.promptForWaiver } : {}),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!context.config.providers.some((provider) => provider.command !== undefined)) {
|
|
43
|
+
return runAdmission(input, finalAdmissionOptions);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
|
|
47
|
+
const attempted = new Set<string>();
|
|
48
|
+
const attemptBlockers: Blocker[] = [];
|
|
49
|
+
let admission = await runAdmission(input, admissionOptions);
|
|
50
|
+
|
|
51
|
+
while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
|
|
52
|
+
const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
|
|
53
|
+
for (const resolution of admission.decision.resolutions) {
|
|
54
|
+
if (resolution.kind !== "blocked") continue;
|
|
55
|
+
const obligation = context.config.obligations.find((entry) => entry.id === resolution.obligationId);
|
|
56
|
+
if (obligation === undefined) continue;
|
|
57
|
+
const missingCode = obligation.freshness === "live" ? "live_provider_missing" : "review_evidence_missing";
|
|
58
|
+
for (const finding of resolution.providerFindings ?? []) {
|
|
59
|
+
if (finding.code !== missingCode || finding.providerId === undefined || attempted.has(finding.providerId)) continue;
|
|
60
|
+
const registration = context.config.providers.find((provider) => provider.id === finding.providerId);
|
|
61
|
+
if (registration?.command === undefined || !obligation.providers.includes(registration.id)) continue;
|
|
62
|
+
const entry = requested.get(registration.id) ?? { obligationIds: [], requiresEvidence: false, needsLiveResult: false };
|
|
63
|
+
entry.obligationIds.push(obligation.id);
|
|
64
|
+
entry.requiresEvidence ||= obligation.freshness === "exact_candidate";
|
|
65
|
+
entry.needsLiveResult ||= obligation.freshness === "live";
|
|
66
|
+
requested.set(registration.id, entry);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const next = requested.entries().next().value as
|
|
71
|
+
| [string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }]
|
|
72
|
+
| undefined;
|
|
73
|
+
if (next === undefined) break;
|
|
74
|
+
const [providerId, request] = next;
|
|
75
|
+
attempted.add(providerId);
|
|
76
|
+
const result = await context.invokeProvider?.({
|
|
77
|
+
providerId,
|
|
78
|
+
requiresEvidence: request.requiresEvidence,
|
|
79
|
+
payload: {
|
|
80
|
+
gateId: context.config.gateId,
|
|
81
|
+
providerId,
|
|
82
|
+
obligationIds: [...new Set(request.obligationIds)].sort(),
|
|
83
|
+
candidate: admission.candidate,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (result === undefined) continue;
|
|
87
|
+
if (result.kind === "interrupted") throw new CliInterruption("Provider invocation interrupted before a trustworthy terminal outcome.");
|
|
88
|
+
if (result.kind === "blocked") {
|
|
89
|
+
attemptBlockers.push(...result.blockers);
|
|
90
|
+
if (request.needsLiveResult) {
|
|
91
|
+
liveResults.push({ providerId, runId: result.runId, status: "failed", findings: [] });
|
|
92
|
+
}
|
|
93
|
+
} else if (request.needsLiveResult) {
|
|
94
|
+
liveResults.push(result.liveResult);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
admission = await runAdmission(
|
|
98
|
+
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
99
|
+
admissionOptions,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (admission.admitted) return admission;
|
|
104
|
+
const final = await runAdmission(
|
|
105
|
+
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
106
|
+
finalAdmissionOptions,
|
|
107
|
+
);
|
|
108
|
+
return attemptBlockers.length === 0 || final.admitted
|
|
109
|
+
? final
|
|
110
|
+
: { ...final, blockers: [...attemptBlockers, ...final.blockers] };
|
|
111
|
+
}
|
|
14
112
|
|
|
15
113
|
export const gateCommand: CommandDescriptor = {
|
|
16
114
|
name: "gate",
|
|
17
115
|
sourceId: "delivery-harness.cli.gate",
|
|
18
116
|
summary: "Evaluate the delivery gate for the current candidate.",
|
|
19
117
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
20
|
-
const
|
|
21
|
-
const result = await runAdmission(
|
|
22
|
-
{
|
|
23
|
-
rootDir: context.rootDir,
|
|
24
|
-
config: context.config,
|
|
25
|
-
context: context.classifyContext(),
|
|
26
|
-
...(context.liveResults === undefined ? {} : { liveResults: context.liveResults }),
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
captureCandidate: wiring.captureCandidate,
|
|
30
|
-
projectActivation: wiring.projectActivation,
|
|
31
|
-
...(context.promptForWaiver === undefined ? {} : { promptForWaiver: context.promptForWaiver }),
|
|
32
|
-
...wiring.storageOptions,
|
|
33
|
-
},
|
|
34
|
-
);
|
|
118
|
+
const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
|
|
35
119
|
|
|
36
120
|
if (result.admitted) {
|
|
37
121
|
const waiverNote =
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `maintain` — the installation-scoped maintenance lane: update, rollback, and
|
|
3
|
+
* trust-state maintenance.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS A SEPARATE COMMAND. `managed` addresses one delivery and its
|
|
6
|
+
* checkpoints; these operations address the installation itself and touch no
|
|
7
|
+
* delivery journal at all. Folding them into `managed` would have made a
|
|
8
|
+
* delivery resolve before an operator could repair the installation that
|
|
9
|
+
* delivery is blocked on — the wrong order for exactly the case the lane
|
|
10
|
+
* exists to serve.
|
|
11
|
+
*
|
|
12
|
+
* WHAT DEFENDS THIS SURFACE, PRECISELY. Every operation here consumes a
|
|
13
|
+
* maintenance-lane sensitive assertion bound to the target installation and
|
|
14
|
+
* generation identities, evaluated by the installation's configured assertion
|
|
15
|
+
* source in the model-external lane, and consumed BEFORE any byte moves. The
|
|
16
|
+
* caller cannot mint that assertion and cannot answer the evaluation: the
|
|
17
|
+
* operating system evaluates it and the product never sees a credential. So a
|
|
18
|
+
* model that invokes one of these cannot complete it.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT DOES NOT DEFEND, STATED PLAINLY BECAUSE IT IS EASY TO ASSUME
|
|
21
|
+
* OTHERWISE. It does not stop the prompt from being raised. Probing an
|
|
22
|
+
* OS-native source checks that the platform's authentication surfaces exist,
|
|
23
|
+
* and existence is independent of whether the caller holds a terminal — so on a
|
|
24
|
+
* desktop platform a non-interactive session can still reach `evaluate`, which
|
|
25
|
+
* raises an interactive dialog in the operator's own session. Authorization
|
|
26
|
+
* holds, because nobody but the operator can answer it; what is not defended is
|
|
27
|
+
* the operator's attention. Prompt fatigue is the residual risk on this surface,
|
|
28
|
+
* and it is the reason to keep the lane narrow rather than to believe it closed.
|
|
29
|
+
*
|
|
30
|
+
* Operator confirmations are the class that must never be nameable on a shell
|
|
31
|
+
* surface, and none of them appear here.
|
|
32
|
+
*
|
|
33
|
+
* The installation is located the same way `managed` locates it: from the
|
|
34
|
+
* product namespace's pointer under the common git directory, never a
|
|
35
|
+
* candidate-writable path.
|
|
36
|
+
*/
|
|
37
|
+
import { execFile } from "node:child_process";
|
|
38
|
+
import { readFile } from "node:fs/promises";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { compiledAdopterPolicyBindingDigest, createManagedDeliveryFacade, type CompiledAdopterPolicyBinding, type ManagedDeliveryFacade } from "@agent-delivery-harness/kernel";
|
|
41
|
+
import { commandBlocker } from "../boundary.ts";
|
|
42
|
+
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
43
|
+
|
|
44
|
+
const SOURCE_ID = "delivery-harness.cli.maintain";
|
|
45
|
+
|
|
46
|
+
/** Every operation this command answers, in the order its usage lists them. */
|
|
47
|
+
const MAINTAIN_OPERATIONS: readonly string[] = Object.freeze([
|
|
48
|
+
"update",
|
|
49
|
+
"rollback",
|
|
50
|
+
"pin",
|
|
51
|
+
"revoke",
|
|
52
|
+
"unrevoke",
|
|
53
|
+
"advance-high-water-mark",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const blocked = (code: string, summary: string, remediation: string): CommandResult => ({
|
|
57
|
+
kind: "blocked",
|
|
58
|
+
blockers: [
|
|
59
|
+
commandBlocker({
|
|
60
|
+
code,
|
|
61
|
+
sourceId: SOURCE_ID,
|
|
62
|
+
summary,
|
|
63
|
+
remediations: [{ id: `${code.replaceAll("_", "-")}-remediation`, kind: "manual_action", summary: remediation }],
|
|
64
|
+
}),
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const gitCommonDir = (cwd: string): Promise<string | undefined> =>
|
|
69
|
+
new Promise((resolve) => {
|
|
70
|
+
execFile("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd, encoding: "utf8" }, (error, stdout) => {
|
|
71
|
+
resolve(error === null ? stdout.trim() : undefined);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const flag = (args: readonly string[], name: string): string | undefined => {
|
|
76
|
+
const index = args.indexOf(name);
|
|
77
|
+
return index === -1 ? undefined : args[index + 1];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function nowInstant(): string {
|
|
81
|
+
return `${new Date().toISOString().slice(0, 19)}Z`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveFacade(context: CommandContext): Promise<ManagedDeliveryFacade | CommandResult> {
|
|
85
|
+
const common = await gitCommonDir(context.rootDir);
|
|
86
|
+
if (common === undefined) {
|
|
87
|
+
return blocked("not_a_repository", "The working directory is not a git repository.", "Run from the repository the product is installed for.");
|
|
88
|
+
}
|
|
89
|
+
let pointer: { installationPath: string; receiptDir: string; hostVersion: string; policyBindingDigest: string };
|
|
90
|
+
try {
|
|
91
|
+
pointer = JSON.parse(await readFile(path.join(common, "managed-delivery", "facade.json"), "utf8")) as typeof pointer;
|
|
92
|
+
} catch {
|
|
93
|
+
return blocked(
|
|
94
|
+
"no_managed_installation",
|
|
95
|
+
"No managed installation is registered for this repository.",
|
|
96
|
+
"Install the composition and register a delivery first.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
let policyBinding: CompiledAdopterPolicyBinding | undefined = context.policyBinding;
|
|
100
|
+
if (policyBinding === undefined) {
|
|
101
|
+
try {
|
|
102
|
+
policyBinding = JSON.parse(await readFile(path.join(common, "managed-delivery", "policy-binding.json"), "utf8")) as CompiledAdopterPolicyBinding;
|
|
103
|
+
} catch {
|
|
104
|
+
return blocked(
|
|
105
|
+
"policy_binding_missing",
|
|
106
|
+
"No compiled adopter policy binding is retained for this installation.",
|
|
107
|
+
"Register a delivery through an adopter binding, or pass one through the embedding runtime.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (pointer.policyBindingDigest !== compiledAdopterPolicyBindingDigest(policyBinding)) {
|
|
112
|
+
return blocked(
|
|
113
|
+
"policy_binding_mismatch",
|
|
114
|
+
"The retained compiled adopter policy binding does not match the product namespace pointer.",
|
|
115
|
+
"Restore the exact binding captured at registration; drift requires a new owner-approved delivery.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return createManagedDeliveryFacade({
|
|
119
|
+
repoDir: context.rootDir,
|
|
120
|
+
policyBinding,
|
|
121
|
+
installation: { installationPath: pointer.installationPath, receiptDir: pointer.receiptDir },
|
|
122
|
+
hostVersion: pointer.hostVersion,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isCommandResult = (value: ManagedDeliveryFacade | CommandResult): value is CommandResult => "kind" in value;
|
|
127
|
+
|
|
128
|
+
export const maintainCommand: CommandDescriptor = {
|
|
129
|
+
name: "maintain",
|
|
130
|
+
sourceId: SOURCE_ID,
|
|
131
|
+
summary: "Maintain the product installation (update, rollback, trust-state pin/revoke/unrevoke/high-water-mark).",
|
|
132
|
+
async run(context: CommandContext): Promise<CommandResult> {
|
|
133
|
+
const [operation, ...rest] = context.args;
|
|
134
|
+
if (operation === undefined) {
|
|
135
|
+
return { kind: "usage", message: `maintain requires an operation: ${MAINTAIN_OPERATIONS.join(" | ")}` };
|
|
136
|
+
}
|
|
137
|
+
// A typo is a usage error about the call, answered before the repository
|
|
138
|
+
// or the installation is consulted.
|
|
139
|
+
if (!MAINTAIN_OPERATIONS.includes(operation)) {
|
|
140
|
+
return { kind: "usage", message: `Unknown maintain operation: ${operation}.` };
|
|
141
|
+
}
|
|
142
|
+
const resolved = await resolveFacade(context);
|
|
143
|
+
if (isCommandResult(resolved)) return resolved;
|
|
144
|
+
const facade = resolved;
|
|
145
|
+
const now = nowInstant();
|
|
146
|
+
const emit = (value: unknown): void => context.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
147
|
+
|
|
148
|
+
switch (operation) {
|
|
149
|
+
case "update": {
|
|
150
|
+
const packedDir = flag(rest, "--packed");
|
|
151
|
+
if (packedDir === undefined) {
|
|
152
|
+
return { kind: "usage", message: "update requires --packed <dir> naming the verified packed generation to install." };
|
|
153
|
+
}
|
|
154
|
+
const outcome = await facade.updateComposition({ packedDir: path.resolve(context.rootDir, packedDir), now });
|
|
155
|
+
if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
|
|
156
|
+
emit(outcome);
|
|
157
|
+
return {
|
|
158
|
+
kind: "ok",
|
|
159
|
+
summary: outcome.noOp
|
|
160
|
+
? `already at ${outcome.generationDigest}`
|
|
161
|
+
: `updated ${outcome.priorGenerationDigest} -> ${outcome.generationDigest}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
case "rollback": {
|
|
165
|
+
const target = flag(rest, "--generation");
|
|
166
|
+
if (target === undefined) {
|
|
167
|
+
return { kind: "usage", message: "rollback requires --generation <digest> naming a previously accepted generation." };
|
|
168
|
+
}
|
|
169
|
+
const outcome = await facade.rollbackComposition({ targetGenerationDigest: target, now });
|
|
170
|
+
if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
|
|
171
|
+
return { kind: "ok", summary: `rolled back to ${outcome.generationDigest}` };
|
|
172
|
+
}
|
|
173
|
+
case "pin":
|
|
174
|
+
case "revoke":
|
|
175
|
+
case "unrevoke": {
|
|
176
|
+
const generationDigest = flag(rest, "--generation");
|
|
177
|
+
if (generationDigest === undefined) {
|
|
178
|
+
return { kind: "usage", message: `${operation} requires --generation <digest>.` };
|
|
179
|
+
}
|
|
180
|
+
const outcome = await facade.maintainTrustState({ operation, generationDigest, now });
|
|
181
|
+
if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
|
|
182
|
+
emit(outcome.state);
|
|
183
|
+
return { kind: "ok", summary: `${operation} recorded at revocation epoch ${outcome.state.revocationEpoch}` };
|
|
184
|
+
}
|
|
185
|
+
case "advance-high-water-mark": {
|
|
186
|
+
const raw = flag(rest, "--to");
|
|
187
|
+
const highWaterMark = raw === undefined ? Number.NaN : Number.parseInt(raw, 10);
|
|
188
|
+
if (!Number.isInteger(highWaterMark)) {
|
|
189
|
+
return { kind: "usage", message: "advance-high-water-mark requires --to <integer>." };
|
|
190
|
+
}
|
|
191
|
+
const outcome = await facade.maintainTrustState({ operation: "advance-high-water-mark", highWaterMark, now });
|
|
192
|
+
if (!outcome.ok) return { kind: "blocked", blockers: [...outcome.blockers] };
|
|
193
|
+
emit(outcome.state);
|
|
194
|
+
return { kind: "ok", summary: `high-water mark is ${outcome.state.highWaterMark}` };
|
|
195
|
+
}
|
|
196
|
+
default:
|
|
197
|
+
// Unreachable: checked against MAINTAIN_OPERATIONS above. Kept so a
|
|
198
|
+
// name added to that list without a case here fails loudly.
|
|
199
|
+
return { kind: "usage", message: `Unknown maintain operation: ${operation}.` };
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|