@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
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Work-level overview (iteration 08, C5): "where is this thing, and who
|
|
2
|
+
// moves next" — the question the owner opened three sessions with
|
|
3
|
+
// ("X 上线了吗 / 离上线还差多远 / 从每个系统说待办") and that inbox,
|
|
4
|
+
// which only knows about runs waiting on a human, cannot answer.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is derived: work directories and decision ledgers in the
|
|
7
|
+
// Git plane, run ledgers in the runtime plane (run-records fill in for runs
|
|
8
|
+
// whose runtime was wiped), and git ancestry for "merged". Nothing is
|
|
9
|
+
// written.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
|
+
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
|
+
import { nextReply } from "./notify.js";
|
|
19
|
+
import { computeWorkCost, renderWorkCost } from "./work-cost.js";
|
|
20
|
+
|
|
21
|
+
function sha256File(path) {
|
|
22
|
+
return `sha256:${createHash("sha256").update(readFileSync(path, "utf8"), "utf8").digest("hex")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readJsonl(path) {
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return readFileSync(path, "utf8")
|
|
30
|
+
.split("\n")
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((line) => {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(line);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function artifactStatus(workDir, decisions, artifact) {
|
|
43
|
+
const path = join(workDir, `${artifact}.md`);
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
return { exists: false, accepted: false, stale: false };
|
|
46
|
+
}
|
|
47
|
+
const digest = sha256File(path);
|
|
48
|
+
const accepts = decisions.filter((row) => row.transition === `accept-${artifact}` && row.decision === "approved");
|
|
49
|
+
const latest = accepts[accepts.length - 1];
|
|
50
|
+
if (!latest) {
|
|
51
|
+
return { exists: true, accepted: false, stale: false };
|
|
52
|
+
}
|
|
53
|
+
const stale = latest.subject?.digest !== digest;
|
|
54
|
+
return { exists: true, accepted: !stale, stale, by: latest.by, at: latest.ts };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAncestor(repoRoot, sha, ref) {
|
|
58
|
+
try {
|
|
59
|
+
execFileSync("git", ["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref], { stdio: "ignore" });
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function headRef(repoRoot) {
|
|
67
|
+
try {
|
|
68
|
+
return execFileSync("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
69
|
+
} catch {
|
|
70
|
+
return "HEAD";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Runs for a work: runtime ledgers first, run-records for the rest.
|
|
75
|
+
function runsFor(repoRoot, workId) {
|
|
76
|
+
const runs = new Map();
|
|
77
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
78
|
+
if (existsSync(runsDir)) {
|
|
79
|
+
for (const entry of readdirSync(runsDir)) {
|
|
80
|
+
const path = join(runsDir, entry, "events.jsonl");
|
|
81
|
+
if (!existsSync(path)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const ledger = EventLedger.open(path);
|
|
85
|
+
const state = ledger.state;
|
|
86
|
+
if (!state.run || state.run.work !== workId) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
runs.set(state.run.id, {
|
|
90
|
+
id: state.run.id,
|
|
91
|
+
status: state.run.status,
|
|
92
|
+
terminal: state.terminal,
|
|
93
|
+
pendingHuman: state.pendingHuman,
|
|
94
|
+
candidate: state.workspaces[state.run.id]?.candidate ?? null,
|
|
95
|
+
createdAt: ledger.events[0]?.ts ?? null,
|
|
96
|
+
lastAt: ledger.events[ledger.events.length - 1]?.ts ?? null,
|
|
97
|
+
workflow: state.run.workflowRef ?? null,
|
|
98
|
+
steps: Object.keys(state.steps),
|
|
99
|
+
state,
|
|
100
|
+
source: "runtime",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
|
|
105
|
+
if (existsSync(recordsDir)) {
|
|
106
|
+
for (const entry of readdirSync(recordsDir)) {
|
|
107
|
+
if (runs.has(entry)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const path = join(recordsDir, entry, "run-record.json");
|
|
111
|
+
if (!existsSync(path)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const record = JSON.parse(readFileSync(path, "utf8"));
|
|
116
|
+
runs.set(entry, {
|
|
117
|
+
id: entry,
|
|
118
|
+
status: record.terminal?.status ?? "UNKNOWN",
|
|
119
|
+
terminal: record.terminal ?? null,
|
|
120
|
+
pendingHuman: null,
|
|
121
|
+
candidate: record.workspaces?.[entry]?.candidate ?? null,
|
|
122
|
+
createdAt: record.startedAt ?? null,
|
|
123
|
+
lastAt: record.finishedAt ?? null,
|
|
124
|
+
workflow: record.workflow ?? null,
|
|
125
|
+
steps: Object.keys(record.attempts ?? {}),
|
|
126
|
+
state: null,
|
|
127
|
+
source: "run-record",
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
// an unreadable record is reported as absent, not guessed
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...runs.values()].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function computeOverview(repoRoot, { work = null, repoLabel = "." } = {}) {
|
|
138
|
+
const workRoot = join(repoRoot, "delivery", "work");
|
|
139
|
+
const rows = [];
|
|
140
|
+
if (!existsSync(workRoot)) {
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
143
|
+
const mainRef = headRef(repoRoot);
|
|
144
|
+
for (const workId of readdirSync(workRoot).sort()) {
|
|
145
|
+
if (work && workId !== work) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const workDir = join(workRoot, workId);
|
|
149
|
+
if (!existsSync(join(workDir, "intent.md")) && !existsSync(join(workDir, "plan.md")) && !existsSync(join(workDir, "decisions.jsonl")) && !existsSync(join(workDir, "runs"))) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const decisions = readJsonl(join(workDir, "decisions.jsonl"));
|
|
153
|
+
const intent = artifactStatus(workDir, decisions, "intent");
|
|
154
|
+
const plan = artifactStatus(workDir, decisions, "plan");
|
|
155
|
+
const envFacts = existsSync(join(workDir, "env-facts.md"));
|
|
156
|
+
const findingRows = readFindingsAccount(repoRoot, workId);
|
|
157
|
+
const adjudicated = latestAdjudications(findingRows);
|
|
158
|
+
const openFindings = findingRows.filter(
|
|
159
|
+
(row) => row.kind === "finding" && (row.severity === "P0" || row.severity === "P1") && !adjudicated.has(row.fingerprint),
|
|
160
|
+
).length;
|
|
161
|
+
const runs = runsFor(repoRoot, workId);
|
|
162
|
+
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
163
|
+
const latest = live[live.length - 1] ?? null;
|
|
164
|
+
// "Merged" is a fact about any candidate of the work, not only the
|
|
165
|
+
// latest run's: a pilot's shipped candidate sat behind a CANCELLED run
|
|
166
|
+
// (its in-run review budget ran out and closure happened elsewhere) and
|
|
167
|
+
// overview reported the shipped work as STOPPED_CANCELLED.
|
|
168
|
+
const mergedRun =
|
|
169
|
+
[...runs].reverse().find((run) => run.candidate && isAncestor(repoRoot, run.candidate, mainRef)) ?? null;
|
|
170
|
+
const merged = Boolean(mergedRun);
|
|
171
|
+
const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
|
|
172
|
+
const isReleaseLane = (run) =>
|
|
173
|
+
Boolean(run) && (run.workflow === "release-readback" || run.steps.some((step) => RELEASE_STEPS.includes(step)));
|
|
174
|
+
|
|
175
|
+
// A Work is closed by an explicit row in decisions.jsonl:
|
|
176
|
+
// {"transition":"close-work","decision":"closed"|"cancelled","subject":{"result":"..."}}
|
|
177
|
+
// A live run (RUNNING / WAITING_HUMAN) contradicts a closure and wins, so a
|
|
178
|
+
// stale close row can never hide something that still needs a human.
|
|
179
|
+
const closure = [...decisions].reverse().find((row) => row.transition === "close-work");
|
|
180
|
+
const liveRun = latest && (latest.status === "RUNNING" || latest.status === "WAITING_HUMAN");
|
|
181
|
+
|
|
182
|
+
let stage;
|
|
183
|
+
let next;
|
|
184
|
+
if (closure && !liveRun) {
|
|
185
|
+
stage = closure.decision === "cancelled" ? "CANCELLED" : "CLOSED";
|
|
186
|
+
const result = typeof closure.subject?.result === "string" && closure.subject.result.length > 0 ? closure.subject.result : "see decisions.jsonl";
|
|
187
|
+
next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
|
|
188
|
+
} else if (!intent.exists) {
|
|
189
|
+
stage = "NO_INTENT";
|
|
190
|
+
next = `write delivery/work/${workId}/intent.md (what and why), then plan.md`;
|
|
191
|
+
} else if (!latest) {
|
|
192
|
+
if (!plan.exists) {
|
|
193
|
+
stage = intent.accepted ? "INTENT_ACCEPTED" : "INTENT_DRAFT";
|
|
194
|
+
next = intent.accepted
|
|
195
|
+
? `write delivery/work/${workId}/plan.md, then accept it`
|
|
196
|
+
: `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact intent --by <you>`;
|
|
197
|
+
} else if (!plan.accepted) {
|
|
198
|
+
stage = plan.stale ? "PLAN_STALE" : "PLAN_DRAFT";
|
|
199
|
+
next = `buildbeat-v2 accept --repo ${repoLabel} --work ${workId} --artifact plan --by <you>${plan.stale ? " # plan changed since acceptance" : ""}`;
|
|
200
|
+
} else {
|
|
201
|
+
stage = "READY_TO_RUN";
|
|
202
|
+
const configs = readdirSync(workDir).filter((name) => /^run-config.*\.ya?ml$/.test(name));
|
|
203
|
+
next =
|
|
204
|
+
configs.length > 0
|
|
205
|
+
? `buildbeat-v2 start --config delivery/work/${workId}/${configs[0]} --attempt new`
|
|
206
|
+
: `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`;
|
|
207
|
+
}
|
|
208
|
+
} else if (latest.status === "RUNNING") {
|
|
209
|
+
stage = "RUNNING";
|
|
210
|
+
next = `buildbeat-v2 status --repo ${repoLabel} --run ${latest.id}`;
|
|
211
|
+
} else if (latest.status === "WAITING_HUMAN") {
|
|
212
|
+
stage = latest.pendingHuman?.kind === "final-decision" ? "MERGE_DECISION" : "WAITING_HUMAN";
|
|
213
|
+
const replies = latest.state ? nextReply({ repoLabel, state: latest.state }) : [];
|
|
214
|
+
next = replies[0] ?? `buildbeat-v2 inbox --repo ${repoLabel}`;
|
|
215
|
+
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
216
|
+
// A release-readback lane that reached wait-close and was approved is
|
|
217
|
+
// a closed release window, not "nothing to merge".
|
|
218
|
+
stage = "RELEASED";
|
|
219
|
+
next = `release window closed by ${latest.id}; close the work with a decisions.jsonl row {"transition":"close-work","decision":"closed","subject":{"result":"released"}}`;
|
|
220
|
+
} else if (merged) {
|
|
221
|
+
stage = "MERGED";
|
|
222
|
+
next =
|
|
223
|
+
`candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release/deploy stays a human action; then buildbeat-v2 gc --repo ${repoLabel}` +
|
|
224
|
+
(latest.status !== "SUCCEEDED" ? ` # latest run ${latest.id} ended ${latest.status} after the merge` : "");
|
|
225
|
+
} else if (latest.status === "SUCCEEDED") {
|
|
226
|
+
stage = "MERGE_READY";
|
|
227
|
+
next = latest.candidate
|
|
228
|
+
? `merge ${latest.candidate.slice(0, 7)} (run/${latest.id}) into ${mainRef} — manual, then push`
|
|
229
|
+
: "run succeeded without a candidate; nothing to merge";
|
|
230
|
+
} else {
|
|
231
|
+
stage = `STOPPED_${latest.status}`;
|
|
232
|
+
next = plan.accepted
|
|
233
|
+
? `decide: retry (buildbeat-v2 start ... --attempt new) or close the work`
|
|
234
|
+
: `plan not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
|
|
235
|
+
}
|
|
236
|
+
rows.push({
|
|
237
|
+
work: workId,
|
|
238
|
+
stage,
|
|
239
|
+
intent,
|
|
240
|
+
plan,
|
|
241
|
+
envFacts,
|
|
242
|
+
openFindings,
|
|
243
|
+
runs: runs.length,
|
|
244
|
+
cost: runs.length > 0 ? computeWorkCost(repoRoot, workId) : null,
|
|
245
|
+
latest: latest
|
|
246
|
+
? { id: latest.id, status: latest.status, candidate: latest.candidate, at: latest.lastAt, source: latest.source, terminalReason: latest.terminal?.reason ?? null, waiting: latest.pendingHuman?.transition ?? null }
|
|
247
|
+
: null,
|
|
248
|
+
merged,
|
|
249
|
+
mergedCandidate: mergedRun?.candidate ?? null,
|
|
250
|
+
next,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return rows;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function mark(status) {
|
|
257
|
+
if (!status.exists) {
|
|
258
|
+
return "–";
|
|
259
|
+
}
|
|
260
|
+
if (status.stale) {
|
|
261
|
+
return "stale";
|
|
262
|
+
}
|
|
263
|
+
return status.accepted ? "✓" : "draft";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function renderOverview(rows) {
|
|
267
|
+
if (rows.length === 0) {
|
|
268
|
+
return "overview: no work items under delivery/work";
|
|
269
|
+
}
|
|
270
|
+
const lines = [];
|
|
271
|
+
for (const row of rows) {
|
|
272
|
+
lines.push(`${row.work} ${row.stage}`);
|
|
273
|
+
const parts = [`intent ${mark(row.intent)}`, `plan ${mark(row.plan)}`, `runs ${row.runs}`];
|
|
274
|
+
const settled = ["MERGED", "RELEASED", "CLOSED", "CANCELLED"].includes(row.stage);
|
|
275
|
+
if (row.openFindings > 0 && !settled) {
|
|
276
|
+
// Unadjudicated, not necessarily unresolved: a fixer may have closed
|
|
277
|
+
// them without anyone recording a verdict. The number says "nobody
|
|
278
|
+
// ruled on these", which is exactly what a human should know.
|
|
279
|
+
parts.push(`unadjudicated P0/P1 findings ${row.openFindings}`);
|
|
280
|
+
}
|
|
281
|
+
if (row.envFacts) {
|
|
282
|
+
parts.push("env-facts ✓");
|
|
283
|
+
}
|
|
284
|
+
lines.push(` ${parts.join(" · ")}`);
|
|
285
|
+
if (row.cost) {
|
|
286
|
+
// What this work has already consumed across every run, superseded
|
|
287
|
+
// ones included: the number a "continue or cut" decision needs.
|
|
288
|
+
lines.push(` cost: ${renderWorkCost(row.cost)}`);
|
|
289
|
+
}
|
|
290
|
+
if (row.latest) {
|
|
291
|
+
const cand = row.latest.candidate
|
|
292
|
+
? ` candidate ${row.latest.candidate.slice(0, 7)}${row.latest.candidate === row.mergedCandidate ? " (merged)" : ""}`
|
|
293
|
+
: "";
|
|
294
|
+
const wait = row.latest.waiting ? ` waiting ${row.latest.waiting}` : "";
|
|
295
|
+
const why = row.latest.terminalReason ? ` — ${row.latest.terminalReason.slice(0, 100)}` : "";
|
|
296
|
+
lines.push(` latest ${row.latest.id} ${row.latest.status}${cand}${wait} @ ${row.latest.at ?? "?"}${why}`);
|
|
297
|
+
}
|
|
298
|
+
lines.push(` next: ${row.next}`);
|
|
299
|
+
}
|
|
300
|
+
return lines.join("\n");
|
|
301
|
+
}
|
|
@@ -9,6 +9,7 @@ import { join, relative } from "node:path";
|
|
|
9
9
|
|
|
10
10
|
import { canonicalJson } from "../storage/event-ledger.js";
|
|
11
11
|
import { normalizeRepoRef } from "./repo-ref.js";
|
|
12
|
+
import { ledgerCost } from "./work-cost.js";
|
|
12
13
|
|
|
13
14
|
const KERNEL = { kind: "kernel", id: "orchestrator" };
|
|
14
15
|
|
|
@@ -40,12 +41,14 @@ export function writeRunRecord({ repoRoot, ledger, ts }) {
|
|
|
40
41
|
const record = {
|
|
41
42
|
run: first.run,
|
|
42
43
|
work: first.work,
|
|
44
|
+
workflow: state.run?.workflowRef ?? null,
|
|
43
45
|
terminal: state.terminal,
|
|
44
46
|
events: { from: first.seq, to: last.seq, lastDigest: last.digest },
|
|
45
47
|
startedAt: first.ts,
|
|
46
48
|
finishedAt: last.ts,
|
|
47
49
|
attempts,
|
|
48
50
|
budgets: state.budgets,
|
|
51
|
+
cost: ledgerCost(ledger),
|
|
49
52
|
workspaces,
|
|
50
53
|
evidence,
|
|
51
54
|
decisions: state.decisions,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Work-level cost (iteration 09): what a work has already consumed across
|
|
2
|
+
// every run of it, superseded ones included. Per-run budgets were bypassed
|
|
3
|
+
// by "one run per review round" (a pilot work ran 21 runs and 9 review
|
|
4
|
+
// rounds while the preset's two-round cap never fired), and a work that ate
|
|
5
|
+
// ten runs and a day was cut by the owner as "cost > benefit" with no
|
|
6
|
+
// number in front of them. Derived only: runtime ledgers first, run-records
|
|
7
|
+
// for runs whose runtime was wiped. Nothing is written.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { EventLedger } from "../storage/event-ledger.js";
|
|
13
|
+
import { readFindingsAccount } from "./findings.js";
|
|
14
|
+
|
|
15
|
+
function emptyCost() {
|
|
16
|
+
return {
|
|
17
|
+
runs: 0,
|
|
18
|
+
reviewRounds: 0,
|
|
19
|
+
findings: 0,
|
|
20
|
+
humanWaits: 0,
|
|
21
|
+
infraFailures: 0,
|
|
22
|
+
workerMs: 0,
|
|
23
|
+
firstAt: null,
|
|
24
|
+
lastAt: null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Cost facts of one ledger: review rounds, human waits, infra failures and
|
|
29
|
+
// worker wall time (STEP_STARTED → STEP_FINISHED per attempt).
|
|
30
|
+
export function ledgerCost(ledger) {
|
|
31
|
+
const cost = { reviewRounds: 0, humanWaits: 0, infraFailures: 0, workerMs: 0 };
|
|
32
|
+
const open = new Map();
|
|
33
|
+
for (const event of ledger.events) {
|
|
34
|
+
if (event.type === "STEP_STARTED") {
|
|
35
|
+
open.set(`${event.data.step}#${event.data.attempt}`, Date.parse(event.ts));
|
|
36
|
+
} else if (event.type === "STEP_FINISHED") {
|
|
37
|
+
const key = `${event.data.step}#${event.data.attempt}`;
|
|
38
|
+
const startedAt = open.get(key);
|
|
39
|
+
if (startedAt !== undefined) {
|
|
40
|
+
const ms = Date.parse(event.ts) - startedAt;
|
|
41
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
42
|
+
cost.workerMs += ms;
|
|
43
|
+
}
|
|
44
|
+
open.delete(key);
|
|
45
|
+
}
|
|
46
|
+
if (event.data.step === "review") {
|
|
47
|
+
cost.reviewRounds += 1;
|
|
48
|
+
}
|
|
49
|
+
if (event.data.infra === true) {
|
|
50
|
+
cost.infraFailures += 1;
|
|
51
|
+
}
|
|
52
|
+
} else if (event.type === "HUMAN_REQUESTED") {
|
|
53
|
+
cost.humanWaits += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return cost;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function computeWorkCost(repoRoot, workId, { excludeRun = null } = {}) {
|
|
60
|
+
const cost = emptyCost();
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
63
|
+
if (existsSync(runsDir)) {
|
|
64
|
+
for (const entry of readdirSync(runsDir)) {
|
|
65
|
+
const path = join(runsDir, entry, "events.jsonl");
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const ledger = EventLedger.open(path);
|
|
70
|
+
const state = ledger.state;
|
|
71
|
+
if (ledger.corruption || !state.run || state.run.work !== workId) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(state.run.id);
|
|
75
|
+
if (state.run.id === excludeRun) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const row = ledgerCost(ledger);
|
|
79
|
+
cost.runs += 1;
|
|
80
|
+
cost.reviewRounds += row.reviewRounds;
|
|
81
|
+
cost.humanWaits += row.humanWaits;
|
|
82
|
+
cost.infraFailures += row.infraFailures;
|
|
83
|
+
cost.workerMs += row.workerMs;
|
|
84
|
+
track(cost, ledger.events[0]?.ts, ledger.events[ledger.events.length - 1]?.ts);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const recordsDir = join(repoRoot, "delivery", "work", workId, "runs");
|
|
88
|
+
if (existsSync(recordsDir)) {
|
|
89
|
+
for (const entry of readdirSync(recordsDir)) {
|
|
90
|
+
if (seen.has(entry) || entry === excludeRun) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const path = join(recordsDir, entry, "run-record.json");
|
|
94
|
+
if (!existsSync(path)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let record;
|
|
98
|
+
try {
|
|
99
|
+
record = JSON.parse(readFileSync(path, "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
cost.runs += 1;
|
|
104
|
+
// Records written before iteration 09 carry attempts and decisions
|
|
105
|
+
// only; the cost block is preferred when present.
|
|
106
|
+
cost.reviewRounds += record.cost?.reviewRounds ?? record.attempts?.review ?? 0;
|
|
107
|
+
cost.humanWaits += record.cost?.humanWaits ?? record.decisions?.length ?? 0;
|
|
108
|
+
cost.infraFailures += record.cost?.infraFailures ?? 0;
|
|
109
|
+
cost.workerMs += record.cost?.workerMs ?? 0;
|
|
110
|
+
track(cost, record.startedAt, record.finishedAt);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
cost.findings = readFindingsAccount(repoRoot, workId).filter((row) => row.kind === "finding").length;
|
|
114
|
+
return cost;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function track(cost, firstAt, lastAt) {
|
|
118
|
+
if (firstAt && (!cost.firstAt || firstAt < cost.firstAt)) {
|
|
119
|
+
cost.firstAt = firstAt;
|
|
120
|
+
}
|
|
121
|
+
if (lastAt && (!cost.lastAt || lastAt > cost.lastAt)) {
|
|
122
|
+
cost.lastAt = lastAt;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatWorkerMs(ms) {
|
|
127
|
+
if (!ms || ms < 1000) {
|
|
128
|
+
return "0s";
|
|
129
|
+
}
|
|
130
|
+
if (ms < 60000) {
|
|
131
|
+
return `${Math.round(ms / 1000)}s`;
|
|
132
|
+
}
|
|
133
|
+
const totalMinutes = Math.round(ms / 60000);
|
|
134
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
135
|
+
const minutes = totalMinutes % 60;
|
|
136
|
+
return hours > 0 ? `${hours}h${String(minutes).padStart(2, "0")}m` : `${minutes}m`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function renderWorkCost(cost) {
|
|
140
|
+
return [
|
|
141
|
+
`review rounds ${cost.reviewRounds}`,
|
|
142
|
+
`findings ${cost.findings}`,
|
|
143
|
+
`human waits ${cost.humanWaits}`,
|
|
144
|
+
...(cost.infraFailures > 0 ? [`infra failures ${cost.infraFailures}`] : []),
|
|
145
|
+
`worker ${formatWorkerMs(cost.workerMs)}`,
|
|
146
|
+
].join(" · ");
|
|
147
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// deleted here — the pinned candidate must stay reachable for evidence.
|
|
5
5
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
|
|
10
10
|
export class WorkspaceError extends Error {
|
|
@@ -44,6 +44,19 @@ export function acquireLock(repoRoot, runId) {
|
|
|
44
44
|
return lockPath;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Run ids currently holding a lock in this repository (the repository-wide
|
|
48
|
+
// active-run marker excluded): who a blocked `start` is queued behind.
|
|
49
|
+
export function listHeldRunLocks(repoRoot) {
|
|
50
|
+
const lockDir = join(repoRoot, ".buildbeat", "runtime", "locks");
|
|
51
|
+
if (!existsSync(lockDir)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return readdirSync(lockDir)
|
|
55
|
+
.filter((entry) => entry.endsWith(".lock") && entry !== "active-run.lock")
|
|
56
|
+
.map((entry) => entry.slice(0, -".lock".length))
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
export function releaseLock(repoRoot, runId) {
|
|
48
61
|
const lockPath = join(repoRoot, ".buildbeat", "runtime", "locks", `${runId}.lock`);
|
|
49
62
|
rmSync(lockPath, { recursive: true, force: true });
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
<!-- buildbeat-multirepo-map:v1
|
|
14
14
|
repo=<代码子仓1>|contract=contracts/PROTOCOL.md|deployment=<bus-baseline.json app 名或 n/a>
|
|
15
15
|
-->
|
|
16
|
+
<!-- map 行格式:repo=<子仓路径>|contract=<contracts/*.md 或 n/a>|deployment=<bus-baseline.json app 名或 n/a>[|changelog=<该仓内模块 CHANGELOG 路径>]
|
|
17
|
+
· changelog= 给多模块仓用(根下没有 CHANGELOG,由某个模块 CHANGELOG 承载契约版本);缺省 <repo>/CHANGELOG.md。
|
|
18
|
+
· contract=n/a 表示该仓没有契约版本域(如只读存量前端、npm 包 semver 与契约版本不同域),只登记不核对;不得拿它掩盖真实的契约关系。
|
|
19
|
+
· 被核对的 CHANGELOG 首个已发布 H2 须以契约快照版本开头,如 `## [v1.3 · Deployed 2026-09-05 · <sha> · <流水线>]`。 -->
|
|
16
20
|
|
|
17
21
|
---
|
|
18
22
|
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
# verify-status 的「上次全绿」标记(本地实查产物,不入 git)
|
|
15
15
|
.last-green-*
|
|
16
16
|
|
|
17
|
+
# BuildBeat v2 运行时面与隔离工作树(可随时整删重建;不入 git)
|
|
18
|
+
# 同时让 rg / 尊重 .gitignore 的工具不再走进旧工作树;vitest / jest 等要另配 exclude,见 docs/v2/guide/02-workflow-guide.md
|
|
19
|
+
.buildbeat/runtime/
|
|
20
|
+
.buildbeat/worktrees/
|
|
21
|
+
|
|
17
22
|
# 代码子仓(各自独立 git,meta 仓不跟踪;按实际仓名替换)
|
|
18
23
|
/<代码仓1>/
|
|
19
24
|
/<代码仓2>/
|
|
@@ -438,14 +438,18 @@ check_multirepo_drift() {
|
|
|
438
438
|
multirepo_line="${multirepo_line%$'\r'}"
|
|
439
439
|
[ -n "$multirepo_line" ] || continue
|
|
440
440
|
if ! printf '%s\n' "$multirepo_line" \
|
|
441
|
-
| grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]
|
|
441
|
+
| grep -Eq '^repo=[^|]+\|contract=[^|]+\|deployment=[^|]+(\|changelog=[^|]+)?$'; then
|
|
442
442
|
multirepo_map_invalid=1
|
|
443
443
|
continue
|
|
444
444
|
fi
|
|
445
|
-
IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field <<< "$multirepo_line"
|
|
445
|
+
IFS='|' read -r multirepo_repo_field multirepo_contract_field multirepo_deployment_field multirepo_changelog_field <<< "$multirepo_line"
|
|
446
446
|
multirepo_repo="${multirepo_repo_field#repo=}"
|
|
447
447
|
multirepo_contract="${multirepo_contract_field#contract=}"
|
|
448
448
|
multirepo_deployment="${multirepo_deployment_field#deployment=}"
|
|
449
|
+
# 可选第 4 字段 changelog=<repo 内的 CHANGELOG 路径>:多模块仓没有根 CHANGELOG 时,
|
|
450
|
+
# 由 map 显式指定承载契约版本的模块 CHANGELOG;缺省仍为 <repo>/CHANGELOG.md。
|
|
451
|
+
multirepo_changelog_override="${multirepo_changelog_field#changelog=}"
|
|
452
|
+
[ -n "$multirepo_changelog_field" ] || multirepo_changelog_override=""
|
|
449
453
|
multirepo_repo_trimmed="$(printf '%s' "$multirepo_repo" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
450
454
|
multirepo_contract_trimmed="$(printf '%s' "$multirepo_contract" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
451
455
|
multirepo_deployment_trimmed="$(printf '%s' "$multirepo_deployment" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
|
@@ -454,12 +458,23 @@ check_multirepo_drift() {
|
|
|
454
458
|
|| [ "$multirepo_deployment" != "$multirepo_deployment_trimmed" ] \
|
|
455
459
|
|| ! multirepo_repo_path_safe "$multirepo_repo" \
|
|
456
460
|
|| ! multirepo_map_value_safe "$multirepo_contract" 240 \
|
|
457
|
-
|| !
|
|
461
|
+
|| ! { [ "$multirepo_contract" = "n/a" ] \
|
|
462
|
+
|| printf '%s\n' "$multirepo_contract" | grep -Eq '^contracts/[^/].*\.md$'; } \
|
|
458
463
|
|| ! multirepo_map_value_safe "$multirepo_deployment" 100; then
|
|
459
464
|
multirepo_map_invalid=1
|
|
460
465
|
continue
|
|
461
466
|
fi
|
|
462
|
-
|
|
467
|
+
# contract=n/a 表示该仓没有契约版本域(如只读存量前端、npm 包 semver 与契约版本不同域),
|
|
468
|
+
# 只登记入 inventory、不做版本核对;不得用它掩盖真实存在的契约版本关系。
|
|
469
|
+
if [ -n "$multirepo_changelog_override" ]; then
|
|
470
|
+
if ! multirepo_repo_path_safe "$multirepo_changelog_override" \
|
|
471
|
+
|| [ "${#multirepo_changelog_override}" -gt 240 ] \
|
|
472
|
+
|| ! printf '%s\n' "$multirepo_changelog_override" | grep -Eq "^$(printf '%s' "$multirepo_repo" | sed 's/[.[\*^$]/\\&/g')/.+/CHANGELOG\.md$"; then
|
|
473
|
+
multirepo_map_invalid=1
|
|
474
|
+
continue
|
|
475
|
+
fi
|
|
476
|
+
fi
|
|
477
|
+
printf '%s\t%s\t%s\t%s\n' "$multirepo_repo" "$multirepo_contract" "$multirepo_deployment" "$multirepo_changelog_override" >> "$multirepo_records"
|
|
463
478
|
printf '%s\n' "$multirepo_repo" >> "$multirepo_expected"
|
|
464
479
|
done < "$multirepo_map_raw"
|
|
465
480
|
|
|
@@ -485,9 +500,9 @@ check_multirepo_drift() {
|
|
|
485
500
|
add_finding "sync.unverified" "unverified" "Discovered repo=$multirepo_repo is absent from buildbeat-multirepo-map:v1." "$multirepo_repo"
|
|
486
501
|
done < "$multirepo_discovered"
|
|
487
502
|
|
|
488
|
-
while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment; do
|
|
503
|
+
while IFS=$'\t' read -r multirepo_repo multirepo_contract multirepo_deployment multirepo_changelog_override; do
|
|
489
504
|
[ -n "$multirepo_repo" ] || continue
|
|
490
|
-
multirepo_changelog="$multirepo_repo/CHANGELOG.md"
|
|
505
|
+
multirepo_changelog="${multirepo_changelog_override:-$multirepo_repo/CHANGELOG.md}"
|
|
491
506
|
multirepo_issue=0
|
|
492
507
|
multirepo_drift=0
|
|
493
508
|
multirepo_changelog_ok=0
|
|
@@ -544,6 +559,10 @@ check_multirepo_drift() {
|
|
|
544
559
|
;;
|
|
545
560
|
esac
|
|
546
561
|
|
|
562
|
+
if [ "$multirepo_contract" = "n/a" ] && [ "$multirepo_deployment" = "n/a" ]; then
|
|
563
|
+
echo " · $multirepo_repo 已登记,无契约/部署版本域(contract=n/a, deployment=n/a),不做版本核对"
|
|
564
|
+
continue
|
|
565
|
+
fi
|
|
547
566
|
if [ -L "$multirepo_changelog" ] \
|
|
548
567
|
|| { [ -e "$multirepo_changelog" ] \
|
|
549
568
|
&& path_uses_symlink_component "$ROOT_PHYS/$multirepo_changelog"; }; then
|
|
@@ -557,8 +576,8 @@ check_multirepo_drift() {
|
|
|
557
576
|
"the mapped CHANGELOG version source was not readable"
|
|
558
577
|
multirepo_issue=1
|
|
559
578
|
elif [ ! -f "$multirepo_changelog" ]; then
|
|
560
|
-
echo " ⚠️ $multirepo_repo 缺少可读 regular
|
|
561
|
-
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG
|
|
579
|
+
echo " ⚠️ $multirepo_repo 缺少可读 regular $multirepo_changelog"
|
|
580
|
+
add_finding "sync.unverified" "unverified" "Repo=$multirepo_repo has no readable regular CHANGELOG version source: $multirepo_changelog." "$multirepo_changelog"
|
|
562
581
|
multirepo_issue=1
|
|
563
582
|
else
|
|
564
583
|
multirepo_changelog_version="$(read_changelog_head_version "$multirepo_changelog" || true)"
|
|
@@ -572,8 +591,14 @@ check_multirepo_drift() {
|
|
|
572
591
|
fi
|
|
573
592
|
|
|
574
593
|
multirepo_contract_rc=0
|
|
575
|
-
|
|
576
|
-
|
|
594
|
+
if [ "$multirepo_contract" = "n/a" ]; then
|
|
595
|
+
multirepo_contract_rc=0
|
|
596
|
+
else
|
|
597
|
+
validate_reference "$multirepo_contract" "$multirepo_map_path" || multirepo_contract_rc=$?
|
|
598
|
+
fi
|
|
599
|
+
if [ "$multirepo_contract" = "n/a" ]; then
|
|
600
|
+
:
|
|
601
|
+
elif [ "$multirepo_contract_rc" -ne 0 ]; then
|
|
577
602
|
echo " ⚠️ $multirepo_repo 的契约版本来源不可读:$multirepo_contract"
|
|
578
603
|
if [ "$multirepo_contract_rc" -eq 4 ]; then
|
|
579
604
|
add_scan_boundary "symlink" "$multirepo_contract" \
|
|
@@ -672,9 +697,9 @@ check_multirepo_drift() {
|
|
|
672
697
|
add_finding "sync.multirepo_drift" "conflict" "Version sources disagree for repo=$multirepo_repo: $multirepo_changelog=$multirepo_changelog_fact; $multirepo_contract=$multirepo_contract_fact; $multirepo_deployment_fact." "$multirepo_changelog"
|
|
673
698
|
elif [ "$multirepo_issue" -eq 0 ]; then
|
|
674
699
|
if [ "$multirepo_deployment" = "n/a" ]; then
|
|
675
|
-
echo " ✅ $multirepo_repo 多仓版本一致:
|
|
700
|
+
echo " ✅ $multirepo_repo 多仓版本一致: $multirepo_changelog ↔ $multirepo_contract (deployment=n/a)"
|
|
676
701
|
else
|
|
677
|
-
echo " ✅ $multirepo_repo 多仓版本一致:
|
|
702
|
+
echo " ✅ $multirepo_repo 多仓版本一致: $multirepo_changelog ↔ $multirepo_contract ↔ $multirepo_baseline#apps.$multirepo_deployment.imageTag"
|
|
678
703
|
fi
|
|
679
704
|
fi
|
|
680
705
|
done < "$multirepo_records"
|