@kylecheng3146/agent-ops 0.5.3 → 0.7.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 +103 -1
- package/dist/packages/cli/src/args.js +183 -3
- package/dist/packages/cli/src/bin.js +76 -19
- package/dist/packages/cli/src/cli.js +53 -8
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +42 -5
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +115 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +64 -1
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +54 -3
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +230 -0
- 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 +321 -0
- package/dist/packages/cli/src/run-entry.js +952 -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/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +11 -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/codex-loop.js +4 -0
- 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 +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +233 -0
- package/dist/runtime/src/office/page.js +873 -0
- package/dist/runtime/src/office/scene.js +168 -0
- package/dist/runtime/src/office/server.js +139 -0
- package/dist/runtime/src/office/sessions.js +115 -0
- package/dist/runtime/src/office/snapshot.js +107 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +12 -2
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- 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/phase.js +37 -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 +433 -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 +80 -9
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- 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/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/harness-adapters.md +113 -1
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/harness-adapters.md +93 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +230 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -3,6 +3,8 @@ import { TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
|
3
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
4
|
import { validateTask } from "../schema/validate.js";
|
|
5
5
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
6
|
+
import { taskContractHash } from "./contract.js";
|
|
7
|
+
import { commitIdentity, acceptanceSourcePath } from "../schema/acceptance.js";
|
|
6
8
|
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
7
9
|
const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
|
|
8
10
|
function isMissing(error) {
|
|
@@ -66,12 +68,9 @@ function parseTaskRecord(value) {
|
|
|
66
68
|
];
|
|
67
69
|
// Every record written before an optional field existed must still parse, so
|
|
68
70
|
// the accepted shapes are every combination of them.
|
|
69
|
-
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
|
|
73
|
-
].sort().join("\0")));
|
|
74
|
-
if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
|
|
71
|
+
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase", "createdSourceCommit", "revisions", "supersededBy", "noChangePaths"];
|
|
72
|
+
if (!isRecord(value) || !baseKeys.every(k => Object.hasOwn(value, k)) ||
|
|
73
|
+
Object.keys(value).some(k => !baseKeys.includes(k) && !optionalKeys.includes(k))) {
|
|
75
74
|
return invalidState("Task state contains an invalid task record.");
|
|
76
75
|
}
|
|
77
76
|
const task = validateTask(value.task);
|
|
@@ -150,8 +149,43 @@ function parseTaskRecord(value) {
|
|
|
150
149
|
(typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
|
|
151
150
|
return invalidState("Task state contains an invalid review base.");
|
|
152
151
|
}
|
|
152
|
+
if (value.noChangePaths !== undefined && (!Array.isArray(value.noChangePaths) || value.noChangePaths.length === 0 || value.noChangePaths.length > 128 ||
|
|
153
|
+
value.noChangePaths.some(p => typeof p !== "string" || !acceptanceSourcePath(p)) || new Set(value.noChangePaths).size !== value.noChangePaths.length))
|
|
154
|
+
return invalidState("Invalid no-change scope.");
|
|
155
|
+
if (value.supersededBy !== undefined && (!Array.isArray(value.supersededBy) || value.supersededBy.length === 0 || value.supersededBy.length > 128 ||
|
|
156
|
+
value.supersededBy.some(v => typeof v !== "string" || !/^[a-z][a-z0-9-]{0,127}$/u.test(v)) || new Set(value.supersededBy).size !== value.supersededBy.length || status !== "archived"))
|
|
157
|
+
return invalidState("Invalid supersession mapping.");
|
|
158
|
+
if (value.createdSourceCommit !== undefined && !commitIdentity(value.createdSourceCommit))
|
|
159
|
+
return invalidState("Invalid task creation commit.");
|
|
160
|
+
if (value.revisions !== undefined) {
|
|
161
|
+
if (!Array.isArray(value.revisions) || value.revisions.length > 1024)
|
|
162
|
+
return invalidState("Invalid revision history.");
|
|
163
|
+
let previousCurrent;
|
|
164
|
+
for (const revision of value.revisions) {
|
|
165
|
+
if (!isRecord(revision) || !hasExactKeys(revision, ["previousTask", "previousEvidence", "previousFailure", "previousHash", "currentHash", "reason", "at", "diagnostics"]) ||
|
|
166
|
+
typeof revision.reason !== "string" || revision.reason.trim() === "" || revision.reason.length > 8192 || revision.reason.includes("\0") ||
|
|
167
|
+
!isTimestamp(revision.at) || !Array.isArray(revision.diagnostics) || revision.diagnostics.length > 512 ||
|
|
168
|
+
revision.diagnostics.some(d => typeof d !== "string" || d.length > 4096 || d.includes("\0")) ||
|
|
169
|
+
typeof revision.currentHash !== "string" || !/^[a-f0-9]{64}$/u.test(revision.currentHash))
|
|
170
|
+
return invalidState("Invalid revision metadata.");
|
|
171
|
+
const before = validateTask(revision.previousTask);
|
|
172
|
+
if (!before.ok || before.value.id !== task.value.id || taskContractHash(before.value) !== revision.previousHash ||
|
|
173
|
+
(previousCurrent !== undefined && revision.previousHash !== previousCurrent))
|
|
174
|
+
return invalidState("Invalid revision chain.");
|
|
175
|
+
parseEvidence(revision.previousEvidence, before.value, false);
|
|
176
|
+
if (revision.previousFailure !== null && (!isRecord(revision.previousFailure) || typeof revision.previousFailure.value !== "string" || !/^[a-f0-9]{64}$/u.test(revision.previousFailure.value)))
|
|
177
|
+
return invalidState("Invalid revision failure provenance.");
|
|
178
|
+
previousCurrent = revision.currentHash;
|
|
179
|
+
}
|
|
180
|
+
if (previousCurrent !== undefined && previousCurrent !== taskContractHash(task.value))
|
|
181
|
+
return invalidState("Revision history does not match current contract.");
|
|
182
|
+
}
|
|
153
183
|
return {
|
|
154
184
|
task: task.value,
|
|
185
|
+
...(value.noChangePaths === undefined ? {} : { noChangePaths: value.noChangePaths }),
|
|
186
|
+
...(value.createdSourceCommit === undefined ? {} : { createdSourceCommit: value.createdSourceCommit }),
|
|
187
|
+
...(value.revisions === undefined ? {} : { revisions: value.revisions }),
|
|
188
|
+
...(value.supersededBy === undefined ? {} : { supersededBy: value.supersededBy }),
|
|
155
189
|
status,
|
|
156
190
|
evidence,
|
|
157
191
|
createdAt: value.createdAt,
|
|
@@ -204,7 +238,7 @@ function parseState(source) {
|
|
|
204
238
|
}
|
|
205
239
|
if (!isRecord(value) ||
|
|
206
240
|
!hasExactKeys(value, ["schemaVersion", "sessions", "tasks"]) ||
|
|
207
|
-
value.schemaVersion !== TASK_SCHEMA_VERSION ||
|
|
241
|
+
(value.schemaVersion !== 1 && value.schemaVersion !== TASK_SCHEMA_VERSION) ||
|
|
208
242
|
!Array.isArray(value.tasks) ||
|
|
209
243
|
!Array.isArray(value.sessions)) {
|
|
210
244
|
return invalidState("Task state has an unsupported structure.");
|
|
@@ -217,6 +251,22 @@ function parseState(source) {
|
|
|
217
251
|
return invalidState("Task and session identifiers must be unique.");
|
|
218
252
|
}
|
|
219
253
|
const tasksById = new Map(tasks.map((record) => [record.task.id, record]));
|
|
254
|
+
for (const old of tasks) {
|
|
255
|
+
for (const successor of old.supersededBy ?? []) {
|
|
256
|
+
if (successor === old.task.id || !tasksById.has(successor))
|
|
257
|
+
return invalidState("Supersession must name a distinct known task.");
|
|
258
|
+
const seen = new Set([old.task.id]);
|
|
259
|
+
const visit = (id) => {
|
|
260
|
+
if (seen.has(id))
|
|
261
|
+
return invalidState("Cyclic task supersession.");
|
|
262
|
+
seen.add(id);
|
|
263
|
+
for (const next of tasksById.get(id)?.supersededBy ?? [])
|
|
264
|
+
visit(next);
|
|
265
|
+
seen.delete(id);
|
|
266
|
+
};
|
|
267
|
+
visit(successor);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
220
270
|
if (sessions.some(({ taskId }) => {
|
|
221
271
|
const record = tasksById.get(taskId);
|
|
222
272
|
return record === undefined || record.status === "archived";
|
|
@@ -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
|
+
}
|