@agent-delivery-harness/cli 0.7.4 → 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/src/scoped-checks.ts
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { access, mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { captureCheckBindings, captureCheckOutputSnapshots, candidateTreeEvidenceReader, candidateTreeSourceReader, createExecPort, digestCanonical, resolveRecordStorage, runGitCommand, scopedCheckIdentity, selectScopedCheckAttempt, sha256Hex, submitManifest,
|
|
6
|
-
|
|
5
|
+
import { deliveryRecordPathFor, recordOutputProvider, computeDeliverableIdentity, capturePhysicalSource, captureCheckBindings, captureCheckOutputSnapshots, candidateTreeEvidenceReader, candidateTreeSourceReader, createExecPort, digestCanonical, resolveRecordStorage, runGitCommand, scopedCheckIdentity, selectScopedCheckAttempt, sha256Hex, submitManifest,
|
|
6
|
+
createResourceApplicabilityProjection, matchResourceApplicabilityProjection, createResourceApplicabilityBinding, reconstructResourceApplicability, discoverRecords, portableArtifactContents, isRecordFreshForCandidate, isRecordNeutralPath, type ResourceApplicabilityBinding, type InstalledDataAttemptOrigin, type InstalledDataResourceRequest, normalizeInstalledDataResourceRequests, type InstalledDataResourceContext, type InstalledDataResourceCapture,
|
|
7
|
+
type CandidateBinding, type CapturedCandidate, type ProviderRegistration, type ScopedCheckPlan, type ScopedExecutionProfile, type ScopedRuntimeObservation, type RecordCandidateBinding } from "@agent-delivery-harness/kernel";
|
|
7
8
|
import type { CommandContext } from "./boundary.ts";
|
|
8
9
|
import { AttemptStore, type AttemptPayload, type StoredAttempt } from "./scoped-attempts.ts";
|
|
9
|
-
import { CheckSnapshotError, createCheckSnapshot, executionPath, type CheckSnapshot } from "./check-snapshot.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { CheckSnapshotError, createCheckSnapshot, executionPath, snapshotCloneMethod, type CheckSnapshot, type CloneMethod, type SnapshotRequest } from "./check-snapshot.ts";
|
|
11
|
+
import { captureInstalledDataBinding, type InstalledDataSourceTree } from "./scoped-installed-data.ts";
|
|
12
|
+
import { captureOwnedInstalledDataResources } from "./installed-data-resources.ts";
|
|
13
|
+
import { redactScopedStreams, scopedCommandDiagnostic, scopedDependencyDiagnostic, scopedDiagnosticFailure, type RecordedScopedAttemptDiagnostic, type ScopedSetupDiagnostic } from "./scoped-diagnostics.ts";
|
|
11
14
|
export function scopedCandidate(candidate: CandidateBinding): RecordCandidateBinding {
|
|
12
15
|
return { treeSha: candidate.treeSha, deliverableDigest: candidate.deliverable.digest, identityToken: candidate.deliverable.identity, baseRef: candidate.base.ref, baseTipSha: candidate.base.tipSha, mergeBaseSha: candidate.base.mergeBaseSha, workspaceId: candidate.workspaceId };
|
|
13
16
|
}
|
|
@@ -28,33 +31,132 @@ async function executableIdentity(command: string, root: string, searchPath: str
|
|
|
28
31
|
}
|
|
29
32
|
throw new CheckSnapshotError("check_runtime_unavailable", "A declared execution tool cannot be resolved and bound.");
|
|
30
33
|
}
|
|
34
|
+
/** Checks whose profiles declare the same dependency setup and Git context share one installed snapshot. */
|
|
35
|
+
function setupKey(profile: ScopedExecutionProfile, physicalSource?: SnapshotRequest["physicalSource"]): string {
|
|
36
|
+
return digestCanonical({ dependencies: profile.dependencies ?? null, dependencyInputs: [...profile.dependencyInputs].sort(), gitContext: profile.gitContext ?? "full", ...(physicalSource ? { physicalSource } : {}) });
|
|
37
|
+
}
|
|
38
|
+
/** Structural checks shared by execution and preparation without an identity. */
|
|
39
|
+
function scopedProviders(context: CommandContext) {
|
|
40
|
+
const providers = context.config.providers.filter(p => p.check?.scope);
|
|
41
|
+
if (providers.length && context.config.scopedExecution?.version !== "scoped-execution/1") throw new CheckSnapshotError("scoped_executor_required", "Scoped checks require supported private execution profiles before preparation.");
|
|
42
|
+
return providers;
|
|
43
|
+
}
|
|
44
|
+
async function preparedSource(context: CommandContext, candidate: CapturedCandidate) {
|
|
45
|
+
const read = await candidateTreeSourceReader(context.rootDir, candidate.treeSha);
|
|
46
|
+
const listing = await runGitCommand(["git", "ls-tree", "-r", "--name-only", "-z", candidate.treeSha], { cwd: context.rootDir });
|
|
47
|
+
if (listing.exitCode !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Cannot enumerate the prepared source tree.");
|
|
48
|
+
return { inventory: listing.stdout.split("\0").filter(Boolean), read };
|
|
49
|
+
}
|
|
50
|
+
function validateScopedProvider(context: CommandContext, provider: ProviderRegistration, inventory: readonly string[]) {
|
|
51
|
+
const scope = provider.check!.scope!, profile = context.config.scopedExecution!.profiles.find(p => p.id === scope.profile);
|
|
52
|
+
if (!profile) throw new CheckSnapshotError("scoped_executor_required", "A selected execution profile is unsupported.");
|
|
53
|
+
if (profile.mutableOutputs.some(o => inventory.some(f => f === o.replace(/\/$/, "") || f.startsWith(`${o.replace(/\/$/, "")}/`)))) throw new CheckSnapshotError("check_output_overlaps_source", "Mutable outputs overlap prepared source.");
|
|
54
|
+
if ((provider.check!.outputs ?? []).some(o => !profile.mutableOutputs.some(m => m.endsWith("/") ? o.startsWith(m) : o === m))) throw new CheckSnapshotError("check_output_undeclared", "Every retained output must be declared mutable in its profile.");
|
|
55
|
+
return profile;
|
|
56
|
+
}
|
|
57
|
+
const shownArgument = (argument: string) => /^[\w@%+=:,./-]+$/.test(argument) ? argument : JSON.stringify(argument);
|
|
58
|
+
/** Runs the tasks it is given one at a time, in call order. */
|
|
59
|
+
function oneAtATime() {
|
|
60
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
61
|
+
return <T>(task: () => Promise<T>): Promise<T> => { const result = tail.then(task); tail = result.catch(() => undefined); return result; };
|
|
62
|
+
}
|
|
63
|
+
type AcquiredSetup = Omit<ScopedSetupDiagnostic, "verification" | "phases">;
|
|
64
|
+
/** A fail-fast gate's hold on one check: the signal that cancels it, and the check whose failure did. */
|
|
65
|
+
interface Cancellation { readonly signal: AbortSignal; readonly after: () => string }
|
|
66
|
+
/** One worker's view while it runs a check: the snapshots only it uses, by setup key, and where its lines go. */
|
|
67
|
+
interface Lane { readonly snapshots: Map<string, CheckSnapshot>; readonly write: (text: string) => void }
|
|
31
68
|
export class ScopedChecks {
|
|
32
|
-
|
|
69
|
+
/** `scopedExecution.concurrency` workers, each owning its snapshots; the first is the serial lane. */
|
|
70
|
+
private readonly workers: Map<string, CheckSnapshot>[];
|
|
71
|
+
readonly concurrency: number;
|
|
72
|
+
private readonly phaseWriters = new WeakMap<Map<string, CheckSnapshot>, (phase: string, durationMs: number) => void>();
|
|
73
|
+
/** Attempt allocation is two writes, so readers of attempt history wait for it to finish. */
|
|
74
|
+
private readonly locked = oneAtATime();
|
|
75
|
+
/** Evidence submission captures the candidate through the author's Git index, which one writer may hold. */
|
|
76
|
+
private readonly publishing = oneAtATime();
|
|
77
|
+
/** Resource discovery is a pre-execution phase on the serial worker only. */
|
|
78
|
+
private readonly capturing = oneAtATime();
|
|
79
|
+
private captureTail: Promise<unknown> = Promise.resolve();
|
|
80
|
+
private capturePhaseOpen = true;
|
|
81
|
+
private cleanupStarted = false;
|
|
82
|
+
/** With more than one worker: one read-only warm base per setup key, which no command runs in. Each
|
|
83
|
+
* worker takes a copy-on-write clone of it; without cloning each worker builds its own, as before. */
|
|
84
|
+
private readonly bases = new Map<string, Promise<CheckSnapshot>>();
|
|
85
|
+
private cloning?: Promise<CloneMethod | undefined>;
|
|
86
|
+
/** Profiles whose checks a tree has run and verified since it was made: their outputs may be in it. */
|
|
87
|
+
private readonly ranOutputs = new WeakMap<CheckSnapshot, Set<string>>();
|
|
88
|
+
private readonly executionLocks = new Map<Map<string, CheckSnapshot>, ReturnType<typeof oneAtATime>>();
|
|
89
|
+
private readonly executionTails = new Map<Map<string, CheckSnapshot>, Promise<unknown>>();
|
|
90
|
+
private retainedResourceBindings?: Promise<Readonly<Record<string, ResourceApplicabilityBinding>>>;
|
|
91
|
+
private readonly sourceTreeCache = new Map<string, Promise<InstalledDataSourceTree>>();
|
|
92
|
+
private candidateSource!: InstalledDataSourceTree;
|
|
93
|
+
private sourceFrames?: Promise<{ base: InstalledDataSourceTree; candidate: InstalledDataSourceTree }>;
|
|
94
|
+
private readonly installed = new Map<string, { binding: Awaited<ReturnType<typeof captureInstalledDataBinding>>; capture: InstalledDataResourceCapture; snapshot: CheckSnapshot }>();
|
|
33
95
|
private readonly owned = new Map<string, Awaited<ReturnType<AttemptStore["allocate"]>>>();
|
|
34
96
|
private readonly stores = new Map<string, AttemptStore>();
|
|
35
97
|
private readonly identities = new Map<string, Awaited<ReturnType<typeof scopedCheckIdentity>>>();
|
|
98
|
+
private readonly executionIdentities = new Map<string, Awaited<ReturnType<typeof scopedCheckIdentity>>>();
|
|
36
99
|
private readonly observations = new Map<string, ScopedRuntimeObservation>();
|
|
37
100
|
private readonly runId = randomUUID();
|
|
38
101
|
private readonly submissions: (() => Promise<void>)[] = [];
|
|
39
102
|
readonly context: CommandContext;
|
|
40
103
|
readonly candidate: CapturedCandidate;
|
|
41
|
-
private constructor(context: CommandContext, candidate: CapturedCandidate) {
|
|
104
|
+
private constructor(context: CommandContext, candidate: CapturedCandidate) {
|
|
105
|
+
this.context = context; this.candidate = candidate;
|
|
106
|
+
this.concurrency = context.config.scopedExecution?.concurrency ?? 1;
|
|
107
|
+
this.workers = Array.from({ length: this.concurrency }, () => new Map<string, CheckSnapshot>());
|
|
108
|
+
}
|
|
109
|
+
private get serial(): Lane { return { snapshots: this.workers[0]!, write: text => this.context.write(text) }; }
|
|
110
|
+
/** Zero mechanical preparation validates the real narrow physical frame,
|
|
111
|
+
* but does not hash broad consumer inputs or observe execution tools. Gate
|
|
112
|
+
* creates its own fresh session; no deferred identity is retained here. */
|
|
113
|
+
static async validatePreparation(context: CommandContext, candidate: CapturedCandidate): Promise<void> {
|
|
114
|
+
const providers = scopedProviders(context);
|
|
115
|
+
if (!providers.length) return;
|
|
116
|
+
const { inventory, read } = await preparedSource(context, candidate);
|
|
117
|
+
const output = deliveryRecordPathFor(context.config, candidate.deliverable.digest);
|
|
118
|
+
const ordinaryInventory = inventory.filter(file => file !== output), inventoryPaths = new Set(ordinaryInventory);
|
|
119
|
+
for (const provider of providers) {
|
|
120
|
+
const profile = validateScopedProvider(context, provider, inventory), scope = recordOutputProvider(provider, output).check!.scope!;
|
|
121
|
+
// Metadata retains source/link/type and required-input validation without
|
|
122
|
+
// reading and hashing broad regular consumer input bytes.
|
|
123
|
+
const ordinarySelected = new Set([...scope.files, ...scope.tests,
|
|
124
|
+
...scope.memberships.flatMap(prefix => ordinaryInventory.filter(file => file.startsWith(prefix)))]);
|
|
125
|
+
const selected = new Set([...ordinarySelected, ...profile.dependencyInputs]);
|
|
126
|
+
for (const file of selected) {
|
|
127
|
+
const metadata = await read.metadata(file);
|
|
128
|
+
// The reader owns supported entry/link semantics, including committed
|
|
129
|
+
// directory links. A null mode is exactly its absent-byte result.
|
|
130
|
+
if (metadata.mode === null) {
|
|
131
|
+
if (profile.dependencyInputs.includes(file)) throw new CheckSnapshotError("check_dependency_input_missing", `Required dependency input ${file} is missing from the prepared tree.`);
|
|
132
|
+
if (scope.tests.includes(file)) throw new Error("Required test is absent");
|
|
133
|
+
}
|
|
134
|
+
if (ordinarySelected.has(file) && (metadata.mode !== null) !== inventoryPaths.has(file))
|
|
135
|
+
throw new Error("Snapshot membership and metadata presence disagree");
|
|
136
|
+
}
|
|
137
|
+
if (provider.check!.physicalSource) {
|
|
138
|
+
try { await capturePhysicalSource(provider.check!.physicalSource, read); }
|
|
139
|
+
catch { throw new CheckSnapshotError("check_physical_source_invalid", "Physical source bytes, modes or required absences are unavailable or unsafe."); }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
42
143
|
static async create(context: CommandContext, candidate: CapturedCandidate): Promise<ScopedChecks | undefined> {
|
|
43
|
-
const providers = context
|
|
144
|
+
const providers = scopedProviders(context);
|
|
44
145
|
if (!providers.length) return undefined;
|
|
45
|
-
if (context.config.scopedExecution?.version !== "scoped-execution/1") throw new CheckSnapshotError("scoped_executor_required", "Scoped checks require supported private execution profiles before preparation.");
|
|
46
146
|
const session = new ScopedChecks(context, candidate);
|
|
47
147
|
const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace, leaf: "scoped-attempts" });
|
|
48
|
-
const read = await
|
|
148
|
+
const { inventory, read } = await preparedSource(context, candidate);
|
|
49
149
|
const readEvidence = await candidateTreeEvidenceReader(context.rootDir, candidate.treeSha);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
150
|
+
session.candidateSource = { inventory, read };
|
|
151
|
+
const validationConfig = { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral };
|
|
152
|
+
const validationTrees = new Map<string, Promise<string>>();
|
|
153
|
+
const validation = (treeSha: string) => {
|
|
154
|
+
let digest = validationTrees.get(treeSha);
|
|
155
|
+
if (!digest) { digest = computeDeliverableIdentity({ rootDir: context.rootDir, treeSha, config: validationConfig }); validationTrees.set(treeSha, digest); }
|
|
156
|
+
return digest;
|
|
157
|
+
};
|
|
53
158
|
for (const provider of providers) {
|
|
54
|
-
const scope = provider.check!.scope!, profile = context
|
|
55
|
-
if (!profile) throw new CheckSnapshotError("scoped_executor_required", "A selected execution profile is unsupported.");
|
|
56
|
-
if (profile.mutableOutputs.some(o => inventory.some(f => f === o.replace(/\/$/, "") || f.startsWith(`${o.replace(/\/$/, "")}/`)))) throw new CheckSnapshotError("check_output_overlaps_source", "Mutable outputs overlap prepared source.");
|
|
57
|
-
if ((provider.check!.outputs ?? []).some(o => !profile.mutableOutputs.some(m => m.endsWith("/") ? o.startsWith(m) : o === m))) throw new CheckSnapshotError("check_output_undeclared", "Every retained output must be declared mutable in its profile.");
|
|
159
|
+
const scope = provider.check!.scope!, profile = validateScopedProvider(context, provider, inventory);
|
|
58
160
|
const searchPath = executionPath(context.rootDir, context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin");
|
|
59
161
|
const tools = await Promise.all([[process.execPath, "."], [provider.check!.command[0], scope.cwd], ...(profile.dependencies ? [[profile.dependencies.command[0], "."]] : [])].map(([c, cwd]) => executableIdentity(c!, context.rootDir, searchPath, cwd!, read)));
|
|
60
162
|
const observation: ScopedRuntimeObservation = { version: "scoped-runtime/1", runtimeDigest: digestCanonical({ platform: process.platform, arch: process.arch, tools, searchPath }),
|
|
@@ -65,19 +167,35 @@ export class ScopedChecks {
|
|
|
65
167
|
return [e.name, { present, identity }];
|
|
66
168
|
})) };
|
|
67
169
|
session.observations.set(provider.id, observation);
|
|
68
|
-
|
|
69
|
-
session.stores.set(provider.id,
|
|
170
|
+
const store = new AttemptStore(path.join(storage.storageDir, digestCanonical({ gate: context.config.gateId, provider: provider.id })));
|
|
171
|
+
session.stores.set(provider.id, store);
|
|
172
|
+
let identity = await scopedCheckIdentity(context.config, provider, inventory, read, observation, candidate, readEvidence);
|
|
173
|
+
session.executionIdentities.set(provider.id, identity);
|
|
174
|
+
// Preserve raw Git identity for execution. Only proven record-neutral tree
|
|
175
|
+
// transport may select a prior identity, and only for reusable attempts.
|
|
176
|
+
const history = await store.read();
|
|
177
|
+
if (identity.reusable && profile.gitContext !== "none" && !history.some(({ attempt }) => attempt.inputDigest === identity.inputDigest && attempt.profileDigest === identity.profileDigest)) {
|
|
178
|
+
for (const { attempt } of history.reverse()) {
|
|
179
|
+
const origin = attempt.origin.candidate;
|
|
180
|
+
if (origin.treeSha === candidate.treeSha || origin.baseRef !== candidate.base.ref || origin.baseTipSha !== candidate.base.tipSha ||
|
|
181
|
+
origin.mergeBaseSha !== candidate.base.mergeBaseSha || origin.workspaceId !== candidate.workspaceId ||
|
|
182
|
+
origin.identityToken !== candidate.deliverable.identity || await validation(origin.treeSha) !== await validation(candidate.treeSha)) continue;
|
|
183
|
+
const transported = await scopedCheckIdentity(context.config, provider, inventory, read, observation, { ...candidate, treeSha: origin.treeSha }, readEvidence);
|
|
184
|
+
if (transported.inputDigest === attempt.inputDigest && transported.profileDigest === attempt.profileDigest) { identity = transported; break; }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
session.identities.set(provider.id, identity);
|
|
70
188
|
}
|
|
71
189
|
return session;
|
|
72
190
|
}
|
|
73
191
|
async plan(): Promise<ScopedCheckPlan> {
|
|
74
192
|
const checks: Record<string, ScopedCheckPlan["checks"][string]> = {};
|
|
75
|
-
for (const [id, identity] of this.identities) checks[id] = { inputDigest: identity.inputDigest, profileDigest: identity.profileDigest, reusable: identity.reusable, attempts: (await this.stores.get(id)!.read()).map(r => r.attempt) };
|
|
193
|
+
for (const [id, identity] of this.identities) checks[id] = { inputDigest: identity.inputDigest, profileDigest: identity.profileDigest, reusable: identity.reusable, attempts: (await this.locked(() => this.stores.get(id)!.read())).map(r => r.attempt) };
|
|
76
194
|
return { version: "scoped-plan/1", candidate: scopedCandidate(this.candidate), selectionDigest: digestCanonical(this.context.config.providers.filter(p => p.check?.scope).map(p => ({ id: p.id, check: p.check })).sort((a, b) => a.id.localeCompare(b.id))), checks };
|
|
77
195
|
}
|
|
78
196
|
private async selected(id: string): Promise<StoredAttempt | undefined> {
|
|
79
197
|
const identity = this.identities.get(id)!;
|
|
80
|
-
const rows = await this.stores.get(id)!.read();
|
|
198
|
+
const rows = await this.locked(() => this.stores.get(id)!.read());
|
|
81
199
|
const selected = selectScopedCheckAttempt(id, identity.inputDigest, identity.profileDigest, rows.map(r => r.attempt));
|
|
82
200
|
return rows.find(r => r.attempt.attemptId === selected?.attemptId);
|
|
83
201
|
}
|
|
@@ -93,8 +211,11 @@ export class ScopedChecks {
|
|
|
93
211
|
for (const id of ids) if (!this.identities.get(id)!.reusable && !this.owned.has(id)) await this.allocate(id);
|
|
94
212
|
}
|
|
95
213
|
private async allocate(id: string) {
|
|
96
|
-
|
|
97
|
-
|
|
214
|
+
// Historical identity transport selects retained evidence only. A fresh
|
|
215
|
+
// attempt executes this candidate and must bind its actual source tree.
|
|
216
|
+
const identity = this.executionIdentities.get(id)!;
|
|
217
|
+
this.identities.set(id, identity);
|
|
218
|
+
const attempt = await this.locked(() => this.stores.get(id)!.allocate({ version: "scoped-attempt/1", providerId: id, inputDigest: identity.inputDigest, profileDigest: identity.profileDigest, origin: { candidate: scopedCandidate(this.candidate), runId: this.runId } }, { executionHeadSha: this.candidate.headSha }));
|
|
98
219
|
this.owned.set(id, attempt); return attempt;
|
|
99
220
|
}
|
|
100
221
|
async explainReuse(admitted: readonly string[]): Promise<void> {
|
|
@@ -104,61 +225,427 @@ export class ScopedChecks {
|
|
|
104
225
|
for (const row of await this.stores.get(id)!.read()) if (row.attempt.status !== "passed" && row.attempt.inputDigest !== this.identities.get(id)!.inputDigest) this.context.write(`superseded ${id}: ${row.attempt.status} attempt ${row.attempt.attemptId} has different inputs`);
|
|
105
226
|
}
|
|
106
227
|
}
|
|
228
|
+
private profileOf(id: string): ScopedExecutionProfile {
|
|
229
|
+
const scope = this.context.config.providers.find(p => p.id === id)!.check!.scope!;
|
|
230
|
+
return this.context.config.scopedExecution!.profiles.find(p => p.id === scope.profile)!;
|
|
231
|
+
}
|
|
232
|
+
/** The first declared check runs first: it is where an adopter puts a selection guard that must precede
|
|
233
|
+
* every other check. The rest run cheapest first, each costing its most recent passed or failed attempt's
|
|
234
|
+
* duration less that attempt's own dependency setup; never-timed checks follow timed ones and ties keep
|
|
235
|
+
* declaration order. Checks then run one dependency setup at a time, in the order each setup first
|
|
236
|
+
* appears, so each setup installs once. */
|
|
237
|
+
private async cheapestFirst(ids: readonly string[]): Promise<string[]> {
|
|
238
|
+
const cost = new Map<string, number>();
|
|
239
|
+
for (const id of ids) {
|
|
240
|
+
const last = (await this.stores.get(id)!.read()).filter(r => r.attempt.status === "passed" || r.attempt.status === "failed").at(-1)?.payload;
|
|
241
|
+
// A check that waited on another's warm base did not pay that setup either.
|
|
242
|
+
const waited = last?.diagnostic?.setup?.phases.filter(p => p.phase === "warm-base-wait").reduce((sum, p) => sum + p.durationMs, 0) ?? 0;
|
|
243
|
+
cost.set(id, last?.durationMs === undefined ? Infinity : last.durationMs - (last.diagnostic?.dependency?.durationMs ?? 0) - waited);
|
|
244
|
+
}
|
|
245
|
+
const [first, ...rest] = ids;
|
|
246
|
+
if (first === undefined) return [];
|
|
247
|
+
const byCost = [first, ...rest.sort((a, b) => cost.get(a)! === cost.get(b)! ? 0 : cost.get(a)! < cost.get(b)! ? -1 : 1)];
|
|
248
|
+
const keys = byCost.map(id => setupKey(this.profileOf(id), this.context.config.providers.find(p => p.id === id)?.check?.physicalSource));
|
|
249
|
+
return [...new Set(keys)].flatMap(key => byCost.filter((_, index) => keys[index] === key));
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Runs ids on the session's workers, each taking the next id when it is free. Every worker keeps its own
|
|
253
|
+
* snapshots, so two checks never share one at once. A worker prints a check's lines together when it
|
|
254
|
+
* finishes; a single worker prints them as they come. After a check throws no new id starts, the running
|
|
255
|
+
* ones finish, and the first failure in `ids` order is thrown.
|
|
256
|
+
*/
|
|
257
|
+
private async pool(ids: readonly string[], run: (id: string, lane: Lane) => Promise<void>): Promise<void> {
|
|
258
|
+
const failures = new Map<string, unknown>();
|
|
259
|
+
let next = 0;
|
|
260
|
+
await Promise.all(this.workers.map(async snapshots => {
|
|
261
|
+
while (next < ids.length && failures.size === 0) {
|
|
262
|
+
const id = ids[next++]!, lines: string[] = [];
|
|
263
|
+
try { await run(id, this.concurrency === 1 ? this.serial : { snapshots, write: text => { lines.push(text); } }); }
|
|
264
|
+
catch (error) { failures.set(id, error); }
|
|
265
|
+
finally { for (const line of lines) this.context.write(line); }
|
|
266
|
+
}
|
|
267
|
+
}));
|
|
268
|
+
const failed = ids.find(id => failures.has(id));
|
|
269
|
+
if (failed !== undefined) throw failures.get(failed);
|
|
270
|
+
}
|
|
271
|
+
/** Stops at the first failure: a failed mechanical check publishes no receipt. */
|
|
107
272
|
async satisfyMechanical(): Promise<void> {
|
|
108
|
-
const ids = this.context.config.scopedExecution!.mechanicalProviders;
|
|
273
|
+
const ids = await this.cheapestFirst(this.context.config.scopedExecution!.mechanicalProviders);
|
|
109
274
|
await this.fenceNonReusable(ids);
|
|
110
|
-
|
|
111
|
-
if ((await this.selected(id))?.attempt.status === "passed") {
|
|
275
|
+
const satisfy = async (id: string, lane: Lane) => {
|
|
276
|
+
if ((await this.selected(id))?.attempt.status === "passed") { lane.write(`reusing mechanical ${id}`); return; }
|
|
112
277
|
const provider = this.context.config.providers.find(p => p.id === id)!;
|
|
113
|
-
await this.execute(provider, this.context.config.obligations.filter(o => o.providers.includes(id)).map(o => o.id), true);
|
|
278
|
+
await this.execute(provider, this.context.config.obligations.filter(o => o.providers.includes(id)).map(o => o.id), true, lane);
|
|
279
|
+
};
|
|
280
|
+
// The first declared check guards the rest, so it finishes before any other starts.
|
|
281
|
+
await this.pool(ids.slice(0, 1), satisfy);
|
|
282
|
+
await this.pool(ids.slice(1), satisfy);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Runs the gate's requested checks on the pool. A check's own failure is returned in request order. Without
|
|
286
|
+
* `scopedExecution.failFast` it stops nothing; with it, the first failure cancels every other check: one not
|
|
287
|
+
* yet started never starts and one running has its process group signalled, and both are recorded
|
|
288
|
+
* `interrupted` with `check_cancelled` and left out of the returned failures.
|
|
289
|
+
*/
|
|
290
|
+
async executeAll(requests: readonly { readonly provider: ProviderRegistration; readonly obligationIds: readonly string[] }[]): Promise<CheckSnapshotError[]> {
|
|
291
|
+
const failures = new Map<string, CheckSnapshotError>();
|
|
292
|
+
const failFast = this.context.config.scopedExecution?.failFast === true;
|
|
293
|
+
const running = new Map<string, AbortController>(), unstarted: string[] = [];
|
|
294
|
+
let firstFailure: string | undefined;
|
|
295
|
+
const run = async (id: string, lane: Lane) => {
|
|
296
|
+
if (firstFailure !== undefined) { unstarted.push(id); return; }
|
|
297
|
+
const request = requests.find(r => r.provider.id === id)!;
|
|
298
|
+
const cancel = failFast ? new AbortController() : undefined;
|
|
299
|
+
if (cancel) running.set(id, cancel);
|
|
300
|
+
try { await this.execute(request.provider, request.obligationIds, false, lane, cancel && { signal: cancel.signal, after: () => firstFailure! }); }
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (!(error instanceof CheckSnapshotError)) throw error;
|
|
303
|
+
if (error.code === "check_cancelled") return;
|
|
304
|
+
failures.set(id, error);
|
|
305
|
+
if (failFast && firstFailure === undefined) {
|
|
306
|
+
firstFailure = id;
|
|
307
|
+
for (const [other, controller] of running) if (other !== id) controller.abort();
|
|
308
|
+
}
|
|
309
|
+
} finally { running.delete(id); }
|
|
310
|
+
};
|
|
311
|
+
const resource = requests.find(r => this.acceptsApplicability(r.provider, r.obligationIds));
|
|
312
|
+
if (resource) {
|
|
313
|
+
// V1 has one conditional consumer. Keep the first configured check ahead of
|
|
314
|
+
// it, and execute both on the capture worker before releasing the rest to
|
|
315
|
+
// the pool. Other workers never inherit that worker's installed tree.
|
|
316
|
+
const serialIds = [...new Set([requests[0]!.provider.id, resource.provider.id])];
|
|
317
|
+
for (const id of serialIds) await run(id, this.serial);
|
|
318
|
+
await this.pool(requests.map(r => r.provider.id).filter(id => !serialIds.includes(id)), run);
|
|
319
|
+
} else await this.pool(requests.map(r => r.provider.id), run);
|
|
320
|
+
if (firstFailure !== undefined) await this.cancelUnstarted(unstarted, firstFailure);
|
|
321
|
+
return requests.flatMap(r => failures.has(r.provider.id) ? [failures.get(r.provider.id)!] : []);
|
|
322
|
+
}
|
|
323
|
+
/** Records each check a fail-fast gate never started as `interrupted`, with `check_cancelled` and no command. */
|
|
324
|
+
async cancelUnstarted(ids: readonly string[], after: string): Promise<void> {
|
|
325
|
+
for (const id of ids) {
|
|
326
|
+
const attempt = this.owned.get(id) ?? await this.allocate(id), store = this.stores.get(id)!;
|
|
327
|
+
await store.finish(attempt, "interrupted", { outputs: [], durationMs: 0,
|
|
328
|
+
diagnostic: { availability: "available", phase: "snapshot-setup", failure: { code: "check_cancelled" }, command: { unavailable: "not-started" } }, log: "check_cancelled" });
|
|
329
|
+
this.context.write(`interrupted ${id}: check_cancelled before it started, after ${after} failed; attempt ${attempt.attemptId} retained at ${store.terminalPath(attempt)}`);
|
|
114
330
|
}
|
|
115
331
|
}
|
|
116
332
|
async submitMechanical(): Promise<void> { for (const submit of this.submissions) await submit(); this.submissions.length = 0; }
|
|
117
|
-
|
|
333
|
+
/** One acquisition path preserves setup sharing, output ownership and worker isolation. A worker's tree
|
|
334
|
+
* for a setup key is built here, cloned from the warm base, or retained from its previous check.
|
|
335
|
+
* `onSetup` hears how the tree is being got before any setup runs, so a failed setup is attributed. */
|
|
336
|
+
private async acquireSnapshot(profile: ScopedExecutionProfile, lane: Lane, onDependencyResult?: SnapshotRequest["onDependencyResult"], physicalSource?: SnapshotRequest["physicalSource"], onSetup: (setup: AcquiredSetup) => void = () => {}): Promise<{ snapshot: CheckSnapshot; sharedOutputs: string[]; setup: AcquiredSetup }> {
|
|
337
|
+
const key = setupKey(profile, physicalSource);
|
|
338
|
+
// Every scoped check's profile sharing this setup may write its own outputs in the shared tree;
|
|
339
|
+
// only those profiles passed the overlap refusal in create().
|
|
340
|
+
const sharedOutputs = [...new Set(this.context.config.scopedExecution!.profiles
|
|
341
|
+
.filter(p => this.context.config.providers.some(q => q.check?.scope?.profile === p.id && setupKey(p, q.check.physicalSource) === key)).flatMap(p => p.mutableOutputs))];
|
|
342
|
+
const method = this.concurrency === 1 ? undefined : await (this.cloning ??= snapshotCloneMethod());
|
|
343
|
+
const acquired = (snapshot: CheckSnapshot, setup: AcquiredSetup) => { lane.snapshots.set(key, snapshot); onSetup(setup); return { snapshot, sharedOutputs, setup }; };
|
|
344
|
+
const retained = lane.snapshots.get(key);
|
|
345
|
+
if (retained) return acquired(retained, { action: "reused", snapshot: "retained", clone: this.concurrency === 1 ? "not-used" : method ?? "unavailable" });
|
|
346
|
+
// A single worker retains only consecutive same-setup reuse; durable outputs no longer
|
|
347
|
+
// need the private tree. A pool keeps each worker's snapshots until cleanup().
|
|
348
|
+
if (this.concurrency === 1) for (const [previousKey, previous] of lane.snapshots) {
|
|
349
|
+
await previous.cleanup();
|
|
350
|
+
lane.snapshots.delete(previousKey);
|
|
351
|
+
}
|
|
352
|
+
const onPhase = (phase: string, durationMs: number) => {
|
|
353
|
+
const write = this.phaseWriters.get(lane.snapshots);
|
|
354
|
+
if (write) write(phase, durationMs);
|
|
355
|
+
else lane.write(`snapshot ${profile.id}: bootstrap ${phase} ${Math.round(durationMs)}ms`);
|
|
356
|
+
};
|
|
357
|
+
const build = (dependencyResult?: SnapshotRequest["onDependencyResult"]) => createCheckSnapshot({ rootDir: this.context.rootDir, candidate: this.candidate, outputs: sharedOutputs, gitContext: profile.gitContext ?? "full", environment: { PATH: this.context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin" }, ...(physicalSource ? { physicalSource } : {}), ...(profile.dependencies ? { dependencies: profile.dependencies } : {}), ...(this.context.signal ? { signal: this.context.signal } : {}),
|
|
358
|
+
onPhase, ...(dependencyResult ? { onDependencyResult: dependencyResult } : {}) });
|
|
359
|
+
let executed = false, clone: AcquiredSetup["clone"] = this.concurrency === 1 ? "not-used" : "unavailable";
|
|
360
|
+
if (method) {
|
|
361
|
+
let base = this.bases.get(key);
|
|
362
|
+
if (!base) {
|
|
363
|
+
executed = true;
|
|
364
|
+
base = build(onDependencyResult);
|
|
365
|
+
this.bases.set(key, base);
|
|
366
|
+
// A failed setup is not shared: the next check of this key sets up again, as it would on its own worker.
|
|
367
|
+
const failed = base;
|
|
368
|
+
failed.catch(() => { if (this.bases.get(key) === failed) this.bases.delete(key); });
|
|
369
|
+
}
|
|
370
|
+
onSetup({ action: executed ? "executed" : "reused", snapshot: "cloned", clone: method });
|
|
371
|
+
const waiting = performance.now();
|
|
372
|
+
let warm: CheckSnapshot | undefined;
|
|
373
|
+
try { warm = await base; }
|
|
374
|
+
catch (error) {
|
|
375
|
+
// Another check's setup failed; this worker sets up again itself, as it would have on its own.
|
|
376
|
+
if (executed || !(error instanceof CheckSnapshotError) || this.context.signal?.aborted) throw error;
|
|
377
|
+
lane.write(`snapshot ${profile.id}: shared setup failed (${error.code}); setting up again`);
|
|
378
|
+
}
|
|
379
|
+
if (!executed) onPhase("warm-base-wait", performance.now() - waiting);
|
|
380
|
+
if (warm && !warm.clonable) {
|
|
381
|
+
// The setup wrote the base's own path into it, so clones would still share the base through it.
|
|
382
|
+
clone = "path-bound";
|
|
383
|
+
lane.write(`snapshot ${profile.id}: setup wrote its own path into the tree; each worker builds its own`);
|
|
384
|
+
if (executed) return acquired(warm, { action: "executed", snapshot: "built", clone });
|
|
385
|
+
} else if (warm) {
|
|
386
|
+
try { return acquired(await warm.clone(method, onPhase), { action: executed ? "executed" : "reused", snapshot: "cloned", clone: method }); }
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (!(error instanceof CheckSnapshotError) || this.context.signal?.aborted) throw error;
|
|
389
|
+
// The base stays for other workers; this one sets up its own tree, as without cloning.
|
|
390
|
+
lane.write(`snapshot ${profile.id}: clone unavailable; ${error.code}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// A dependency result belongs to this attempt only for the first setup it ran.
|
|
395
|
+
onSetup({ action: "executed", snapshot: "built", clone });
|
|
396
|
+
return acquired(await build(executed ? undefined : onDependencyResult), { action: "executed", snapshot: "built", clone });
|
|
397
|
+
}
|
|
398
|
+
private scheduleCapture<T>(task: () => Promise<T>, executionStage = false): Promise<T> {
|
|
399
|
+
if (this.cleanupStarted || (!this.capturePhaseOpen && !executionStage)) return Promise.reject(new CheckSnapshotError("installed_data_capture_closed", "Resource capture must finish before execution or cleanup begins."));
|
|
400
|
+
const accepted = this.capturing(task);
|
|
401
|
+
this.captureTail = accepted.catch(() => undefined);
|
|
402
|
+
return accepted;
|
|
403
|
+
}
|
|
404
|
+
private sourceTree(sha: string): Promise<InstalledDataSourceTree> {
|
|
405
|
+
if (sha === this.candidate.treeSha) return Promise.resolve(this.candidateSource);
|
|
406
|
+
let tree = this.sourceTreeCache.get(sha);
|
|
407
|
+
if (!tree) {
|
|
408
|
+
tree = (async () => {
|
|
409
|
+
const listing = await runGitCommand(["git", "ls-tree", "-r", "--name-only", "-z", sha], { cwd: this.context.rootDir });
|
|
410
|
+
if (listing.exitCode !== 0) throw new CheckSnapshotError("installed_data_source_unqualified", "Cannot enumerate installed resource source inputs.");
|
|
411
|
+
return { inventory: listing.stdout.split("\0").filter(Boolean), read: await candidateTreeSourceReader(this.context.rootDir, sha) };
|
|
412
|
+
})();
|
|
413
|
+
this.sourceTreeCache.set(sha, tree);
|
|
414
|
+
}
|
|
415
|
+
return tree;
|
|
416
|
+
}
|
|
417
|
+
private async sourceTrees() {
|
|
418
|
+
return this.sourceFrames ??= (async () => ({ base: await this.sourceTree(this.candidate.base.mergeBaseSha), candidate: this.candidateSource }))();
|
|
419
|
+
}
|
|
420
|
+
private async captureResourceFacts(providerId: string, requests: readonly InstalledDataResourceRequest[], context: InstalledDataResourceContext, lane: Lane = this.serial) {
|
|
421
|
+
const provider = this.context.config.providers.find(p => p.id === providerId);
|
|
422
|
+
const profile = this.context.config.scopedExecution?.profiles.find(p => p.id === provider?.check?.scope?.profile);
|
|
423
|
+
if (!provider?.check?.scope || !profile || !this.identities.has(providerId)) throw new CheckSnapshotError("scoped_executor_required", "Installed resources require an already configured scoped provider and profile.");
|
|
424
|
+
const normalized = normalizeInstalledDataResourceRequests(requests);
|
|
425
|
+
try {
|
|
426
|
+
const { snapshot, sharedOutputs } = await this.acquireSnapshot(profile, lane, undefined, provider.check.physicalSource);
|
|
427
|
+
// Prior checks have already retained their outputs. Resource observation
|
|
428
|
+
// reads immutable package DATA, so remove permitted mutable products first
|
|
429
|
+
// and retain the observer's strict source/dependency verification.
|
|
430
|
+
for (const output of sharedOutputs) await rm(path.join(snapshot.rootDir, output), { recursive: true, force: true });
|
|
431
|
+
const capture = await captureOwnedInstalledDataResources(snapshot, normalized, context, this.context.signal ? { signal: this.context.signal } : {});
|
|
432
|
+
return { capture, snapshot };
|
|
433
|
+
} catch (error) {
|
|
434
|
+
const key = setupKey(profile, provider.check.physicalSource), damaged = lane.snapshots.get(key);
|
|
435
|
+
// Keep a failed cleanup in the pool so final cleanup can retry it.
|
|
436
|
+
await damaged?.cleanup();
|
|
437
|
+
lane.snapshots.delete(key);
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** Native resource facts only: caller-supplied context never seeds applicability. */
|
|
442
|
+
captureInstalledData(providerId: string, requests: readonly InstalledDataResourceRequest[], context: InstalledDataResourceContext): Promise<InstalledDataResourceCapture> {
|
|
443
|
+
return this.scheduleCapture(async () => (await this.captureResourceFacts(providerId, requests, context)).capture);
|
|
444
|
+
}
|
|
445
|
+
/** Derive authority from the configured consumer and immutable native source frames.
|
|
446
|
+
* Matching resources do not make the prior full-Git check attempt current. */
|
|
447
|
+
captureInstalledDataFor(providerId: string) {
|
|
448
|
+
return this.scheduleCapture(() => this.captureDeclaredInstalledData(providerId));
|
|
449
|
+
}
|
|
450
|
+
private async captureDeclaredInstalledData(providerId: string, lane: Lane = this.serial) {
|
|
451
|
+
const provider = this.context.config.providers.find(p => p.id === providerId);
|
|
452
|
+
if (!provider?.check?.installedData || !this.observations.has(providerId)) throw new CheckSnapshotError("installed_data_source_unqualified", "A configured installed data consumer is required.");
|
|
453
|
+
const binding = await captureInstalledDataBinding(this.context.config, provider, this.candidate, this.observations.get(providerId)!, await this.sourceTrees(), await candidateTreeEvidenceReader(this.context.rootDir, this.candidate.treeSha));
|
|
454
|
+
const { capture, snapshot } = await this.captureResourceFacts(providerId, binding.declaration.requests, binding.context, lane);
|
|
455
|
+
this.installed.set(providerId, { binding: structuredClone(binding), capture: structuredClone(capture), snapshot });
|
|
456
|
+
// A newer running, failed or unqualified pass fences older successful history.
|
|
457
|
+
const latest = (await this.locked(() => this.stores.get(providerId)!.read())).at(-1);
|
|
458
|
+
const origin = latest && this.resourceOrigin(latest);
|
|
459
|
+
const match = matchResourceApplicabilityProjection(latest?.payload?.resourceApplicability, { consumerId: binding.declaration.consumerId, declarationDigest: binding.declarationDigest, sourceApplicabilityDigest: binding.sourceApplicabilityDigest, capture, originAttempt: origin ?? null });
|
|
460
|
+
return { capture, ...match };
|
|
461
|
+
}
|
|
462
|
+
/** Reuse only an already accepted same-candidate applicability observation.
|
|
463
|
+
* New source candidates still require fresh native installed-resource capture. */
|
|
464
|
+
private async acceptedResourceBindings() {
|
|
465
|
+
return this.retainedResourceBindings ??= (async () => {
|
|
466
|
+
const bindings: Record<string, ResourceApplicabilityBinding> = {};
|
|
467
|
+
const wiring = await this.context.wire();
|
|
468
|
+
for (const obligation of this.context.config.obligations.filter(o => o.acceptedPayloadSpecs.includes("installed-data.applicable/1"))) {
|
|
469
|
+
const found = await discoverRecords(this.context.rootDir, { ...wiring.storageOptions, gateId: this.context.config.gateId, obligationId: obligation.id });
|
|
470
|
+
for (const record of found.records) {
|
|
471
|
+
if (record.resolution.kind !== "evidence" || !record.resolution.resourceBinding || !record.resolution.portable || !isRecordFreshForCandidate(this.context.config, record.candidateBinding, this.candidate)) continue;
|
|
472
|
+
const id = record.resolution.providerId, provider = this.context.config.providers.find(p => p.id === id);
|
|
473
|
+
if (!provider?.check?.installedData || !this.observations.has(id)) continue;
|
|
474
|
+
try {
|
|
475
|
+
if (record.candidateBinding.treeSha !== this.candidate.treeSha) {
|
|
476
|
+
const diff = await runGitCommand(["git", "diff", "--name-only", "--no-renames", "-z", record.candidateBinding.treeSha, this.candidate.treeSha], { cwd: this.context.rootDir });
|
|
477
|
+
if (diff.exitCode !== 0 || diff.stdout.split("\0").filter(Boolean).some(file => !isRecordNeutralPath(this.context.config, file))) continue;
|
|
478
|
+
}
|
|
479
|
+
const contents = portableArtifactContents(record.resolution.portable.artifacts);
|
|
480
|
+
if (contents.blockers.length) continue;
|
|
481
|
+
const proof = JSON.parse(contents.artifacts.get("resource-proof.json") ?? "null");
|
|
482
|
+
if (digestCanonical(proof?.observation) !== digestCanonical(this.observations.get(id))) continue;
|
|
483
|
+
const reconstructed = await reconstructResourceApplicability(this.context.config, provider, { ...this.candidate, treeSha: record.candidateBinding.treeSha }, proof, sha => this.sourceTree(sha), await candidateTreeEvidenceReader(this.context.rootDir, this.candidate.treeSha));
|
|
484
|
+
if (digestCanonical(reconstructed.binding) === digestCanonical(record.resolution.resourceBinding)) bindings[id] = reconstructed.binding;
|
|
485
|
+
} catch { /* Invalid retained authority supplies no skip; normal admission/fallback remains. */ }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return bindings;
|
|
489
|
+
})();
|
|
490
|
+
}
|
|
491
|
+
/** Current native origin precedence is rechecked on each admission/submission. */
|
|
492
|
+
async resourceBindings(): Promise<Readonly<Record<string, ResourceApplicabilityBinding>>> {
|
|
493
|
+
const bindings: Record<string, ResourceApplicabilityBinding> = { ...await this.acceptedResourceBindings() };
|
|
494
|
+
for (const id of Object.keys(bindings)) {
|
|
495
|
+
const latest = (await this.locked(() => this.stores.get(id)!.read())).at(-1);
|
|
496
|
+
const origin = latest && this.resourceOrigin(latest);
|
|
497
|
+
if (!origin || !latest?.payload?.resourceApplicability || digestCanonical(origin) !== bindings[id]!.originDigest) delete bindings[id];
|
|
498
|
+
}
|
|
499
|
+
for (const [id, current] of this.installed) {
|
|
500
|
+
const latest = (await this.locked(() => this.stores.get(id)!.read())).at(-1);
|
|
501
|
+
const origin = latest && this.resourceOrigin(latest), projection = latest?.payload?.resourceApplicability;
|
|
502
|
+
if (!origin || !projection) continue;
|
|
503
|
+
const { binding, capture } = current;
|
|
504
|
+
if (!matchResourceApplicabilityProjection(projection, { consumerId: binding.declaration.consumerId, declarationDigest: binding.declarationDigest, sourceApplicabilityDigest: binding.sourceApplicabilityDigest, capture, originAttempt: origin }).applicable) continue;
|
|
505
|
+
const provider = this.context.config.providers.find(p => p.id === id)!;
|
|
506
|
+
bindings[id] = createResourceApplicabilityBinding({ candidateDigest: digestCanonical(scopedCandidate(this.candidate)), definitionDigest: digestCanonical(recordOutputProvider(provider, deliveryRecordPathFor(this.context.config, this.candidate.deliverable.digest)).check),
|
|
507
|
+
declarationDigest: binding.declarationDigest, sourceApplicabilityDigest: binding.sourceApplicabilityDigest, capture, projection, originAttempt: origin });
|
|
508
|
+
}
|
|
509
|
+
return bindings;
|
|
510
|
+
}
|
|
511
|
+
/** Publish a current applicability outcome, with no allocation or consumer pass. */
|
|
512
|
+
async publishInstalledDataApplicability(providerId: string, obligationIds: readonly string[]): Promise<void> {
|
|
513
|
+
const current = this.installed.get(providerId);
|
|
514
|
+
if (!current) throw new CheckSnapshotError("installed_data_source_unqualified", "Native resource capture is required before applicability publication.");
|
|
515
|
+
await current.snapshot.verify({ outputs: this.profileOf(providerId).mutableOutputs });
|
|
516
|
+
const binding = (await this.resourceBindings())[providerId];
|
|
517
|
+
const origin = (await this.locked(() => this.stores.get(providerId)!.read())).at(-1);
|
|
518
|
+
if (!binding || !origin?.payload?.resourceApplicability || digestCanonical(origin.payload.resourceApplicability.origin) !== binding.originDigest)
|
|
519
|
+
throw new CheckSnapshotError("check_attempt_superseded", "Native resource origin changed before applicability publication.");
|
|
520
|
+
const runId = randomUUID(), finalPassId = "applicability-1", c = this.candidate;
|
|
521
|
+
const allocation = await this.context.artifacts.allocateRunRoot({ providerId, runId });
|
|
522
|
+
if (!allocation.ok) throw new CheckSnapshotError("check_artifact_unavailable", "Cannot allocate resource applicability evidence.");
|
|
523
|
+
const artifacts: { path: string; sha256: string; role: string }[] = [];
|
|
524
|
+
const write = async (name: string, value: unknown, role: string) => { const text = JSON.stringify(value); await this.context.artifacts.writeTextFile(path.join(allocation.runRoot.path, name), text); artifacts.push({ path: name, sha256: sha256Hex(text), role }); };
|
|
525
|
+
const claim = { verdict: "applicable", binding };
|
|
526
|
+
await write("resource-result.json", { providerId, runId, finalPassId, ...claim }, "resource-result");
|
|
527
|
+
await write("resource-proof.json", { version: "installed-data-proof/1", observation: this.observations.get(providerId), origin }, "resource-proof");
|
|
528
|
+
const manifest = { spec: "delivery-evidence/1", provider: { id: providerId, runId, finalPassId }, candidate: { vcs: "git", treeSha: c.treeSha, headSha: c.headSha, deliverable: c.deliverable, base: c.base, workspaceId: c.workspaceId },
|
|
529
|
+
runHistory: [{ preparedTreeSha: c.treeSha, evaluatedInPassId: finalPassId }], artifacts, attestation: { level: "self", signatures: [] }, recordedAt: new Date().toISOString(),
|
|
530
|
+
claims: obligationIds.map(obligation => ({ obligation, payloadSpec: "installed-data.applicable/1", payload: claim })) };
|
|
531
|
+
const manifestPath = path.join(allocation.runRoot.path, "manifest.json"); await this.context.artifacts.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
532
|
+
const wiring = await this.context.wire();
|
|
533
|
+
const submitted = await this.publishing(async () => submitManifest({ rootDir: this.context.rootDir, config: this.context.config, manifestPath }, {
|
|
534
|
+
captureCandidate: wiring.captureCandidate, artifacts: this.context.artifacts, ...wiring.storageOptions, scopedPlan: await this.plan(), readOutput: this.readOutput, resourceBindings: await this.resourceBindings(),
|
|
535
|
+
}));
|
|
536
|
+
if (submitted.status !== "accepted") throw new CheckSnapshotError("check_evidence_rejected", submitted.blockers.map(b => b.code).join(", "));
|
|
537
|
+
this.context.write(`applicable ${providerId}: matching native source/resources; consumer command omitted; origin ${origin.attempt.attemptId}`);
|
|
538
|
+
}
|
|
539
|
+
private resourceOrigin(row: StoredAttempt): InstalledDataAttemptOrigin | undefined {
|
|
540
|
+
if (row.attempt.status !== "passed" || !row.executionHeadSha) return undefined;
|
|
541
|
+
const a = row.attempt;
|
|
542
|
+
return { providerId: a.providerId, runId: a.origin.runId, attemptId: a.attemptId, generation: a.generation, inputDigest: a.inputDigest, profileDigest: a.profileDigest,
|
|
543
|
+
source: { headSha: row.executionHeadSha, treeSha: a.origin.candidate.treeSha }, status: "passed", superseded: false };
|
|
544
|
+
}
|
|
545
|
+
private async closeCapturePhase(): Promise<void> {
|
|
546
|
+
this.capturePhaseOpen = false;
|
|
547
|
+
await this.captureTail;
|
|
548
|
+
}
|
|
549
|
+
private acceptsApplicability(provider: ProviderRegistration, obligationIds: readonly string[]): boolean {
|
|
550
|
+
return provider.check?.installedData !== undefined && obligationIds.length > 0 && obligationIds.every(id => this.context.config.obligations.find(o => o.id === id)?.acceptedPayloadSpecs.includes("installed-data.applicable/1"));
|
|
551
|
+
}
|
|
552
|
+
private onWorker<T>(lane: Lane, task: () => Promise<T>): Promise<T> {
|
|
553
|
+
let locked = this.executionLocks.get(lane.snapshots);
|
|
554
|
+
if (!locked) { locked = oneAtATime(); this.executionLocks.set(lane.snapshots, locked); }
|
|
555
|
+
const running = locked(task);
|
|
556
|
+
this.executionTails.set(lane.snapshots, running.catch(() => undefined));
|
|
557
|
+
return running;
|
|
558
|
+
}
|
|
559
|
+
/** `cancel` is a fail-fast gate's: once it aborts, the check stops and is recorded `interrupted` with `check_cancelled`. */
|
|
560
|
+
execute(provider: ProviderRegistration, obligationIds: readonly string[], deferSubmission = false, lane: Lane = this.serial, cancel?: Cancellation): Promise<void> {
|
|
561
|
+
this.capturePhaseOpen = false;
|
|
562
|
+
return this.onWorker(lane, async () => {
|
|
563
|
+
if (!deferSubmission && this.acceptsApplicability(provider, obligationIds)) {
|
|
564
|
+
await this.closeCapturePhase();
|
|
565
|
+
let applicable = false;
|
|
566
|
+
try {
|
|
567
|
+
// Resolve at this consumer's execution stage, after preceding checks.
|
|
568
|
+
// Its capture and cold command stay on one worker without changing
|
|
569
|
+
// serial snapshot eviction or adding a second resource cache.
|
|
570
|
+
applicable = (await this.scheduleCapture(() => this.captureDeclaredInstalledData(provider.id, lane), true)).applicable;
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (this.context.signal?.aborted || this.cleanupStarted) throw error;
|
|
573
|
+
lane.write(`resource fallback ${provider.id}: ${error instanceof Error ? error.message : "capture unavailable"}`);
|
|
574
|
+
}
|
|
575
|
+
if (applicable) { await this.publishInstalledDataApplicability(provider.id, obligationIds); return; }
|
|
576
|
+
}
|
|
577
|
+
await this.executeInLane(provider, obligationIds, deferSubmission, lane, cancel);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
private async executeInLane(provider: ProviderRegistration, obligationIds: readonly string[], deferSubmission: boolean, lane: Lane, cancel?: Cancellation): Promise<void> {
|
|
581
|
+
await this.closeCapturePhase();
|
|
582
|
+
// The command's own signal: the CLI's interruption, or this check's cancellation, whichever comes first.
|
|
583
|
+
const commandSignal = cancel ? AbortSignal.any([cancel.signal, ...(this.context.signal ? [this.context.signal] : [])]) : this.context.signal;
|
|
584
|
+
const cancelled = () => cancel?.signal.aborted === true && !this.context.signal?.aborted;
|
|
118
585
|
const started = Date.now(), check = provider.check!, profile = this.context.config.scopedExecution!.profiles.find(p => p.id === check.scope!.profile)!;
|
|
119
586
|
const attempt = this.owned.get(provider.id) ?? await this.allocate(provider.id);
|
|
587
|
+
// Phases up to the command are this attempt's setup; the only verification among them is pre-command.
|
|
588
|
+
const phases: { phase: ScopedSetupDiagnostic["phases"][number]["phase"]; durationMs: number }[] = [];
|
|
589
|
+
let settingUp = true;
|
|
590
|
+
this.phaseWriters.set(lane.snapshots, (phase, durationMs) => {
|
|
591
|
+
if (settingUp) phases.push({ phase: (phase === "verification" ? "pre-command-verification" : phase) as ScopedSetupDiagnostic["phases"][number]["phase"], durationMs: Math.round(durationMs) });
|
|
592
|
+
lane.write(`snapshot ${provider.id}: attempt ${attempt.attemptId}; ${phase} ${Math.round(durationMs)}ms`);
|
|
593
|
+
});
|
|
120
594
|
let payload: AttemptPayload = { outputs: [] }, terminal = false;
|
|
121
595
|
let diagnostic: RecordedScopedAttemptDiagnostic = { availability: "available", phase: "snapshot-setup", failure: { unavailable: "not-failed" }, command: { unavailable: "not-started" } };
|
|
122
596
|
let executionErrorCode: string | undefined;
|
|
123
597
|
const store = this.stores.get(provider.id)!;
|
|
124
598
|
const secrets = check.scope!.environment.filter(e => e.kind === "credential").map(e => this.context.env[e.name]).filter((v): v is string => !!v);
|
|
125
599
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await snapshot.verify();
|
|
142
|
-
|
|
143
|
-
|
|
600
|
+
// A reused tree is verified as its kind allows: from the index, or (a finite tree) by its inventory.
|
|
601
|
+
const verificationOf = (setup: AcquiredSetup) => setup.snapshot === "built" || check.physicalSource ? "inventory" as const : "index" as const;
|
|
602
|
+
const { snapshot, sharedOutputs, setup } = await this.acquireSnapshot(profile, lane,
|
|
603
|
+
(result, durationMs) => { diagnostic = { ...diagnostic, dependency: scopedDependencyDiagnostic(result, durationMs, secrets) }; }, check.physicalSource,
|
|
604
|
+
setup => { diagnostic = { ...diagnostic, setup: { ...setup, verification: verificationOf(setup), phases } }; });
|
|
605
|
+
const verification = setup.snapshot === "built" ? "inventory" : snapshot.reusedVerification;
|
|
606
|
+
diagnostic = { ...diagnostic, phase: "pre-command-verification", setup: { ...setup, verification, phases } };
|
|
607
|
+
// A sibling or dependency setup cannot supply this command's result, and
|
|
608
|
+
// another profile's outputs are not this profile's to read or write.
|
|
609
|
+
const removed = [...check.outputs ?? [], ...sharedOutputs.filter(o => !profile.mutableOutputs.includes(o))];
|
|
610
|
+
const remove = async () => { for (const output of removed) await rm(path.join(snapshot.rootDir, output), { recursive: true, force: true }); };
|
|
611
|
+
if (verification === "index") {
|
|
612
|
+
// Checked before removal, so a stray write to an output no check here produced is refused, not erased.
|
|
613
|
+
await snapshot.verify({ outputs: profile.mutableOutputs, unchanged: { allowed: [...profile.mutableOutputs, ...this.ranOutputs.get(snapshot) ?? []] } });
|
|
614
|
+
await remove();
|
|
615
|
+
} else { await remove(); await snapshot.verify({ outputs: profile.mutableOutputs }); }
|
|
616
|
+
settingUp = false;
|
|
617
|
+
lane.write(`setup ${provider.id}: attempt ${attempt.attemptId}; ${setup.action} on a ${setup.snapshot} tree (clone ${setup.clone}), ${verification} verification; ${Date.now() - started}ms`);
|
|
618
|
+
// Setup is not this check's to cancel, as its snapshot outlives it on the worker; the command is.
|
|
619
|
+
if (cancelled()) throw new CheckSnapshotError("check_cancelled", `Declared scoped check ${provider.id} was cancelled before its command, after ${cancel!.after()} failed.`);
|
|
144
620
|
const injected = Object.fromEntries(check.scope!.environment.filter(e => this.context.env[e.name] !== undefined).map(e => [e.name, this.context.env[e.name]!]));
|
|
145
|
-
|
|
621
|
+
lane.write(`checking ${provider.id}: attempt ${attempt.attemptId}`);
|
|
146
622
|
diagnostic = { ...diagnostic, phase: "command" };
|
|
147
623
|
const commandHome = path.join(snapshot.commandRoot, attempt.attemptId, "home"), commandTemp = path.join(snapshot.commandRoot, attempt.attemptId, "tmp");
|
|
148
624
|
await mkdir(commandHome, { recursive: true }); await mkdir(commandTemp, { recursive: true });
|
|
149
625
|
diagnostic = { ...diagnostic, command: { unavailable: "not-completed" } };
|
|
150
|
-
const result = await createExecPort().run({ command: check.command[0], args: check.command.slice(1), cwd: path.join(snapshot.rootDir, check.scope!.cwd), env: { ...snapshot.environment, ...injected, HOME: commandHome, TMPDIR: commandTemp }, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024, ...(
|
|
151
|
-
const redact = (s: string) => redactScopedOutput(s, secrets);
|
|
626
|
+
const result = await createExecPort().run({ command: check.command[0], args: check.command.slice(1), cwd: path.join(snapshot.rootDir, check.scope!.cwd), env: { ...snapshot.environment, ...injected, HOME: commandHome, TMPDIR: commandTemp }, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024, ...(commandSignal ? { signal: commandSignal } : {}) });
|
|
152
627
|
executionErrorCode = result.errorCode;
|
|
153
628
|
diagnostic = { ...diagnostic, command: scopedCommandDiagnostic(result, secrets) };
|
|
154
|
-
payload = { outputs: [], durationMs: Date.now() - started, log:
|
|
629
|
+
payload = { outputs: [], durationMs: Date.now() - started, log: redactScopedStreams(result, secrets).slice(-4000), dependencyDigest: snapshot.dependencyDigest };
|
|
630
|
+
// Only a command the cancellation actually stopped is cancelled; one that ended on its own keeps its result.
|
|
631
|
+
if (cancelled() && result.errorCode === "ABORT_ERR") throw new CheckSnapshotError("check_cancelled", `Declared scoped check ${provider.id} was cancelled, its process group signalled, after ${cancel!.after()} failed.`);
|
|
155
632
|
if (result.code !== 0 || this.context.signal?.aborted) throw new CheckSnapshotError("check_command_failed", `Declared scoped check ${provider.id} did not complete successfully (exit ${result.code}).`);
|
|
156
633
|
diagnostic = { ...diagnostic, phase: "post-command-verification" };
|
|
157
|
-
await snapshot.verify();
|
|
634
|
+
await snapshot.verify({ outputs: profile.mutableOutputs });
|
|
635
|
+
const ran = this.ranOutputs.get(snapshot) ?? new Set<string>();
|
|
636
|
+
for (const output of profile.mutableOutputs) ran.add(output);
|
|
637
|
+
this.ranOutputs.set(snapshot, ran);
|
|
158
638
|
diagnostic = { ...diagnostic, phase: "output-capture" };
|
|
159
639
|
const outputs = await captureCheckOutputSnapshots(snapshot.rootDir, check.outputs ?? []);
|
|
160
640
|
if (!outputs || outputs.some(o => secrets.some(secret => Buffer.from(o.base64, "base64").includes(Buffer.from(secret))))) throw new CheckSnapshotError("check_output_missing", "A retained output is absent, corrupt, escaped or contains credential bytes.");
|
|
161
641
|
payload = { ...payload, outputs, diagnostic: { ...diagnostic, phase: "complete" } };
|
|
642
|
+
const installed = this.installed.get(provider.id);
|
|
643
|
+
// Only the same owned snapshot that supplied the facts can seed a pass.
|
|
644
|
+
if (installed?.snapshot === snapshot) payload = { ...payload, resourceScope: structuredClone(check.scope!), resourceApplicability: createResourceApplicabilityProjection({
|
|
645
|
+
origin: this.resourceOrigin({ attempt: { ...attempt, status: "passed" }, executionHeadSha: this.candidate.headSha })!,
|
|
646
|
+
consumerId: installed.binding.declaration.consumerId, declarationDigest: installed.binding.declarationDigest,
|
|
647
|
+
sourceApplicabilityDigest: installed.binding.sourceApplicabilityDigest, capture: installed.capture,
|
|
648
|
+
}) };
|
|
162
649
|
await store.finish(attempt, "passed", payload); terminal = true;
|
|
163
650
|
const scopedPlan = await this.plan();
|
|
164
651
|
const wiring = await this.context.wire();
|
|
@@ -172,25 +659,40 @@ export class ScopedChecks {
|
|
|
172
659
|
for (const [index, output] of outputs.entries()) await write(`check-output-${index}.json`, { path: output.path, base64: output.base64 }, "check-output");
|
|
173
660
|
const claim = { verdict: "green", exitCode: 0, binding };
|
|
174
661
|
await write("check-result.json", { providerId: provider.id, runId, finalPassId, ...claim }, "check-result");
|
|
175
|
-
await write("scoped-inputs.json", { observation: this.observations.get(provider.id), attempt: { ...attempt, status: "passed" }, durationMs: payload.durationMs, dependencyDigest: payload.dependencyDigest }, "scoped-inputs");
|
|
662
|
+
await write("scoped-inputs.json", { observation: this.observations.get(provider.id), attempt: { ...attempt, status: "passed" }, durationMs: payload.durationMs, dependencyDigest: payload.dependencyDigest, ...(payload.resourceApplicability ? { resourceApplicability: payload.resourceApplicability } : {}) }, "scoped-inputs");
|
|
176
663
|
const c = this.candidate;
|
|
177
664
|
const manifest = { spec: "delivery-evidence/1", provider: { id: provider.id, runId, finalPassId }, candidate: { vcs: "git", treeSha: c.treeSha, headSha: c.headSha, deliverable: c.deliverable, base: c.base, workspaceId: c.workspaceId }, runHistory: [{ preparedTreeSha: c.treeSha, evaluatedInPassId: finalPassId }], artifacts, attestation: { level: "self", signatures: [] }, recordedAt: new Date().toISOString(), claims: obligationIds.map(obligation => ({ obligation, payloadSpec: "checks.passed/1", payload: claim })) };
|
|
178
665
|
const manifestPath = path.join(allocation.runRoot.path, "manifest.json"); await this.context.artifacts.writeTextFile(manifestPath, JSON.stringify(manifest));
|
|
179
|
-
const submit = async () => {
|
|
180
|
-
const outcome = await submitManifest({ rootDir: this.context.rootDir, config: this.context.config, manifestPath }, { captureCandidate: wiring.captureCandidate, artifacts: this.context.artifacts, ...wiring.storageOptions, scopedPlan: await this.plan(), readOutput: this.readOutput });
|
|
666
|
+
const submit = () => this.publishing(async () => {
|
|
667
|
+
const outcome = await submitManifest({ rootDir: this.context.rootDir, config: this.context.config, manifestPath }, { captureCandidate: wiring.captureCandidate, artifacts: this.context.artifacts, ...wiring.storageOptions, scopedPlan: await this.plan(), readOutput: this.readOutput, resourceBindings: await this.resourceBindings() });
|
|
181
668
|
if (outcome.status !== "accepted") throw new CheckSnapshotError("check_evidence_rejected", outcome.blockers.map(b => b.code).join(", "));
|
|
182
|
-
};
|
|
669
|
+
});
|
|
183
670
|
if (deferSubmission) this.submissions.push(submit); else await submit();
|
|
184
|
-
|
|
671
|
+
lane.write(`passed ${provider.id}: ${Date.now() - started}ms including snapshot setup; retained ${runId}`);
|
|
185
672
|
} catch (error) {
|
|
186
|
-
|
|
673
|
+
const status = this.context.signal?.aborted || (error instanceof CheckSnapshotError && error.code === "check_cancelled") ? "interrupted" : "failed";
|
|
674
|
+
if (!terminal) await store.finish(attempt, status, { ...payload, durationMs: Date.now() - started,
|
|
187
675
|
diagnostic: { ...diagnostic, failure: scopedDiagnosticFailure(error, executionErrorCode) },
|
|
188
676
|
...(error instanceof CheckSnapshotError ? { log: `${payload.log ?? ""}\n${error.code}`.slice(-4000) } : {}) });
|
|
189
|
-
|
|
190
|
-
|
|
677
|
+
// Setup failures report the dependency command that ran; every other failure reports the check's own.
|
|
678
|
+
const ran = diagnostic.phase === "snapshot-setup" && diagnostic.dependency
|
|
679
|
+
? { argv: profile.dependencies!.command, cwd: ".", result: diagnostic.dependency.command } : { argv: check.command, cwd: check.scope!.cwd, result: diagnostic.command };
|
|
680
|
+
lane.write([`${status} ${provider.id}: ${error instanceof CheckSnapshotError ? error.code : "unclassified"}; attempt ${attempt.attemptId} retained at ${store.terminalPath(attempt)}`,
|
|
681
|
+
` command (cwd ${ran.cwd}): ${ran.argv.map(shownArgument).join(" ")}`,
|
|
682
|
+
...("unavailable" in ran.result ? [` output ${ran.result.unavailable}`]
|
|
683
|
+
: [` exit ${ran.result.exitCode ?? "unavailable"}; output tail (last 40 lines):`, ...ran.result.outputTail.trimEnd().split("\n").slice(-40).map(line => ` ${line}`)])].join("\n"));
|
|
684
|
+
const damaged = lane.snapshots.get(setupKey(profile, check.physicalSource));
|
|
685
|
+
lane.snapshots.delete(setupKey(profile, check.physicalSource));
|
|
191
686
|
await damaged?.cleanup();
|
|
192
687
|
throw error;
|
|
193
688
|
}
|
|
194
689
|
}
|
|
195
|
-
async cleanup(): Promise<void> {
|
|
690
|
+
async cleanup(): Promise<void> {
|
|
691
|
+
this.cleanupStarted = true;
|
|
692
|
+
await this.closeCapturePhase();
|
|
693
|
+
await Promise.all(this.executionTails.values());
|
|
694
|
+
for (const snapshots of this.workers) { for (const snapshot of snapshots.values()) await snapshot.cleanup(); snapshots.clear(); }
|
|
695
|
+
const bases = [...this.bases.values()]; this.bases.clear();
|
|
696
|
+
for (const base of bases) await (await base.catch(() => undefined))?.cleanup();
|
|
697
|
+
}
|
|
196
698
|
}
|