@agent-delivery-harness/cli 0.7.3 → 0.8.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 +2 -2
- package/src/boundary.ts +16 -5
- package/src/check-snapshot.ts +264 -35
- package/src/command-activity.ts +15 -1
- package/src/commands/admit.ts +5 -4
- package/src/commands/emit-review-evidence.ts +1 -0
- package/src/commands/gate.ts +59 -24
- package/src/commands/prepare.ts +22 -13
- package/src/commands/record.ts +136 -122
- package/src/commands/resume.ts +3 -0
- package/src/commands/review-context.ts +1 -0
- package/src/commands/save-context.ts +9 -2
- package/src/commands/submit-evidence.ts +18 -4
- package/src/commands/verify.ts +5 -0
- package/src/declared-checks.ts +4 -4
- package/src/delivery-posture.ts +39 -0
- package/src/installed-data-resources.ts +57 -0
- package/src/installed-data-worker.ts +83 -0
- package/src/review-evidence.ts +1 -1
- package/src/scoped-attempts.ts +34 -8
- package/src/scoped-budget.ts +20 -0
- package/src/scoped-checks.ts +561 -59
- package/src/scoped-diagnostics.ts +82 -4
- package/src/scoped-installed-data.ts +8 -0
- package/src/snapshot-inventory-worker.ts +160 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-delivery-harness/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=22.6.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@agent-delivery-harness/kernel": "0.
|
|
16
|
+
"@agent-delivery-harness/kernel": "0.8.0"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./src/index.ts"
|
package/src/boundary.ts
CHANGED
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
type ProviderRailInvocationResult,
|
|
64
64
|
type ProviderRailSession,
|
|
65
65
|
} from "./provider-rails.ts";
|
|
66
|
-
import { beginCommandObservation, type CommandObservation } from "./command-activity.ts";
|
|
66
|
+
import { beginCommandObservation, observeCandidateCaptures, type CommandObservation } from "./command-activity.ts";
|
|
67
67
|
|
|
68
68
|
// ── Exit codes ───────────────────────────────────────────────────────────────
|
|
69
69
|
|
|
@@ -91,7 +91,9 @@ export class CliInterruption extends Error {
|
|
|
91
91
|
* the typed blockers to render; `usage` is an argument or invocation error.
|
|
92
92
|
*/
|
|
93
93
|
export type CommandResult =
|
|
94
|
-
| { readonly kind: "ok"; readonly summary?: string; readonly digest?: string; readonly preparation?: RunPreparationObservation
|
|
94
|
+
| { readonly kind: "ok"; readonly summary?: string; readonly digest?: string; readonly preparation?: RunPreparationObservation;
|
|
95
|
+
/** The command did not do its work, so no completion of it goes in the run's journal. */
|
|
96
|
+
readonly unobserved?: true }
|
|
95
97
|
| { readonly kind: "blocked"; readonly blockers: readonly Blocker[] }
|
|
96
98
|
| { readonly kind: "usage"; readonly message: string };
|
|
97
99
|
|
|
@@ -136,12 +138,17 @@ export interface CommandContext {
|
|
|
136
138
|
}) => Promise<ProviderRailInvocationResult | undefined>;
|
|
137
139
|
/** Emits one line of operator-facing output to stdout. */
|
|
138
140
|
readonly write: (text: string) => void;
|
|
141
|
+
/** Emits one line of operator-facing output to stderr, leaving stdout as it is. */
|
|
142
|
+
readonly writeError: (text: string) => void;
|
|
139
143
|
/** Classifies the execution context from this invocation's env + TTY. */
|
|
140
144
|
classifyContext(): ExecutionContext;
|
|
141
145
|
}
|
|
142
146
|
|
|
143
147
|
export interface CommandDescriptor {
|
|
144
148
|
readonly name: string;
|
|
149
|
+
/** Opt in to progress from this command's fresh captures. Other descriptors
|
|
150
|
+
* retain the boundary's eager capture; this flag grants no candidate reuse. */
|
|
151
|
+
readonly capturesCandidate?: boolean;
|
|
145
152
|
/** The blocker `source.id` this command stamps on failures it raises itself. */
|
|
146
153
|
readonly sourceId: string;
|
|
147
154
|
readonly summary: string;
|
|
@@ -426,7 +433,7 @@ export async function runCliBoundary(
|
|
|
426
433
|
? await beginCommandObservation(runtime.cwd, descriptor.name) : undefined;
|
|
427
434
|
let completed: Extract<CommandResult, { kind: "ok" }> | undefined;
|
|
428
435
|
const code = await runConfiguredCommand(descriptor, args, runtime, value => { completed = value; }, observation);
|
|
429
|
-
await observation?.finish(code, Date.now() - startedAt, completed?.digest, completed?.preparation);
|
|
436
|
+
if (!completed?.unobserved) await observation?.finish(code, Date.now() - startedAt, completed?.digest, completed?.preparation);
|
|
430
437
|
return code;
|
|
431
438
|
}
|
|
432
439
|
|
|
@@ -491,11 +498,14 @@ async function runConfiguredCommand(
|
|
|
491
498
|
|
|
492
499
|
let wiringPromise: Promise<RepoWiring> | undefined;
|
|
493
500
|
const wire = (): Promise<RepoWiring> => {
|
|
494
|
-
wiringPromise ??= wireRepo(runtime.cwd, config)
|
|
501
|
+
wiringPromise ??= wireRepo(runtime.cwd, config).then(wiring =>
|
|
502
|
+
descriptor.capturesCandidate && observation?.needsCandidate
|
|
503
|
+
? { ...wiring, captureCandidate: observeCandidateCaptures(wiring.captureCandidate, observation) }
|
|
504
|
+
: wiring);
|
|
495
505
|
return wiringPromise;
|
|
496
506
|
};
|
|
497
507
|
|
|
498
|
-
if (observation?.needsCandidate) {
|
|
508
|
+
if (observation?.needsCandidate && !descriptor.capturesCandidate) {
|
|
499
509
|
try {
|
|
500
510
|
const capture = await (await wire()).captureCandidate();
|
|
501
511
|
if (capture.ok) await observation.start(capture.candidate.treeSha);
|
|
@@ -579,6 +589,7 @@ async function runConfiguredCommand(
|
|
|
579
589
|
}
|
|
580
590
|
},
|
|
581
591
|
write: (text) => runtime.stdout(`${text}\n`),
|
|
592
|
+
writeError: (text) => runtime.stderr(`${text}\n`),
|
|
582
593
|
classifyContext: () =>
|
|
583
594
|
classifyExecutionContext({
|
|
584
595
|
config,
|
package/src/check-snapshot.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Private execution tree. Never shares writable source, objects, index or dependencies. */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { createExecPort, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
6
|
+
import { candidateTreeSourceReader, capturePhysicalSource, createExecPort, isPhysicalSourceDefinition, physicalSourceOutputOverlap, type CandidateCommandRunner, type PhysicalSourceDefinition, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
7
7
|
|
|
8
8
|
import { snapshotInventoryWorker } from "./snapshot-inventory-worker.ts";
|
|
9
9
|
|
|
@@ -12,14 +12,20 @@ export class CheckSnapshotError extends Error {
|
|
|
12
12
|
readonly code: string;
|
|
13
13
|
constructor(code: string, message: string) { super(message); this.code = code; }
|
|
14
14
|
}
|
|
15
|
+
export type SnapshotPhase = "materialization" | "baseline-inventory" | "dependencies" | "post-dependency-inventory" | "clone" | "verification";
|
|
16
|
+
/** Copy-on-write cloning: APFS `clonefile(2)` on macOS, a reflink copy on Linux. */
|
|
17
|
+
export type CloneMethod = "clonefile" | "reflink";
|
|
15
18
|
export interface SnapshotRequest {
|
|
16
19
|
readonly rootDir: string;
|
|
17
20
|
readonly candidate: { readonly treeSha: string; readonly headSha: string; readonly base: { readonly ref: string; readonly tipSha: string; readonly mergeBaseSha: string } };
|
|
18
21
|
readonly outputs: readonly string[];
|
|
19
22
|
readonly gitContext?: "full" | "none";
|
|
23
|
+
readonly physicalSource?: PhysicalSourceDefinition;
|
|
20
24
|
readonly environment: Readonly<Record<string, string>>;
|
|
21
25
|
readonly dependencies?: { readonly command: readonly [string, ...string[]]; readonly timeoutMs: number };
|
|
22
26
|
readonly onDependencyResult?: (result: ExecOutcome, durationMs: number) => void;
|
|
27
|
+
/** Best-effort phase timing only; never admission evidence. */
|
|
28
|
+
readonly onPhase?: (phase: SnapshotPhase, durationMs: number) => void;
|
|
23
29
|
readonly signal?: AbortSignal;
|
|
24
30
|
}
|
|
25
31
|
export interface CheckSnapshot {
|
|
@@ -27,9 +33,76 @@ export interface CheckSnapshot {
|
|
|
27
33
|
readonly rootDir: string;
|
|
28
34
|
readonly environment: Readonly<Record<string, string>>;
|
|
29
35
|
readonly dependencyDigest: string;
|
|
30
|
-
|
|
36
|
+
/** False when the setup wrote this tree's own absolute path into one of its files: a clone would
|
|
37
|
+
* still read and write this tree through that path, so each worker builds its own instead. */
|
|
38
|
+
readonly clonable: boolean;
|
|
39
|
+
/** What `verify` checks when given `unchanged`: Git's index and untracked listing, or (finite snapshots, which have no Git) the full inventory. */
|
|
40
|
+
readonly reusedVerification: "index" | "inventory";
|
|
41
|
+
/** `outputs` narrows the writable paths to one profile's within a snapshot shared by several.
|
|
42
|
+
* `unchanged` verifies a reused tree that a full verification already accepted after its last
|
|
43
|
+
* command: untracked paths may exist only where the setup left them or inside `allowed`. */
|
|
44
|
+
verify(options?: { readonly timeoutMs?: number; readonly outputs?: readonly string[]; readonly unchanged?: { readonly allowed: readonly string[] } }): Promise<void>;
|
|
45
|
+
/** A private copy-on-write tree of this one, which stays untouched. The clone's own phases go to `onPhase`. */
|
|
46
|
+
clone(method: CloneMethod, onPhase?: (phase: SnapshotPhase, durationMs: number) => void): Promise<CheckSnapshot>;
|
|
31
47
|
cleanup(): Promise<void>;
|
|
32
48
|
}
|
|
49
|
+
/** A materialized tree: the work tree, the Git-none control directory, and the directories cleanup removes. */
|
|
50
|
+
interface PrivateTree { readonly rootDir: string; readonly privateControl?: string; readonly owned: readonly string[] }
|
|
51
|
+
const nulSeparated = (text: string) => text.split("\0").filter(Boolean);
|
|
52
|
+
/** A listed path (a directory ends in a slash) lies at or below a declared output. As in the
|
|
53
|
+
* inventory, an output without a trailing slash that is a directory covers what is inside it. */
|
|
54
|
+
function withinOutputs(entry: string, outputs: readonly string[]): boolean {
|
|
55
|
+
return outputs.some(output => { const root = output.replace(/\/$/, ""); return entry === root || entry.startsWith(`${root}/`); });
|
|
56
|
+
}
|
|
57
|
+
function phaseMeter(onPhase?: (phase: SnapshotPhase, durationMs: number) => void) {
|
|
58
|
+
return async <T>(phase: SnapshotPhase, operation: () => Promise<T>): Promise<T> => {
|
|
59
|
+
const started = performance.now();
|
|
60
|
+
try { return await operation(); } finally { try { onPhase?.(phase, performance.now() - started); } catch { /* Timing cannot change admission. */ } }
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function removeTrees(directories: readonly string[]): Promise<void> {
|
|
64
|
+
const removed = await Promise.allSettled(directories.map(dir => rm(dir, { recursive: true, force: true })));
|
|
65
|
+
if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
|
|
66
|
+
}
|
|
67
|
+
/** One copy-on-write clone of a directory tree into a path that must not exist yet. On macOS
|
|
68
|
+
* `clonefileat(2)` (syscall 462) clones the hierarchy in one call; `cp -c` clones file by file
|
|
69
|
+
* and silently copies where cloning is unsupported. */
|
|
70
|
+
async function cloneTree(method: CloneMethod, source: string, destination: string, deadline: number, signal?: AbortSignal): Promise<void> {
|
|
71
|
+
const [command, args] = method === "clonefile"
|
|
72
|
+
? ["/usr/bin/perl", ["-e", "syscall(462, -2, $ARGV[0], -2, $ARGV[1], 0) == 0 or exit 1", source, destination]] as const
|
|
73
|
+
: ["cp", ["-a", "--reflink=always", "--", source, destination]] as const;
|
|
74
|
+
const result = await snapshotProcess(command, [...args], path.dirname(destination), { PATH: "/usr/bin:/bin" }, deadline, signal);
|
|
75
|
+
if (result.code !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "The warm snapshot could not be cloned.");
|
|
76
|
+
}
|
|
77
|
+
let cloneProbe: Promise<CloneMethod | undefined> | undefined;
|
|
78
|
+
/** The copy-on-write method the temporary directory supports, probed once per process; undefined where none does. */
|
|
79
|
+
export function snapshotCloneMethod(): Promise<CloneMethod | undefined> {
|
|
80
|
+
return cloneProbe ??= (async () => {
|
|
81
|
+
const method: CloneMethod | undefined = process.platform === "darwin" ? "clonefile" : process.platform === "linux" ? "reflink" : undefined;
|
|
82
|
+
if (!method) return undefined;
|
|
83
|
+
const probe = await mkdtemp(path.join(tmpdir(), "delivery-clone-probe-"));
|
|
84
|
+
try {
|
|
85
|
+
await mkdir(path.join(probe, "source")); await writeFile(path.join(probe, "source", "probe"), "probe");
|
|
86
|
+
await cloneTree(method, path.join(probe, "source"), path.join(probe, "clone"), performance.now() + 30_000);
|
|
87
|
+
return method;
|
|
88
|
+
} catch { return undefined; } finally { await rm(probe, { recursive: true, force: true }).catch(() => undefined); }
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
async function cloneSnapshot(tree: PrivateTree, method: CloneMethod, signal: AbortSignal | undefined, onPhase: SnapshotRequest["onPhase"], open: (tree: PrivateTree, onPhase?: SnapshotRequest["onPhase"]) => CheckSnapshot): Promise<CheckSnapshot> {
|
|
92
|
+
return phaseMeter(onPhase)("clone", async () => {
|
|
93
|
+
const parent = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-clone-")));
|
|
94
|
+
const cloned: PrivateTree = { rootDir: path.join(parent, "tree"), ...(tree.privateControl ? { privateControl: path.join(parent, "control") } : {}), owned: [parent] };
|
|
95
|
+
try {
|
|
96
|
+
const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
|
|
97
|
+
await cloneTree(method, tree.rootDir, cloned.rootDir, deadline, signal);
|
|
98
|
+
if (tree.privateControl) await cloneTree(method, tree.privateControl, cloned.privateControl!, deadline, signal);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await rm(parent, { recursive: true, force: true }).catch(() => undefined);
|
|
101
|
+
throw error instanceof CheckSnapshotError ? error : new CheckSnapshotError("check_snapshot_unavailable", "The warm snapshot could not be cloned.");
|
|
102
|
+
}
|
|
103
|
+
return open(cloned, onPhase);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
33
106
|
function inside(root: string, target: string): boolean {
|
|
34
107
|
const relative = path.relative(root, target);
|
|
35
108
|
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
@@ -37,7 +110,7 @@ function inside(root: string, target: string): boolean {
|
|
|
37
110
|
/** Kill the owned read-only process and await close before callers may clean up.
|
|
38
111
|
* AbortSignal is handled here: child_process's early AbortError event is not
|
|
39
112
|
* 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 }> {
|
|
113
|
+
async function snapshotProcess(command: string, args: string[], cwd: string, env: Readonly<Record<string, string>>, deadline: number, signal?: AbortSignal, limit = 64 * 1024): Promise<{ code: number | null; stdout: string }> {
|
|
41
114
|
if (signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Snapshot verification was interrupted.");
|
|
42
115
|
const remaining = deadline - performance.now();
|
|
43
116
|
if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
|
|
@@ -54,7 +127,7 @@ async function snapshotProcess(command: string, args: string[], cwd: string, env
|
|
|
54
127
|
if (signal?.aborted) abort();
|
|
55
128
|
child.stdout.on("data", (bytes: Buffer) => {
|
|
56
129
|
size += bytes.length;
|
|
57
|
-
if (size >
|
|
130
|
+
if (size > limit) stop("check_snapshot_unavailable", "Snapshot verifier output exceeded its limit.");
|
|
58
131
|
else stdout += bytes.toString();
|
|
59
132
|
});
|
|
60
133
|
child.stderr.on("data", () => {}); // Drain; never expose private paths or source bytes.
|
|
@@ -67,18 +140,32 @@ async function snapshotProcess(command: string, args: string[], cwd: string, env
|
|
|
67
140
|
});
|
|
68
141
|
}
|
|
69
142
|
/** 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> {
|
|
143
|
+
export async function snapshotInventory(root: string, outputs: readonly string[], excludeDependencies = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS, signal?: AbortSignal, forbidGit = false, absentInputs: readonly string[] = []): Promise<string> {
|
|
144
|
+
return (await snapshotDigests(root, outputs, excludeDependencies, deadline, signal, forbidGit, false, absentInputs)).digest;
|
|
145
|
+
}
|
|
146
|
+
async function snapshotDigests(root: string, outputs: readonly string[], excludeDependencies: boolean, deadline: number, signal?: AbortSignal, forbidGit = false, includeSource = false, absentInputs: readonly string[] = [], selfReferences: readonly string[] = []): Promise<{ digest: string; sourceDigest?: string; selfReferenced: boolean }> {
|
|
71
147
|
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 };
|
|
148
|
+
{ PATH: process.env["PATH"] ?? "/usr/bin:/bin", DELIVERY_SNAPSHOT_REQUEST: JSON.stringify({ root, outputs, excludeDependencies, forbidGit, includeSource, absentInputs, selfReferences }) }, deadline, signal);
|
|
149
|
+
let parsed: { code?: string; digest?: string; sourceDigest?: string; selfReferenced?: boolean };
|
|
74
150
|
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
|
-
|
|
151
|
+
if (result.code !== 0 || !/^[a-f0-9]{64}$/.test(parsed.digest ?? "") || (includeSource && !/^[a-f0-9]{64}$/.test(parsed.sourceDigest ?? ""))) 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.");
|
|
152
|
+
if (selfReferences.length && typeof parsed.selfReferenced !== "boolean") throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot inventory returned no valid result.");
|
|
153
|
+
return { digest: parsed.digest!, ...(includeSource ? { sourceDigest: parsed.sourceDigest! } : {}), selfReferenced: parsed.selfReferenced === true };
|
|
154
|
+
}
|
|
155
|
+
/** The spellings of a tree's absolute paths a setup could write: as created under the temporary directory and resolved. */
|
|
156
|
+
function selfReferences(...created: (string | undefined)[]): string[] {
|
|
157
|
+
return [...new Set(created.flatMap(dir => dir === undefined ? [] : [dir, path.join(tmpdir(), path.basename(dir))]))];
|
|
77
158
|
}
|
|
78
159
|
export function executionPath(root: string, value: string): string {
|
|
79
160
|
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
161
|
}
|
|
81
162
|
export async function createCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
163
|
+
if (input.physicalSource !== undefined) return createFiniteCheckSnapshot(input);
|
|
164
|
+
const report = (phase: SnapshotPhase, started: number) => {
|
|
165
|
+
try { input.onPhase?.(phase, performance.now() - started); } catch { /* Timing cannot change admission. */ }
|
|
166
|
+
};
|
|
167
|
+
const measured = phaseMeter(input.onPhase);
|
|
168
|
+
const materializationStarted = performance.now();
|
|
82
169
|
const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
|
|
83
170
|
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
171
|
let privateControl: string | undefined;
|
|
@@ -99,10 +186,7 @@ export async function createCheckSnapshot(input: SnapshotRequest): Promise<Check
|
|
|
99
186
|
return result.stdout.trim();
|
|
100
187
|
} finally { clearTimeout(timer); input.signal?.removeEventListener("abort", abort); }
|
|
101
188
|
};
|
|
102
|
-
const cleanup =
|
|
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
|
-
};
|
|
189
|
+
const cleanup = () => removeTrees([rootDir, ...(privateControl ? [privateControl] : [])]);
|
|
106
190
|
try {
|
|
107
191
|
await git("init", "-q");
|
|
108
192
|
// Fetch transfers object bytes; unlike worktrees/alternates/local clones it
|
|
@@ -129,39 +213,184 @@ export async function createCheckSnapshot(input: SnapshotRequest): Promise<Check
|
|
|
129
213
|
await rename(path.join(rootDir, ".git"), gitDirectory);
|
|
130
214
|
}
|
|
131
215
|
const controlRoot = privateControl ?? path.join(rootDir, ".git");
|
|
132
|
-
const inventory = (outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(
|
|
133
|
-
|
|
134
|
-
|
|
216
|
+
const inventory = (root: string, outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(root, outputs, sourceOnly, deadline, input.signal, input.gitContext === "none");
|
|
217
|
+
report("materialization", materializationStarted);
|
|
218
|
+
// Setup needs a source baseline only when a dependency command can mutate it.
|
|
219
|
+
const baseline = await measured("baseline-inventory", () => inventory(rootDir, input.outputs, Boolean(input.dependencies)));
|
|
220
|
+
let dependencyDigest = baseline, clonable = true;
|
|
221
|
+
const environmentFor = (root: string, control: string): Record<string, string> => ({ ...input.environment, PATH: `${path.join(root, "node_modules/.bin")}${path.delimiter}${cleanEnv["PATH"]}`, HOME: path.join(control, "home"), TMPDIR: path.join(control, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(root),
|
|
135
222
|
...(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 }) };
|
|
223
|
+
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 }) });
|
|
224
|
+
const environment = environmentFor(rootDir, controlRoot);
|
|
137
225
|
await mkdir(environment["HOME"]!, { recursive: true }); await mkdir(environment["TMPDIR"]!, { recursive: true });
|
|
138
226
|
if (input.dependencies) {
|
|
139
227
|
const started = performance.now();
|
|
140
|
-
const result = await createExecPort().run({ command: input.dependencies
|
|
228
|
+
const result = await measured("dependencies", () => 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
229
|
input.onDependencyResult?.(result, Math.round(performance.now() - started));
|
|
142
230
|
if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private dependency installation did not complete successfully.");
|
|
231
|
+
// Both digests use the same tuples from one safety walk, hashing each
|
|
232
|
+
// immutable file once. Source comparison still excludes node_modules.
|
|
233
|
+
const installed = await measured("post-dependency-inventory", () => snapshotDigests(rootDir, input.outputs, false, performance.now() + SNAPSHOT_TIMEOUT_MS, input.signal, input.gitContext === "none", true, [], selfReferences(rootDir, privateControl)));
|
|
234
|
+
if (baseline !== installed.sourceDigest) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed prepared source bytes.");
|
|
235
|
+
dependencyDigest = installed.digest;
|
|
236
|
+
clonable = !installed.selfReferenced;
|
|
143
237
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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();
|
|
238
|
+
// Explicit directories and settings: a full-Git command can edit .git/config, and core.worktree,
|
|
239
|
+
// core.fileMode=false, a sparse checkout or an fsmonitor hook would otherwise hide changes from
|
|
240
|
+
// verification (and the hook would run inside it).
|
|
241
|
+
const treeGit = (tree: PrivateTree, deadline: number, accepted: readonly number[] = [0], limit?: number) => {
|
|
242
|
+
const gitDir = tree.privateControl ? path.join(tree.privateControl, "repository") : path.join(tree.rootDir, ".git");
|
|
243
|
+
return async (...args: string[]) => {
|
|
244
|
+
const result = await snapshotProcess("git", [`--git-dir=${gitDir}`, `--work-tree=${tree.rootDir}`, "-c", "core.fileMode=true", "-c", "core.sparseCheckout=false", "-c", "core.symlinks=true", "-c", "core.fsmonitor=false", ...args], tree.rootDir, cleanEnv, deadline, input.signal, limit);
|
|
245
|
+
if (!accepted.includes(result.code ?? -1)) throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git identity could not be read.");
|
|
246
|
+
return result.stdout;
|
|
157
247
|
};
|
|
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
248
|
};
|
|
161
|
-
|
|
249
|
+
// Untracked entries the setup left, with each wholly untracked directory
|
|
250
|
+
// (an installed dependency tree) collapsed to one entry. Exclude rules are
|
|
251
|
+
// not read: a command could write one to hide its own files.
|
|
252
|
+
const others = ["ls-files", "--others", "--directory", "--no-empty-directory", "-z"];
|
|
253
|
+
const setupEntries = new Set(nulSeparated(await treeGit({ rootDir, ...(privateControl ? { privateControl } : {}), owned: [] }, performance.now() + SNAPSHOT_TIMEOUT_MS)(...others)));
|
|
254
|
+
const reusedVerification = "index" as const;
|
|
255
|
+
const open = (tree: PrivateTree, onPhase?: SnapshotRequest["onPhase"]): CheckSnapshot => {
|
|
256
|
+
const control = tree.privateControl ?? path.join(tree.rootDir, ".git");
|
|
257
|
+
const verify: CheckSnapshot["verify"] = async (options = {}) => phaseMeter(onPhase)("verification", async () => {
|
|
258
|
+
const timeoutMs = options.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
|
|
259
|
+
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.");
|
|
260
|
+
const deadline = performance.now() + timeoutMs;
|
|
261
|
+
// The worker also checks file-only Git metadata, so lstat cannot stall the parent.
|
|
262
|
+
const verifyGit = treeGit(tree, deadline);
|
|
263
|
+
if (options.unchanged && reusedVerification === "index") {
|
|
264
|
+
// A reused tree passed a full inventory after its last command. Before
|
|
265
|
+
// the next one it must still match the index: no tracked edit, and no
|
|
266
|
+
// untracked entry the setup did not leave outside the allowed outputs.
|
|
267
|
+
const allowed = options.unchanged.allowed;
|
|
268
|
+
// A clone has new inodes, so the refresh rehashes; 1 means a tracked file differs, which
|
|
269
|
+
// diff-files names. Every entry must be a plain cached one ("H"): an assume-unchanged
|
|
270
|
+
// (lowercase) or skip-worktree ("S") flag would hide an edit from both.
|
|
271
|
+
await treeGit(tree, deadline, [0, 1])("update-index", "-q", "--refresh");
|
|
272
|
+
if (nulSeparated(await treeGit(tree, deadline, [0], 128 * 1024 * 1024)("ls-files", "-v", "-z")).some(entry => !entry.startsWith("H ")))
|
|
273
|
+
throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot's index hides a tracked path.");
|
|
274
|
+
if (input.gitContext === "none" && await lstat(path.join(tree.rootDir, ".git")).then(() => true, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git absence could not be read."))))
|
|
275
|
+
throw new CheckSnapshotError("check_snapshot_drift", "A file-only snapshot gained Git metadata.");
|
|
276
|
+
if (nulSeparated(await verifyGit("diff-files", "--name-only", "-z")).length) throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot changed tracked source.");
|
|
277
|
+
for (const entry of nulSeparated(await verifyGit(...others))) {
|
|
278
|
+
if (setupEntries.has(entry) || withinOutputs(entry, allowed)) continue;
|
|
279
|
+
// A collapsed directory is looked into only when an allowed output lies inside it.
|
|
280
|
+
if (!entry.endsWith("/") || !allowed.some(output => output.startsWith(entry))) throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot gained an undeclared untracked path.");
|
|
281
|
+
if (nulSeparated(await verifyGit("ls-files", "--others", "-z", "--", `:(literal)${entry}`)).some(file => !withinOutputs(file, allowed))) throw new CheckSnapshotError("check_snapshot_drift", "A reused snapshot gained an undeclared untracked path.");
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
// One worker checks every non-Git link/type and hashes only immutable bytes.
|
|
285
|
+
// Finish safety validation before Git reads, under the same owned deadline.
|
|
286
|
+
if (await inventory(tree.rootDir, options.outputs ?? input.outputs, false, deadline) !== dependencyDigest) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
287
|
+
}
|
|
288
|
+
const indexTree = (await verifyGit("write-tree")).trim();
|
|
289
|
+
const identities = (await verifyGit("rev-parse", "HEAD", `${candidateRef}^{tree}`, baseRef)).trim().split("\n");
|
|
290
|
+
if (indexTree !== input.candidate.treeSha || identities.length !== 3 || identities[0] !== candidateCommit || identities[1] !== input.candidate.treeSha || identities[2] !== input.candidate.base.tipSha) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
291
|
+
});
|
|
292
|
+
return { rootDir: tree.rootDir, commandRoot: path.join(control, "commands"), environment: environmentFor(tree.rootDir, control), dependencyDigest, clonable, reusedVerification, verify,
|
|
293
|
+
clone: (method, onClonePhase) => cloneSnapshot(tree, method, input.signal, onClonePhase, open), cleanup: () => removeTrees(tree.owned) };
|
|
294
|
+
};
|
|
295
|
+
return open({ rootDir, ...(privateControl ? { privateControl } : {}), owned: [rootDir, ...(privateControl ? [privateControl] : [])] }, input.onPhase);
|
|
162
296
|
} catch (error) {
|
|
163
297
|
await cleanup();
|
|
164
298
|
if (error instanceof CheckSnapshotError) throw error;
|
|
165
299
|
throw new CheckSnapshotError("check_snapshot_unavailable", "The exact prepared Git tree could not be materialized privately.");
|
|
166
300
|
}
|
|
167
301
|
}
|
|
302
|
+
|
|
303
|
+
/** Opt-in file-only execution; legacy snapshots above retain complete Git semantics. */
|
|
304
|
+
function finiteSourceReaderRunner(input: SnapshotRequest): CandidateCommandRunner {
|
|
305
|
+
const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
|
|
306
|
+
// Logical source bounds belong to capturePhysicalSource, including repeated
|
|
307
|
+
// blobs. Reserve separate bounded transport room for listings/size responses;
|
|
308
|
+
// protocol overhead must not consume the supported 64 MiB of source bytes.
|
|
309
|
+
const transportLimit = 128 * 1024 * 1024;
|
|
310
|
+
let transportBytes = 0;
|
|
311
|
+
return async (command, options) => {
|
|
312
|
+
if (input.signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Finite source capture was cancelled.");
|
|
313
|
+
const remaining = deadline - performance.now();
|
|
314
|
+
if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Finite source capture exceeded its deadline.");
|
|
315
|
+
return await new Promise((resolve, reject) => {
|
|
316
|
+
const child = spawn(command[0]!, command.slice(1), { cwd: options.cwd,
|
|
317
|
+
env: { 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_OPTIONAL_LOCKS: "0" },
|
|
318
|
+
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
|
|
319
|
+
let failure: CheckSnapshotError | undefined;
|
|
320
|
+
const chunks: Buffer[] = [];
|
|
321
|
+
const stop = (code: string) => { failure ??= new CheckSnapshotError(code, "Finite source reader did not complete safely."); child.kill("SIGKILL"); };
|
|
322
|
+
const abort = () => stop("check_snapshot_interrupted"), timer = setTimeout(() => stop("check_snapshot_timeout"), remaining);
|
|
323
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
324
|
+
if (input.signal?.aborted) abort();
|
|
325
|
+
child.stdout!.on("data", (chunk: Buffer) => { transportBytes += chunk.length; if (transportBytes > transportLimit) stop("check_snapshot_unavailable"); else chunks.push(chunk); });
|
|
326
|
+
child.stderr!.on("data", () => {});
|
|
327
|
+
child.once("error", () => { failure ??= new CheckSnapshotError("check_snapshot_unavailable", "Finite source reader failed to start."); });
|
|
328
|
+
child.stdin?.once("error", () => stop("check_snapshot_unavailable"));
|
|
329
|
+
child.stdin?.end(options.stdin);
|
|
330
|
+
child.once("close", code => {
|
|
331
|
+
clearTimeout(timer); input.signal?.removeEventListener("abort", abort);
|
|
332
|
+
if (failure) reject(failure);
|
|
333
|
+
else { const bytes = Buffer.concat(chunks); resolve({ exitCode: code ?? -1, stdout: bytes.toString("utf8"), stderr: "", ...(options.captureBytes ? { stdoutBase64: bytes.toString("base64") } : {}) }); }
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function createFiniteCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
339
|
+
const materializationStarted = performance.now();
|
|
340
|
+
const source = input.physicalSource!;
|
|
341
|
+
if (input.gitContext !== "none" || !isPhysicalSourceDefinition(source) || physicalSourceOutputOverlap(source, input.outputs))
|
|
342
|
+
throw new CheckSnapshotError("check_physical_source_invalid", "Finite source requires Git-none and absences disjoint from all mutable outputs.");
|
|
343
|
+
let captured: Awaited<ReturnType<typeof capturePhysicalSource>>;
|
|
344
|
+
try { captured = await capturePhysicalSource(source, await candidateTreeSourceReader(input.rootDir, input.candidate.treeSha, finiteSourceReaderRunner(input))); }
|
|
345
|
+
catch (error) { if (error instanceof CheckSnapshotError) throw error; throw new CheckSnapshotError("check_physical_source_invalid", "Finite source inputs are missing, linked or unsupported in the candidate."); }
|
|
346
|
+
const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
|
|
347
|
+
let control: string | undefined;
|
|
348
|
+
const cleanup = () => removeTrees([rootDir, ...(control ? [control] : [])]);
|
|
349
|
+
const measured = phaseMeter(input.onPhase);
|
|
350
|
+
const inventory = async (root: string, outputs: readonly string[], sourceOnly = false, deadline?: number) => {
|
|
351
|
+
if (physicalSourceOutputOverlap(source, outputs)) throw new CheckSnapshotError("check_physical_source_invalid", "Mutable output overlaps a required absence.");
|
|
352
|
+
return snapshotInventory(root, outputs, sourceOnly, deadline, input.signal, true, source.absentInputs);
|
|
353
|
+
};
|
|
354
|
+
try {
|
|
355
|
+
control = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-control-")));
|
|
356
|
+
for (const file of captured.files) {
|
|
357
|
+
await mkdir(path.dirname(path.join(rootDir, file.file)), { recursive: true });
|
|
358
|
+
await writeFile(path.join(rootDir, file.file), file.bytes, { flag: "wx" });
|
|
359
|
+
await chmod(path.join(rootDir, file.file), file.mode === "100755" ? 0o755 : 0o644);
|
|
360
|
+
}
|
|
361
|
+
try { input.onPhase?.("materialization", performance.now() - materializationStarted); } catch { /* Best-effort observation. */ }
|
|
362
|
+
const cleanPath = executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin");
|
|
363
|
+
const environmentFor = (root: string, controlDir: string) => ({ ...input.environment, PATH: `${path.join(root, "node_modules/.bin")}${path.delimiter}${cleanPath}`,
|
|
364
|
+
HOME: path.join(controlDir, "home"), TMPDIR: path.join(controlDir, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(root) });
|
|
365
|
+
const environment = environmentFor(rootDir, control);
|
|
366
|
+
await mkdir(environment.HOME, { recursive: true }); await mkdir(environment.TMPDIR, { recursive: true });
|
|
367
|
+
const baseline = await measured("baseline-inventory", () => inventory(rootDir, input.outputs, Boolean(input.dependencies)));
|
|
368
|
+
if (input.dependencies) {
|
|
369
|
+
const start = performance.now();
|
|
370
|
+
const result = await measured("dependencies", () => createExecPort().run({ command: input.dependencies!.command[0], args: input.dependencies!.command.slice(1), cwd: rootDir,
|
|
371
|
+
env: environment, timeoutMs: input.dependencies!.timeoutMs, maxBuffer: 1024 * 1024, ...(input.signal ? { signal: input.signal } : {}) }));
|
|
372
|
+
input.onDependencyResult?.(result, Math.round(performance.now() - start));
|
|
373
|
+
if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private finite dependency installation failed.");
|
|
374
|
+
}
|
|
375
|
+
const installed = input.dependencies ? await measured("post-dependency-inventory", () =>
|
|
376
|
+
snapshotDigests(rootDir, input.outputs, false, performance.now() + SNAPSHOT_TIMEOUT_MS, input.signal, true, true, source.absentInputs, selfReferences(rootDir, control))) : { digest: baseline, sourceDigest: baseline, selfReferenced: false };
|
|
377
|
+
if (baseline !== installed.sourceDigest) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed finite source bytes or modes.");
|
|
378
|
+
// Without Git there is no index to compare, so a reused finite tree is verified by its full inventory.
|
|
379
|
+
const open = (tree: PrivateTree, onPhase?: SnapshotRequest["onPhase"]): CheckSnapshot => {
|
|
380
|
+
const controlDir = tree.privateControl!;
|
|
381
|
+
const verify: CheckSnapshot["verify"] = async (options = {}) => phaseMeter(onPhase)("verification", async () => {
|
|
382
|
+
const timeout = options.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
|
|
383
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > SNAPSHOT_TIMEOUT_MS) throw new CheckSnapshotError("check_snapshot_timeout", "Invalid finite verification deadline.");
|
|
384
|
+
if (await inventory(tree.rootDir, options.outputs ?? input.outputs, false, performance.now() + timeout) !== installed.digest)
|
|
385
|
+
throw new CheckSnapshotError("check_snapshot_drift", "Finite source or installed dependencies changed.");
|
|
386
|
+
});
|
|
387
|
+
return { rootDir: tree.rootDir, commandRoot: path.join(controlDir, "commands"), environment: environmentFor(tree.rootDir, controlDir), dependencyDigest: installed.digest, clonable: !installed.selfReferenced, reusedVerification: "inventory", verify,
|
|
388
|
+
clone: (method, onClonePhase) => cloneSnapshot(tree, method, input.signal, onClonePhase, open), cleanup: () => removeTrees(tree.owned) };
|
|
389
|
+
};
|
|
390
|
+
return open({ rootDir, privateControl: control, owned: [rootDir, control] }, input.onPhase);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
await cleanup();
|
|
393
|
+
if (error instanceof CheckSnapshotError) throw error;
|
|
394
|
+
throw new CheckSnapshotError("check_snapshot_unavailable", "Finite source could not be materialized or verified.");
|
|
395
|
+
}
|
|
396
|
+
}
|
package/src/command-activity.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Best-effort CLI observations. No caller uses these results for admission. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type { RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
3
|
+
import type { CaptureCandidate, RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
4
4
|
import { buildRunEvent, resolveRunSurface } from "./run-surface.ts";
|
|
5
5
|
|
|
6
6
|
export interface CommandObservation {
|
|
@@ -12,6 +12,20 @@ export interface CommandObservation {
|
|
|
12
12
|
const absent: CommandObservation = { needsCandidate: false, start: async () => {}, prompt: prompt => prompt, finish: async () => {} };
|
|
13
13
|
function outcome(code: number) { return code === 0 ? "ok" : code === 2 ? "usage" : code === 130 ? "interrupted" : "policy"; }
|
|
14
14
|
|
|
15
|
+
/** Observe the first successful command capture; never reuse or replace a result. */
|
|
16
|
+
export function observeCandidateCaptures(capture: CaptureCandidate, observation: CommandObservation): CaptureCandidate {
|
|
17
|
+
let start: Promise<void> | undefined;
|
|
18
|
+
return async () => {
|
|
19
|
+
const result = await capture();
|
|
20
|
+
if (result.ok) {
|
|
21
|
+
start ??= Promise.resolve().then(() => observation.start(result.candidate.treeSha))
|
|
22
|
+
.catch(() => { /* Progress reporting never changes capture or command outcomes. */ });
|
|
23
|
+
await start;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
/** Pin the current run once, so a pointer change cannot split an invocation. */
|
|
16
30
|
export async function beginCommandObservation(cwd: string, command: string): Promise<CommandObservation> {
|
|
17
31
|
try {
|
package/src/commands/admit.ts
CHANGED
|
@@ -13,9 +13,9 @@ import path from "node:path";
|
|
|
13
13
|
|
|
14
14
|
import { commandBlocker, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
15
15
|
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { runGate } from "./gate.ts";
|
|
17
17
|
import { prepareCommand } from "./prepare.ts";
|
|
18
|
-
import {
|
|
18
|
+
import { writeRecord } from "./record.ts";
|
|
19
19
|
import { reviewContextCommand } from "./review-context.ts";
|
|
20
20
|
import { submitEvidenceCommand } from "./submit-evidence.ts";
|
|
21
21
|
|
|
@@ -52,6 +52,7 @@ function invalidOutcome(message: string): CommandResult {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export const admitCommand: CommandDescriptor = {
|
|
55
|
+
capturesCandidate: true,
|
|
55
56
|
name: "admit",
|
|
56
57
|
sourceId: "delivery-harness.cli.admit",
|
|
57
58
|
summary: "Prepare and admit a concluded review outcome through the tracked record write.",
|
|
@@ -97,10 +98,10 @@ export const admitCommand: CommandDescriptor = {
|
|
|
97
98
|
const stoppedAfterSubmission = report(context, "submitted", submitted);
|
|
98
99
|
if (stoppedAfterSubmission !== undefined) return stoppedAfterSubmission;
|
|
99
100
|
|
|
100
|
-
const gated = await
|
|
101
|
+
const gated = await runGate(withArgs(context, []));
|
|
101
102
|
const stoppedAfterGate = report(context, "gate", gated);
|
|
102
103
|
if (stoppedAfterGate !== undefined) return stoppedAfterGate;
|
|
103
104
|
|
|
104
|
-
return
|
|
105
|
+
return writeRecord(withArgs(context, []));
|
|
105
106
|
},
|
|
106
107
|
};
|
|
@@ -4,6 +4,7 @@ import { commandBlocker, type CommandDescriptor } from "../boundary.ts";
|
|
|
4
4
|
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
5
5
|
|
|
6
6
|
export const emitReviewEvidenceCommand: CommandDescriptor = {
|
|
7
|
+
capturesCandidate: true,
|
|
7
8
|
name: "emit-review-evidence",
|
|
8
9
|
sourceId: "delivery-harness.cli.emit-review-evidence",
|
|
9
10
|
summary: "Bind concluded review outcomes to their original prepared context.",
|