@haiyangbg/buildbeat 2.0.0-beta.3 → 2.0.0-beta.5
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 +47 -7
- package/SKILL.md +86 -2
- 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 +1 -1
- package/docs/V2.0.0-BETA.3-RELEASE-EVIDENCE-2026-09-01.md +8 -0
- package/docs/V2.0.0-BETA.4-RELEASE-EVIDENCE-2026-09-03.md +9 -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 +7 -6
- package/docs/v2/guide/00-how-to-talk.md +57 -0
- package/docs/v2/guide/01-quickstart.md +4 -0
- package/docs/v2/guide/02-workflow-guide.md +48 -1
- 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 +4 -0
- package/docs/v2/guide/07-approval-guide.md +47 -0
- package/docs/v2/guide/10-recovery.md +25 -3
- package/docs/v2/guide/README.md +3 -0
- package/example/.buildbeat/manifest.json +1 -1
- package/lessons.md +37 -0
- package/package.json +1 -1
- package/src/v2/adapters/mock.js +9 -2
- package/src/v2/adapters/shell.js +87 -14
- package/src/v2/cli/run.js +607 -30
- package/src/v2/domain/event-registry.js +1 -0
- package/src/v2/engine/reducer.js +29 -1
- package/src/v2/engine/workflow.js +8 -1
- package/src/v2/evidence/collector.js +14 -3
- package/src/v2/presets/release-readback.yaml +36 -0
- package/src/v2/presets/risk/release.yaml +21 -0
- package/src/v2/runtime/cache.js +124 -0
- package/src/v2/runtime/decisions.js +80 -0
- package/src/v2/runtime/env-contract.js +35 -1
- package/src/v2/runtime/envelope.js +183 -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 +267 -19
- package/src/v2/runtime/overview.js +301 -0
- package/src/v2/runtime/run-record.js +3 -0
- package/src/v2/runtime/work-cost.js +147 -0
- package/src/v2/workspace/workspace-manager.js +14 -1
- package/templates/contracts/PROTOCOL.md +4 -0
- package/templates/gitignore.template +5 -0
- package/templates/scripts/bus-check.sh +37 -12
- package/templates/v2/AGENTS.md +73 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
|
@@ -33,6 +33,7 @@ export const EVENT_REGISTRY = {
|
|
|
33
33
|
TRANSITION: ["from", "to", "cause"],
|
|
34
34
|
FAILURE_FINGERPRINT: ["step", "command", "exitCode", "errorDigest", "diffDigest"],
|
|
35
35
|
BUDGET_CONSUMED: ["kind", "amount", "remaining"],
|
|
36
|
+
BUDGET_EXTENDED: ["step", "amount", "maxAttempts"],
|
|
36
37
|
HUMAN_REQUESTED: ["transition", "subject", "reasons"],
|
|
37
38
|
DECISION_RECORDED: ["decision", "transition", "subject", "decisionRef"],
|
|
38
39
|
APPROVAL_STALE: ["approvalRef", "changed"],
|
package/src/v2/engine/reducer.js
CHANGED
|
@@ -23,6 +23,8 @@ export function initialState() {
|
|
|
23
23
|
fingerprints: [],
|
|
24
24
|
consecutiveSameFailure: 0,
|
|
25
25
|
budgets: {},
|
|
26
|
+
budgetExtensions: {},
|
|
27
|
+
workReviewGrants: 0,
|
|
26
28
|
pendingHuman: null,
|
|
27
29
|
decisions: [],
|
|
28
30
|
approvals: [],
|
|
@@ -107,7 +109,12 @@ export function applyEvent(state, event) {
|
|
|
107
109
|
`step ${data.step} expected attempt ${expectedAttempt}, got ${data.attempt}`,
|
|
108
110
|
);
|
|
109
111
|
}
|
|
110
|
-
next.steps[data.step] = {
|
|
112
|
+
next.steps[data.step] = {
|
|
113
|
+
status: "RUNNING",
|
|
114
|
+
attempts: data.attempt,
|
|
115
|
+
detail: null,
|
|
116
|
+
infraAttempts: state.steps[data.step]?.infraAttempts ?? 0,
|
|
117
|
+
};
|
|
111
118
|
next.currentStep = data.step;
|
|
112
119
|
break;
|
|
113
120
|
}
|
|
@@ -120,6 +127,11 @@ export function applyEvent(state, event) {
|
|
|
120
127
|
}
|
|
121
128
|
next.steps[data.step].status = data.status === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
122
129
|
next.steps[data.step].detail = data.status;
|
|
130
|
+
if (data.infra === true) {
|
|
131
|
+
// A worker-infrastructure failure (backend outage, timeout, garbage
|
|
132
|
+
// output, exit 75) is not charged to the step's budget.
|
|
133
|
+
next.steps[data.step].infraAttempts = (step.infraAttempts ?? 0) + 1;
|
|
134
|
+
}
|
|
123
135
|
next.currentStep = null;
|
|
124
136
|
break;
|
|
125
137
|
}
|
|
@@ -143,6 +155,8 @@ export function applyEvent(state, event) {
|
|
|
143
155
|
...(data.suppressedFingerprints
|
|
144
156
|
? { suppressedFingerprints: data.suppressedFingerprints }
|
|
145
157
|
: {}),
|
|
158
|
+
...(data.cacheKey ? { cacheKey: data.cacheKey } : {}),
|
|
159
|
+
...(data.reused ? { reused: data.reused } : {}),
|
|
146
160
|
});
|
|
147
161
|
break;
|
|
148
162
|
}
|
|
@@ -166,6 +180,19 @@ export function applyEvent(state, event) {
|
|
|
166
180
|
next.budgets[data.kind] = { consumed: consumed + data.amount, remaining: data.remaining };
|
|
167
181
|
break;
|
|
168
182
|
}
|
|
183
|
+
case "BUDGET_EXTENDED": {
|
|
184
|
+
// A human approving resume-<step> after its budget ran out grants
|
|
185
|
+
// exactly one more attempt; the grant is a ledger fact, not a config
|
|
186
|
+
// edit, so the effective cap is replayable.
|
|
187
|
+
if (data.scope === "work") {
|
|
188
|
+
// Work-level review cap (budgets.reviewRoundsPerWork) lifted once by
|
|
189
|
+
// a human for this run.
|
|
190
|
+
next.workReviewGrants = (state.workReviewGrants ?? 0) + data.amount;
|
|
191
|
+
} else {
|
|
192
|
+
next.budgetExtensions[data.step] = (state.budgetExtensions?.[data.step] ?? 0) + data.amount;
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
169
196
|
case "HUMAN_REQUESTED": {
|
|
170
197
|
next.pendingHuman = {
|
|
171
198
|
transition: data.transition,
|
|
@@ -192,6 +219,7 @@ export function applyEvent(state, event) {
|
|
|
192
219
|
transition: data.transition,
|
|
193
220
|
subject: data.subject,
|
|
194
221
|
stale: false,
|
|
222
|
+
...(data.resumeAt ? { resumeAt: data.resumeAt } : {}),
|
|
195
223
|
});
|
|
196
224
|
}
|
|
197
225
|
next.pendingHuman = null;
|
|
@@ -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")}`;
|
|
@@ -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
|
+
}
|
|
@@ -185,6 +185,86 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// A human (or the driving session) supplies the candidate: the fix was made
|
|
189
|
+
// by hand in the run's worktree and committed, so the fixer step has nothing
|
|
190
|
+
// to do. The pending request is answered with the adopted commit as its
|
|
191
|
+
// subject and the run resumes at `resumeAt` (verify, by convention). Real
|
|
192
|
+
// incident: hand fixes inside a run cost a no-op fixer and an extra verify
|
|
193
|
+
// each time (a frontend run reached verify #5 and fix #3 for three hand
|
|
194
|
+
// fixes). The commit must already be the worktree HEAD: git is read back,
|
|
195
|
+
// the claim is not trusted.
|
|
196
|
+
export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, ts } = {}) {
|
|
197
|
+
if (!sha || typeof sha !== "string" || sha.length < 7) {
|
|
198
|
+
throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
|
|
199
|
+
}
|
|
200
|
+
if (!resumeAt) {
|
|
201
|
+
throw new DecisionError("adopt requires the step to resume at (resumeAt)");
|
|
202
|
+
}
|
|
203
|
+
const ledger = openWaiting(repoRoot, runId);
|
|
204
|
+
const pending = ledger.state.pendingHuman;
|
|
205
|
+
if (pending.kind === "final-decision") {
|
|
206
|
+
throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
|
|
207
|
+
}
|
|
208
|
+
acquireLock(repoRoot, runId);
|
|
209
|
+
try {
|
|
210
|
+
const bound = ledger.state.workspaces[runId];
|
|
211
|
+
const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
|
|
212
|
+
if (!bound || !existsSync(worktreePath)) {
|
|
213
|
+
throw new DecisionError(`worktree missing for ${runId}; cannot adopt a candidate`);
|
|
214
|
+
}
|
|
215
|
+
const tree = readback(worktreePath);
|
|
216
|
+
if (tree.dirty) {
|
|
217
|
+
throw new DecisionError("worktree is dirty; commit the hand fix before adopting it");
|
|
218
|
+
}
|
|
219
|
+
if (!tree.head.startsWith(sha)) {
|
|
220
|
+
throw new DecisionError(`worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`);
|
|
221
|
+
}
|
|
222
|
+
const when = ts ?? new Date().toISOString();
|
|
223
|
+
const lastEvidence = ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
224
|
+
if (bound.candidate !== tree.head) {
|
|
225
|
+
ledger.append({
|
|
226
|
+
type: "CANDIDATE_PINNED",
|
|
227
|
+
actor: { kind: "human", id: by },
|
|
228
|
+
ts: when,
|
|
229
|
+
data: { workspaceId: runId, base: bound.base, candidate: tree.head, adopted: true },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const subject = {
|
|
233
|
+
candidate: tree.head,
|
|
234
|
+
planDigest: ledger.state.run.planDigest,
|
|
235
|
+
evidenceDigest: lastEvidence?.digest ?? "UNVERIFIED",
|
|
236
|
+
};
|
|
237
|
+
const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
|
|
238
|
+
ledger.append({
|
|
239
|
+
type: "DECISION_RECORDED",
|
|
240
|
+
actor: { kind: "human", id: by },
|
|
241
|
+
ts: when,
|
|
242
|
+
data: {
|
|
243
|
+
decision: "approved",
|
|
244
|
+
transition: pending.transition,
|
|
245
|
+
subject,
|
|
246
|
+
decisionRef,
|
|
247
|
+
adopted: tree.head,
|
|
248
|
+
resumeAt,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
recordDecisionFile(repoRoot, ledger.state.run.work, {
|
|
252
|
+
ts: when,
|
|
253
|
+
run: runId,
|
|
254
|
+
decisionRef,
|
|
255
|
+
decision: "approved",
|
|
256
|
+
transition: pending.transition,
|
|
257
|
+
subject,
|
|
258
|
+
by,
|
|
259
|
+
adopted: tree.head,
|
|
260
|
+
resumeAt,
|
|
261
|
+
});
|
|
262
|
+
return { adopted: tree.head, decisionRef, transition: pending.transition, resumeAt, state: ledger.state };
|
|
263
|
+
} finally {
|
|
264
|
+
releaseLock(repoRoot, runId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
188
268
|
// Accepts a work artifact (plan, intent, spec) by binding a decision to the
|
|
189
269
|
// file's current digest in the Git plane. If the file changes afterwards,
|
|
190
270
|
// artifact.accepted evaluates false again — acceptance cannot go stale
|
|
@@ -49,8 +49,42 @@ export function checkRequires(requires) {
|
|
|
49
49
|
const problems = [];
|
|
50
50
|
const checked = [];
|
|
51
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
|
+
}
|
|
52
86
|
if (!entry || typeof entry !== "object" || typeof entry.command !== "string" || entry.command.length === 0) {
|
|
53
|
-
problems.push(`requires entries need a command name, got: ${JSON.stringify(entry)}`);
|
|
87
|
+
problems.push(`requires entries need a command name or a probe, got: ${JSON.stringify(entry)}`);
|
|
54
88
|
continue;
|
|
55
89
|
}
|
|
56
90
|
// A generous timeout: a missing binary fails instantly (ENOENT), while a
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Envelope as a first-class run-config section (iteration 08, C6). The deploy
|
|
2
|
+
// campaign paid for every Run with a 34-line yaml, a 10 KB worker.sh and four
|
|
3
|
+
// prompt files, and each worker line was a `git show <meta sha>:path | bash`
|
|
4
|
+
// incantation. The kernel now does that part: it reads the prompt for the
|
|
5
|
+
// step's worker (optionally pinned to a commit), substitutes declared vars,
|
|
6
|
+
// materialises it into the run directory and hands the path to the worker as
|
|
7
|
+
// BUILDBEAT_PROMPT. The envelope digest is recorded on RUN_CREATED so "which
|
|
8
|
+
// prompts did this run see" is answerable from the ledger.
|
|
9
|
+
//
|
|
10
|
+
// envelope:
|
|
11
|
+
// prompts: run-envelope/prompts # dir, relative to the run config
|
|
12
|
+
// pin: <commit sha> # optional: read prompts from that commit
|
|
13
|
+
// vars:
|
|
14
|
+
// component: auth # {vars.component} in prompts and args
|
|
15
|
+
//
|
|
16
|
+
// Prompt lookup per worker: <component>-<worker>.md (when vars.component is
|
|
17
|
+
// set) then <worker>.md. Missing prompt for a configured worker is not an
|
|
18
|
+
// error — that worker simply gets no BUILDBEAT_PROMPT.
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { join, relative, resolve } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { canonicalJson } from "../storage/event-ledger.js";
|
|
26
|
+
|
|
27
|
+
export class EnvelopeError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "EnvelopeError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function gitToplevel(dir) {
|
|
35
|
+
try {
|
|
36
|
+
return execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
|
37
|
+
encoding: "utf8",
|
|
38
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
+
}).trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readPinned(toplevel, pin, relPath) {
|
|
46
|
+
try {
|
|
47
|
+
return execFileSync("git", ["-C", toplevel, "show", `${pin}:${relPath}`], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function substituteVars(text, vars) {
|
|
57
|
+
let out = String(text);
|
|
58
|
+
for (const [key, value] of Object.entries(vars ?? {})) {
|
|
59
|
+
out = out.replaceAll(`{vars.${key}}`, String(value));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Loads every prompt the configured workers could use. Returns null when the
|
|
65
|
+
// run config declares no envelope.
|
|
66
|
+
export function loadEnvelope(config, configDir, workerNames) {
|
|
67
|
+
const spec = config.envelope;
|
|
68
|
+
if (spec === undefined) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
|
72
|
+
throw new EnvelopeError("envelope must be a map");
|
|
73
|
+
}
|
|
74
|
+
if (typeof spec.prompts !== "string" || spec.prompts.length === 0) {
|
|
75
|
+
throw new EnvelopeError("envelope.prompts (a directory) is required");
|
|
76
|
+
}
|
|
77
|
+
const vars = spec.vars ?? {};
|
|
78
|
+
if (!vars || typeof vars !== "object" || Array.isArray(vars)) {
|
|
79
|
+
throw new EnvelopeError("envelope.vars must be a map");
|
|
80
|
+
}
|
|
81
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
82
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(key) || (typeof value !== "string" && typeof value !== "number")) {
|
|
83
|
+
throw new EnvelopeError(`envelope.vars.${key} must be a scalar`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const pin = spec.pin ?? null;
|
|
87
|
+
if (pin !== null && !/^[0-9a-f]{7,40}$/.test(String(pin))) {
|
|
88
|
+
throw new EnvelopeError(`envelope.pin must be a commit sha, got: ${pin}`);
|
|
89
|
+
}
|
|
90
|
+
// realpath: git reports the toplevel through resolved symlinks (macOS
|
|
91
|
+
// /var → /private/var), so the config dir must be resolved the same way
|
|
92
|
+
// before a repository-relative prompt path can be computed.
|
|
93
|
+
const configReal = existsSync(configDir) ? realpathSync(configDir) : resolve(configDir);
|
|
94
|
+
const promptsDir = resolve(configReal, spec.prompts);
|
|
95
|
+
let toplevel = null;
|
|
96
|
+
let promptsRel = null;
|
|
97
|
+
if (pin) {
|
|
98
|
+
toplevel = gitToplevel(configReal);
|
|
99
|
+
if (!toplevel) {
|
|
100
|
+
throw new EnvelopeError("envelope.pin needs the run config to live inside a git repository");
|
|
101
|
+
}
|
|
102
|
+
promptsRel = relative(toplevel, promptsDir).split("\\").join("/");
|
|
103
|
+
if (promptsRel.startsWith("..")) {
|
|
104
|
+
throw new EnvelopeError("envelope.prompts must be inside the repository that holds the run config when pinned");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const component = vars.component !== undefined ? String(vars.component) : null;
|
|
108
|
+
const prompts = {};
|
|
109
|
+
for (const worker of workerNames) {
|
|
110
|
+
const candidates = [];
|
|
111
|
+
if (component) {
|
|
112
|
+
candidates.push(`${component}-${worker}.md`);
|
|
113
|
+
}
|
|
114
|
+
candidates.push(`${worker}.md`);
|
|
115
|
+
for (const name of candidates) {
|
|
116
|
+
let text = null;
|
|
117
|
+
if (pin) {
|
|
118
|
+
text = readPinned(toplevel, String(pin), `${promptsRel}/${name}`);
|
|
119
|
+
} else if (existsSync(join(promptsDir, name))) {
|
|
120
|
+
text = readFileSync(join(promptsDir, name), "utf8");
|
|
121
|
+
}
|
|
122
|
+
if (text !== null) {
|
|
123
|
+
prompts[worker] = { file: name, text: substituteVars(text, vars) };
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(prompts).length === 0) {
|
|
129
|
+
throw new EnvelopeError(
|
|
130
|
+
`envelope.prompts ${spec.prompts} holds no prompt for any configured worker (${workerNames.join(", ") || "none"})${pin ? ` at ${pin}` : ""}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const digest = `sha256:${createHash("sha256")
|
|
134
|
+
.update(canonicalJson({ prompts, vars, pin }), "utf8")
|
|
135
|
+
.digest("hex")}`;
|
|
136
|
+
return { prompts, vars, pin: pin ? String(pin) : null, digest, source: pin ? `${promptsRel}@${pin}` : spec.prompts };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Writes the step's prompt into the run directory and returns its absolute
|
|
140
|
+
// path plus the repository-relative reference for the worker input.
|
|
141
|
+
export function materialisePrompt({ envelope, worker, runtimeDir, runId, step, attempt, repoRoot }) {
|
|
142
|
+
const prompt = envelope?.prompts?.[worker];
|
|
143
|
+
if (!prompt) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const dir = join(runtimeDir, "runs", runId, "prompts");
|
|
147
|
+
mkdirSync(dir, { recursive: true });
|
|
148
|
+
const path = join(dir, `${step}-${attempt}.md`);
|
|
149
|
+
writeFileSync(path, prompt.text, "utf8");
|
|
150
|
+
return { path, ref: relative(repoRoot, path).split("\\").join("/"), file: prompt.file };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Next attempt id for a run family: RUN-X → RUN-X-01, RUN-X-02, ... scanning
|
|
154
|
+
// both the runtime plane and the Git plane (run-records survive a runtime
|
|
155
|
+
// wipe, so numbering never collides with a compacted run).
|
|
156
|
+
export function nextAttemptId(repoRoot, workId, family) {
|
|
157
|
+
const pattern = new RegExp(`^${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-(\\d{2,})$`);
|
|
158
|
+
let max = 0;
|
|
159
|
+
const dirs = [
|
|
160
|
+
join(repoRoot, ".buildbeat", "runtime", "runs"),
|
|
161
|
+
join(repoRoot, "delivery", "work", workId, "runs"),
|
|
162
|
+
];
|
|
163
|
+
for (const dir of dirs) {
|
|
164
|
+
if (!existsSync(dir)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const entry of readdirSafe(dir)) {
|
|
168
|
+
const match = entry.match(pattern);
|
|
169
|
+
if (match) {
|
|
170
|
+
max = Math.max(max, Number(match[1]));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return `${family}-${String(max + 1).padStart(2, "0")}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readdirSafe(dir) {
|
|
178
|
+
try {
|
|
179
|
+
return readdirSync(dir);
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|