@haiyangbg/buildbeat 2.0.0-beta.3 → 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 +29 -7
- package/SKILL.md +76 -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/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 +4 -0
- package/docs/v2/guide/02-workflow-guide.md +14 -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 +4 -0
- package/docs/v2/guide/07-approval-guide.md +33 -0
- package/docs/v2/guide/10-recovery.md +22 -1
- 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 +461 -25
- package/src/v2/engine/reducer.js +2 -0
- 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/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 +145 -8
- package/src/v2/runtime/overview.js +264 -0
- package/templates/v2/AGENTS.md +72 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +36 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Runtime garbage collection (iteration 08, C3): terminal runs leave a
|
|
2
|
+
// worktree, a run/* branch and sometimes a lock behind. Sixteen of them had
|
|
3
|
+
// piled up in the deploy campaign before the owner asked for "打扫卫生".
|
|
4
|
+
//
|
|
5
|
+
// Rules (fail-closed toward keeping things):
|
|
6
|
+
// - only terminal AND compacted runs are candidates; a run-record in the Git
|
|
7
|
+
// plane must exist before anything in the runtime plane goes;
|
|
8
|
+
// - the worktree is removable (commits live on the branch);
|
|
9
|
+
// - the branch goes only when its candidate is reachable from some other
|
|
10
|
+
// ref (merged, tagged, on a remote) or the run produced no candidate;
|
|
11
|
+
// otherwise the branch is the only thing keeping evidence reachable and
|
|
12
|
+
// stays, listed as such;
|
|
13
|
+
// - a dirty worktree is never removed without --force.
|
|
14
|
+
// The ledger is not touched: after RUN_COMPACTED nothing may be appended.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
21
|
+
import { readback } from "../workspace/workspace-manager.js";
|
|
22
|
+
import { resolveRepoRef } from "./repo-ref.js";
|
|
23
|
+
|
|
24
|
+
function git(cwd, args) {
|
|
25
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
}).trimEnd();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function branchExists(repoRoot, branch) {
|
|
32
|
+
try {
|
|
33
|
+
git(repoRoot, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function worktreeRegistered(repoRoot, worktreePath) {
|
|
41
|
+
try {
|
|
42
|
+
const listing = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
43
|
+
return listing.split("\n").some((line) => line === `worktree ${worktreePath}`);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Refs (other than the run's own branch and other run/* branches) that
|
|
50
|
+
// already contain the candidate commit.
|
|
51
|
+
function otherRefsContaining(repoRoot, sha, ownBranch) {
|
|
52
|
+
try {
|
|
53
|
+
const out = git(repoRoot, ["for-each-ref", "--contains", sha, "--format=%(refname)"]);
|
|
54
|
+
return out
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.filter((ref) => ref !== `refs/heads/${ownBranch}` && !ref.startsWith("refs/heads/run/"));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function planGc(repoRoot) {
|
|
64
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
65
|
+
const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
66
|
+
const rows = [];
|
|
67
|
+
if (!existsSync(runsDir)) {
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of readdirSync(runsDir).sort()) {
|
|
71
|
+
const ledgerPath = join(runsDir, entry, "events.jsonl");
|
|
72
|
+
if (!existsSync(ledgerPath)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const ledger = EventLedger.open(ledgerPath);
|
|
76
|
+
const state = ledger.state;
|
|
77
|
+
const row = { run: entry, status: state.run?.status ?? "(no run)", actions: [], keep: [] };
|
|
78
|
+
rows.push(row);
|
|
79
|
+
if (ledger.corruption) {
|
|
80
|
+
row.keep.push(`ledger corrupted after seq=${ledger.corruption.afterSeq}; human decision required`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!state.run) {
|
|
84
|
+
row.keep.push("ledger has no run");
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!state.terminal) {
|
|
88
|
+
row.keep.push(`run not terminal (${state.run.status})`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (!state.compacted) {
|
|
92
|
+
row.keep.push("terminal but not compacted (no run-record in the Git plane)");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const lockPath = join(locksDir, `${entry}.lock`);
|
|
96
|
+
if (existsSync(lockPath)) {
|
|
97
|
+
row.actions.push({ kind: "remove-lock", path: lockPath });
|
|
98
|
+
}
|
|
99
|
+
const workspace = state.workspaces[entry];
|
|
100
|
+
if (!workspace) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const worktreePath = resolveRepoRef(repoRoot, workspace.worktreePath);
|
|
104
|
+
const registered = worktreeRegistered(repoRoot, worktreePath);
|
|
105
|
+
const present = existsSync(worktreePath);
|
|
106
|
+
row.candidate = workspace.candidate;
|
|
107
|
+
row.branch = workspace.branch;
|
|
108
|
+
if (present || registered) {
|
|
109
|
+
let dirty = false;
|
|
110
|
+
if (present) {
|
|
111
|
+
try {
|
|
112
|
+
dirty = readback(worktreePath).dirty;
|
|
113
|
+
} catch {
|
|
114
|
+
dirty = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
row.actions.push({ kind: "remove-worktree", path: worktreePath, dirty, registered, present });
|
|
118
|
+
}
|
|
119
|
+
if (workspace.branch && branchExists(repoRoot, workspace.branch)) {
|
|
120
|
+
if (!workspace.candidate) {
|
|
121
|
+
row.actions.push({ kind: "delete-branch", branch: workspace.branch, reason: "run produced no candidate" });
|
|
122
|
+
} else {
|
|
123
|
+
const elsewhere = otherRefsContaining(repoRoot, workspace.candidate, workspace.branch);
|
|
124
|
+
if (elsewhere.length > 0) {
|
|
125
|
+
row.actions.push({
|
|
126
|
+
kind: "delete-branch",
|
|
127
|
+
branch: workspace.branch,
|
|
128
|
+
reason: `candidate ${workspace.candidate.slice(0, 7)} also on ${elsewhere.slice(0, 3).join(", ")}`,
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
row.keep.push(
|
|
132
|
+
`branch ${workspace.branch} kept: candidate ${workspace.candidate.slice(0, 7)} is reachable only there (evidence)`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return rows;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function applyGc(repoRoot, rows, { force = false } = {}) {
|
|
142
|
+
const results = [];
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
for (const action of row.actions) {
|
|
145
|
+
const result = { run: row.run, ...action, done: false, error: null };
|
|
146
|
+
results.push(result);
|
|
147
|
+
try {
|
|
148
|
+
if (action.kind === "remove-lock") {
|
|
149
|
+
rmSync(action.path, { recursive: true, force: true });
|
|
150
|
+
result.done = true;
|
|
151
|
+
} else if (action.kind === "remove-worktree") {
|
|
152
|
+
if (action.dirty && !force) {
|
|
153
|
+
result.error = "worktree dirty; rerun with --force true to discard";
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (action.registered) {
|
|
157
|
+
const args = ["worktree", "remove"];
|
|
158
|
+
if (force || action.dirty) {
|
|
159
|
+
args.push("--force");
|
|
160
|
+
}
|
|
161
|
+
args.push(action.path);
|
|
162
|
+
git(repoRoot, args);
|
|
163
|
+
} else if (action.present) {
|
|
164
|
+
rmSync(action.path, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
git(repoRoot, ["worktree", "prune"]);
|
|
168
|
+
} catch {
|
|
169
|
+
// prune is best-effort
|
|
170
|
+
}
|
|
171
|
+
result.done = true;
|
|
172
|
+
} else if (action.kind === "delete-branch") {
|
|
173
|
+
git(repoRoot, ["branch", "-D", action.branch]);
|
|
174
|
+
result.done = true;
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
result.error = error.stderr ? String(error.stderr).trim() : error.message;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return results;
|
|
182
|
+
}
|