@haiyangbg/buildbeat 2.0.0-beta.2 → 2.0.0-beta.4
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/CHANGELOG.md +42 -6
- package/SKILL.md +76 -2
- package/bin/buildbeat-v2.js +13 -1
- package/docs/CLI-PILOT-2026-08-23.md +1 -1
- package/docs/CLI.md +1 -1
- package/docs/EXECUTION-PLAN.md +2 -2
- package/docs/PHASE2-PILOT-PREFLIGHT-2026-08-25.md +2 -2
- package/docs/PHASE4-V1.20-PILOT-2026-08-25.md +2 -2
- package/docs/RELEASING.md +1 -1
- package/docs/V2-D2-DECISION-CARD.md +2 -2
- package/docs/V2-DECISIONS.md +2 -2
- package/docs/V2-ITERATION-01.md +13 -13
- package/docs/V2-ITERATION-06.md +2 -2
- package/docs/V2-ITERATION-08.md +62 -0
- package/docs/V2-PLAN.md +6 -6
- package/docs/V2-PROPOSAL.md +2 -2
- package/docs/V2.0.0-BETA.1-RELEASE-EVIDENCE-2026-08-28.md +1 -1
- package/docs/V2.0.0-BETA.2-RELEASE-EVIDENCE-2026-08-28.md +8 -0
- package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
- package/docs/v2/M4-EXTERNAL-PILOT-2026-08-28.md +11 -11
- package/docs/v2/{M4-CHICKAI-PILOT-2026-08-28.md → M4-PILOT-APP-2026-08-28.md} +4 -4
- package/docs/v2/M4-SELFHOST-2026-08-28.md +1 -1
- package/docs/v2/RFC-0001-product-definition.md +2 -2
- package/docs/v2/SPEC-0001-events-v1.md +2 -2
- package/docs/v2/guide/00-how-to-talk.md +57 -0
- package/docs/v2/guide/01-quickstart.md +6 -0
- package/docs/v2/guide/02-workflow-guide.md +31 -0
- package/docs/v2/guide/04-adapter-guide.md +4 -0
- package/docs/v2/guide/05-worker-contract.md +10 -0
- package/docs/v2/guide/06-evidence-guide.md +10 -0
- package/docs/v2/guide/07-approval-guide.md +50 -0
- package/docs/v2/guide/10-recovery.md +25 -2
- package/docs/v2/guide/README.md +3 -0
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +12 -0
- package/package.json +1 -1
- package/src/v2/adapters/shell.js +87 -14
- package/src/v2/cli/run.js +602 -30
- package/src/v2/domain/event-registry.js +24 -0
- package/src/v2/engine/reducer.js +5 -0
- package/src/v2/engine/workflow.js +8 -1
- package/src/v2/evidence/collector.js +14 -3
- package/src/v2/observe/observe.js +9 -2
- package/src/v2/presets/release-readback.yaml +36 -0
- package/src/v2/presets/risk/release.yaml +21 -0
- package/src/v2/presets/software-delivery.yaml +5 -0
- package/src/v2/runtime/cache.js +124 -0
- package/src/v2/runtime/decisions.js +6 -4
- package/src/v2/runtime/env-contract.js +135 -0
- package/src/v2/runtime/envelope.js +183 -0
- package/src/v2/runtime/findings.js +158 -0
- package/src/v2/runtime/gc.js +182 -0
- package/src/v2/runtime/liveness.js +193 -0
- package/src/v2/runtime/metrics.js +8 -0
- package/src/v2/runtime/notify.js +223 -0
- package/src/v2/runtime/orchestrator.js +252 -24
- package/src/v2/runtime/overview.js +264 -0
- package/src/v2/runtime/repo-ref.js +38 -0
- package/src/v2/runtime/run-record.js +18 -3
- package/src/v2/workspace/workspace-manager.js +4 -1
- package/templates/v2/AGENTS.md +72 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Initial event type registry v1 per docs/v2/SPEC-0001-events-v1.md §4.
|
|
2
2
|
// Semantics are frozen; the registry and per-type data may only grow additively.
|
|
3
3
|
|
|
4
|
+
import { isAbsolute } from "node:path";
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
ACTOR_KINDS,
|
|
6
8
|
BUDGET_KINDS,
|
|
@@ -58,6 +60,23 @@ const EVENT_ENUMS = {
|
|
|
58
60
|
TRIAGE_RECORDED: { action: TRIAGE_ACTIONS },
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
const REPO_REF_FIELDS = {
|
|
64
|
+
WORKSPACE_BOUND: ["repo", "worktreePath"],
|
|
65
|
+
EVIDENCE_RECORDED: ["evidenceRef"],
|
|
66
|
+
RUN_COMPACTED: ["runRecordRef"],
|
|
67
|
+
INTENT_DRAFTED: ["intentRef"],
|
|
68
|
+
TRIAGE_RECORDED: ["intentRef"],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function unsafeRepoRef(value) {
|
|
72
|
+
return (
|
|
73
|
+
typeof value !== "string" ||
|
|
74
|
+
value.length === 0 ||
|
|
75
|
+
isAbsolute(value) ||
|
|
76
|
+
/(^|[\\/])\.\.([\\/]|$)/.test(value)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
export class EventInputError extends Error {
|
|
62
81
|
constructor(message) {
|
|
63
82
|
super(message);
|
|
@@ -87,6 +106,11 @@ export function validateEventInput(type, actor, data) {
|
|
|
87
106
|
throw new EventInputError(`event ${type} missing required data field: ${field}`);
|
|
88
107
|
}
|
|
89
108
|
}
|
|
109
|
+
for (const field of REPO_REF_FIELDS[type] ?? []) {
|
|
110
|
+
if (unsafeRepoRef(data[field])) {
|
|
111
|
+
throw new EventInputError(`event ${type} field ${field} must be a repository-relative reference`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
90
114
|
const enums = EVENT_ENUMS[type];
|
|
91
115
|
if (enums) {
|
|
92
116
|
for (const [field, allowed] of Object.entries(enums)) {
|
package/src/v2/engine/reducer.js
CHANGED
|
@@ -140,6 +140,11 @@ export function applyEvent(state, event) {
|
|
|
140
140
|
status: data.status,
|
|
141
141
|
grade: data.grade,
|
|
142
142
|
...(data.findings ? { findings: data.findings } : {}),
|
|
143
|
+
...(data.suppressedFingerprints
|
|
144
|
+
? { suppressedFingerprints: data.suppressedFingerprints }
|
|
145
|
+
: {}),
|
|
146
|
+
...(data.cacheKey ? { cacheKey: data.cacheKey } : {}),
|
|
147
|
+
...(data.reused ? { reused: data.reused } : {}),
|
|
143
148
|
});
|
|
144
149
|
break;
|
|
145
150
|
}
|
|
@@ -27,7 +27,8 @@ const TOP_FIELDS = new Set([
|
|
|
27
27
|
"policies",
|
|
28
28
|
"budgets",
|
|
29
29
|
]);
|
|
30
|
-
const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly"]);
|
|
30
|
+
const STEP_FIELDS = new Set(["id", "worker", "optional", "requiredWhen", "readonly", "grade"]);
|
|
31
|
+
const EVIDENCE_GRADES = new Set(["L0", "L1", "L2", "L3", "L4"]);
|
|
31
32
|
const TRANSITION_FIELDS = new Set(["from", "on", "to"]);
|
|
32
33
|
|
|
33
34
|
function rejectUnknownFields(object, allowed, where) {
|
|
@@ -82,6 +83,11 @@ export function parseWorkflow(text) {
|
|
|
82
83
|
if (raw.readonly !== undefined && typeof raw.readonly !== "boolean") {
|
|
83
84
|
throw new WorkflowError(`step ${id} readonly must be a boolean`);
|
|
84
85
|
}
|
|
86
|
+
// Evidence grade a step's command evidence is recorded at (iteration 08:
|
|
87
|
+
// release readbacks run against the real environment and are L4).
|
|
88
|
+
if (raw.grade !== undefined && !EVIDENCE_GRADES.has(raw.grade)) {
|
|
89
|
+
throw new WorkflowError(`step ${id} grade must be one of L0-L4`);
|
|
90
|
+
}
|
|
85
91
|
stepIds.add(id);
|
|
86
92
|
steps.push({
|
|
87
93
|
id,
|
|
@@ -89,6 +95,7 @@ export function parseWorkflow(text) {
|
|
|
89
95
|
optional: raw.optional ?? false,
|
|
90
96
|
requiredWhen: raw.requiredWhen ?? null,
|
|
91
97
|
readonly: raw.readonly ?? false,
|
|
98
|
+
grade: raw.grade ?? "L2",
|
|
92
99
|
});
|
|
93
100
|
}
|
|
94
101
|
|
|
@@ -17,21 +17,32 @@ export function collectCommandEvidence({
|
|
|
17
17
|
kind = "command",
|
|
18
18
|
grade = "L2",
|
|
19
19
|
coverage = null,
|
|
20
|
+
redact = [],
|
|
20
21
|
}) {
|
|
21
22
|
const logsDir = join(runtimeDir, "runs", runId, "logs");
|
|
22
23
|
mkdirSync(logsDir, { recursive: true });
|
|
23
24
|
const logName = `${step}-${attempt}.log`;
|
|
24
25
|
const logPath = join(logsDir, logName);
|
|
26
|
+
// Redaction (iteration 08, C6): patterns from the run config are applied
|
|
27
|
+
// to worker output before it becomes evidence. The digest binds the
|
|
28
|
+
// redacted text — what is on disk is what was hashed.
|
|
29
|
+
const scrub = (text) => {
|
|
30
|
+
let out = String(text ?? "");
|
|
31
|
+
for (const pattern of redact) {
|
|
32
|
+
out = out.replace(pattern, "<REDACTED>");
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
};
|
|
25
36
|
const logBody = [
|
|
26
|
-
`command: ${execResult.command}`,
|
|
37
|
+
`command: ${scrub(execResult.command)}`,
|
|
27
38
|
`exitCode: ${execResult.exitCode}`,
|
|
28
39
|
`signal: ${execResult.signal}`,
|
|
29
40
|
`timedOut: ${execResult.timedOut}`,
|
|
30
41
|
`spawnError: ${execResult.spawnError}`,
|
|
31
42
|
"--- stdout ---",
|
|
32
|
-
execResult.stdout,
|
|
43
|
+
scrub(execResult.stdout),
|
|
33
44
|
"--- stderr ---",
|
|
34
|
-
execResult.stderr,
|
|
45
|
+
scrub(execResult.stderr),
|
|
35
46
|
].join("\n");
|
|
36
47
|
writeFileSync(logPath, logBody, "utf8");
|
|
37
48
|
const digest = `sha256:${createHash("sha256").update(logBody, "utf8").digest("hex")}`;
|
|
@@ -22,6 +22,7 @@ import { collectCommandEvidence } from "../evidence/collector.js";
|
|
|
22
22
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
23
23
|
import { loadObserveConfig, severityRank } from "./observe-config.js";
|
|
24
24
|
import { OBSERVE_REDUCER } from "./observe-reducer.js";
|
|
25
|
+
import { toRepoRef } from "../runtime/repo-ref.js";
|
|
25
26
|
|
|
26
27
|
const OBSERVE_IDS = { run: "OBSERVE", work: "OBSERVE" };
|
|
27
28
|
const KERNEL_ACTOR = { kind: "kernel", id: "observe" };
|
|
@@ -142,7 +143,7 @@ function recordEvidence({ ledger, config, cycle, provider, execResult, kind, ste
|
|
|
142
143
|
type: "EVIDENCE_RECORDED",
|
|
143
144
|
actor: { kind: "provider", id: provider.id },
|
|
144
145
|
data: {
|
|
145
|
-
evidenceRef: record.location,
|
|
146
|
+
evidenceRef: toRepoRef(config.repoRoot, record.location),
|
|
146
147
|
kind: record.kind,
|
|
147
148
|
subject: record.subject,
|
|
148
149
|
digest: record.digest,
|
|
@@ -350,7 +351,13 @@ export function runObserveCycle({ configPath, now = new Date().toISOString() })
|
|
|
350
351
|
data: { cycle, providersRun: config.providers.length },
|
|
351
352
|
...OBSERVE_IDS,
|
|
352
353
|
});
|
|
353
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
cycle,
|
|
356
|
+
ledgerPath: ledger.path,
|
|
357
|
+
ledgerRef: toRepoRef(config.repoRoot, ledger.path),
|
|
358
|
+
results,
|
|
359
|
+
state: ledger.state,
|
|
360
|
+
};
|
|
354
361
|
}
|
|
355
362
|
|
|
356
363
|
export function triageIntent({ repoRoot, intentRef, action, by = "unknown", note, now = new Date().toISOString() }) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Release readback lane (iteration 08, C8). The kernel still has no deploy
|
|
2
|
+
# capability (invariant 20): every production action stays a human's. What
|
|
3
|
+
# this lane fixes is the bookkeeping around it — a pilot go-live was
|
|
4
|
+
# ledgered by hand as forty "step N readback" commits. Here each step is a
|
|
5
|
+
# read-only command that proves the world is in the expected state; every
|
|
6
|
+
# failure stops for a human (maxAttempts 1, no fix edge), and evidence is
|
|
7
|
+
# graded L4 because it was read from the real environment.
|
|
8
|
+
#
|
|
9
|
+
# Shape: preflight (before the action) → [human performs the action; the
|
|
10
|
+
# risk preset "release" stops here] → apply-readback (proves it happened) →
|
|
11
|
+
# observe (proves it is healthy) → wait-close (human closes the window).
|
|
12
|
+
kind: workflow
|
|
13
|
+
version: 1
|
|
14
|
+
name: release-readback
|
|
15
|
+
entry: preflight
|
|
16
|
+
steps:
|
|
17
|
+
- id: preflight
|
|
18
|
+
worker: readback
|
|
19
|
+
readonly: true
|
|
20
|
+
grade: L4
|
|
21
|
+
- id: apply-readback
|
|
22
|
+
worker: readback
|
|
23
|
+
readonly: true
|
|
24
|
+
grade: L4
|
|
25
|
+
- id: observe
|
|
26
|
+
worker: observe
|
|
27
|
+
readonly: true
|
|
28
|
+
grade: L4
|
|
29
|
+
- id: wait-close
|
|
30
|
+
terminal:
|
|
31
|
+
- wait-close
|
|
32
|
+
budgets:
|
|
33
|
+
maxAttempts:
|
|
34
|
+
preflight: 1
|
|
35
|
+
apply-readback: 1
|
|
36
|
+
observe: 1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Release lane (iteration 08, C8): pairs with the release-readback workflow.
|
|
2
|
+
# Automation stops before apply-readback — that pause is where the human
|
|
3
|
+
# performs the production action — and the window may only close behind L4
|
|
4
|
+
# evidence read from the real environment. (No finding floor: the lane has
|
|
5
|
+
# no reviewer step, and a rule that cannot be satisfied is not a gate.)
|
|
6
|
+
kind: risk-preset
|
|
7
|
+
version: 1
|
|
8
|
+
name: release
|
|
9
|
+
stopAt:
|
|
10
|
+
- apply-readback
|
|
11
|
+
policies:
|
|
12
|
+
- kind: policy
|
|
13
|
+
version: 1
|
|
14
|
+
name: close-evidence-floor
|
|
15
|
+
type: transition
|
|
16
|
+
appliesTo: enter-wait-close
|
|
17
|
+
enforcement: LOCAL_ENFORCED
|
|
18
|
+
rule:
|
|
19
|
+
evidence.exists:
|
|
20
|
+
kind: command
|
|
21
|
+
minGrade: L4
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Verification reuse and incremental review (iteration 08, C7).
|
|
2
|
+
//
|
|
3
|
+
// Reuse: a verify step whose input is byte-identical to one that already
|
|
4
|
+
// passed — same tree, same worker command, same envelope — is not re-run; the
|
|
5
|
+
// earlier evidence is referenced and the reuse is visible in the ledger
|
|
6
|
+
// (`reused`) and in status. Only *passed* results are ever reused; a failure
|
|
7
|
+
// always runs again. Real number: the deploy campaign's envelope-side cache
|
|
8
|
+
// cut verify from 25 to 13 minutes per round.
|
|
9
|
+
//
|
|
10
|
+
// Incremental review: a reviewer is told which candidate the last review
|
|
11
|
+
// looked at (when it is an ancestor of the current one) so it can focus on
|
|
12
|
+
// the delta instead of re-reading the whole change every round.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
20
|
+
import { canonicalJson } from "../storage/event-ledger.js";
|
|
21
|
+
|
|
22
|
+
function git(cwd, args) {
|
|
23
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
}).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function treeHash(worktreePath) {
|
|
30
|
+
return git(worktreePath, ["rev-parse", "HEAD^{tree}"]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cacheKey({ tree, worker, adapterSpec, adapterName, envelopeDigest }) {
|
|
34
|
+
const body = canonicalJson({
|
|
35
|
+
tree,
|
|
36
|
+
worker,
|
|
37
|
+
adapter: adapterSpec ?? adapterName ?? null,
|
|
38
|
+
envelope: envelopeDigest ?? null,
|
|
39
|
+
});
|
|
40
|
+
return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function eachLedger(repoRoot, { excludeRun = null } = {}) {
|
|
44
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
45
|
+
const out = [];
|
|
46
|
+
if (!existsSync(runsDir)) {
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of readdirSync(runsDir).sort()) {
|
|
50
|
+
if (entry === excludeRun) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const path = join(runsDir, entry, "events.jsonl");
|
|
54
|
+
if (!existsSync(path)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const ledger = EventLedger.open(path);
|
|
58
|
+
if (ledger.state.run) {
|
|
59
|
+
out.push(ledger);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Latest passed command evidence carrying this cache key, across the
|
|
66
|
+
// repository's runs (the current run included: a re-verify after an
|
|
67
|
+
// unrelated fix step on the same tree is the common case).
|
|
68
|
+
export function findReusableEvidence(repoRoot, key) {
|
|
69
|
+
let best = null;
|
|
70
|
+
for (const ledger of eachLedger(repoRoot)) {
|
|
71
|
+
for (const event of ledger.events) {
|
|
72
|
+
if (
|
|
73
|
+
event.type === "EVIDENCE_RECORDED" &&
|
|
74
|
+
event.data.cacheKey === key &&
|
|
75
|
+
event.data.status === "passed" &&
|
|
76
|
+
event.data.kind === "command" &&
|
|
77
|
+
!event.data.reused
|
|
78
|
+
) {
|
|
79
|
+
if (!best || event.ts > best.ts) {
|
|
80
|
+
best = { run: ledger.state.run.id, evidenceRef: event.data.evidenceRef, digest: event.data.digest, grade: event.data.grade, ts: event.ts };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The most recent review evidence for this work whose subject is an ancestor
|
|
89
|
+
// of (and not equal to) the current head — the anchor for an incremental
|
|
90
|
+
// review. Null when there is no such review.
|
|
91
|
+
export function lastReviewedCandidate(repoRoot, workId, worktreePath, head) {
|
|
92
|
+
let best = null;
|
|
93
|
+
for (const ledger of eachLedger(repoRoot)) {
|
|
94
|
+
if (ledger.state.run.work !== workId) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
for (const event of ledger.events) {
|
|
98
|
+
if (event.type !== "EVIDENCE_RECORDED" || event.data.kind !== "review") {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const subject = event.data.subject;
|
|
102
|
+
if (!subject || subject === head) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (best && event.ts <= best.ts) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let ancestor = false;
|
|
109
|
+
try {
|
|
110
|
+
execFileSync("git", ["-C", worktreePath, "merge-base", "--is-ancestor", subject, head], { stdio: "ignore" });
|
|
111
|
+
ancestor = true;
|
|
112
|
+
} catch {
|
|
113
|
+
ancestor = false;
|
|
114
|
+
}
|
|
115
|
+
if (ancestor) {
|
|
116
|
+
best = { candidate: subject, run: ledger.state.run.id, evidenceRef: event.data.evidenceRef, ts: event.ts };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!best) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return { candidate: best.candidate, run: best.run, evidenceRef: best.evidenceRef, range: `${best.candidate}..${head}` };
|
|
124
|
+
}
|
|
@@ -13,6 +13,7 @@ import { evaluatePolicies, sha256Text } from "../policy/policy.js";
|
|
|
13
13
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
14
14
|
import { acquireLock, readback, releaseLock } from "../workspace/workspace-manager.js";
|
|
15
15
|
import { writeRunRecord } from "./run-record.js";
|
|
16
|
+
import { resolveRepoRef } from "./repo-ref.js";
|
|
16
17
|
|
|
17
18
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
18
19
|
|
|
@@ -68,10 +69,11 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
|
|
|
68
69
|
acquireLock(repoRoot, runId);
|
|
69
70
|
try {
|
|
70
71
|
const bound = ledger.state.workspaces[runId];
|
|
71
|
-
|
|
72
|
+
const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
|
|
73
|
+
if (!bound || !existsSync(worktreePath)) {
|
|
72
74
|
throw new DecisionError(`worktree missing for ${runId}; cannot verify the approval subject`);
|
|
73
75
|
}
|
|
74
|
-
const tree = readback(
|
|
76
|
+
const tree = readback(worktreePath);
|
|
75
77
|
const when = ts ?? new Date().toISOString();
|
|
76
78
|
if (tree.dirty || tree.head !== pending.subject.candidate) {
|
|
77
79
|
const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
@@ -102,8 +104,8 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
|
|
|
102
104
|
state: ledger.state,
|
|
103
105
|
candidate: pending.subject.candidate,
|
|
104
106
|
workDir: join(repoRoot, "delivery", "work", ledger.state.run.work),
|
|
105
|
-
worktreePath
|
|
106
|
-
readWorktree: () => readback(
|
|
107
|
+
worktreePath,
|
|
108
|
+
readWorktree: () => readback(worktreePath),
|
|
107
109
|
},
|
|
108
110
|
);
|
|
109
111
|
for (const row of policyRows) {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Environment contract: a run config may declare the binaries (and minimum
|
|
2
|
+
// versions) its frozen envelope silently depends on, and the kernel checks
|
|
3
|
+
// them fail-closed before a run starts. Absorbed from real incidents: a
|
|
4
|
+
// frozen verifier needed `rg` that only a vendored PATH provided, candidate
|
|
5
|
+
// scripts assumed bash >= 4 on a /bin/bash 3.2 host, and a fresh shell
|
|
6
|
+
// resolved Node 14 — each burned runs before anyone saw the real cause.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export class EnvContractError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "EnvContractError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Version detection in --version output needs at least major.minor (a bare
|
|
18
|
+
// integer in arbitrary output is too easy to false-match); a declared min
|
|
19
|
+
// may be major-only ("20").
|
|
20
|
+
function parseVersion(text) {
|
|
21
|
+
const match = String(text).match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
22
|
+
if (!match) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseMin(value) {
|
|
29
|
+
const match = String(value).trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
30
|
+
if (!match) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function compareVersions(left, right) {
|
|
37
|
+
for (let index = 0; index < 3; index += 1) {
|
|
38
|
+
if ((left[index] ?? 0) !== (right[index] ?? 0)) {
|
|
39
|
+
return (left[index] ?? 0) - (right[index] ?? 0);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Checks every entry and reports all problems at once (a run that dies on
|
|
46
|
+
// the first missing binary hides the second). Non-zero --version exits are
|
|
47
|
+
// tolerated; only a spawn failure means "not runnable".
|
|
48
|
+
export function checkRequires(requires) {
|
|
49
|
+
const problems = [];
|
|
50
|
+
const checked = [];
|
|
51
|
+
for (const entry of requires ?? []) {
|
|
52
|
+
// Probe entries (iteration 08, C10): an environment fact that is not a
|
|
53
|
+
// binary version — "Redis answers PING on the target", "python on the
|
|
54
|
+
// host is >= 3.9" — expressed as a shell command whose exit code (and
|
|
55
|
+
// optionally output) must match. Absorbed from the first-proof rerun:
|
|
56
|
+
// Redis < 7 and Python 3.6 on the target burned a window each because
|
|
57
|
+
// nothing checked them before the run.
|
|
58
|
+
if (entry && typeof entry === "object" && typeof entry.probe === "string" && entry.probe.length > 0) {
|
|
59
|
+
const label = entry.name ?? entry.probe;
|
|
60
|
+
const run = spawnSync("bash", ["-lc", entry.probe], { encoding: "utf8", timeout: entry.timeoutMs ?? 30_000 });
|
|
61
|
+
if (run.error) {
|
|
62
|
+
problems.push(`${label}: probe could not run (${run.error.code ?? run.error.message})`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
|
|
66
|
+
if (run.status !== 0) {
|
|
67
|
+
problems.push(`${label}: probe exited ${run.status}${output.trim() ? ` — ${output.trim().split("\n").slice(-1)[0].slice(0, 160)}` : ""}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (entry.expect !== undefined) {
|
|
71
|
+
let matcher;
|
|
72
|
+
try {
|
|
73
|
+
matcher = new RegExp(String(entry.expect));
|
|
74
|
+
} catch {
|
|
75
|
+
problems.push(`${label}: expect ${JSON.stringify(entry.expect)} is not a valid regular expression`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!matcher.test(output)) {
|
|
79
|
+
problems.push(`${label}: probe output does not match expect ${JSON.stringify(entry.expect)}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
checked.push({ command: label, version: null, probe: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry || typeof entry !== "object" || typeof entry.command !== "string" || entry.command.length === 0) {
|
|
87
|
+
problems.push(`requires entries need a command name or a probe, got: ${JSON.stringify(entry)}`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// A generous timeout: a missing binary fails instantly (ENOENT), while a
|
|
91
|
+
// loaded host must not turn a present binary into a false "not runnable".
|
|
92
|
+
const probe = spawnSync(entry.command, [entry.versionFlag ?? "--version"], {
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
timeout: 30_000,
|
|
95
|
+
});
|
|
96
|
+
if (probe.error) {
|
|
97
|
+
problems.push(
|
|
98
|
+
`${entry.command}: not runnable in this environment (${probe.error.code ?? probe.error.message})`,
|
|
99
|
+
);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const version = parseVersion(`${probe.stdout ?? ""}\n${probe.stderr ?? ""}`);
|
|
103
|
+
if (entry.min !== undefined) {
|
|
104
|
+
const min = parseMin(entry.min);
|
|
105
|
+
if (!min) {
|
|
106
|
+
problems.push(`${entry.command}: min ${JSON.stringify(entry.min)} is not a version`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!version) {
|
|
110
|
+
problems.push(
|
|
111
|
+
`${entry.command}: version undetectable but min ${entry.min} is required (fail closed)`,
|
|
112
|
+
);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (compareVersions(version, min) < 0) {
|
|
116
|
+
problems.push(
|
|
117
|
+
`${entry.command}: resolves to ${version.join(".")}, below required ${entry.min}`,
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
checked.push({ command: entry.command, version: version ? version.join(".") : null });
|
|
123
|
+
}
|
|
124
|
+
return { ok: problems.length === 0, problems, checked };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function assertRequires(requires) {
|
|
128
|
+
const result = checkRequires(requires);
|
|
129
|
+
if (!result.ok) {
|
|
130
|
+
throw new EnvContractError(
|
|
131
|
+
`environment contract not satisfied:\n - ${result.problems.join("\n - ")}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|