@agent-delivery-harness/cli 0.5.0 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/boundary.ts +7 -2
- package/src/check-snapshot.ts +167 -0
- package/src/commands/gate.ts +33 -7
- package/src/commands/managed.ts +104 -34
- package/src/commands/prepare.ts +29 -4
- package/src/commands/record.ts +10 -1
- package/src/commands/runs.ts +53 -7
- package/src/commands/verify.ts +127 -2
- package/src/index.ts +3 -0
- package/src/main.ts +36 -18
- package/src/ordinary-context.ts +2 -18
- package/src/run-export.ts +40 -2
- package/src/run-projection.ts +198 -1
- package/src/run-server.ts +9 -0
- package/src/run-surface.ts +115 -1
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-delivery-harness/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Delivery harness CLI: admit, prepare, review-context, submit-evidence, gate, record, verify, check",
|
|
6
6
|
"license": "FSL-1.1-ALv2",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/v26-technologies/agent-delivery-harness.git",
|
|
10
10
|
"directory": "packages/cli"
|
|
11
11
|
},
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=22.6.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@agent-delivery-harness/kernel": "0.
|
|
16
|
+
"@agent-delivery-harness/kernel": "0.7.4"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
package/src/boundary.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CheckSnapshotError } from "./check-snapshot.ts";
|
|
2
|
+
import { captureScopedCandidate } from "./scoped-candidate.ts";
|
|
1
3
|
/**
|
|
2
4
|
* The one boundary every CLI command runs behind.
|
|
3
5
|
*
|
|
@@ -307,12 +309,13 @@ export async function importHarnessConfig(rootDir: string): Promise<HarnessConfi
|
|
|
307
309
|
export async function wireRepo(rootDir: string, config: HarnessConfig): Promise<RepoWiring> {
|
|
308
310
|
const storageOptions = { storageNamespace: config.storageNamespace };
|
|
309
311
|
const storage = await resolveRecordStorage(rootDir, storageOptions);
|
|
310
|
-
const
|
|
312
|
+
const captureOptions = {
|
|
311
313
|
rootDir,
|
|
312
314
|
config,
|
|
313
315
|
workspaceId: storage.workspaceId,
|
|
314
316
|
computeIdentity: withDeliverableIdentity(),
|
|
315
|
-
}
|
|
317
|
+
};
|
|
318
|
+
const captureCandidate = config.scopedExecution ? () => captureScopedCandidate(captureOptions) : createCandidateCapture(captureOptions);
|
|
316
319
|
const projectActivation = (candidate: CapturedCandidate): Promise<ReviewActivationProjection> =>
|
|
317
320
|
evaluateCandidateActivation({ rootDir, candidate, config });
|
|
318
321
|
return { rootDir, workspaceId: storage.workspaceId, captureCandidate, projectActivation, storageOptions };
|
|
@@ -458,6 +461,7 @@ async function runConfigFreeCommand(
|
|
|
458
461
|
runtime.stderr(`${error.message}\n`);
|
|
459
462
|
return EXIT_INTERRUPTED;
|
|
460
463
|
}
|
|
464
|
+
if (error instanceof CheckSnapshotError) error = new BlockedError([commandBlocker({ code: error.code, sourceId: "delivery-harness.cli.scoped-check", summary: error.message, remediations: [{ id: "repair-scoped-execution", kind: "manual_action", summary: "Repair the scoped execution inputs or profile, prepare and retry." }] })]);
|
|
461
465
|
if (error instanceof BlockedError) {
|
|
462
466
|
runtime.stderr(`${renderBlockers(error.blockers)}\n`);
|
|
463
467
|
return EXIT_POLICY;
|
|
@@ -602,6 +606,7 @@ async function runConfiguredCommand(
|
|
|
602
606
|
runtime.stderr(`${error.message}\n`);
|
|
603
607
|
return EXIT_INTERRUPTED;
|
|
604
608
|
}
|
|
609
|
+
if (error instanceof CheckSnapshotError) error = new BlockedError([commandBlocker({ code: error.code, sourceId: "delivery-harness.cli.scoped-check", summary: error.message, remediations: [{ id: "repair-scoped-execution", kind: "manual_action", summary: "Repair the scoped execution inputs or profile, prepare and retry." }] })]);
|
|
605
610
|
if (error instanceof BlockedError) {
|
|
606
611
|
runtime.stderr(`${renderBlockers(error.blockers)}\n`);
|
|
607
612
|
return EXIT_POLICY;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Private execution tree. Never shares writable source, objects, index or dependencies. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createExecPort, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
7
|
+
|
|
8
|
+
import { snapshotInventoryWorker } from "./snapshot-inventory-worker.ts";
|
|
9
|
+
|
|
10
|
+
const SNAPSHOT_TIMEOUT_MS = 5 * 60_000;
|
|
11
|
+
export class CheckSnapshotError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
constructor(code: string, message: string) { super(message); this.code = code; }
|
|
14
|
+
}
|
|
15
|
+
export interface SnapshotRequest {
|
|
16
|
+
readonly rootDir: string;
|
|
17
|
+
readonly candidate: { readonly treeSha: string; readonly headSha: string; readonly base: { readonly ref: string; readonly tipSha: string; readonly mergeBaseSha: string } };
|
|
18
|
+
readonly outputs: readonly string[];
|
|
19
|
+
readonly gitContext?: "full" | "none";
|
|
20
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
21
|
+
readonly dependencies?: { readonly command: readonly [string, ...string[]]; readonly timeoutMs: number };
|
|
22
|
+
readonly onDependencyResult?: (result: ExecOutcome, durationMs: number) => void;
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
export interface CheckSnapshot {
|
|
26
|
+
readonly commandRoot: string;
|
|
27
|
+
readonly rootDir: string;
|
|
28
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
29
|
+
readonly dependencyDigest: string;
|
|
30
|
+
verify(options?: { readonly timeoutMs: number }): Promise<void>;
|
|
31
|
+
cleanup(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
function inside(root: string, target: string): boolean {
|
|
34
|
+
const relative = path.relative(root, target);
|
|
35
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
36
|
+
}
|
|
37
|
+
/** Kill the owned read-only process and await close before callers may clean up.
|
|
38
|
+
* AbortSignal is handled here: child_process's early AbortError event is not
|
|
39
|
+
* proof that the process has stopped touching the snapshot. */
|
|
40
|
+
async function snapshotProcess(command: string, args: string[], cwd: string, env: Readonly<Record<string, string>>, deadline: number, signal?: AbortSignal): Promise<{ code: number | null; stdout: string }> {
|
|
41
|
+
if (signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Snapshot verification was interrupted.");
|
|
42
|
+
const remaining = deadline - performance.now();
|
|
43
|
+
if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
46
|
+
let failure: CheckSnapshotError | undefined, stdout = "", size = 0;
|
|
47
|
+
const stop = (code: string, message: string) => {
|
|
48
|
+
failure ??= new CheckSnapshotError(code, message);
|
|
49
|
+
child.kill("SIGKILL");
|
|
50
|
+
};
|
|
51
|
+
const abort = () => stop("check_snapshot_interrupted", "Snapshot verification was interrupted.");
|
|
52
|
+
const timer = setTimeout(() => stop("check_snapshot_timeout", "Snapshot verification exceeded its deadline."), remaining);
|
|
53
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
54
|
+
if (signal?.aborted) abort();
|
|
55
|
+
child.stdout.on("data", (bytes: Buffer) => {
|
|
56
|
+
size += bytes.length;
|
|
57
|
+
if (size > 64 * 1024) stop("check_snapshot_unavailable", "Snapshot verifier output exceeded its limit.");
|
|
58
|
+
else stdout += bytes.toString();
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on("data", () => {}); // Drain; never expose private paths or source bytes.
|
|
61
|
+
child.on("error", () => { failure ??= new CheckSnapshotError("check_snapshot_unavailable", "Snapshot verifier could not execute."); });
|
|
62
|
+
child.once("close", code => {
|
|
63
|
+
clearTimeout(timer); signal?.removeEventListener("abort", abort);
|
|
64
|
+
if (!failure && performance.now() >= deadline) failure = new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
|
|
65
|
+
if (failure) reject(failure); else resolve({ code, stdout });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** The inventory's blocking filesystem operations live only in a killable process. */
|
|
70
|
+
export async function snapshotInventory(root: string, outputs: readonly string[], excludeDependencies = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS, signal?: AbortSignal, forbidGit = false): Promise<string> {
|
|
71
|
+
const result = await snapshotProcess(process.execPath, ["--input-type=module", "--eval", snapshotInventoryWorker], root,
|
|
72
|
+
{ PATH: process.env["PATH"] ?? "/usr/bin:/bin", DELIVERY_SNAPSHOT_REQUEST: JSON.stringify({ root, outputs, excludeDependencies, forbidGit }) }, deadline, signal);
|
|
73
|
+
let parsed: { code?: string; digest?: string };
|
|
74
|
+
try { parsed = JSON.parse(result.stdout); } catch { throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot inventory returned no valid result."); }
|
|
75
|
+
if (result.code !== 0 || !/^[a-f0-9]{64}$/.test(parsed.digest ?? "")) throw new CheckSnapshotError(["check_snapshot_escape", "check_snapshot_drift"].includes(parsed.code ?? "") ? parsed.code! : "check_snapshot_unavailable", "Snapshot inventory could not verify private bytes and links.");
|
|
76
|
+
return parsed.digest!;
|
|
77
|
+
}
|
|
78
|
+
export function executionPath(root: string, value: string): string {
|
|
79
|
+
return value.split(path.delimiter).filter(p => p && path.isAbsolute(p) && !inside(root, p) && !p.split(path.sep).includes("node_modules")).join(path.delimiter) || "/usr/bin:/bin";
|
|
80
|
+
}
|
|
81
|
+
export async function createCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
82
|
+
const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
|
|
83
|
+
const cleanEnv = { ...input.environment, PATH: executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z" };
|
|
84
|
+
let privateControl: string | undefined;
|
|
85
|
+
let gitDirectory: string | undefined;
|
|
86
|
+
const git = async (...args: string[]) => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
|
|
90
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, SNAPSHOT_TIMEOUT_MS);
|
|
91
|
+
const abort = () => controller.abort();
|
|
92
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
93
|
+
if (input.signal?.aborted) abort();
|
|
94
|
+
try {
|
|
95
|
+
const result = await createExecPort().run({ command: "git", args: gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args,
|
|
96
|
+
cwd: rootDir, env: cleanEnv, maxBuffer: 128 * 1024 * 1024, signal: controller.signal });
|
|
97
|
+
timedOut ||= performance.now() >= deadline;
|
|
98
|
+
if (timedOut || result.code !== 0) throw new CheckSnapshotError(timedOut ? "check_snapshot_timeout" : input.signal?.aborted ? "check_snapshot_interrupted" : "check_snapshot_unavailable", "Private Git materialization did not complete successfully.");
|
|
99
|
+
return result.stdout.trim();
|
|
100
|
+
} finally { clearTimeout(timer); input.signal?.removeEventListener("abort", abort); }
|
|
101
|
+
};
|
|
102
|
+
const cleanup = async () => {
|
|
103
|
+
const removed = await Promise.allSettled([rootDir, ...(privateControl ? [privateControl] : [])].map(dir => rm(dir, { recursive: true, force: true })));
|
|
104
|
+
if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
await git("init", "-q");
|
|
108
|
+
// Fetch transfers object bytes; unlike worktrees/alternates/local clones it
|
|
109
|
+
// cannot create writable links back to the author's Git metadata.
|
|
110
|
+
// Accept the source's shallow boundary in this private repository. Without
|
|
111
|
+
// it, fetch can reject objects or succeed while dropping required grafts.
|
|
112
|
+
await git("fetch", "--update-shallow", "--no-tags", "--no-write-fetch-head", input.rootDir, input.candidate.headSha, input.candidate.base.tipSha, input.candidate.treeSha);
|
|
113
|
+
await git("update-ref", "HEAD", input.candidate.headSha);
|
|
114
|
+
await git("read-tree", input.candidate.treeSha);
|
|
115
|
+
await git("checkout-index", "--all", "--force");
|
|
116
|
+
if ((await git("ls-tree", "-r", "--name-only", input.candidate.treeSha)).split("\n").some(p => p.split("/").includes("node_modules"))) throw new CheckSnapshotError("check_dependency_source_overlap", "Tracked node_modules cannot be replaced by private dependency setup.");
|
|
117
|
+
const candidateRef = "refs/delivery/candidate", baseRef = "refs/delivery/base";
|
|
118
|
+
const candidateCommit = await git("-c", "user.name=Delivery", "-c", "user.email=delivery@example.invalid", "-c", "commit.gpgsign=false", "commit-tree", input.candidate.treeSha, "-p", input.candidate.headSha, "-m", "Prepared execution snapshot");
|
|
119
|
+
await git("update-ref", candidateRef, candidateCommit);
|
|
120
|
+
await git("update-ref", "refs/delivery/origin-head", input.candidate.headSha);
|
|
121
|
+
await git("update-ref", "HEAD", candidateCommit);
|
|
122
|
+
await git("update-ref", baseRef, input.candidate.base.tipSha);
|
|
123
|
+
await git("update-ref", "refs/delivery/merge-base", input.candidate.base.mergeBaseSha);
|
|
124
|
+
// Conventional branch refs also resolve to the pinned base for existing checks.
|
|
125
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(input.candidate.base.ref)) await git("update-ref", input.candidate.base.ref.startsWith("refs/") ? input.candidate.base.ref : `refs/remotes/${input.candidate.base.ref}`, input.candidate.base.tipSha);
|
|
126
|
+
if (input.gitContext === "none") {
|
|
127
|
+
privateControl = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-control-")));
|
|
128
|
+
gitDirectory = path.join(privateControl, "repository");
|
|
129
|
+
await rename(path.join(rootDir, ".git"), gitDirectory);
|
|
130
|
+
}
|
|
131
|
+
const controlRoot = privateControl ?? path.join(rootDir, ".git");
|
|
132
|
+
const inventory = (outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(rootDir, outputs, sourceOnly, deadline, input.signal, input.gitContext === "none");
|
|
133
|
+
const sourceDigest = await inventory(input.outputs, true);
|
|
134
|
+
const environment: Record<string, string> = { ...input.environment, PATH: `${path.join(rootDir, "node_modules/.bin")}${path.delimiter}${cleanEnv["PATH"]}`, HOME: path.join(controlRoot, "home"), TMPDIR: path.join(controlRoot, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(rootDir),
|
|
135
|
+
...(input.gitContext === "none" ? {} : {
|
|
136
|
+
DELIVERY_CHECK_BASE_REF: baseRef, DELIVERY_CHECK_CANDIDATE_REF: candidateRef, DELIVERY_CHECK_ORIGIN_HEAD: input.candidate.headSha, DELIVERY_CHECK_ORIGIN_TREE: input.candidate.treeSha, DELIVERY_CHECK_MERGE_BASE: input.candidate.base.mergeBaseSha }) };
|
|
137
|
+
await mkdir(environment["HOME"]!, { recursive: true }); await mkdir(environment["TMPDIR"]!, { recursive: true });
|
|
138
|
+
if (input.dependencies) {
|
|
139
|
+
const started = performance.now();
|
|
140
|
+
const result = await createExecPort().run({ command: input.dependencies.command[0], args: input.dependencies.command.slice(1), cwd: rootDir, env: environment, timeoutMs: input.dependencies.timeoutMs, maxBuffer: 1024 * 1024, ...(input.signal ? { signal: input.signal } : {}) });
|
|
141
|
+
input.onDependencyResult?.(result, Math.round(performance.now() - started));
|
|
142
|
+
if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private dependency installation did not complete successfully.");
|
|
143
|
+
}
|
|
144
|
+
if (sourceDigest !== await inventory(input.outputs, true)) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed prepared source bytes.");
|
|
145
|
+
const dependencyDigest = await inventory(input.outputs);
|
|
146
|
+
// The second digest covers both source and installed dependency bytes and
|
|
147
|
+
// all links. It intentionally excludes only declared mutable output paths.
|
|
148
|
+
const verify = async (options?: { readonly timeoutMs: number }) => {
|
|
149
|
+
const timeoutMs = options?.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
|
|
150
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > SNAPSHOT_TIMEOUT_MS) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot deadline must be positive and cannot exceed five minutes.");
|
|
151
|
+
const deadline = performance.now() + timeoutMs;
|
|
152
|
+
// The worker also checks file-only Git metadata, so lstat cannot stall the parent.
|
|
153
|
+
const verifyGit = async (...args: string[]) => {
|
|
154
|
+
const result = await snapshotProcess("git", gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args, rootDir, cleanEnv, deadline, input.signal);
|
|
155
|
+
if (result.code !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git identity could not be read.");
|
|
156
|
+
return result.stdout.trim();
|
|
157
|
+
};
|
|
158
|
+
await inventory([], false, deadline);
|
|
159
|
+
if (await verifyGit("write-tree") !== input.candidate.treeSha || await verifyGit("rev-parse", "HEAD") !== candidateCommit || await verifyGit("rev-parse", `${candidateRef}^{tree}`) !== input.candidate.treeSha || await verifyGit("rev-parse", baseRef) !== input.candidate.base.tipSha || dependencyDigest !== await inventory(input.outputs, false, deadline)) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
160
|
+
};
|
|
161
|
+
return { rootDir, commandRoot: path.join(controlRoot, "commands"), environment, dependencyDigest, verify, cleanup };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await cleanup();
|
|
164
|
+
if (error instanceof CheckSnapshotError) throw error;
|
|
165
|
+
throw new CheckSnapshotError("check_snapshot_unavailable", "The exact prepared Git tree could not be materialized privately.");
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/commands/gate.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ScopedChecks } from "../scoped-checks.ts";
|
|
2
|
+
import { CheckSnapshotError } from "../check-snapshot.ts";
|
|
3
|
+
import { commandBlocker } from "../boundary.ts";
|
|
1
4
|
import { runDeclaredCheck } from "../declared-checks.ts";
|
|
2
5
|
/**
|
|
3
6
|
* `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
|
|
@@ -20,10 +23,15 @@ import { oneLine } from "../run-surface.ts";
|
|
|
20
23
|
* through the same admission adapter. Configs without provider commands take
|
|
21
24
|
* the pre-existing path unchanged.
|
|
22
25
|
*/
|
|
23
|
-
|
|
26
|
+
async function providerAdmission(
|
|
24
27
|
context: CommandContext,
|
|
25
28
|
options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
|
|
29
|
+
session?: ScopedChecks,
|
|
26
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
|
+
}
|
|
27
35
|
const wiring = await context.wire();
|
|
28
36
|
const admissionOptions = {
|
|
29
37
|
captureCandidate: wiring.captureCandidate,
|
|
@@ -42,13 +50,14 @@ export async function runProviderBackedAdmission(
|
|
|
42
50
|
};
|
|
43
51
|
|
|
44
52
|
if (!context.config.providers.some((provider) => provider.command !== undefined || provider.check !== undefined)) {
|
|
45
|
-
return
|
|
53
|
+
return admit(input, finalAdmissionOptions);
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
|
|
49
57
|
const attempted = new Set<string>();
|
|
50
58
|
const attemptBlockers: Blocker[] = [];
|
|
51
|
-
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] : []));
|
|
52
61
|
|
|
53
62
|
while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
|
|
54
63
|
const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
|
|
@@ -78,8 +87,14 @@ export async function runProviderBackedAdmission(
|
|
|
78
87
|
attempted.add(providerId);
|
|
79
88
|
const registration = context.config.providers.find(provider => provider.id === providerId)!;
|
|
80
89
|
if (registration.check !== undefined) {
|
|
81
|
-
|
|
82
|
-
|
|
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);
|
|
83
98
|
continue;
|
|
84
99
|
}
|
|
85
100
|
const result = await context.invokeProvider?.({
|
|
@@ -103,14 +118,14 @@ export async function runProviderBackedAdmission(
|
|
|
103
118
|
liveResults.push(result.liveResult);
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
admission = await
|
|
121
|
+
admission = await admit(
|
|
107
122
|
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
108
123
|
admissionOptions,
|
|
109
124
|
);
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
if (admission.admitted) return { ...admission, observedLiveResults: liveResults };
|
|
113
|
-
const final = await
|
|
128
|
+
const final = await admit(
|
|
114
129
|
{ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
|
|
115
130
|
finalAdmissionOptions,
|
|
116
131
|
);
|
|
@@ -119,6 +134,17 @@ export async function runProviderBackedAdmission(
|
|
|
119
134
|
: { ...final, observedLiveResults: liveResults, blockers: [...attemptBlockers, ...final.blockers] };
|
|
120
135
|
}
|
|
121
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(); }
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
export const gateCommand: CommandDescriptor = {
|
|
123
149
|
name: "gate",
|
|
124
150
|
sourceId: "delivery-harness.cli.gate",
|
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,7 +260,7 @@ 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",
|
|
@@ -255,6 +293,38 @@ export const managedCommand: CommandDescriptor = {
|
|
|
255
293
|
return { kind: "ok", summary: `${FACADE_OPERATIONS.length} facade operations` };
|
|
256
294
|
}
|
|
257
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
|
+
|
|
258
328
|
// Only the retention operations may name a delivery; everything else binds
|
|
259
329
|
// the fence of the worktree it runs in.
|
|
260
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
|
*
|
|
@@ -32,6 +34,8 @@ export const prepareCommand: CommandDescriptor = {
|
|
|
32
34
|
if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
|
|
33
35
|
return { kind: "usage", message: USAGE };
|
|
34
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;
|
|
35
39
|
const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
|
|
36
40
|
const wiring = await context.wire();
|
|
37
41
|
let retainedAttemptId: string | undefined;
|
|
@@ -44,6 +48,18 @@ export const prepareCommand: CommandDescriptor = {
|
|
|
44
48
|
let reusableFingerprint: string | undefined;
|
|
45
49
|
let attemptId: string;
|
|
46
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
|
+
}
|
|
47
63
|
capture = await wiring.captureCandidate();
|
|
48
64
|
if (capture.ok && refreshRecordNeutral) {
|
|
49
65
|
const previous = await evaluatePreparationReceipt(context.rootDir,
|
|
@@ -54,7 +70,7 @@ export const prepareCommand: CommandDescriptor = {
|
|
|
54
70
|
}
|
|
55
71
|
}
|
|
56
72
|
} finally {
|
|
57
|
-
attemptId = retainedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
73
|
+
attemptId = retainedAttemptId ?? ownedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
|
|
58
74
|
ownedAttemptId = attemptId;
|
|
59
75
|
}
|
|
60
76
|
if (!capture.ok) {
|
|
@@ -71,7 +87,7 @@ export const prepareCommand: CommandDescriptor = {
|
|
|
71
87
|
ownedAttemptId = attemptId;
|
|
72
88
|
retainedAttemptId = undefined;
|
|
73
89
|
}
|
|
74
|
-
|
|
90
|
+
let validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
|
|
75
91
|
config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
|
|
76
92
|
if (reusable) context.write("reusing preparation checks: strict validation projection, base, policy and wiring unchanged");
|
|
77
93
|
const exec = createExecPort();
|
|
@@ -111,12 +127,20 @@ export const prepareCommand: CommandDescriptor = {
|
|
|
111
127
|
})],
|
|
112
128
|
};
|
|
113
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
|
+
}
|
|
114
136
|
const published = await publishPreparationReceipt(
|
|
115
137
|
context.rootDir,
|
|
116
138
|
{ config: context.config, candidate: capture.candidate, attemptId, validationDigest },
|
|
117
139
|
wiring.storageOptions,
|
|
118
140
|
);
|
|
119
141
|
if (context.signal?.aborted) throw new CliInterruption();
|
|
142
|
+
await scoped?.submitMechanical();
|
|
143
|
+
await scoped?.cleanup();
|
|
120
144
|
refreshed = true;
|
|
121
145
|
// The labelled line exists so a reader — an operator or a review round
|
|
122
146
|
// about to bind itself to this candidate — has one unambiguous token to
|
|
@@ -131,8 +155,9 @@ export const prepareCommand: CommandDescriptor = {
|
|
|
131
155
|
].join("\n"),
|
|
132
156
|
};
|
|
133
157
|
} finally {
|
|
134
|
-
|
|
135
|
-
|
|
158
|
+
try { await scoped?.cleanup(); }
|
|
159
|
+
finally {
|
|
160
|
+
if (ownedAttemptId !== undefined && !refreshed) await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
|
|
136
161
|
}
|
|
137
162
|
}
|
|
138
163
|
},
|
package/src/commands/record.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import { commandBlocker } from "../boundary.ts";
|
|
26
26
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
27
|
-
import { oneLine } from "../run-surface.ts";
|
|
27
|
+
import { oneLine, resolveRunSpan } from "../run-surface.ts";
|
|
28
28
|
import { applyDeliveryRecordRetention } from "../record-retention.ts";
|
|
29
29
|
import { runProviderBackedAdmission } from "./gate.ts";
|
|
30
30
|
|
|
@@ -127,7 +127,16 @@ export const recordCommand: CommandDescriptor = {
|
|
|
127
127
|
await computePreparationFingerprint(context.rootDir, context.config));
|
|
128
128
|
const compiledPolicy = context.policyBinding?.compiledPolicy ??
|
|
129
129
|
await readCompiledRepositoryPolicy(await candidateTreeEvidenceReader(context.rootDir, recheck.candidate.treeSha));
|
|
130
|
+
// The delivery's own span, read off the run journal rather than off a
|
|
131
|
+
// clock: `startedAt` is the journal's first instant and `endedAt` the last
|
|
132
|
+
// it had reached by this write, which is the time this delivery had spent
|
|
133
|
+
// when it recorded. A delivery with no journal records exactly as it always
|
|
134
|
+
// did — the member is simply absent — because the record is evidence about
|
|
135
|
+
// a candidate and this is a cycle-time reading beside it, never a condition
|
|
136
|
+
// on writing one.
|
|
137
|
+
const runSpan = await resolveRunSpan({ cwd: context.rootDir, treeSha: recheck.candidate.treeSha, allowCurrentRun: true });
|
|
130
138
|
const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords, context: evidenceContext,
|
|
139
|
+
...(runSpan === undefined ? {} : { runSpan: { startedAt: runSpan.startedAt, endedAt: runSpan.endedAt } }),
|
|
131
140
|
...(compiledPolicy === null ? {} : { compiledPolicy, observedAt }) });
|
|
132
141
|
if (!built.ok) {
|
|
133
142
|
return { kind: "blocked", blockers: [...built.blockers] };
|