@haiyangbg/buildbeat 3.3.0 → 4.0.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/CHANGELOG.md +26 -0
- package/README.en.md +20 -124
- package/README.md +20 -124
- package/SKILL.md +73 -84
- package/docs/CAPABILITY-MATRIX.md +30 -39
- package/docs/MIGRATION.md +93 -0
- package/docs/README.md +8 -37
- package/docs/RELEASING.md +7 -3
- package/docs/v2/RFC-0001-product-definition.md +3 -0
- package/docs/v2/RFC-0002-domain-model.md +3 -0
- package/docs/v2/RFC-0003-workflow-policy.md +3 -0
- package/docs/v2/SPEC-0001-events-v1.md +3 -0
- package/docs/v2/guide/00-how-to-talk.en.md +12 -58
- package/docs/v2/guide/00-how-to-talk.md +12 -58
- package/docs/v2/guide/01-quickstart.en.md +41 -112
- package/docs/v2/guide/01-quickstart.md +41 -112
- package/docs/v2/guide/02-workflow-guide.en.md +13 -123
- package/docs/v2/guide/02-workflow-guide.md +14 -122
- package/docs/v2/guide/03-policy-guide.en.md +6 -50
- package/docs/v2/guide/03-policy-guide.md +6 -50
- package/docs/v2/guide/04-adapter-guide.en.md +7 -60
- package/docs/v2/guide/04-adapter-guide.md +7 -60
- package/docs/v2/guide/05-worker-contract.en.md +6 -53
- package/docs/v2/guide/05-worker-contract.md +7 -52
- package/docs/v2/guide/06-evidence-guide.en.md +8 -44
- package/docs/v2/guide/06-evidence-guide.md +9 -43
- package/docs/v2/guide/07-approval-guide.en.md +8 -110
- package/docs/v2/guide/07-approval-guide.md +8 -121
- package/docs/v2/guide/09-security-boundaries.en.md +13 -37
- package/docs/v2/guide/09-security-boundaries.md +13 -37
- package/docs/v2/guide/10-recovery.en.md +12 -96
- package/docs/v2/guide/10-recovery.md +12 -96
- package/docs/v2/guide/11-session-handoff.en.md +7 -78
- package/docs/v2/guide/11-session-handoff.md +7 -78
- package/docs/v2/guide/12-without-runtime.en.md +46 -0
- package/docs/v2/guide/12-without-runtime.md +46 -0
- package/docs/v2/guide/README.en.md +16 -36
- package/docs/v2/guide/README.md +16 -36
- package/example/AGENTS.md +15 -69
- package/example/README.md +8 -23
- package/example/delivery/envelope/prompts/builder.md +3 -1
- package/example/delivery/envelope/prompts/fixer.md +3 -1
- package/example/delivery/envelope/prompts/reviewer.md +3 -1
- package/example//346/214/207/346/214/245/345/217/260.md +8 -40
- package/lessons.md +7 -1
- package/package.json +2 -1
- package/src/v2/adapters/notification-payload.js +40 -0
- package/src/v2/adapters/shell.js +3 -1
- package/src/v2/cli/run-config-check.js +129 -45
- package/src/v2/cli/run.js +824 -255
- package/src/v2/engine/reducer.js +2 -0
- package/src/v2/engine/risk-preset.js +14 -47
- package/src/v2/engine/workflow.js +177 -185
- package/src/v2/evidence/collector.js +12 -1
- package/src/v2/evidence/image.js +346 -0
- package/src/v2/policy/policy.js +219 -286
- package/src/v2/presets/software-delivery.yaml +1 -16
- package/src/v2/runtime/cache.js +22 -1
- package/src/v2/runtime/decisions.js +185 -31
- package/src/v2/runtime/findings.js +74 -1
- package/src/v2/runtime/notify.js +42 -36
- package/src/v2/runtime/orchestrator.js +823 -301
- package/src/v2/runtime/overview.js +163 -34
- package/src/v2/runtime/release.js +233 -0
- package/src/v2/runtime/run-record.js +1 -0
- package/src/v2/workspace/workspace-manager.js +8 -0
- package/templates/v2/AGENTS.md +15 -69
- package/templates/v2/BUILDBEAT.md +2 -13
- package/templates/v2/envelope/prompts/builder.md +3 -1
- package/templates/v2/envelope/prompts/fixer.md +3 -1
- package/templates/v2/envelope/prompts/reviewer.md +3 -1
- package/templates/v2/run-config.example.yaml +7 -5
- package/templates/v2/work.example.md +16 -0
- package/templates/v2//346/214/207/346/214/245/345/217/260.md +8 -40
- package/docs/v2/skill/01-principles.md +0 -26
- package/docs/v2/skill/02-project-layout.md +0 -31
- package/docs/v2/skill/03-collaboration-rules.md +0 -45
- package/docs/v2/skill/04-rhythm-and-rituals.md +0 -104
- package/docs/v2/skill/05-red-lines.md +0 -13
- package/docs/v2/skill/06-bootstrap-and-takeover.md +0 -84
- package/docs/v2/skill/07-templates-and-lessons.md +0 -24
- package/example/.buildbeat/observe.yaml +0 -31
- package/src/v2/observe/observe-config.js +0 -194
- package/src/v2/observe/observe-reducer.js +0 -117
- package/src/v2/observe/observe.js +0 -427
- package/src/v2/presets/observe.yaml +0 -45
- package/src/v2/presets/policies/ui-render-gate.yaml +0 -13
- package/src/v2/presets/release-readback.yaml +0 -36
- package/src/v2/presets/risk/controlled.yaml +0 -39
- package/src/v2/presets/risk/fast.yaml +0 -19
- package/src/v2/presets/risk/release.yaml +0 -21
- package/src/v2/presets/risk/standard.yaml +0 -28
- package/templates/ARCHITECTURE.md +0 -50
- package/templates/contracts/PROTOCOL.md +0 -28
- package/templates/pm/adr/ADR-0000-template.md +0 -25
- package/templates/pm/adr/README.md +0 -15
- package/templates/pm/decisions.md +0 -11
- package/templates/standards/CODE.md +0 -23
- package/templates/standards/DESIGN.md +0 -36
- package/templates/standards/REVIEW.md +0 -20
- package/templates/standards/STACK.md +0 -31
|
@@ -16,6 +16,7 @@ import { join } from "node:path";
|
|
|
16
16
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
17
|
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
18
|
import { nextReply } from "./notify.js";
|
|
19
|
+
import { readReleases } from "./release.js";
|
|
19
20
|
import { computeWorkCost, renderWorkCost } from "./work-cost.js";
|
|
20
21
|
|
|
21
22
|
function sha256File(path) {
|
|
@@ -45,18 +46,31 @@ export function artifactStatus(workDir, decisions, artifact) {
|
|
|
45
46
|
return { exists: false, accepted: false, stale: false };
|
|
46
47
|
}
|
|
47
48
|
const digest = sha256File(path);
|
|
48
|
-
const accepts = decisions.filter(
|
|
49
|
+
const accepts = decisions.filter(
|
|
50
|
+
(row) =>
|
|
51
|
+
row.transition === `accept-${artifact}` && row.decision === "approved",
|
|
52
|
+
);
|
|
49
53
|
const latest = accepts[accepts.length - 1];
|
|
50
54
|
if (!latest) {
|
|
51
55
|
return { exists: true, accepted: false, stale: false };
|
|
52
56
|
}
|
|
53
57
|
const stale = latest.subject?.digest !== digest;
|
|
54
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
exists: true,
|
|
60
|
+
accepted: !stale,
|
|
61
|
+
stale,
|
|
62
|
+
by: latest.by,
|
|
63
|
+
at: latest.ts,
|
|
64
|
+
};
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
function isAncestor(repoRoot, sha, ref) {
|
|
58
68
|
try {
|
|
59
|
-
execFileSync(
|
|
69
|
+
execFileSync(
|
|
70
|
+
"git",
|
|
71
|
+
["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref],
|
|
72
|
+
{ stdio: "ignore" },
|
|
73
|
+
);
|
|
60
74
|
return true;
|
|
61
75
|
} catch {
|
|
62
76
|
return false;
|
|
@@ -65,14 +79,18 @@ function isAncestor(repoRoot, sha, ref) {
|
|
|
65
79
|
|
|
66
80
|
function headRef(repoRoot) {
|
|
67
81
|
try {
|
|
68
|
-
return execFileSync(
|
|
82
|
+
return execFileSync(
|
|
83
|
+
"git",
|
|
84
|
+
["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
85
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
86
|
+
).trim();
|
|
69
87
|
} catch {
|
|
70
88
|
return "HEAD";
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
// Runs for a work: runtime ledgers first, run-records for the rest.
|
|
75
|
-
function runsFor(repoRoot, workId) {
|
|
93
|
+
export function runsFor(repoRoot, workId) {
|
|
76
94
|
const runs = new Map();
|
|
77
95
|
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
78
96
|
if (existsSync(runsDir)) {
|
|
@@ -131,10 +149,15 @@ function runsFor(repoRoot, workId) {
|
|
|
131
149
|
}
|
|
132
150
|
}
|
|
133
151
|
}
|
|
134
|
-
return [...runs.values()].sort((a, b) =>
|
|
152
|
+
return [...runs.values()].sort((a, b) =>
|
|
153
|
+
String(a.createdAt).localeCompare(String(b.createdAt)),
|
|
154
|
+
);
|
|
135
155
|
}
|
|
136
156
|
|
|
137
|
-
export function computeOverview(
|
|
157
|
+
export function computeOverview(
|
|
158
|
+
repoRoot,
|
|
159
|
+
{ work = null, repoLabel = "." } = {},
|
|
160
|
+
) {
|
|
138
161
|
const workRoot = join(repoRoot, "delivery", "work");
|
|
139
162
|
const rows = [];
|
|
140
163
|
if (!existsSync(workRoot)) {
|
|
@@ -146,17 +169,31 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
146
169
|
continue;
|
|
147
170
|
}
|
|
148
171
|
const workDir = join(workRoot, workId);
|
|
149
|
-
if (
|
|
172
|
+
if (
|
|
173
|
+
!existsSync(join(workDir, "work.md")) &&
|
|
174
|
+
!existsSync(join(workDir, "intent.md")) &&
|
|
175
|
+
!existsSync(join(workDir, "plan.md")) &&
|
|
176
|
+
!existsSync(join(workDir, "decisions.jsonl")) &&
|
|
177
|
+
!existsSync(join(workDir, "runs"))
|
|
178
|
+
) {
|
|
150
179
|
continue;
|
|
151
180
|
}
|
|
152
181
|
const decisions = readJsonl(join(workDir, "decisions.jsonl"));
|
|
153
|
-
const
|
|
154
|
-
const
|
|
182
|
+
const unified = existsSync(join(workDir, "work.md"));
|
|
183
|
+
const intent = artifactStatus(
|
|
184
|
+
workDir,
|
|
185
|
+
decisions,
|
|
186
|
+
unified ? "work" : "intent",
|
|
187
|
+
);
|
|
188
|
+
const plan = unified ? intent : artifactStatus(workDir, decisions, "plan");
|
|
155
189
|
const envFacts = existsSync(join(workDir, "env-facts.md"));
|
|
156
190
|
const findingRows = readFindingsAccount(repoRoot, workId);
|
|
157
191
|
const adjudicated = latestAdjudications(findingRows);
|
|
158
192
|
const openFindings = findingRows.filter(
|
|
159
|
-
(row) =>
|
|
193
|
+
(row) =>
|
|
194
|
+
row.kind === "finding" &&
|
|
195
|
+
(row.severity === "P0" || row.severity === "P1") &&
|
|
196
|
+
!adjudicated.has(row.fingerprint),
|
|
160
197
|
).length;
|
|
161
198
|
const runs = runsFor(repoRoot, workId);
|
|
162
199
|
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
@@ -166,28 +203,44 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
166
203
|
// (its in-run review budget ran out and closure happened elsewhere) and
|
|
167
204
|
// overview reported the shipped work as STOPPED_CANCELLED.
|
|
168
205
|
const mergedRun =
|
|
169
|
-
[...runs]
|
|
206
|
+
[...runs]
|
|
207
|
+
.reverse()
|
|
208
|
+
.find(
|
|
209
|
+
(run) =>
|
|
210
|
+
run.candidate && isAncestor(repoRoot, run.candidate, mainRef),
|
|
211
|
+
) ?? null;
|
|
170
212
|
const merged = Boolean(mergedRun);
|
|
171
213
|
const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
|
|
172
214
|
const isReleaseLane = (run) =>
|
|
173
|
-
Boolean(run) &&
|
|
215
|
+
Boolean(run) &&
|
|
216
|
+
(run.workflow === "release-readback" ||
|
|
217
|
+
run.steps.some((step) => RELEASE_STEPS.includes(step)));
|
|
174
218
|
|
|
175
219
|
// A Work is closed by an explicit row in decisions.jsonl:
|
|
176
220
|
// {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
|
|
177
221
|
// A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
|
|
178
222
|
// stale close row can never hide something that still needs a human.
|
|
179
|
-
const closure = [...decisions]
|
|
180
|
-
|
|
223
|
+
const closure = [...decisions]
|
|
224
|
+
.reverse()
|
|
225
|
+
.find((row) => row.transition === "close-work");
|
|
226
|
+
const readback = readReleases(repoRoot, workId).at(-1) ?? null;
|
|
227
|
+
const liveRun =
|
|
228
|
+
latest &&
|
|
229
|
+
(latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
|
|
181
230
|
|
|
182
231
|
let stage;
|
|
183
232
|
let next;
|
|
184
233
|
if (closure && !liveRun) {
|
|
185
234
|
stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
|
|
186
|
-
const result =
|
|
235
|
+
const result =
|
|
236
|
+
typeof closure.subject?.result === "string" &&
|
|
237
|
+
closure.subject.result.length > 0
|
|
238
|
+
? closure.subject.result
|
|
239
|
+
: "see decisions.jsonl";
|
|
187
240
|
next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
|
|
188
241
|
} else if (!intent.exists) {
|
|
189
242
|
stage = "NO_INTENT";
|
|
190
|
-
next = `write delivery/work/${workId}/
|
|
243
|
+
next = `write delivery/work/${workId}/work.md (goal, scope, acceptance, implementation plan)`;
|
|
191
244
|
} else if (!latest) {
|
|
192
245
|
if (!plan.exists) {
|
|
193
246
|
stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
|
|
@@ -195,23 +248,40 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
195
248
|
? `write delivery/work/${workId}/plan.md, then accept it`
|
|
196
249
|
: `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
|
|
197
250
|
} else if (!plan.accepted) {
|
|
198
|
-
stage =
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
stage = unified
|
|
252
|
+
? plan.stale
|
|
253
|
+
? "WORK_STALE"
|
|
254
|
+
: "WORK_DRAFT"
|
|
255
|
+
: plan.stale
|
|
256
|
+
? "PLAN_STALE"
|
|
257
|
+
: "PLAN_DRAFT";
|
|
258
|
+
const artifacts = unified
|
|
259
|
+
? "work"
|
|
260
|
+
: intent.accepted
|
|
261
|
+
? "plan"
|
|
262
|
+
: "intent,plan";
|
|
263
|
+
next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? ` # ${unified ? "work.md" : "plan"} changed since acceptance` : ""}`;
|
|
201
264
|
} else {
|
|
202
265
|
stage = "READY_TO_RUN";
|
|
203
|
-
const configs = readdirSync(workDir).filter((name) =>
|
|
266
|
+
const configs = readdirSync(workDir).filter((name) =>
|
|
267
|
+
/^run-config.*\.ya?ml$/.test(name),
|
|
268
|
+
);
|
|
204
269
|
next =
|
|
205
270
|
configs.length > 0
|
|
206
|
-
? `buildbeat
|
|
271
|
+
? `buildbeat run --config delivery/work/${workId}/${configs[0]}`
|
|
207
272
|
: `no run-config in delivery/work/${workId}: write one, or close it with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"..."}} if it was doc-only`;
|
|
208
273
|
}
|
|
209
274
|
} else if (latest.status === "RUNNING") {
|
|
210
275
|
stage = "RUNNING";
|
|
211
276
|
next = `buildbeat status --repo ${repoLabel} --run ${latest.id}`;
|
|
212
277
|
} else if (latest.status === "WAITING_HUMAN") {
|
|
213
|
-
stage =
|
|
214
|
-
|
|
278
|
+
stage =
|
|
279
|
+
latest.pendingHuman?.kind === "final-decision"
|
|
280
|
+
? "MERGE_DECISION"
|
|
281
|
+
: "WAITING_HUMAN";
|
|
282
|
+
const replies = latest.state
|
|
283
|
+
? nextReply({ repoLabel, state: latest.state })
|
|
284
|
+
: [];
|
|
215
285
|
next = replies[0] ?? `buildbeat inbox --repo ${repoLabel}`;
|
|
216
286
|
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
217
287
|
// A release-readback lane that reached wait-close and was approved is
|
|
@@ -220,9 +290,23 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
220
290
|
next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
|
|
221
291
|
} else if (merged) {
|
|
222
292
|
stage = "MERGED";
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
293
|
+
// After the merge: the person releases, the project's readback proves
|
|
294
|
+
// it, and the window closes on a passing readback.
|
|
295
|
+
const configs = readdirSync(workDir).filter((name) =>
|
|
296
|
+
/^run-config.*\.ya?ml$/.test(name),
|
|
297
|
+
);
|
|
298
|
+
const config = configs.length
|
|
299
|
+
? `delivery/work/${workId}/${configs[0]}`
|
|
300
|
+
: "<run-config.yaml>";
|
|
301
|
+
const close = `buildbeat decide --repo ${repoLabel} --work ${workId} --action close --result <what was released> --by <you>`;
|
|
302
|
+
next = !readback
|
|
303
|
+
? `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release it (a human action), then buildbeat release --config ${config}; then ${close}`
|
|
304
|
+
: readback.status === "passed"
|
|
305
|
+
? `readback passed @ ${readback.ts} (${readback.commit.slice(0, 7)}); close the window: ${close}`
|
|
306
|
+
: `latest readback failed @ ${readback.ts} (exit ${readback.exitCode}); fix the release, then buildbeat release --config ${config} again`;
|
|
307
|
+
if (latest.status !== "SUCCEEDED") {
|
|
308
|
+
next += ` # latest run ${latest.id} ended ${latest.status} after the merge`;
|
|
309
|
+
}
|
|
226
310
|
} else if (latest.status === "SUCCEEDED") {
|
|
227
311
|
stage = "MERGE_READY";
|
|
228
312
|
next = latest.candidate
|
|
@@ -231,23 +315,49 @@ export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {})
|
|
|
231
315
|
} else {
|
|
232
316
|
stage = `STOPPED_${latest.status}`;
|
|
233
317
|
next = plan.accepted
|
|
234
|
-
? `decide: retry (buildbeat
|
|
235
|
-
:
|
|
318
|
+
? `decide: retry (buildbeat run --config <run-config.yaml> --new) or close the work`
|
|
319
|
+
: `${unified ? "work.md" : "plan"} not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
|
|
236
320
|
}
|
|
237
321
|
rows.push({
|
|
238
322
|
work: workId,
|
|
239
323
|
stage,
|
|
240
324
|
intent,
|
|
241
325
|
plan,
|
|
326
|
+
...(unified ? { workArtifact: "work" } : {}),
|
|
242
327
|
envFacts,
|
|
243
328
|
openFindings,
|
|
329
|
+
findings: findingRows
|
|
330
|
+
.filter((item) => item.kind === "finding")
|
|
331
|
+
.map((item) => ({
|
|
332
|
+
...item,
|
|
333
|
+
adjudication: adjudicated.get(item.fingerprint)?.action ?? "open",
|
|
334
|
+
})),
|
|
244
335
|
runs: runs.length,
|
|
245
336
|
cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
|
|
246
337
|
latest: latest
|
|
247
|
-
? {
|
|
338
|
+
? {
|
|
339
|
+
id: latest.id,
|
|
340
|
+
status: latest.status,
|
|
341
|
+
candidate: latest.candidate,
|
|
342
|
+
at: latest.lastAt,
|
|
343
|
+
source: latest.source,
|
|
344
|
+
terminalReason: latest.terminal?.reason ?? null,
|
|
345
|
+
waiting: latest.pendingHuman?.transition ?? null,
|
|
346
|
+
}
|
|
248
347
|
: null,
|
|
249
348
|
merged,
|
|
250
349
|
mergedCandidate: mergedRun?.candidate ?? null,
|
|
350
|
+
...(readback
|
|
351
|
+
? {
|
|
352
|
+
readback: {
|
|
353
|
+
status: readback.status,
|
|
354
|
+
at: readback.ts,
|
|
355
|
+
commit: readback.commit,
|
|
356
|
+
digest: readback.digest,
|
|
357
|
+
...(readback.note ? { note: readback.note } : {}),
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
: {}),
|
|
251
361
|
next,
|
|
252
362
|
});
|
|
253
363
|
}
|
|
@@ -271,14 +381,29 @@ export function renderOverview(rows) {
|
|
|
271
381
|
const lines = [];
|
|
272
382
|
for (const row of rows) {
|
|
273
383
|
lines.push(`${row.work} ${row.stage}`);
|
|
274
|
-
const parts =
|
|
275
|
-
|
|
384
|
+
const parts =
|
|
385
|
+
row.workArtifact === "work"
|
|
386
|
+
? [`work.md ${mark(row.plan)}`, `runs ${row.runs}`]
|
|
387
|
+
: [
|
|
388
|
+
`intent ${mark(row.intent)}`,
|
|
389
|
+
`plan ${mark(row.plan)}`,
|
|
390
|
+
`runs ${row.runs}`,
|
|
391
|
+
];
|
|
392
|
+
const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(
|
|
393
|
+
row.stage,
|
|
394
|
+
);
|
|
276
395
|
if (row.openFindings > 0 && !settled) {
|
|
277
396
|
// Unadjudicated, not necessarily unresolved: a fixer may have closed
|
|
278
397
|
// them without anyone recording a verdict. The number says "nobody
|
|
279
398
|
// ruled on these", which is exactly what a human should know.
|
|
280
399
|
parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
|
|
281
400
|
}
|
|
401
|
+
if (!settled && row.findings?.length) {
|
|
402
|
+
for (const finding of row.findings)
|
|
403
|
+
lines.push(
|
|
404
|
+
` [${finding.severity} ${finding.fingerprint}] (${finding.adjudication}) ${finding.summary}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
282
407
|
if (row.envFacts) {
|
|
283
408
|
parts.push("env-facts ✓");
|
|
284
409
|
}
|
|
@@ -293,8 +418,12 @@ export function renderOverview(rows) {
|
|
|
293
418
|
? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
|
|
294
419
|
: "";
|
|
295
420
|
const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
|
|
296
|
-
const why = row.latest.terminalReason
|
|
297
|
-
|
|
421
|
+
const why = row.latest.terminalReason
|
|
422
|
+
? ` — ${row.latest.terminalReason.slice(0, 100)}`
|
|
423
|
+
: "";
|
|
424
|
+
lines.push(
|
|
425
|
+
` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`,
|
|
426
|
+
);
|
|
298
427
|
}
|
|
299
428
|
lines.push(` next: ${row.next}`);
|
|
300
429
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Release readback and closeout (4.0; replaces the retired release-readback
|
|
2
|
+
// lane, owner decision 2026-10-04). BuildBeat still performs no deployment
|
|
3
|
+
// (invariant 20): after a person releases a merged candidate, the project's
|
|
4
|
+
// own read-only command proves the release took effect. Every readback is a
|
|
5
|
+
// line in the Work's releases.jsonl, next to its decisions, and the window
|
|
6
|
+
// closes only on a passing readback. Real cost behind it: one pilot go-live
|
|
7
|
+
// was recorded by hand as forty "step N readback" commits.
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
appendFileSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { createShellAdapter } from "../adapters/shell.js";
|
|
21
|
+
import { toRepoRef } from "./repo-ref.js";
|
|
22
|
+
|
|
23
|
+
const TAIL_LINES = 20;
|
|
24
|
+
|
|
25
|
+
export class ReleaseError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ReleaseError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function workDir(repoRoot, workId) {
|
|
33
|
+
return join(repoRoot, "delivery", "work", workId);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readRows(path) {
|
|
37
|
+
if (!existsSync(path)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return readFileSync(path, "utf8")
|
|
41
|
+
.split("\n")
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((line) => {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(line);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readReleases(repoRoot, workId) {
|
|
54
|
+
return readRows(join(workDir(repoRoot, workId), "releases.jsonl"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAncestor(repoRoot, sha, commit) {
|
|
58
|
+
try {
|
|
59
|
+
execFileSync(
|
|
60
|
+
"git",
|
|
61
|
+
["-C", repoRoot, "merge-base", "--is-ancestor", sha, commit],
|
|
62
|
+
{ stdio: "ignore" },
|
|
63
|
+
);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Runs the configured readback in the main checkout once and records it.
|
|
71
|
+
// `runs` are the Work's runs (overview's runsFor); the newest SUCCEEDED one
|
|
72
|
+
// whose candidate the release ref contains is what this readback is about.
|
|
73
|
+
export function runReadback({
|
|
74
|
+
repoRoot,
|
|
75
|
+
workId,
|
|
76
|
+
spec,
|
|
77
|
+
redact = [],
|
|
78
|
+
runs,
|
|
79
|
+
ref = "HEAD",
|
|
80
|
+
note = null,
|
|
81
|
+
ts = new Date().toISOString(),
|
|
82
|
+
}) {
|
|
83
|
+
const resolveCommit = (name) =>
|
|
84
|
+
execFileSync(
|
|
85
|
+
"git",
|
|
86
|
+
["-C", repoRoot, "rev-parse", "--verify", `${name}^{commit}`],
|
|
87
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
88
|
+
).trim();
|
|
89
|
+
let commit;
|
|
90
|
+
try {
|
|
91
|
+
commit = resolveCommit(ref);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new ReleaseError(`--ref ${ref} does not name a commit`);
|
|
94
|
+
}
|
|
95
|
+
// The command runs in the main checkout, which may sit elsewhere than the
|
|
96
|
+
// released ref; both are recorded so neither is claimed for the other.
|
|
97
|
+
const checkout = resolveCommit("HEAD");
|
|
98
|
+
const succeeded = runs
|
|
99
|
+
.filter((run) => run.status === "SUCCEEDED" && run.candidate)
|
|
100
|
+
.sort((a, b) => String(a.lastAt).localeCompare(String(b.lastAt)));
|
|
101
|
+
if (succeeded.length === 0) {
|
|
102
|
+
throw new ReleaseError(
|
|
103
|
+
`${workId} has no succeeded run; approve its merge decision first`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const released = [...succeeded]
|
|
107
|
+
.reverse()
|
|
108
|
+
.find((run) => isAncestor(repoRoot, run.candidate, commit));
|
|
109
|
+
if (!released) {
|
|
110
|
+
const latest = succeeded.at(-1);
|
|
111
|
+
throw new ReleaseError(
|
|
112
|
+
`candidate ${latest.candidate.slice(0, 7)} (${latest.id}) is not contained in ${ref} (${commit.slice(0, 7)}); merge it first`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const adapter = createShellAdapter({
|
|
117
|
+
name: "shell:release",
|
|
118
|
+
command: spec.command,
|
|
119
|
+
args: spec.args ?? [],
|
|
120
|
+
timeoutMs: spec.timeoutMs,
|
|
121
|
+
inheritEnv: spec.inheritEnv === true,
|
|
122
|
+
env: spec.env ?? {},
|
|
123
|
+
});
|
|
124
|
+
const exec = adapter.execute({
|
|
125
|
+
step: "release",
|
|
126
|
+
worker: "release",
|
|
127
|
+
workspacePath: repoRoot,
|
|
128
|
+
input: { workId, run: released.id, candidate: released.candidate, ref, commit },
|
|
129
|
+
});
|
|
130
|
+
const scrub = (text) => {
|
|
131
|
+
let out = String(text ?? "");
|
|
132
|
+
for (const pattern of redact) {
|
|
133
|
+
out = out.replace(pattern, "<REDACTED>");
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
};
|
|
137
|
+
const output = [scrub(exec.stdout), scrub(exec.stderr)]
|
|
138
|
+
.filter((text) => text.length > 0)
|
|
139
|
+
.join("\n");
|
|
140
|
+
const body = [
|
|
141
|
+
`command: ${scrub(exec.command)}`,
|
|
142
|
+
`exitCode: ${exec.exitCode}`,
|
|
143
|
+
`signal: ${exec.signal}`,
|
|
144
|
+
`timedOut: ${exec.timedOut}`,
|
|
145
|
+
`spawnError: ${exec.spawnError}`,
|
|
146
|
+
"--- stdout ---",
|
|
147
|
+
scrub(exec.stdout),
|
|
148
|
+
"--- stderr ---",
|
|
149
|
+
scrub(exec.stderr),
|
|
150
|
+
].join("\n");
|
|
151
|
+
const logDir = join(repoRoot, ".buildbeat", "runtime", "releases", workId);
|
|
152
|
+
mkdirSync(logDir, { recursive: true });
|
|
153
|
+
// Unique per readback: concurrent readbacks never share a log file.
|
|
154
|
+
const logPath = join(
|
|
155
|
+
logDir,
|
|
156
|
+
`release-${ts.replace(/[^0-9A-Za-z]/g, "")}-${randomUUID().slice(0, 8)}.log`,
|
|
157
|
+
);
|
|
158
|
+
writeFileSync(logPath, body, { encoding: "utf8", flag: "wx" });
|
|
159
|
+
const passed =
|
|
160
|
+
exec.exitCode === 0 && !exec.timedOut && !exec.signal && !exec.spawnError;
|
|
161
|
+
const row = {
|
|
162
|
+
ts,
|
|
163
|
+
work: workId,
|
|
164
|
+
run: released.id,
|
|
165
|
+
candidate: released.candidate,
|
|
166
|
+
ref,
|
|
167
|
+
commit,
|
|
168
|
+
checkout,
|
|
169
|
+
...(note ? { note } : {}),
|
|
170
|
+
command: scrub(exec.command),
|
|
171
|
+
exitCode: exec.exitCode,
|
|
172
|
+
status: passed ? "passed" : "failed",
|
|
173
|
+
grade: "L4",
|
|
174
|
+
digest: `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`,
|
|
175
|
+
log: toRepoRef(repoRoot, logPath),
|
|
176
|
+
tail: output.split("\n").filter(Boolean).slice(-TAIL_LINES),
|
|
177
|
+
};
|
|
178
|
+
mkdirSync(workDir(repoRoot, workId), { recursive: true });
|
|
179
|
+
appendFileSync(
|
|
180
|
+
join(workDir(repoRoot, workId), "releases.jsonl"),
|
|
181
|
+
`${JSON.stringify(row)}\n`,
|
|
182
|
+
"utf8",
|
|
183
|
+
);
|
|
184
|
+
return row;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Closes the release window: a close-work decision bound to the latest
|
|
188
|
+
// readback, which must have passed.
|
|
189
|
+
export function closeWork({
|
|
190
|
+
repoRoot,
|
|
191
|
+
workId,
|
|
192
|
+
result,
|
|
193
|
+
by = "human",
|
|
194
|
+
ts = new Date().toISOString(),
|
|
195
|
+
}) {
|
|
196
|
+
if (typeof result !== "string" || result.trim() === "") {
|
|
197
|
+
throw new ReleaseError("closing a work needs --result <text>");
|
|
198
|
+
}
|
|
199
|
+
const decisionsPath = join(workDir(repoRoot, workId), "decisions.jsonl");
|
|
200
|
+
const decisions = readRows(decisionsPath);
|
|
201
|
+
if (decisions.some((row) => row.transition === "close-work")) {
|
|
202
|
+
throw new ReleaseError(`${workId} is already closed`);
|
|
203
|
+
}
|
|
204
|
+
const latest = readReleases(repoRoot, workId).at(-1);
|
|
205
|
+
if (!latest) {
|
|
206
|
+
throw new ReleaseError(
|
|
207
|
+
`${workId} has no readback; run buildbeat release --config <run-config.yaml> after the release first`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (latest.status !== "passed") {
|
|
211
|
+
throw new ReleaseError(
|
|
212
|
+
`the latest readback failed (exit ${latest.exitCode} at ${latest.ts}); fix the release and run buildbeat release again before closing`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const count = existsSync(decisionsPath)
|
|
216
|
+
? readFileSync(decisionsPath, "utf8").split("\n").filter(Boolean).length
|
|
217
|
+
: 0;
|
|
218
|
+
const row = {
|
|
219
|
+
ts,
|
|
220
|
+
decisionRef: `C-${workId}-${count + 1}`,
|
|
221
|
+
decision: "closed",
|
|
222
|
+
transition: "close-work",
|
|
223
|
+
subject: {
|
|
224
|
+
result: result.trim(),
|
|
225
|
+
readback: latest.digest,
|
|
226
|
+
commit: latest.commit,
|
|
227
|
+
candidate: latest.candidate,
|
|
228
|
+
},
|
|
229
|
+
by,
|
|
230
|
+
};
|
|
231
|
+
appendFileSync(decisionsPath, `${JSON.stringify(row)}\n`, "utf8");
|
|
232
|
+
return row;
|
|
233
|
+
}
|
|
@@ -42,6 +42,7 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
|
42
42
|
run: first.run,
|
|
43
43
|
work: first.work,
|
|
44
44
|
workflow: state.run?.workflowRef ?? null,
|
|
45
|
+
...(state.run?.deliveryChecks ? { deliveryChecks: state.run.deliveryChecks } : {}),
|
|
45
46
|
terminal: state.terminal,
|
|
46
47
|
events: { from: first.seq, to: last.seq, lastDigest: last.digest },
|
|
47
48
|
startedAt: first.ts,
|
|
@@ -374,6 +374,14 @@ export function pinCandidate(workspace) {
|
|
|
374
374
|
return head;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// Undoes createWorkspace for a run that never started (nothing was written
|
|
378
|
+
// to its ledger): the worktree and its branch both go, so the same run id
|
|
379
|
+
// can be created again.
|
|
380
|
+
export function discardWorkspace(workspace) {
|
|
381
|
+
git(workspace.repoRoot, ["worktree", "remove", "--force", workspace.worktreePath]);
|
|
382
|
+
git(workspace.repoRoot, ["branch", "-D", workspace.branch]);
|
|
383
|
+
}
|
|
384
|
+
|
|
377
385
|
export function removeWorkspace(workspace, { force = false } = {}) {
|
|
378
386
|
const { dirty } = readback(workspace.worktreePath);
|
|
379
387
|
if (dirty && !force) {
|