@agent-delivery-harness/cli 0.2.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +5 -5
- package/src/boundary.ts +84 -63
- package/src/check-snapshot.ts +167 -0
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +64 -13
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +105 -34
- package/src/commands/prepare.ts +148 -22
- package/src/commands/record.ts +107 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +461 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +156 -10
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +17 -1
- package/src/main.ts +56 -65
- package/src/ordinary-context.ts +83 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +202 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +327 -12
- package/src/run-server.ts +403 -149
- package/src/run-surface.ts +196 -9
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
package/src/run-view.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/** One operational readout for terminal, JSON and HTML; no admission decisions. */
|
|
2
|
+
import type { RunEvent } from "@agent-delivery-harness/kernel";
|
|
3
|
+
import {
|
|
4
|
+
projectRunProgress,
|
|
5
|
+
projectCosts,
|
|
6
|
+
costLabel,
|
|
7
|
+
} from "./run-projection.ts";
|
|
8
|
+
export interface RunViewItem {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly fields: readonly { label: string; value: string }[];
|
|
12
|
+
readonly artifactId?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface RunViewSection {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly empty: string;
|
|
18
|
+
readonly items: readonly RunViewItem[];
|
|
19
|
+
}
|
|
20
|
+
export interface RunView {
|
|
21
|
+
readonly spec: "run-view/1";
|
|
22
|
+
readonly historical: boolean;
|
|
23
|
+
readonly asOf: string;
|
|
24
|
+
readonly authority: string;
|
|
25
|
+
readonly sections: readonly RunViewSection[];
|
|
26
|
+
}
|
|
27
|
+
const value = (v: unknown): string =>
|
|
28
|
+
v === undefined
|
|
29
|
+
? "Unreported"
|
|
30
|
+
: typeof v === "string"
|
|
31
|
+
? v
|
|
32
|
+
: JSON.stringify(v);
|
|
33
|
+
const item = (
|
|
34
|
+
id: string,
|
|
35
|
+
label: string,
|
|
36
|
+
fields: Record<string, unknown>,
|
|
37
|
+
artifactId?: string,
|
|
38
|
+
): RunViewItem => ({
|
|
39
|
+
id,
|
|
40
|
+
label,
|
|
41
|
+
fields: Object.entries(fields).map(([label, v]) => ({
|
|
42
|
+
label,
|
|
43
|
+
value: value(v),
|
|
44
|
+
})),
|
|
45
|
+
...(artifactId === undefined ? {} : { artifactId }),
|
|
46
|
+
});
|
|
47
|
+
export function projectRunView(
|
|
48
|
+
events: readonly RunEvent[],
|
|
49
|
+
options: {
|
|
50
|
+
readonly now: string;
|
|
51
|
+
readonly historical?: boolean;
|
|
52
|
+
readonly freshnessWindowMs?: number;
|
|
53
|
+
},
|
|
54
|
+
): RunView {
|
|
55
|
+
const p = projectRunProgress(events, options.now, options.freshnessWindowMs);
|
|
56
|
+
const historical = options.historical === true;
|
|
57
|
+
const costs = projectCosts(events);
|
|
58
|
+
const runCost = costs.run as Record<string, unknown>;
|
|
59
|
+
const attempts = p.activities.flatMap((a) => a.attempts);
|
|
60
|
+
const current = attempts.filter((a) => !a.superseded);
|
|
61
|
+
const sections: RunViewSection[] = [
|
|
62
|
+
{
|
|
63
|
+
id: "waiting",
|
|
64
|
+
title: "Waiting and required action",
|
|
65
|
+
empty: "No current waiting observation. Unreported waits remain unknown.",
|
|
66
|
+
items: p.waits
|
|
67
|
+
.filter((w) => w.current && !w.resolvedAt)
|
|
68
|
+
.map((w) =>
|
|
69
|
+
item(w.waitId, w.reason, {
|
|
70
|
+
Owner: w.owner,
|
|
71
|
+
"Human action required":
|
|
72
|
+
w.waitingOn === "human"
|
|
73
|
+
? "Yes — human"
|
|
74
|
+
: w.waitingOn === "unknown"
|
|
75
|
+
? "Unknown"
|
|
76
|
+
: `No — ${w.waitingOn}`,
|
|
77
|
+
"Next action": w.nextAction,
|
|
78
|
+
Scope: w.scope,
|
|
79
|
+
Attempt: w.attemptId,
|
|
80
|
+
Observed: w.startedAt,
|
|
81
|
+
Freshness: historical
|
|
82
|
+
? "Historical observation"
|
|
83
|
+
: !Number.isFinite(Date.parse(w.startedAt))
|
|
84
|
+
? "unknown"
|
|
85
|
+
: Date.parse(options.now) - Date.parse(w.startedAt) >
|
|
86
|
+
(options.freshnessWindowMs ?? 300000)
|
|
87
|
+
? "stale"
|
|
88
|
+
: "recent",
|
|
89
|
+
Candidate: w.candidateTreeSha,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: "work",
|
|
95
|
+
title: "Current work",
|
|
96
|
+
empty: attempts.length === 0
|
|
97
|
+
? "No activity observations; execution status unknown."
|
|
98
|
+
: "No active work in the latest observations. Retained attempts appear in history.",
|
|
99
|
+
items: current
|
|
100
|
+
.filter(
|
|
101
|
+
(a) => !["completed", "failed", "interrupted"].includes(a.state),
|
|
102
|
+
)
|
|
103
|
+
.map((a) =>
|
|
104
|
+
item(a.attemptId, a.activityId, {
|
|
105
|
+
Owner: a.owner,
|
|
106
|
+
Phase: a.phase,
|
|
107
|
+
State: `${a.state} (reported)`,
|
|
108
|
+
Freshness: historical ? "Historical observation" : a.freshness,
|
|
109
|
+
"Last observed": a.lastObservedAt,
|
|
110
|
+
"Elapsed since start": a.startedAt
|
|
111
|
+
? `${Math.max(0, Math.floor((Date.parse(options.now) - Date.parse(a.startedAt)) / 1000))}s`
|
|
112
|
+
: "Unknown — start not observed",
|
|
113
|
+
"Next step": a.nextStep,
|
|
114
|
+
Candidate: a.candidateTreeSha,
|
|
115
|
+
"Lifecycle history": a.lifecycleIncomplete
|
|
116
|
+
? "Incomplete"
|
|
117
|
+
: "Observed",
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "reviews",
|
|
123
|
+
title: "Reviewer attempts and history",
|
|
124
|
+
empty: "No reviewer attempt observations.",
|
|
125
|
+
items: attempts
|
|
126
|
+
.filter((a) => a.lensId !== undefined || a.phase === "review")
|
|
127
|
+
.map((a) =>
|
|
128
|
+
item(a.attemptId, a.lensId ?? a.activityId, {
|
|
129
|
+
Attempt: a.attemptId,
|
|
130
|
+
Round: a.round,
|
|
131
|
+
"Round ID": a.roundId,
|
|
132
|
+
Owner: a.owner,
|
|
133
|
+
State: a.state,
|
|
134
|
+
History: a.superseded
|
|
135
|
+
? "Superseded attempt"
|
|
136
|
+
: "Latest observed attempt",
|
|
137
|
+
Verdict: a.verdict,
|
|
138
|
+
Cost: costLabel(a.cost),
|
|
139
|
+
Candidate: a.candidateTreeSha,
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "findings",
|
|
145
|
+
title: "Current unresolved findings",
|
|
146
|
+
empty:
|
|
147
|
+
p.findingsCoverage === "unreported"
|
|
148
|
+
? "Unknown — detailed finding observations were not recorded."
|
|
149
|
+
: "No unresolved findings in latest reported observations; this is not approval.",
|
|
150
|
+
items: p.currentFindings
|
|
151
|
+
.filter((f) => f.payload["state"] === "unresolved")
|
|
152
|
+
.map((f) =>
|
|
153
|
+
item(String(f.payload["findingId"]), String(f.payload["findingId"]), {
|
|
154
|
+
Severity: f.payload["severity"],
|
|
155
|
+
State: "Unresolved (reported)",
|
|
156
|
+
Report: f.payload["reportId"],
|
|
157
|
+
Attempt: f.payload["attemptId"],
|
|
158
|
+
Candidate: f.payload["candidateTreeSha"],
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "evidence",
|
|
164
|
+
title: "Evidence and candidate",
|
|
165
|
+
empty: "No evidence observations. Applicability unknown.",
|
|
166
|
+
items: events
|
|
167
|
+
.filter((e) =>
|
|
168
|
+
[
|
|
169
|
+
"gate.reported",
|
|
170
|
+
"command.completed",
|
|
171
|
+
"review.round.closed",
|
|
172
|
+
].includes(e.kind),
|
|
173
|
+
)
|
|
174
|
+
.map((e) =>
|
|
175
|
+
item(String(e.seq), e.kind, {
|
|
176
|
+
"Reported at": e.at,
|
|
177
|
+
Candidate: e.candidateTreeSha ?? e.payload["candidateTreeSha"],
|
|
178
|
+
Observation: e.payload,
|
|
179
|
+
Authority:
|
|
180
|
+
"Reported only — Applicability unknown; no verification is run by this view",
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "reports",
|
|
186
|
+
title: "Retained reports",
|
|
187
|
+
empty: "No report references recorded.",
|
|
188
|
+
items: p.reports.map((r) =>
|
|
189
|
+
item(
|
|
190
|
+
String(r.payload["reportId"]),
|
|
191
|
+
String(r.payload["role"]),
|
|
192
|
+
{
|
|
193
|
+
Report: r.payload["reportId"],
|
|
194
|
+
Availability: r.payload["availability"],
|
|
195
|
+
Reason: r.payload["reason"],
|
|
196
|
+
"Originating report": r.payload["originatingReportId"],
|
|
197
|
+
Lens: r.payload["lensId"],
|
|
198
|
+
Attempt: r.payload["attemptId"],
|
|
199
|
+
Candidate: r.payload["candidateTreeSha"],
|
|
200
|
+
History: r.current
|
|
201
|
+
? "Latest observed attempt"
|
|
202
|
+
: "Historical or incomplete binding",
|
|
203
|
+
},
|
|
204
|
+
typeof r.payload["artifactId"] === "string"
|
|
205
|
+
? r.payload["artifactId"]
|
|
206
|
+
: undefined,
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: "finish",
|
|
212
|
+
title: "Declared finish line",
|
|
213
|
+
empty: "Finish steps unreported; delivery completion unknown.",
|
|
214
|
+
items: [],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "cost",
|
|
218
|
+
title: "Reported cost",
|
|
219
|
+
empty: "Cost unreported.",
|
|
220
|
+
items: [
|
|
221
|
+
{
|
|
222
|
+
id: "review-cost",
|
|
223
|
+
label: "Review totals",
|
|
224
|
+
fields: [
|
|
225
|
+
...item("review-cost", "Review totals", {
|
|
226
|
+
Coverage: costs.review.coverage,
|
|
227
|
+
"Unreported round entries": costs.review.unreportedEntries,
|
|
228
|
+
...(costs.review.totals.length === 0
|
|
229
|
+
? { Measurement: "Unreported" }
|
|
230
|
+
: {}),
|
|
231
|
+
}).fields,
|
|
232
|
+
...costs.review.totals.map((total) => ({
|
|
233
|
+
label: `${total.reportedBy} · ${total.unit}`,
|
|
234
|
+
value:
|
|
235
|
+
total.total === null
|
|
236
|
+
? "Unavailable — reported sum exceeds numeric range"
|
|
237
|
+
: `${total.total} ${total.unit}`,
|
|
238
|
+
})),
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
item("run-cost", "Run total", {
|
|
242
|
+
Coverage: runCost["coverage"],
|
|
243
|
+
Measurement: costLabel(runCost),
|
|
244
|
+
"Reported by": runCost["reportedBy"],
|
|
245
|
+
Accounting:
|
|
246
|
+
"Run totals can include review and attempt costs. These totals are shown separately and are not added together.",
|
|
247
|
+
}),
|
|
248
|
+
...attempts.filter((a) => a.cost !== undefined).map((a) => {
|
|
249
|
+
const cost = a.cost as Record<string, unknown>;
|
|
250
|
+
return item(`attempt-cost-${a.attemptId}`, `Attempt cost · ${a.activityId}`, {
|
|
251
|
+
Attempt: a.attemptId,
|
|
252
|
+
Owner: a.owner,
|
|
253
|
+
Phase: a.phase,
|
|
254
|
+
State: `${a.state} (reported)`,
|
|
255
|
+
History: a.superseded ? "Superseded attempt" : "Latest observed attempt",
|
|
256
|
+
Candidate: a.candidateTreeSha,
|
|
257
|
+
Coverage: cost["coverage"],
|
|
258
|
+
Measurement: costLabel(a.cost),
|
|
259
|
+
"Reported by": cost["reportedBy"],
|
|
260
|
+
Accounting: "Reported attempt measurement; not added to run or review totals, which may overlap.",
|
|
261
|
+
});
|
|
262
|
+
}),
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "activity-history",
|
|
267
|
+
title: "Activity history",
|
|
268
|
+
empty: "No completed, failed, interrupted or superseded activity observations.",
|
|
269
|
+
items: attempts
|
|
270
|
+
.filter((a) => a.superseded || ["completed", "failed", "interrupted"].includes(a.state))
|
|
271
|
+
.map((a) => item(a.attemptId, a.activityId, {
|
|
272
|
+
Attempt: a.attemptId,
|
|
273
|
+
Owner: a.owner,
|
|
274
|
+
Phase: a.phase,
|
|
275
|
+
State: `${a.state} (reported)`,
|
|
276
|
+
History: a.superseded ? "Superseded attempt" : "Latest observed attempt",
|
|
277
|
+
Freshness: historical ? "Historical observation" : a.freshness,
|
|
278
|
+
"Last observed": a.lastObservedAt,
|
|
279
|
+
Candidate: a.candidateTreeSha,
|
|
280
|
+
"Lifecycle history": a.lifecycleIncomplete ? "Incomplete" : "Observed",
|
|
281
|
+
Cost: costLabel(a.cost),
|
|
282
|
+
})),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "finding-history",
|
|
286
|
+
title: "Finding history",
|
|
287
|
+
empty: "No detailed finding history.",
|
|
288
|
+
items: p.findings.map((f) =>
|
|
289
|
+
item(String(f.seq), String(f.payload["findingId"]), {
|
|
290
|
+
State: f.payload["state"],
|
|
291
|
+
Severity: f.payload["severity"],
|
|
292
|
+
Attempt: f.payload["attemptId"],
|
|
293
|
+
Candidate: f.payload["candidateTreeSha"],
|
|
294
|
+
"Deferred issue": f.payload["deferredIssueId"],
|
|
295
|
+
Observed: f.at,
|
|
296
|
+
}),
|
|
297
|
+
),
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
const reviewSection = sections.find((s) => s.id === "reviews")!;
|
|
301
|
+
sections[2] = {
|
|
302
|
+
...reviewSection,
|
|
303
|
+
items: [
|
|
304
|
+
...events
|
|
305
|
+
.filter((e) => e.kind === "review.round.opened")
|
|
306
|
+
.map((e) =>
|
|
307
|
+
item(`round-${e.seq}`, "Declared review round", {
|
|
308
|
+
Round: e.payload["round"],
|
|
309
|
+
"Round ID": e.payload["roundId"],
|
|
310
|
+
Bound: e.payload["bound"],
|
|
311
|
+
Grace: e.payload["grace"],
|
|
312
|
+
Reopens: e.payload["reopensRoundId"],
|
|
313
|
+
Candidate: e.payload["candidateTreeSha"],
|
|
314
|
+
}),
|
|
315
|
+
),
|
|
316
|
+
...reviewSection.items,
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
const finish = new Map<string, (typeof p.finishSteps)[number]>();
|
|
320
|
+
for (const step of p.finishSteps)
|
|
321
|
+
finish.set(String(step.payload["stepId"]), step);
|
|
322
|
+
sections[6] = {
|
|
323
|
+
id: "finish",
|
|
324
|
+
title: "Declared finish line",
|
|
325
|
+
empty: "Finish steps unreported; delivery completion unknown.",
|
|
326
|
+
items: [...finish.values()].map((s) =>
|
|
327
|
+
item(String(s.payload["stepId"]), String(s.payload["name"]), {
|
|
328
|
+
State: s.payload["state"],
|
|
329
|
+
Owner: s.payload["owner"],
|
|
330
|
+
Reason: s.payload["reason"],
|
|
331
|
+
Candidate: s.payload["candidateTreeSha"],
|
|
332
|
+
}),
|
|
333
|
+
),
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
spec: "run-view/1",
|
|
337
|
+
historical,
|
|
338
|
+
asOf: options.now,
|
|
339
|
+
authority:
|
|
340
|
+
"Self-attested observations. Applicability unknown; no permission or candidate approval is granted.",
|
|
341
|
+
sections,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Durable per-check history; allocation order, never completion time, owns precedence. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { link, mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { digestCanonical, type ScopedCheckAttempt } from "@agent-delivery-harness/kernel";
|
|
6
|
+
import { CheckSnapshotError } from "./check-snapshot.ts";
|
|
7
|
+
import type { RecordedScopedAttemptDiagnostic } from "./scoped-diagnostics.ts";
|
|
8
|
+
export interface AttemptPayload {
|
|
9
|
+
readonly outputs: readonly { readonly path: string; readonly base64: string; readonly sha256: string }[];
|
|
10
|
+
readonly durationMs?: number;
|
|
11
|
+
readonly dependencyDigest?: string;
|
|
12
|
+
readonly log?: string;
|
|
13
|
+
readonly diagnostic?: RecordedScopedAttemptDiagnostic;
|
|
14
|
+
}
|
|
15
|
+
export interface StoredAttempt { readonly attempt: ScopedCheckAttempt; readonly payload?: AttemptPayload }
|
|
16
|
+
const corrupt = () => new CheckSnapshotError("check_attempt_corrupt", "Scoped check attempt history is missing, corrupt or inconsistent.");
|
|
17
|
+
export class AttemptStore {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
constructor(root: string) { this.root = root; }
|
|
20
|
+
private async generations(): Promise<number[]> {
|
|
21
|
+
let names: string[];
|
|
22
|
+
try { names = await readdir(this.root); }
|
|
23
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
|
|
24
|
+
if (names.some(n => !/^[1-9][0-9]*$/.test(n) || !Number.isSafeInteger(Number(n)))) throw corrupt();
|
|
25
|
+
return names.map(Number).sort((a, b) => a - b);
|
|
26
|
+
}
|
|
27
|
+
async allocate(input: Omit<ScopedCheckAttempt, "attemptId" | "generation" | "status">): Promise<ScopedCheckAttempt> {
|
|
28
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
29
|
+
let generation = Math.max(0, ...await this.generations()) + 1;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const dir = path.join(this.root, String(generation));
|
|
32
|
+
try { await mkdir(dir, { mode: 0o700 }); }
|
|
33
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") { generation++; continue; } throw error; }
|
|
34
|
+
const attempt: ScopedCheckAttempt = { ...input, generation, attemptId: randomUUID(), status: "running" };
|
|
35
|
+
await this.publish(path.join(dir, "running.json"), { attempt });
|
|
36
|
+
return attempt;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
private async publish(target: string, entry: StoredAttempt): Promise<void> {
|
|
40
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temp, JSON.stringify({ digest: digestCanonical(entry), entry }), { flag: "wx", mode: 0o600 });
|
|
43
|
+
// Linking a complete private file publishes atomically without replacing an
|
|
44
|
+
// already-published terminal result from this or a competing invocation.
|
|
45
|
+
await link(temp, target);
|
|
46
|
+
} finally { await unlink(temp).catch(() => undefined); }
|
|
47
|
+
}
|
|
48
|
+
async finish(attempt: ScopedCheckAttempt, status: "passed" | "failed" | "interrupted", payload: AttemptPayload): Promise<void> {
|
|
49
|
+
const rows = await this.read();
|
|
50
|
+
const running = rows.find(row => row.attempt.generation === attempt.generation)?.attempt;
|
|
51
|
+
if (running?.status !== "running" || digestCanonical(running) !== digestCanonical(attempt)) throw corrupt();
|
|
52
|
+
await this.publish(path.join(this.root, String(attempt.generation), "terminal.json"), { attempt: { ...attempt, status }, payload });
|
|
53
|
+
}
|
|
54
|
+
private async load(file: string): Promise<StoredAttempt> {
|
|
55
|
+
const raw = JSON.parse(await readFile(file, "utf8")) as { digest?: string; entry?: StoredAttempt };
|
|
56
|
+
const row = raw.entry;
|
|
57
|
+
const attempt = row?.attempt;
|
|
58
|
+
const nonempty = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
59
|
+
const candidate = attempt?.origin?.candidate;
|
|
60
|
+
if (!row || !attempt || raw.digest !== digestCanonical(row) || attempt.version !== "scoped-attempt/1" ||
|
|
61
|
+
!nonempty(attempt.attemptId) || !nonempty(attempt.providerId) || !nonempty(attempt.origin?.runId) ||
|
|
62
|
+
!Number.isSafeInteger(attempt.generation) || attempt.generation < 1 ||
|
|
63
|
+
!["running", "passed", "failed", "interrupted"].includes(attempt.status) ||
|
|
64
|
+
![attempt.inputDigest, attempt.profileDigest].every(value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)) ||
|
|
65
|
+
!candidate || ![candidate.treeSha, candidate.deliverableDigest, candidate.identityToken, candidate.baseRef,
|
|
66
|
+
candidate.baseTipSha, candidate.mergeBaseSha, candidate.workspaceId].every(nonempty)) throw corrupt();
|
|
67
|
+
if (row.payload?.durationMs !== undefined && (typeof row.payload.durationMs !== "number" || !Number.isFinite(row.payload.durationMs) || row.payload.durationMs < 0)) throw corrupt();
|
|
68
|
+
return row;
|
|
69
|
+
}
|
|
70
|
+
async read(): Promise<StoredAttempt[]> {
|
|
71
|
+
try {
|
|
72
|
+
const rows: StoredAttempt[] = [];
|
|
73
|
+
for (const generation of await this.generations()) {
|
|
74
|
+
const dir = path.join(this.root, String(generation));
|
|
75
|
+
const start = await this.load(path.join(dir, "running.json"));
|
|
76
|
+
if (start.attempt.status !== "running" || start.attempt.generation !== generation) throw corrupt();
|
|
77
|
+
let terminal: StoredAttempt | undefined;
|
|
78
|
+
try { terminal = await this.load(path.join(dir, "terminal.json")); }
|
|
79
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
80
|
+
if (terminal && (terminal.attempt.status === "running" || digestCanonical({ ...terminal.attempt, status: "running" }) !== digestCanonical(start.attempt))) throw corrupt();
|
|
81
|
+
rows.push(terminal ?? start);
|
|
82
|
+
}
|
|
83
|
+
return rows;
|
|
84
|
+
} catch { throw corrupt(); }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Opt-in worktree capture through a private index; the author's staging is untouched. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { captureGitCandidate, type CandidateCaptureOptions, type CandidateCommandRunner } from "@agent-delivery-harness/kernel";
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
export async function captureScopedCandidate(options: CandidateCaptureOptions) {
|
|
10
|
+
const dir = await mkdtemp(path.join(tmpdir(), "delivery-index-"));
|
|
11
|
+
const env = { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), GIT_INDEX_FILE: path.join(dir, "index"), GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
12
|
+
const run: CandidateCommandRunner = async (command, invocation) => {
|
|
13
|
+
try {
|
|
14
|
+
const result = await exec(command[0]!, command.slice(1), { cwd: invocation.cwd, env, encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
|
|
15
|
+
return { exitCode: 0, stdout: result.stdout.toString("utf8"), ...(invocation.captureBytes ? { stdoutBase64: result.stdout.toString("base64") } : {}), stderr: result.stderr.toString("utf8") };
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const failure = error as { code?: number; stdout?: Buffer; stderr?: Buffer };
|
|
18
|
+
return { exitCode: typeof failure.code === "number" ? failure.code : -1, stdout: failure.stdout?.toString("utf8") ?? "", stderr: failure.stderr?.toString("utf8") ?? "Git capture failed" };
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
try {
|
|
22
|
+
for (const args of [["read-tree", "HEAD"], ["add", "--all"]]) {
|
|
23
|
+
const result = await run(["git", ...args], { cwd: options.rootDir });
|
|
24
|
+
if (result.exitCode !== 0) throw new Error("Cannot capture source into a private index");
|
|
25
|
+
}
|
|
26
|
+
return await captureGitCandidate({ ...options, run });
|
|
27
|
+
} finally { await rm(dir, { recursive: true, force: true }); }
|
|
28
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/** Invocation-scoped execution coordinator; durable truth remains per-check evidence. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { access, mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { captureCheckBindings, captureCheckOutputSnapshots, candidateTreeEvidenceReader, candidateTreeSourceReader, createExecPort, digestCanonical, resolveRecordStorage, runGitCommand, scopedCheckIdentity, selectScopedCheckAttempt, sha256Hex, submitManifest,
|
|
6
|
+
type CandidateBinding, type CapturedCandidate, type ProviderRegistration, type ScopedCheckPlan, type ScopedRuntimeObservation, type RecordCandidateBinding } from "@agent-delivery-harness/kernel";
|
|
7
|
+
import type { CommandContext } from "./boundary.ts";
|
|
8
|
+
import { AttemptStore, type AttemptPayload, type StoredAttempt } from "./scoped-attempts.ts";
|
|
9
|
+
import { CheckSnapshotError, createCheckSnapshot, executionPath, type CheckSnapshot } from "./check-snapshot.ts";
|
|
10
|
+
import { redactScopedOutput, scopedCommandDiagnostic, scopedDependencyDiagnostic, scopedDiagnosticFailure, type RecordedScopedAttemptDiagnostic } from "./scoped-diagnostics.ts";
|
|
11
|
+
export function scopedCandidate(candidate: CandidateBinding): RecordCandidateBinding {
|
|
12
|
+
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
|
+
}
|
|
14
|
+
async function executableIdentity(command: string, root: string, searchPath: string, cwd: string, read: Awaited<ReturnType<typeof candidateTreeEvidenceReader>>) {
|
|
15
|
+
if (command.includes(path.sep) && !path.isAbsolute(command)) {
|
|
16
|
+
const file = path.posix.normalize(path.posix.join(cwd, command));
|
|
17
|
+
if (file === ".." || file.startsWith("../")) throw new CheckSnapshotError("check_runtime_unavailable", "A relative execution tool must stay inside the prepared tree.");
|
|
18
|
+
const bytes = await read(file);
|
|
19
|
+
if (bytes === null) throw new CheckSnapshotError("check_runtime_unavailable", "A relative execution tool is missing from the prepared tree.");
|
|
20
|
+
return { command, sha256: sha256Hex(bytes), metadata: await read.metadata(file) };
|
|
21
|
+
}
|
|
22
|
+
const candidates = command.includes(path.sep) ? [path.resolve(root, command)] : searchPath.split(path.delimiter).map(p => path.join(p, command));
|
|
23
|
+
for (const file of candidates) {
|
|
24
|
+
try { await access(file); } catch { continue; }
|
|
25
|
+
const relative = path.relative(await realpath(root), await realpath(file));
|
|
26
|
+
if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) throw new CheckSnapshotError("check_runtime_author_path", "Repository execution tools must use a relative command so they run from the private prepared tree.");
|
|
27
|
+
return { command, sha256: sha256Hex(await readFile(file)), mode: (await stat(file)).mode & 0o111 };
|
|
28
|
+
}
|
|
29
|
+
throw new CheckSnapshotError("check_runtime_unavailable", "A declared execution tool cannot be resolved and bound.");
|
|
30
|
+
}
|
|
31
|
+
export class ScopedChecks {
|
|
32
|
+
private readonly snapshots = new Map<string, CheckSnapshot>();
|
|
33
|
+
private readonly owned = new Map<string, Awaited<ReturnType<AttemptStore["allocate"]>>>();
|
|
34
|
+
private readonly stores = new Map<string, AttemptStore>();
|
|
35
|
+
private readonly identities = new Map<string, Awaited<ReturnType<typeof scopedCheckIdentity>>>();
|
|
36
|
+
private readonly observations = new Map<string, ScopedRuntimeObservation>();
|
|
37
|
+
private readonly runId = randomUUID();
|
|
38
|
+
private readonly submissions: (() => Promise<void>)[] = [];
|
|
39
|
+
readonly context: CommandContext;
|
|
40
|
+
readonly candidate: CapturedCandidate;
|
|
41
|
+
private constructor(context: CommandContext, candidate: CapturedCandidate) { this.context = context; this.candidate = candidate; }
|
|
42
|
+
static async create(context: CommandContext, candidate: CapturedCandidate): Promise<ScopedChecks | undefined> {
|
|
43
|
+
const providers = context.config.providers.filter(p => p.check?.scope);
|
|
44
|
+
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
|
+
const session = new ScopedChecks(context, candidate);
|
|
47
|
+
const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace, leaf: "scoped-attempts" });
|
|
48
|
+
const read = await candidateTreeSourceReader(context.rootDir, candidate.treeSha);
|
|
49
|
+
const readEvidence = await candidateTreeEvidenceReader(context.rootDir, candidate.treeSha);
|
|
50
|
+
const listing = await runGitCommand(["git", "ls-tree", "-r", "--name-only", "-z", candidate.treeSha], { cwd: context.rootDir });
|
|
51
|
+
if (listing.exitCode !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Cannot enumerate the prepared source tree.");
|
|
52
|
+
const inventory = listing.stdout.split("\0").filter(Boolean);
|
|
53
|
+
for (const provider of providers) {
|
|
54
|
+
const scope = provider.check!.scope!, profile = context.config.scopedExecution.profiles.find(p => p.id === scope.profile)!;
|
|
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.");
|
|
58
|
+
const searchPath = executionPath(context.rootDir, context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin");
|
|
59
|
+
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
|
+
const observation: ScopedRuntimeObservation = { version: "scoped-runtime/1", runtimeDigest: digestCanonical({ platform: process.platform, arch: process.arch, tools, searchPath }),
|
|
61
|
+
flags: Object.fromEntries(scope.environment.filter(e => e.kind === "flag" && context.env[e.name] !== undefined).map(e => [e.name, context.env[e.name]!])),
|
|
62
|
+
credentials: Object.fromEntries(scope.environment.filter(e => e.kind === "credential").map(e => {
|
|
63
|
+
const present = context.env[e.name] !== undefined, identity = present ? profile.credentialIdentities[e.name] ?? null : null;
|
|
64
|
+
if (identity !== null && (identity === context.env[e.name] || identity === sha256Hex(context.env[e.name] ?? ""))) throw new CheckSnapshotError("check_credential_identity_invalid", "Credential identity must be an external nonsecret revision.");
|
|
65
|
+
return [e.name, { present, identity }];
|
|
66
|
+
})) };
|
|
67
|
+
session.observations.set(provider.id, observation);
|
|
68
|
+
session.identities.set(provider.id, await scopedCheckIdentity(context.config, provider, inventory, read, observation, candidate, readEvidence));
|
|
69
|
+
session.stores.set(provider.id, new AttemptStore(path.join(storage.storageDir, digestCanonical({ gate: context.config.gateId, provider: provider.id }))));
|
|
70
|
+
}
|
|
71
|
+
return session;
|
|
72
|
+
}
|
|
73
|
+
async plan(): Promise<ScopedCheckPlan> {
|
|
74
|
+
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) };
|
|
76
|
+
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
|
+
}
|
|
78
|
+
private async selected(id: string): Promise<StoredAttempt | undefined> {
|
|
79
|
+
const identity = this.identities.get(id)!;
|
|
80
|
+
const rows = await this.stores.get(id)!.read();
|
|
81
|
+
const selected = selectScopedCheckAttempt(id, identity.inputDigest, identity.profileDigest, rows.map(r => r.attempt));
|
|
82
|
+
return rows.find(r => r.attempt.attemptId === selected?.attemptId);
|
|
83
|
+
}
|
|
84
|
+
readonly readOutput = async (repoPath: string, providerId: string): Promise<Uint8Array> => {
|
|
85
|
+
if (!this.identities.has(providerId)) return readFile(path.join(this.context.rootDir, repoPath));
|
|
86
|
+
const selected = await this.selected(providerId);
|
|
87
|
+
const output = selected?.attempt.status === "passed" ? selected.payload?.outputs.find(o => o.path === repoPath) : undefined;
|
|
88
|
+
if (!output || sha256Hex(Buffer.from(output.base64, "base64")) !== output.sha256) throw new CheckSnapshotError("check_output_missing", "Retained scoped output is absent or corrupt.");
|
|
89
|
+
return Buffer.from(output.base64, "base64");
|
|
90
|
+
};
|
|
91
|
+
/** No safe secret identity means a fresh attempt in every executing invocation. */
|
|
92
|
+
async fenceNonReusable(ids: readonly string[]): Promise<void> {
|
|
93
|
+
for (const id of ids) if (!this.identities.get(id)!.reusable && !this.owned.has(id)) await this.allocate(id);
|
|
94
|
+
}
|
|
95
|
+
private async allocate(id: string) {
|
|
96
|
+
const identity = this.identities.get(id)!;
|
|
97
|
+
const attempt = await 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 } });
|
|
98
|
+
this.owned.set(id, attempt); return attempt;
|
|
99
|
+
}
|
|
100
|
+
async explainReuse(admitted: readonly string[]): Promise<void> {
|
|
101
|
+
for (const id of this.identities.keys()) {
|
|
102
|
+
const selected = await this.selected(id);
|
|
103
|
+
if (admitted.includes(id) && selected?.attempt.status === "passed") this.context.write(`reusing ${id}: matching inputs/profile; attempt ${selected.attempt.attemptId}`);
|
|
104
|
+
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
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async satisfyMechanical(): Promise<void> {
|
|
108
|
+
const ids = this.context.config.scopedExecution!.mechanicalProviders;
|
|
109
|
+
await this.fenceNonReusable(ids);
|
|
110
|
+
for (const id of ids) {
|
|
111
|
+
if ((await this.selected(id))?.attempt.status === "passed") { this.context.write(`reusing mechanical ${id}`); continue; }
|
|
112
|
+
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);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async submitMechanical(): Promise<void> { for (const submit of this.submissions) await submit(); this.submissions.length = 0; }
|
|
117
|
+
async execute(provider: ProviderRegistration, obligationIds: readonly string[], deferSubmission = false): Promise<void> {
|
|
118
|
+
const started = Date.now(), check = provider.check!, profile = this.context.config.scopedExecution!.profiles.find(p => p.id === check.scope!.profile)!;
|
|
119
|
+
const attempt = this.owned.get(provider.id) ?? await this.allocate(provider.id);
|
|
120
|
+
let payload: AttemptPayload = { outputs: [] }, terminal = false;
|
|
121
|
+
let diagnostic: RecordedScopedAttemptDiagnostic = { availability: "available", phase: "snapshot-setup", failure: { unavailable: "not-failed" }, command: { unavailable: "not-started" } };
|
|
122
|
+
let executionErrorCode: string | undefined;
|
|
123
|
+
const store = this.stores.get(provider.id)!;
|
|
124
|
+
const secrets = check.scope!.environment.filter(e => e.kind === "credential").map(e => this.context.env[e.name]).filter((v): v is string => !!v);
|
|
125
|
+
try {
|
|
126
|
+
const key = profile.id;
|
|
127
|
+
let snapshot = this.snapshots.get(key);
|
|
128
|
+
if (!snapshot) {
|
|
129
|
+
// Both invocation paths await each execution. Retain only consecutive
|
|
130
|
+
// same-profile reuse; durable outputs no longer need the private tree.
|
|
131
|
+
for (const [previousKey, previous] of this.snapshots) {
|
|
132
|
+
await previous.cleanup();
|
|
133
|
+
this.snapshots.delete(previousKey);
|
|
134
|
+
}
|
|
135
|
+
snapshot = await createCheckSnapshot({ rootDir: this.context.rootDir, candidate: this.candidate, outputs: profile.mutableOutputs, gitContext: profile.gitContext ?? "full", environment: { PATH: this.context.env["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin" }, ...(profile.dependencies ? { dependencies: profile.dependencies } : {}), ...(this.context.signal ? { signal: this.context.signal } : {}),
|
|
136
|
+
onDependencyResult: (result, durationMs) => { diagnostic = { ...diagnostic, dependency: scopedDependencyDiagnostic(result, durationMs, secrets) }; },
|
|
137
|
+
});
|
|
138
|
+
this.snapshots.set(key, snapshot);
|
|
139
|
+
}
|
|
140
|
+
diagnostic = { ...diagnostic, phase: "pre-command-verification" };
|
|
141
|
+
await snapshot.verify();
|
|
142
|
+
// A sibling or dependency setup cannot supply this command's result.
|
|
143
|
+
for (const output of check.outputs ?? []) await rm(path.join(snapshot.rootDir, output), { recursive: true, force: true });
|
|
144
|
+
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
|
+
this.context.write(`checking ${provider.id}: attempt ${attempt.attemptId}`);
|
|
146
|
+
diagnostic = { ...diagnostic, phase: "command" };
|
|
147
|
+
const commandHome = path.join(snapshot.commandRoot, attempt.attemptId, "home"), commandTemp = path.join(snapshot.commandRoot, attempt.attemptId, "tmp");
|
|
148
|
+
await mkdir(commandHome, { recursive: true }); await mkdir(commandTemp, { recursive: true });
|
|
149
|
+
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, ...(this.context.signal ? { signal: this.context.signal } : {}) });
|
|
151
|
+
const redact = (s: string) => redactScopedOutput(s, secrets);
|
|
152
|
+
executionErrorCode = result.errorCode;
|
|
153
|
+
diagnostic = { ...diagnostic, command: scopedCommandDiagnostic(result, secrets) };
|
|
154
|
+
payload = { outputs: [], durationMs: Date.now() - started, log: redact(`${result.stdout}\n${result.stderr}`).slice(-4000), dependencyDigest: snapshot.dependencyDigest };
|
|
155
|
+
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
|
+
diagnostic = { ...diagnostic, phase: "post-command-verification" };
|
|
157
|
+
await snapshot.verify();
|
|
158
|
+
diagnostic = { ...diagnostic, phase: "output-capture" };
|
|
159
|
+
const outputs = await captureCheckOutputSnapshots(snapshot.rootDir, check.outputs ?? []);
|
|
160
|
+
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
|
+
payload = { ...payload, outputs, diagnostic: { ...diagnostic, phase: "complete" } };
|
|
162
|
+
await store.finish(attempt, "passed", payload); terminal = true;
|
|
163
|
+
const scopedPlan = await this.plan();
|
|
164
|
+
const wiring = await this.context.wire();
|
|
165
|
+
const binding = (await captureCheckBindings(this.context.rootDir, this.context.config, this.candidate, { ...wiring.storageOptions, scopedPlan, readOutput: this.readOutput }))[provider.id];
|
|
166
|
+
if (!binding || binding.scopedAttemptDigest !== digestCanonical({ ...attempt, status: "passed" })) throw new CheckSnapshotError("check_attempt_superseded", "A newer attempt superseded this completion before evidence publication.");
|
|
167
|
+
const runId = attempt.attemptId, finalPassId = "pass-1";
|
|
168
|
+
const allocation = await this.context.artifacts.allocateRunRoot({ providerId: provider.id, runId });
|
|
169
|
+
if (!allocation.ok) throw new CheckSnapshotError("check_artifact_unavailable", "Cannot allocate scoped check evidence.");
|
|
170
|
+
const artifacts: { path: string; sha256: string; role: string }[] = [];
|
|
171
|
+
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 }); };
|
|
172
|
+
for (const [index, output] of outputs.entries()) await write(`check-output-${index}.json`, { path: output.path, base64: output.base64 }, "check-output");
|
|
173
|
+
const claim = { verdict: "green", exitCode: 0, binding };
|
|
174
|
+
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");
|
|
176
|
+
const c = this.candidate;
|
|
177
|
+
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
|
+
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 });
|
|
181
|
+
if (outcome.status !== "accepted") throw new CheckSnapshotError("check_evidence_rejected", outcome.blockers.map(b => b.code).join(", "));
|
|
182
|
+
};
|
|
183
|
+
if (deferSubmission) this.submissions.push(submit); else await submit();
|
|
184
|
+
this.context.write(`passed ${provider.id}: ${Date.now() - started}ms including snapshot setup; retained ${runId}`);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!terminal) await store.finish(attempt, this.context.signal?.aborted ? "interrupted" : "failed", { ...payload, durationMs: Date.now() - started,
|
|
187
|
+
diagnostic: { ...diagnostic, failure: scopedDiagnosticFailure(error, executionErrorCode) },
|
|
188
|
+
...(error instanceof CheckSnapshotError ? { log: `${payload.log ?? ""}\n${error.code}`.slice(-4000) } : {}) });
|
|
189
|
+
const damaged = this.snapshots.get(profile.id);
|
|
190
|
+
this.snapshots.delete(profile.id);
|
|
191
|
+
await damaged?.cleanup();
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async cleanup(): Promise<void> { for (const snapshot of this.snapshots.values()) await snapshot.cleanup(); this.snapshots.clear(); }
|
|
196
|
+
}
|