@kylecheng3146/agent-ops 0.5.2 → 0.6.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/README.md +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { calculateConfigHash, canonicalJson } from "../config/hash.js";
|
|
2
|
+
import { readPrivateFile } from "../security/permissions.js";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { criterionContractHash, taskContractHash } from "../task/contract.js";
|
|
6
|
+
/** Only current, paired check evidence discharges a mechanical contract. */
|
|
7
|
+
export async function acceptanceCoverage(record, config, sourceFingerprint, store) {
|
|
8
|
+
const contract = taskContractHash(record.task);
|
|
9
|
+
const configHash = calculateConfigHash(config);
|
|
10
|
+
const rows = [];
|
|
11
|
+
for (const criterion of record.task.criteria) {
|
|
12
|
+
const definition = criterion.acceptance;
|
|
13
|
+
const refs = record.evidence[criterion.id] ?? [];
|
|
14
|
+
const matching = [];
|
|
15
|
+
for (const reference of refs) {
|
|
16
|
+
if (reference.startsWith("review:"))
|
|
17
|
+
continue;
|
|
18
|
+
const evidence = await store.load(reference);
|
|
19
|
+
if (evidence?.schemaVersion === 4 && evidence.taskId === record.task.id &&
|
|
20
|
+
evidence.criterionId === criterion.id && evidence.configHash === configHash &&
|
|
21
|
+
evidence.sourceFingerprint === sourceFingerprint && evidence.taskContractHash === contract &&
|
|
22
|
+
evidence.acceptance?.criterionContractHash === criterionContractHash(criterion)) {
|
|
23
|
+
matching.push({ reference, evidence });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const authenticated = [];
|
|
27
|
+
for (const item of matching) {
|
|
28
|
+
const proof = item.evidence.acceptance;
|
|
29
|
+
try {
|
|
30
|
+
const source = await readPrivateFile(join(store.root, ...proof.executionArtifact.split("/")), store.root);
|
|
31
|
+
if (source === null || proof.executionArtifact !== ".agent-ops/tasks/acceptance/" + sha256(source) + ".json")
|
|
32
|
+
continue;
|
|
33
|
+
const artifact = JSON.parse(source);
|
|
34
|
+
if (artifact.commit !== proof.commit || artifact.phase !== proof.phase || artifact.pairedCommit !== proof.pairedCommit ||
|
|
35
|
+
artifact.materialDigest !== proof.materialDigest || artifact.executionDigest !== proof.executionDigest ||
|
|
36
|
+
artifact.runner?.id !== item.evidence.commandId || artifact.run?.completed !== true ||
|
|
37
|
+
artifact.aggregate?.status === "UNKNOWN" || !Array.isArray(artifact.run?.results) ||
|
|
38
|
+
proof.checks.some(check => !artifact.run.results.some((actual) => actual.checkId === check.checkId && actual.status === check.status && actual.attempts === 1 &&
|
|
39
|
+
(actual.failureClass === "assertion-failed" ? "assertion" : actual.failureClass) === check.failureClass)))
|
|
40
|
+
continue;
|
|
41
|
+
authenticated.push(item);
|
|
42
|
+
}
|
|
43
|
+
catch { /* Missing, modified or malformed execution records cannot prove a contract. */ }
|
|
44
|
+
}
|
|
45
|
+
const authenticatedRefs = new Set(authenticated.map(item => item.reference));
|
|
46
|
+
for (let i = 0; i < matching.length; i++)
|
|
47
|
+
if (!authenticatedRefs.has(matching[i].reference)) {
|
|
48
|
+
const item = matching[i];
|
|
49
|
+
matching[i] = { ...item, evidence: { ...item.evidence, status: "UNKNOWN", failureClass: "execution-artifact-invalid" } };
|
|
50
|
+
}
|
|
51
|
+
const row = { criterionId: criterion.id, mode: definition?.mode ?? "legacy",
|
|
52
|
+
evidenceReferences: matching.map(item => item.reference) };
|
|
53
|
+
if (definition === undefined || definition.mode === "review-only") {
|
|
54
|
+
rows.push({ ...row, status: "undischarged", reason: definition?.mode === "review-only"
|
|
55
|
+
? "Review-only: " + definition.reviewOnlyReason + "; requires independent review against the original goal."
|
|
56
|
+
: "Requires independent review against the original goal." });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let proven = true;
|
|
60
|
+
for (const binding of definition.bindings) {
|
|
61
|
+
const phase = (name) => matching.filter(({ evidence }) => evidence.commandId === binding.runnerId && evidence.acceptance?.phase === name &&
|
|
62
|
+
evidence.acceptance.bindingHash === sha256(canonicalJson({ runnerId: binding.runnerId, materials: binding.materials })))
|
|
63
|
+
.sort((a, b) => Date.parse(b.evidence.finishedAt) - Date.parse(a.evidence.finishedAt) ||
|
|
64
|
+
(a.evidence.status === "PASS" ? 1 : -1))[0]?.evidence;
|
|
65
|
+
const baseline = phase("baseline");
|
|
66
|
+
const candidate = phase("candidate");
|
|
67
|
+
const base = baseline?.acceptance;
|
|
68
|
+
const head = candidate?.acceptance;
|
|
69
|
+
if (baseline?.status !== "PASS" || candidate?.status !== "PASS" || base === undefined || head === undefined ||
|
|
70
|
+
base.commit !== definition.baselineCommit || head.pairedCommit !== definition.baselineCommit ||
|
|
71
|
+
base.pairedCommit !== head.commit || base.materialDigest !== head.materialDigest ||
|
|
72
|
+
binding.checkIds.some(id => !head.checks.some(check => check.checkId === id && check.status === "PASS")) ||
|
|
73
|
+
binding.checkIds.some(id => !base.checks.some(check => check.checkId === id)) ||
|
|
74
|
+
base.checks.some(check => check.status === "UNKNOWN") ||
|
|
75
|
+
(binding.redCheckIds ?? []).some(id => !base.checks.some(check => check.checkId === id && check.status === "FAIL" && check.failureClass === "assertion")))
|
|
76
|
+
proven = false;
|
|
77
|
+
}
|
|
78
|
+
rows.push({ ...row, status: proven ? "proven" : "undischarged",
|
|
79
|
+
reason: proven ? "Current paired check evidence satisfies the contract." : "Paired check evidence is missing, stale, unknown or non-discriminating." });
|
|
80
|
+
}
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
83
|
+
export function coverageDigest(rows) {
|
|
84
|
+
return sha256(canonicalJson(rows));
|
|
85
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small, framework-neutral result protocol used by acceptance adapters.
|
|
3
|
+
*
|
|
4
|
+
* Adapters are deliberately consumers of structured harness output. They do
|
|
5
|
+
* not infer assertion failures from arbitrary stdout, and this module keeps
|
|
6
|
+
* the final safety decision in one place.
|
|
7
|
+
*/
|
|
8
|
+
export const ACCEPTANCE_PROTOCOL_VERSION = 1;
|
|
9
|
+
export const ACCEPTANCE_FAILURE = {
|
|
10
|
+
assertion: "assertion-failed",
|
|
11
|
+
collection: "collection-error",
|
|
12
|
+
duplicate: "duplicate-check",
|
|
13
|
+
emptyEvidence: "empty-evidence",
|
|
14
|
+
fixture: "fixture-error",
|
|
15
|
+
hook: "hook-error",
|
|
16
|
+
incomplete: "incomplete-output",
|
|
17
|
+
infrastructure: "infrastructure-error",
|
|
18
|
+
malformed: "malformed-output",
|
|
19
|
+
missing: "missing-check",
|
|
20
|
+
retry: "retry",
|
|
21
|
+
skipped: "skipped",
|
|
22
|
+
timeout: "timeout",
|
|
23
|
+
todo: "todo",
|
|
24
|
+
unexpected: "unexpected-check",
|
|
25
|
+
unsupported: "unsupported-output",
|
|
26
|
+
xfail: "xfail"
|
|
27
|
+
};
|
|
28
|
+
const MAX_TEXT = 4096;
|
|
29
|
+
const MAX_RESULTS = 4096;
|
|
30
|
+
const MAX_EVIDENCE = 32;
|
|
31
|
+
function text(value, field) {
|
|
32
|
+
if (typeof value !== "string" || value.length === 0 ||
|
|
33
|
+
value.length > MAX_TEXT || value.includes("\0") ||
|
|
34
|
+
/[\r\n]/u.test(value)) {
|
|
35
|
+
throw new TypeError(`${field} must be a bounded single-line string.`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function boundedEvidence(values) {
|
|
40
|
+
if (values.length === 0 || values.length > MAX_EVIDENCE ||
|
|
41
|
+
values.some((value) => {
|
|
42
|
+
try {
|
|
43
|
+
text(value, "evidence");
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
})) {
|
|
50
|
+
throw new TypeError("evidence must contain one to 32 bounded strings.");
|
|
51
|
+
}
|
|
52
|
+
return [...values];
|
|
53
|
+
}
|
|
54
|
+
function attempts(value) {
|
|
55
|
+
if (value === null || value === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 1000) {
|
|
58
|
+
throw new TypeError("attempts must be a positive safe integer or null.");
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
export function isRecord(value) {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
export function stringValue(value) {
|
|
66
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
export function stableCheckId(parts) {
|
|
69
|
+
const normalized = parts
|
|
70
|
+
.map((part) => part.trim())
|
|
71
|
+
.filter((part) => part.length > 0);
|
|
72
|
+
if (normalized.length === 0) {
|
|
73
|
+
throw new TypeError("A check needs a stable identity.");
|
|
74
|
+
}
|
|
75
|
+
return normalized.join("::");
|
|
76
|
+
}
|
|
77
|
+
export function makeAcceptanceCheck(input) {
|
|
78
|
+
const status = input.status;
|
|
79
|
+
if (status !== "PASS" && status !== "FAIL" && status !== "UNKNOWN") {
|
|
80
|
+
throw new TypeError("Invalid acceptance status.");
|
|
81
|
+
}
|
|
82
|
+
if (input.phase !== "baseline" && input.phase !== "candidate") {
|
|
83
|
+
throw new TypeError("Invalid acceptance phase.");
|
|
84
|
+
}
|
|
85
|
+
const failureClass = input.failureClass ?? (status === "PASS" ? "none" : ACCEPTANCE_FAILURE.infrastructure);
|
|
86
|
+
if (status === "PASS" && failureClass !== "none") {
|
|
87
|
+
throw new TypeError("A passing check must have failureClass none.");
|
|
88
|
+
}
|
|
89
|
+
if (status === "FAIL" && failureClass === "none") {
|
|
90
|
+
throw new TypeError("A failed check needs a failure class.");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
executionId: text(input.executionId, "executionId"),
|
|
94
|
+
framework: text(input.framework, "framework"),
|
|
95
|
+
frameworkVersion: text(input.frameworkVersion, "frameworkVersion"),
|
|
96
|
+
checkId: text(input.checkId, "checkId"),
|
|
97
|
+
phase: input.phase,
|
|
98
|
+
status,
|
|
99
|
+
failureClass,
|
|
100
|
+
attempts: attempts(input.attempts),
|
|
101
|
+
evidence: boundedEvidence(input.evidence)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function makeAcceptanceRun(input) {
|
|
105
|
+
if (input.results.length > MAX_RESULTS) {
|
|
106
|
+
throw new TypeError("An acceptance run contains too many check results.");
|
|
107
|
+
}
|
|
108
|
+
if (input.phase !== "baseline" && input.phase !== "candidate") {
|
|
109
|
+
throw new TypeError("Invalid acceptance phase.");
|
|
110
|
+
}
|
|
111
|
+
const completionEvidence = input.completionEvidence ?? [];
|
|
112
|
+
if (input.completed)
|
|
113
|
+
boundedEvidence(completionEvidence);
|
|
114
|
+
const diagnostics = input.diagnostics ?? [];
|
|
115
|
+
diagnostics.forEach((value) => text(value, "diagnostic"));
|
|
116
|
+
for (const result of input.results) {
|
|
117
|
+
if (result.executionId !== input.executionId ||
|
|
118
|
+
result.framework !== input.framework ||
|
|
119
|
+
result.frameworkVersion !== input.frameworkVersion ||
|
|
120
|
+
result.phase !== input.phase) {
|
|
121
|
+
throw new TypeError("Every check result must use the run execution identity.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
protocolVersion: ACCEPTANCE_PROTOCOL_VERSION,
|
|
126
|
+
executionId: text(input.executionId, "executionId"),
|
|
127
|
+
framework: text(input.framework, "framework"),
|
|
128
|
+
frameworkVersion: text(input.frameworkVersion, "frameworkVersion"),
|
|
129
|
+
phase: input.phase,
|
|
130
|
+
results: [...input.results],
|
|
131
|
+
completed: input.completed,
|
|
132
|
+
completionEvidence: [...completionEvidence],
|
|
133
|
+
diagnostics: [...diagnostics]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function sortedUnique(values) {
|
|
137
|
+
return [...new Set(values)].sort();
|
|
138
|
+
}
|
|
139
|
+
function unknown(run, results, failureClass, diagnostics, missingCheckIds = [], duplicateCheckIds = [], unexpectedCheckIds = []) {
|
|
140
|
+
return {
|
|
141
|
+
status: "UNKNOWN",
|
|
142
|
+
failureClass,
|
|
143
|
+
results,
|
|
144
|
+
missingCheckIds,
|
|
145
|
+
duplicateCheckIds,
|
|
146
|
+
unexpectedCheckIds,
|
|
147
|
+
diagnostics: [...run.diagnostics, ...diagnostics]
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function aggregateAcceptanceRun(run, requestedCheckIds) {
|
|
151
|
+
const requested = sortedUnique(requestedCheckIds);
|
|
152
|
+
if (requested.length !== requestedCheckIds.length || requested.length === 0) {
|
|
153
|
+
return unknown(run, run.results, ACCEPTANCE_FAILURE.malformed, [
|
|
154
|
+
"Requested check IDs must be non-empty and unique."
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
if (!run.completed || run.completionEvidence.length === 0) {
|
|
158
|
+
return unknown(run, run.results, ACCEPTANCE_FAILURE.incomplete, [
|
|
159
|
+
"The adapter did not emit a complete terminal marker."
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
const expected = new Set(requested);
|
|
163
|
+
const counts = new Map();
|
|
164
|
+
for (const result of run.results) {
|
|
165
|
+
counts.set(result.checkId, (counts.get(result.checkId) ?? 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
const duplicateCheckIds = sortedUnique([...counts.entries()].filter(([, count]) => count > 1).map(([id]) => id));
|
|
168
|
+
const unexpectedCheckIds = sortedUnique(run.results.filter((result) => !expected.has(result.checkId)).map((result) => result.checkId));
|
|
169
|
+
const missingCheckIds = requested.filter((id) => !counts.has(id));
|
|
170
|
+
if (duplicateCheckIds.length > 0 || unexpectedCheckIds.length > 0 || missingCheckIds.length > 0) {
|
|
171
|
+
return unknown(run, run.results, duplicateCheckIds.length > 0 ? ACCEPTANCE_FAILURE.duplicate :
|
|
172
|
+
missingCheckIds.length > 0 ? ACCEPTANCE_FAILURE.missing : ACCEPTANCE_FAILURE.unexpected, ["The observed checks do not match the requested checks exactly."], missingCheckIds, duplicateCheckIds, unexpectedCheckIds);
|
|
173
|
+
}
|
|
174
|
+
const requestedResults = requested.map((id) => run.results.find((result) => result.checkId === id));
|
|
175
|
+
if (run.diagnostics.length > 0) {
|
|
176
|
+
return unknown(run, requestedResults, ACCEPTANCE_FAILURE.infrastructure, [
|
|
177
|
+
"The adapter reported execution diagnostics.",
|
|
178
|
+
...run.diagnostics
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
for (const result of requestedResults) {
|
|
182
|
+
if (result.evidence.length === 0) {
|
|
183
|
+
return unknown(run, requestedResults, ACCEPTANCE_FAILURE.emptyEvidence, [
|
|
184
|
+
`Check ${result.checkId} has no evidence.`
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
if (result.attempts === null || result.attempts !== 1 || result.failureClass === ACCEPTANCE_FAILURE.retry) {
|
|
188
|
+
return unknown(run, requestedResults, ACCEPTANCE_FAILURE.retry, [
|
|
189
|
+
`Check ${result.checkId} has missing or repeated attempts.`
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
if (result.status === "UNKNOWN") {
|
|
193
|
+
return unknown(run, requestedResults, result.failureClass, [
|
|
194
|
+
`Check ${result.checkId} did not produce a mechanical result.`
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
if (result.status === "PASS" && result.failureClass !== "none") {
|
|
198
|
+
return unknown(run, requestedResults, ACCEPTANCE_FAILURE.malformed, [
|
|
199
|
+
`Check ${result.checkId} marked PASS with a failure class.`
|
|
200
|
+
]);
|
|
201
|
+
}
|
|
202
|
+
if (result.status === "FAIL" && result.failureClass !== ACCEPTANCE_FAILURE.assertion) {
|
|
203
|
+
return unknown(run, requestedResults, result.failureClass, [
|
|
204
|
+
`Check ${result.checkId} failed outside an assertion.`
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (requestedResults.some((result) => result.status === "FAIL")) {
|
|
209
|
+
return {
|
|
210
|
+
status: "FAIL",
|
|
211
|
+
failureClass: ACCEPTANCE_FAILURE.assertion,
|
|
212
|
+
results: requestedResults,
|
|
213
|
+
missingCheckIds: [],
|
|
214
|
+
duplicateCheckIds: [],
|
|
215
|
+
unexpectedCheckIds: [],
|
|
216
|
+
diagnostics: [...run.diagnostics]
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
status: "PASS",
|
|
221
|
+
failureClass: "none",
|
|
222
|
+
results: requestedResults,
|
|
223
|
+
missingCheckIds: [],
|
|
224
|
+
duplicateCheckIds: [],
|
|
225
|
+
unexpectedCheckIds: [],
|
|
226
|
+
diagnostics: [...run.diagnostics]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export function parseJsonLines(source) {
|
|
230
|
+
const values = [];
|
|
231
|
+
const invalidLines = [];
|
|
232
|
+
source.split(/\r?\n/u).forEach((line, index) => {
|
|
233
|
+
const trimmed = line.trim();
|
|
234
|
+
if (trimmed.length === 0)
|
|
235
|
+
return;
|
|
236
|
+
try {
|
|
237
|
+
values.push(JSON.parse(trimmed));
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
invalidLines.push(index + 1);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return { values, invalidLines };
|
|
244
|
+
}
|
|
245
|
+
export function parseJsonDocument(source) {
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(source);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile, chmod } from "node:fs/promises";
|
|
2
|
+
import { join, delimiter, isAbsolute } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { writePrivateFile } from "../security/permissions.js";
|
|
5
|
+
import { redactSecrets } from "../security/redact.js";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { canonicalJson } from "../config/hash.js";
|
|
8
|
+
import { sha256 } from "../fs/hash.js";
|
|
9
|
+
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
10
|
+
import { criterionContractHash, taskContractHash } from "../task/contract.js";
|
|
11
|
+
import { aggregateAcceptanceRun, makeAcceptanceCheck, makeAcceptanceRun } from "./acceptance-protocol.js";
|
|
12
|
+
import { collectNodeAcceptance, collectJestAcceptance, collectVitestAcceptance, collectPytestAcceptance, collectRustAcceptance } from "./adapters/index.js";
|
|
13
|
+
import { buildVerificationEvidence } from "./evidence.js";
|
|
14
|
+
import { collectChangeSurface, resolveGitCommit } from "./change-surface.js";
|
|
15
|
+
import { runVerificationCommand } from "./spawn.js";
|
|
16
|
+
async function executableIdentity(command, cwd, env) {
|
|
17
|
+
const paths = isAbsolute(command) ? [command] : command.includes("/") ? [join(cwd, command)] :
|
|
18
|
+
(env.PATH ?? "").split(delimiter).filter(Boolean).map(path => join(path, command));
|
|
19
|
+
for (const path of paths) {
|
|
20
|
+
try {
|
|
21
|
+
const resolved = await realpath(path);
|
|
22
|
+
const entry = await lstat(resolved);
|
|
23
|
+
if (!entry.isFile() || (process.platform !== "win32" && (entry.mode & 0o111) === 0))
|
|
24
|
+
continue;
|
|
25
|
+
return sha256(canonicalJson({ path: resolved, bytes: sha256(await readFile(resolved)) }));
|
|
26
|
+
}
|
|
27
|
+
catch { /* Try the next fixed PATH entry. */ }
|
|
28
|
+
}
|
|
29
|
+
return "unavailable";
|
|
30
|
+
}
|
|
31
|
+
function replayEnvironment(checkout, phase, executionId) {
|
|
32
|
+
const env = Object.fromEntries(Object.entries(process.env).filter((pair) => pair[1] !== undefined && !/^(?:NODE_OPTIONS|NODE_PATH|PYTHONPATH|PYTHONHOME|CARGO_TARGET_DIR|RUSTC_WRAPPER|RUSTC_WORKSPACE_WRAPPER|AGENT_OPS_|CLAUDE_|CODEX_)/u.test(pair[0])));
|
|
33
|
+
env.PATH = [join(checkout, "node_modules/.bin"), join(checkout, ".venv/bin"),
|
|
34
|
+
...(process.env.PATH ?? "").split(delimiter).filter(path => !path.includes("node_modules/.bin"))].join(delimiter);
|
|
35
|
+
env.AGENT_OPS_ACCEPTANCE_PHASE = phase;
|
|
36
|
+
env.AGENT_OPS_ACCEPTANCE_EXECUTION_ID = executionId;
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
function collected(runner, output, checkout, options) {
|
|
40
|
+
if (output.timedOut || output.signal !== null || output.stdoutTruncated || output.stderrTruncated || output.status === "UNKNOWN")
|
|
41
|
+
return makeAcceptanceRun({ ...options, framework: runner.adapter, results: [], completed: false, diagnostics: [output.failureClass] });
|
|
42
|
+
// Framework file identities must be repository-relative across disposable checkouts.
|
|
43
|
+
const normalize = (value) => typeof value === "string" && value.startsWith(checkout + "/") ? value.slice(checkout.length + 1) :
|
|
44
|
+
Array.isArray(value) ? value.map(normalize) : typeof value === "object" && value !== null ?
|
|
45
|
+
Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalize(item)])) : value;
|
|
46
|
+
let structured = output.stdout;
|
|
47
|
+
try {
|
|
48
|
+
structured = JSON.stringify(normalize(JSON.parse(structured)));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
structured = structured.split("\n").map(line => { try {
|
|
52
|
+
return JSON.stringify(normalize(JSON.parse(line)));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return line;
|
|
56
|
+
} }).join("\n");
|
|
57
|
+
}
|
|
58
|
+
switch (runner.adapter) {
|
|
59
|
+
case "node": return collectNodeAcceptance(structured, options);
|
|
60
|
+
case "jest": return collectJestAcceptance(structured, options);
|
|
61
|
+
case "vitest": return collectVitestAcceptance(structured, options);
|
|
62
|
+
case "pytest": return collectPytestAcceptance(structured, options);
|
|
63
|
+
case "rust": return collectRustAcceptance(structured, options);
|
|
64
|
+
case "generic": {
|
|
65
|
+
const value = JSON.parse(structured);
|
|
66
|
+
if (value.protocolVersion !== 1 || value.executionId !== options.executionId || value.phase !== options.phase)
|
|
67
|
+
throw new TypeError("Generic output does not match this execution.");
|
|
68
|
+
return makeAcceptanceRun({ ...value, results: value.results.map(makeAcceptanceCheck) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Clean disposable clones are verification checkouts, never managed writer worktrees. */
|
|
73
|
+
export async function replayAcceptance(options) {
|
|
74
|
+
if (!options.trusted)
|
|
75
|
+
throw new AgentOpsError("VERIFICATION_UNTRUSTED", "Acceptance runners require the repository's current trust binding.");
|
|
76
|
+
if ((await collectChangeSurface(options.gitRunner)).paths.length > 0)
|
|
77
|
+
throw new AgentOpsError("ACCEPTANCE_COMMITTED_SOURCE_REQUIRED", "Formal acceptance replay requires a clean committed candidate.");
|
|
78
|
+
const candidate = await resolveGitCommit(options.gitRunner, "HEAD");
|
|
79
|
+
const groups = new Map();
|
|
80
|
+
for (const criterion of options.task.criteria) {
|
|
81
|
+
if (criterion.acceptance === undefined || criterion.acceptance.mode === "review-only")
|
|
82
|
+
continue;
|
|
83
|
+
for (const binding of criterion.acceptance.bindings) {
|
|
84
|
+
const runner = options.config.verification.acceptanceRunners?.find(r => r.id === binding.runnerId);
|
|
85
|
+
if (runner === undefined)
|
|
86
|
+
throw new AgentOpsError("ACCEPTANCE_RUNNER_MISSING", "The acceptance runner is not configured.");
|
|
87
|
+
const key = canonicalJson({ baseline: criterion.acceptance.baselineCommit, runner, materials: binding.materials });
|
|
88
|
+
const group = groups.get(key);
|
|
89
|
+
if (group === undefined)
|
|
90
|
+
groups.set(key, { baseline: criterion.acceptance.baselineCommit, runner, binding, criteria: [criterion] });
|
|
91
|
+
else if (!group.criteria.some(c => c.id === criterion.id))
|
|
92
|
+
group.criteria.push(criterion);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const reports = [];
|
|
96
|
+
for (const group of groups.values()) {
|
|
97
|
+
const materials = [];
|
|
98
|
+
for (const material of group.binding.materials) {
|
|
99
|
+
const metadata = await options.gitRunner.run(["ls-tree", "-z", candidate, "--", material.path]);
|
|
100
|
+
const entry = Buffer.from(metadata.stdout).toString("utf8").match(/^(100644|100755) blob ([a-f0-9]+)\t([^\0]+)\0$/u);
|
|
101
|
+
if (metadata.exitCode !== 0 || entry === null || entry[3] !== material.path)
|
|
102
|
+
throw new AgentOpsError("ACCEPTANCE_UNSAFE_MATERIAL", "Acceptance materials must be committed regular files.");
|
|
103
|
+
const blob = await options.gitRunner.run(["cat-file", "blob", entry[2]]);
|
|
104
|
+
if (blob.exitCode !== 0 || blob.stdout.byteLength > 1024 * 1024)
|
|
105
|
+
throw new AgentOpsError("ACCEPTANCE_MATERIAL_UNAVAILABLE", "Acceptance material exceeds its bound or is unavailable.");
|
|
106
|
+
materials.push({ ...material, bytes: blob.stdout, mode: entry[1] === "100755" ? 0o755 : 0o644 });
|
|
107
|
+
}
|
|
108
|
+
const materialDigest = sha256(canonicalJson(materials.map(m => ({ path: m.path, role: m.role, mode: m.mode, hash: sha256(m.bytes) }))));
|
|
109
|
+
for (const phase of ["baseline", "candidate"]) {
|
|
110
|
+
const commit = phase === "baseline" ? group.baseline : candidate;
|
|
111
|
+
const executionId = randomUUID();
|
|
112
|
+
const startedAt = new Date().toISOString();
|
|
113
|
+
const temp = await mkdtemp(join(tmpdir(), "agent-ops-acceptance-"));
|
|
114
|
+
const checkout = join(temp, "repository");
|
|
115
|
+
const env = replayEnvironment(checkout, phase, executionId);
|
|
116
|
+
const command = async (executable, args, cwd, timeoutMs = 60_000) => await runVerificationCommand({ id: group.runner.id, command: executable, args: [...args], cwd: ".", required: true,
|
|
117
|
+
evidence: { kind: "exit-code" }, timeoutMs }, { cwd, runner: options.processRunner, env, replaceEnv: true,
|
|
118
|
+
outputLimitBytes: 1024 * 1024, ...(options.signal === undefined ? {} : { signal: options.signal }) });
|
|
119
|
+
let aggregate;
|
|
120
|
+
let failure = "replay-unavailable";
|
|
121
|
+
let output;
|
|
122
|
+
let run;
|
|
123
|
+
const steps = [];
|
|
124
|
+
let executionDigest = sha256(executionId);
|
|
125
|
+
let argv = [];
|
|
126
|
+
const requested = [...new Set(group.criteria.flatMap(c => c.acceptance.bindings
|
|
127
|
+
.filter(b => b.runnerId === group.runner.id && canonicalJson(b.materials) === canonicalJson(group.binding.materials)).flatMap(b => b.checkIds)))];
|
|
128
|
+
try {
|
|
129
|
+
for (const [cwd, args] of [[temp, ["clone", "--no-hardlinks", "--quiet", "--", options.root, checkout]],
|
|
130
|
+
[checkout, ["fetch", "--quiet", "--", options.root, commit]], [checkout, ["checkout", "--detach", "--quiet", commit]]]) {
|
|
131
|
+
const result = await command("git", args, cwd);
|
|
132
|
+
if (result.status !== "PASS")
|
|
133
|
+
throw new Error("checkout-unavailable");
|
|
134
|
+
}
|
|
135
|
+
const cwd = group.runner.cwd === "." ? checkout : await resolveContainedPath(checkout, group.runner.cwd);
|
|
136
|
+
for (const step of [...(group.runner.setup ?? []), ...(group.runner.build ?? [])]) {
|
|
137
|
+
const result = await command(step.command, step.args, cwd, step.timeoutMs);
|
|
138
|
+
steps.push({ command: step.command, args: step.args, status: result.status, failureClass: result.failureClass, stderr: result.stderr });
|
|
139
|
+
if (result.status !== "PASS")
|
|
140
|
+
throw new Error("setup-or-build-unavailable");
|
|
141
|
+
}
|
|
142
|
+
// Both sides receive exactly the candidate's explicit test material, after their own build.
|
|
143
|
+
for (const material of materials) {
|
|
144
|
+
let parent = checkout;
|
|
145
|
+
for (const segment of material.path.split("/").slice(0, -1)) {
|
|
146
|
+
parent = join(parent, segment);
|
|
147
|
+
try {
|
|
148
|
+
if (!(await lstat(parent)).isDirectory())
|
|
149
|
+
throw new Error("unsafe-material-parent");
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code !== "ENOENT")
|
|
153
|
+
throw error;
|
|
154
|
+
await mkdir(parent);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const path = join(checkout, ...material.path.split("/"));
|
|
158
|
+
try {
|
|
159
|
+
if (!(await lstat(path)).isFile())
|
|
160
|
+
throw new Error("unsafe-material-target");
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error.code !== "ENOENT")
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
await writeFile(path, material.bytes);
|
|
167
|
+
await chmod(path, material.mode);
|
|
168
|
+
}
|
|
169
|
+
for (const step of group.runner.testBuild ?? []) {
|
|
170
|
+
const result = await command(step.command, step.args, cwd, step.timeoutMs);
|
|
171
|
+
steps.push({ command: step.command, args: step.args, status: result.status, failureClass: result.failureClass, stderr: result.stderr });
|
|
172
|
+
if (result.status !== "PASS")
|
|
173
|
+
throw new Error("test-build-unavailable");
|
|
174
|
+
}
|
|
175
|
+
argv = group.runner.args.flatMap(arg => arg === "{materials}" ?
|
|
176
|
+
materials.filter(m => m.role === "test").map(m => m.path) : [arg]);
|
|
177
|
+
const dependencies = [];
|
|
178
|
+
for (const path of ["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.toml", "Cargo.lock", "pyproject.toml", "requirements.txt"]) {
|
|
179
|
+
try {
|
|
180
|
+
dependencies.push({ path, hash: sha256(await readFile(join(checkout, path))) });
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== "ENOENT")
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const identity = await executableIdentity(group.runner.command, cwd, env);
|
|
188
|
+
if (identity === "unavailable")
|
|
189
|
+
throw new Error("missing-executable");
|
|
190
|
+
executionDigest = sha256(canonicalJson({ commit, materialDigest, runner: group.runner, argv, dependencies,
|
|
191
|
+
executable: identity, platform: process.platform, arch: process.arch, environment: sha256(canonicalJson(env)) }));
|
|
192
|
+
output = await command(group.runner.command, argv, cwd, group.runner.timeoutMs);
|
|
193
|
+
run = collected(group.runner, output, checkout, { executionId, phase,
|
|
194
|
+
frameworkVersion: group.runner.adapter === "node" ? process.version : "dependency-lock:" + sha256(canonicalJson(dependencies)) });
|
|
195
|
+
aggregate = aggregateAcceptanceRun(run, requested);
|
|
196
|
+
// Process errors never become assertion red, even if stdout claims a result.
|
|
197
|
+
if (output.exitCode === null || output.status === "UNKNOWN" ||
|
|
198
|
+
(aggregate.status === "PASS" && output.exitCode !== 0) || (aggregate.status === "FAIL" && output.exitCode === 0))
|
|
199
|
+
aggregate = undefined;
|
|
200
|
+
failure = aggregate?.failureClass ?? output.failureClass;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
failure = error instanceof Error && /^[a-z-]+$/u.test(error.message) ? error.message : "replay-unavailable";
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await rm(temp, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
const finishedAt = new Date().toISOString();
|
|
209
|
+
const artifactContent = redactSecrets(JSON.stringify({ schemaVersion: 1, executionId, phase, commit,
|
|
210
|
+
pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest,
|
|
211
|
+
runner: group.runner, argv, run: run ?? null, aggregate: aggregate ?? null, output: output ?? null, steps, failure, startedAt, finishedAt }));
|
|
212
|
+
const executionArtifact = ".agent-ops/tasks/acceptance/" + sha256(artifactContent) + ".json";
|
|
213
|
+
await writePrivateFile(join(options.root, ...executionArtifact.split("/")), artifactContent, options.root);
|
|
214
|
+
for (const criterion of group.criteria) {
|
|
215
|
+
const bindings = criterion.acceptance.bindings.filter(b => b.runnerId === group.runner.id && canonicalJson(b.materials) === canonicalJson(group.binding.materials));
|
|
216
|
+
const binding = { checkIds: [...new Set(bindings.flatMap(b => b.checkIds))], redCheckIds: [...new Set(bindings.flatMap(b => b.redCheckIds ?? []))] };
|
|
217
|
+
const checks = binding.checkIds.map(checkId => {
|
|
218
|
+
const check = aggregate?.results.find(result => result.checkId === checkId);
|
|
219
|
+
return { checkId, status: check?.status ?? "UNKNOWN",
|
|
220
|
+
failureClass: check?.failureClass === "assertion-failed" ? "assertion" : check?.failureClass ?? failure };
|
|
221
|
+
});
|
|
222
|
+
const red = binding.redCheckIds ?? [];
|
|
223
|
+
const status = aggregate === undefined || aggregate.status === "UNKNOWN" ? "UNKNOWN" :
|
|
224
|
+
phase === "candidate" ? checks.every(c => c.status === "PASS") ? "PASS" : "FAIL" :
|
|
225
|
+
red.every(id => checks.some(c => c.checkId === id && c.status === "FAIL" && c.failureClass === "assertion")) ? "PASS" : "FAIL";
|
|
226
|
+
const evidence = buildVerificationEvidence({ taskId: options.task.id, criterionId: criterion.id,
|
|
227
|
+
command: { id: group.runner.id, command: group.runner.command, args: argv, cwd: group.runner.cwd, required: true, evidence: { kind: "exit-code" } },
|
|
228
|
+
scope: options.scope, startedAt, finishedAt, exitCode: output?.exitCode ?? null, testCount: checks.length,
|
|
229
|
+
status, failureClass: status === "PASS" ? "none" : phase === "baseline" && aggregate?.status === "PASS" ? "non-discriminating" : failure,
|
|
230
|
+
sourceFingerprint: options.sourceFingerprint, toolVersions: { node: process.version }, config: options.config,
|
|
231
|
+
taskContractHash: taskContractHash(options.task), acceptance: { criterionContractHash: criterionContractHash(criterion),
|
|
232
|
+
bindingHash: sha256(canonicalJson({ runnerId: group.runner.id, materials: group.binding.materials })), phase, commit, pairedCommit: phase === "baseline" ? candidate : group.baseline, materialDigest, executionDigest, executionArtifact, checks } });
|
|
233
|
+
const reference = await options.evidenceStore.save(evidence);
|
|
234
|
+
reports.push({ criterionId: criterion.id, runnerId: group.runner.id, phase, status, failureClass: evidence.failureClass, reference });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return reports;
|
|
239
|
+
}
|