@haiyangbg/buildbeat 4.0.0 → 4.2.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 +16 -1
- package/SKILL.md +9 -6
- package/docs/CAPABILITY-MATRIX.md +3 -3
- package/docs/RELEASING.md +1 -1
- package/docs/v2/guide/00-how-to-talk.en.md +2 -0
- package/docs/v2/guide/00-how-to-talk.md +2 -0
- package/docs/v2/guide/01-quickstart.en.md +5 -3
- package/docs/v2/guide/01-quickstart.md +5 -3
- package/docs/v2/guide/07-approval-guide.en.md +12 -1
- package/docs/v2/guide/07-approval-guide.md +12 -1
- package/docs/v2/guide/10-recovery.en.md +4 -0
- package/docs/v2/guide/10-recovery.md +4 -0
- package/docs/v2/guide/11-session-handoff.en.md +6 -0
- package/docs/v2/guide/11-session-handoff.md +6 -0
- package/example/delivery/work/WORK-EXPORT-DATE-FILTER/run-config.yaml +1 -1
- package/package.json +1 -1
- package/src/v2/cli/run.js +91 -34
- package/src/v2/domain/model.js +1 -1
- package/src/v2/engine/reducer.js +2 -1
- package/src/v2/runtime/decisions.js +75 -6
- package/src/v2/runtime/notify.js +28 -5
- package/src/v2/runtime/orchestrator.js +22 -0
- package/src/v2/runtime/overview-repos.js +79 -0
- package/src/v2/runtime/overview.js +141 -38
- package/templates/v2/AGENTS.md +2 -1
- package/templates/v2/run-config.example.yaml +2 -2
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
readdirSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { adjudicateFinding, fingerprintFinding, recordReviewFindings } from "./findings.js";
|
|
17
19
|
|
|
18
20
|
import {
|
|
19
21
|
deliveryChecks,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
24
26
|
import {
|
|
25
27
|
acquireLock,
|
|
28
|
+
listChangedPaths,
|
|
26
29
|
readback,
|
|
27
30
|
releaseLock,
|
|
28
31
|
} from "../workspace/workspace-manager.js";
|
|
@@ -105,7 +108,7 @@ function recordDecisionFile(repoRoot, work, line) {
|
|
|
105
108
|
export function approveRun(
|
|
106
109
|
repoRoot,
|
|
107
110
|
runId,
|
|
108
|
-
{ by = "human", transition, ts, policies } = {},
|
|
111
|
+
{ by = "human", transition, ts, policies, candidate } = {},
|
|
109
112
|
) {
|
|
110
113
|
// Read, check and write under the run lock: a ledger read before the
|
|
111
114
|
// lock may be stale by the time it is written to.
|
|
@@ -113,6 +116,12 @@ export function approveRun(
|
|
|
113
116
|
try {
|
|
114
117
|
const ledger = openWaiting(repoRoot, runId);
|
|
115
118
|
const pending = ledger.state.pendingHuman;
|
|
119
|
+
const repaired = ledger.events.some((event) => event.type === "DECISION_RECORDED" &&
|
|
120
|
+
event.data.transition === "enter-wait-merge" && (event.data.adopted || event.data.decision === "fix"));
|
|
121
|
+
if ((candidate && candidate !== pending.subject.candidate) ||
|
|
122
|
+
(pending.kind === "final-decision" && repaired && !candidate)) {
|
|
123
|
+
throw new DecisionError("approval stale: read the current decision card and bind approval to its candidate");
|
|
124
|
+
}
|
|
116
125
|
if (!transition) {
|
|
117
126
|
throw new DecisionError(
|
|
118
127
|
`an approval must name its transition explicitly (pending: ${pending.transition})`,
|
|
@@ -291,7 +300,7 @@ export function approveRun(
|
|
|
291
300
|
export function adoptCandidate(
|
|
292
301
|
repoRoot,
|
|
293
302
|
runId,
|
|
294
|
-
{ sha, by = "human", resumeAt, ts } = {},
|
|
303
|
+
{ sha, by = "human", resumeAt, ts, allowedPaths } = {},
|
|
295
304
|
) {
|
|
296
305
|
acquireLock(repoRoot, runId);
|
|
297
306
|
try {
|
|
@@ -307,10 +316,12 @@ export function adoptCandidate(
|
|
|
307
316
|
}
|
|
308
317
|
const ledger = openWaiting(repoRoot, runId);
|
|
309
318
|
const pending = ledger.state.pendingHuman;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
319
|
+
const final = pending.kind === "final-decision" && pending.transition === "enter-wait-merge";
|
|
320
|
+
if (pending.kind === "final-decision" && (!final || !ledger.state.run.deliveryChecks)) {
|
|
321
|
+
throw new DecisionError("legacy active run: use its original 3.3.1 runtime (docs/MIGRATION.md)");
|
|
322
|
+
}
|
|
323
|
+
if (final && resumeAt !== "verify") {
|
|
324
|
+
throw new DecisionError("a final-decision repair must resume at verify");
|
|
314
325
|
}
|
|
315
326
|
const bound = ledger.state.workspaces[runId];
|
|
316
327
|
const worktreePath = bound
|
|
@@ -332,6 +343,19 @@ export function adoptCandidate(
|
|
|
332
343
|
`worktree HEAD is ${tree.head}, not ${sha}; adopt what git reads back`,
|
|
333
344
|
);
|
|
334
345
|
}
|
|
346
|
+
if (final) {
|
|
347
|
+
const candidate = bound.candidate ?? bound.base;
|
|
348
|
+
const ancestor = spawnSync("git", ["-C", worktreePath, "merge-base", "--is-ancestor", candidate, tree.head]);
|
|
349
|
+
if (tree.head === candidate || ancestor.status !== 0) {
|
|
350
|
+
throw new DecisionError("adopt requires a new descendant of the current candidate; history rewrites are refused");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const scope = ledger.state.run.repair?.allowedPaths ?? allowedPaths;
|
|
354
|
+
if (scope) {
|
|
355
|
+
const violations = listChangedPaths(worktreePath, bound.base).filter((path) =>
|
|
356
|
+
!scope.some((prefix) => path === prefix || path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`)));
|
|
357
|
+
if (violations.length) throw new DecisionError(`out-of-scope changes: ${violations.join(", ")}`);
|
|
358
|
+
}
|
|
335
359
|
const when = ts ?? new Date().toISOString();
|
|
336
360
|
const lastEvidence =
|
|
337
361
|
ledger.state.evidence[ledger.state.evidence.length - 1];
|
|
@@ -390,6 +414,51 @@ export function adoptCandidate(
|
|
|
390
414
|
}
|
|
391
415
|
}
|
|
392
416
|
|
|
417
|
+
// Queue a human-requested repair without fabricating a review or a budget
|
|
418
|
+
// grant. The ordinary resume loop consumes this decision at the fix step.
|
|
419
|
+
export function requestFix(repoRoot, runId, { by = "human", reason, ts, hasFixer } = {}) {
|
|
420
|
+
acquireLock(repoRoot, runId);
|
|
421
|
+
try {
|
|
422
|
+
const ledger = openWaiting(repoRoot, runId);
|
|
423
|
+
const pending = ledger.state.pendingHuman;
|
|
424
|
+
if (!ledger.state.run.deliveryChecks)
|
|
425
|
+
throw new DecisionError("legacy active run: use its original 3.3.1 runtime (docs/MIGRATION.md)");
|
|
426
|
+
if (pending.kind !== "final-decision" || pending.transition !== "enter-wait-merge")
|
|
427
|
+
throw new DecisionError("fix is only available at the merge decision");
|
|
428
|
+
if (!(ledger.state.run.repair?.hasFixer ?? hasFixer))
|
|
429
|
+
throw new DecisionError("no fixer configured; commit a manual repair and use run --adopt <sha> --by <name>");
|
|
430
|
+
if (typeof reason !== "string" || !reason.trim())
|
|
431
|
+
throw new DecisionError("fix requires a non-empty reason describing what to repair");
|
|
432
|
+
const bound = ledger.state.workspaces[runId];
|
|
433
|
+
const worktreePath = bound && resolveRepoRef(repoRoot, bound.worktreePath);
|
|
434
|
+
if (!worktreePath || !existsSync(worktreePath))
|
|
435
|
+
throw new DecisionError(`worktree missing for ${runId}`);
|
|
436
|
+
const tree = readback(worktreePath);
|
|
437
|
+
if (tree.dirty || tree.head !== (bound.candidate ?? bound.base) || tree.head !== pending.subject.candidate)
|
|
438
|
+
throw new DecisionError("worktree changed since the request; commit the manual repair and use run --adopt");
|
|
439
|
+
const when = ts ?? new Date().toISOString();
|
|
440
|
+
const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
|
|
441
|
+
const finding = { severity: "P1", summary: reason.trim() };
|
|
442
|
+
recordReviewFindings(repoRoot, ledger.state.run.work, {
|
|
443
|
+
run: runId, step: "enter-wait-merge", attempt: ledger.state.decisions.length + 1,
|
|
444
|
+
findings: [finding], ts: when,
|
|
445
|
+
});
|
|
446
|
+
adjudicateFinding(repoRoot, ledger.state.run.work, {
|
|
447
|
+
fingerprint: fingerprintFinding(finding), action: "accept", by,
|
|
448
|
+
note: decisionRef, ts: when,
|
|
449
|
+
});
|
|
450
|
+
const data = {
|
|
451
|
+
decision: "fix", transition: pending.transition, subject: pending.subject,
|
|
452
|
+
decisionRef, reason: finding.summary, resumeAt: "fix",
|
|
453
|
+
};
|
|
454
|
+
ledger.append({ type: "DECISION_RECORDED", actor: { kind: "human", id: by }, ts: when, data });
|
|
455
|
+
recordDecisionFile(repoRoot, ledger.state.run.work, { ts: when, run: runId, ...data, by });
|
|
456
|
+
return { decisionRef, state: ledger.state };
|
|
457
|
+
} finally {
|
|
458
|
+
releaseLock(repoRoot, runId);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
393
462
|
// Accepts a work artifact (plan, intent, spec) by binding a decision to the
|
|
394
463
|
// file's current digest in the Git plane. If the file changes afterwards,
|
|
395
464
|
// artifact.accepted evaluates false again — acceptance cannot go stale
|
package/src/v2/runtime/notify.js
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
|
|
11
11
|
import { payloadFor } from "../adapters/notification-payload.js";
|
|
12
12
|
import { currentScreenshots } from "../policy/policy.js";
|
|
13
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
|
|
16
|
+
import { configTargets, workConfigs } from "./overview-repos.js";
|
|
16
17
|
import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
17
18
|
|
|
18
19
|
export const NOTIFY_CONFIG = join(".buildbeat", "notify.yaml");
|
|
@@ -89,7 +90,22 @@ export function candidateScreenshots(state, candidate) {
|
|
|
89
90
|
|
|
90
91
|
// The exact commands a human can copy to answer a pending request. Shared by
|
|
91
92
|
// status, inbox and notifications so "what do I say now" has one answer.
|
|
92
|
-
|
|
93
|
+
function configuredFixer(repoRoot, state) {
|
|
94
|
+
if (state.run.repair) return state.run.repair.hasFixer;
|
|
95
|
+
if (!repoRoot) return false;
|
|
96
|
+
// Existing 4.x runs have no repair snapshot. Hints are conservative when
|
|
97
|
+
// their configuration is missing, ambiguous or unreadable.
|
|
98
|
+
try {
|
|
99
|
+
const configs = configTargets(workConfigs(repoRoot, state.run.work), [])
|
|
100
|
+
.filter((item) => item.repo === realpathSync(repoRoot))
|
|
101
|
+
.map((item) => parseYamlSubset(readFileSync(item.path, "utf8")))
|
|
102
|
+
.filter((doc) => doc.run === state.run.id || (typeof doc.run === "string" &&
|
|
103
|
+
state.run.id.startsWith(`${doc.run}-`) && /^\d+$/.test(state.run.id.slice(doc.run.length + 1))));
|
|
104
|
+
return configs.length === 1 && Boolean(configs[0].workers?.fixer?.command);
|
|
105
|
+
} catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function nextReply({ repoLabel, state, repoRoot }) {
|
|
93
109
|
const pending = state.pendingHuman;
|
|
94
110
|
if (!pending || !state.run) {
|
|
95
111
|
return [];
|
|
@@ -120,14 +136,21 @@ export function nextReply({ repoLabel, state }) {
|
|
|
120
136
|
);
|
|
121
137
|
}
|
|
122
138
|
lines.push(
|
|
123
|
-
`buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition}
|
|
139
|
+
`buildbeat decide --action approve --repo ${repoLabel} --run ${runId} --transition ${pending.transition}` +
|
|
140
|
+
(pending.kind === "final-decision" ? ` --candidate ${pending.subject.candidate}` : "") + " --by <you>" +
|
|
124
141
|
(pending.kind === "final-decision" ? " # merge-ready; merge/push stay yours" : " # then: run --config <run-config.yaml>"),
|
|
125
142
|
);
|
|
126
143
|
lines.push(`buildbeat decide --action reject --repo ${repoLabel} --run ${runId} --reason <why> --by <you>`);
|
|
144
|
+
if (pending.kind === "final-decision") {
|
|
145
|
+
lines.push(`buildbeat run --config <run-config.yaml> --run ${runId} --adopt <sha> --by <you> # commit a manual repair in the run worktree; verify and review again`);
|
|
146
|
+
if (configuredFixer(repoRoot, state)) {
|
|
147
|
+
lines.push(`buildbeat decide --repo ${repoLabel} --run ${runId} --action fix --reason <what to repair> --by <you> # then: run --config <run-config.yaml> --run ${runId}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
127
150
|
return lines;
|
|
128
151
|
}
|
|
129
152
|
|
|
130
|
-
export function buildNotification(kind, { repoLabel, state, detail = {} }) {
|
|
153
|
+
export function buildNotification(kind, { repoLabel, state, repoRoot, detail = {} }) {
|
|
131
154
|
const run = state.run;
|
|
132
155
|
const base = {
|
|
133
156
|
kind,
|
|
@@ -149,7 +172,7 @@ export function buildNotification(kind, { repoLabel, state, detail = {} }) {
|
|
|
149
172
|
reasons: pending?.reasons ?? [],
|
|
150
173
|
candidate: pending?.subject?.candidate ?? null,
|
|
151
174
|
...(screenshots.length > 0 ? { screenshots } : {}),
|
|
152
|
-
nextReply: nextReply({ repoLabel, state }),
|
|
175
|
+
nextReply: nextReply({ repoLabel, state, repoRoot }),
|
|
153
176
|
};
|
|
154
177
|
}
|
|
155
178
|
if (kind === "RUN_TERMINAL") {
|
|
@@ -744,6 +744,21 @@ function beginStep(context, step, stepDef, attempt) {
|
|
|
744
744
|
}));
|
|
745
745
|
}
|
|
746
746
|
}
|
|
747
|
+
// A human's final-decision repair is separate from reviewer evidence: it
|
|
748
|
+
// must not spend a review round or pretend that a reviewer reported it.
|
|
749
|
+
if (step === "fix") {
|
|
750
|
+
const decision = [...ledger.events].reverse().find((event) =>
|
|
751
|
+
event.type === "DECISION_RECORDED" && event.data.decision === "fix");
|
|
752
|
+
const priorFix = [...ledger.events].reverse().find((event) =>
|
|
753
|
+
event.type === "STEP_FINISHED" && event.data.step === "fix" && event.data.status === "succeeded");
|
|
754
|
+
if (decision && (!priorFix || decision.seq > priorFix.seq)) {
|
|
755
|
+
const finding = { severity: "P1", summary: decision.data.reason };
|
|
756
|
+
input.findings = [...(input.findings ?? []).filter((item) =>
|
|
757
|
+
item.fingerprint !== fingerprintFinding(finding)), {
|
|
758
|
+
...finding, fingerprint: fingerprintFinding(finding), adjudication: "accept",
|
|
759
|
+
}];
|
|
760
|
+
}
|
|
761
|
+
}
|
|
747
762
|
// Envelope (C6): the worker's prompt, materialised into the run
|
|
748
763
|
// directory and handed over as BUILDBEAT_PROMPT / input.envelope.
|
|
749
764
|
const prompt = materialisePrompt({
|
|
@@ -1506,6 +1521,9 @@ export function startRun(options) {
|
|
|
1506
1521
|
run: runId,
|
|
1507
1522
|
work: workId,
|
|
1508
1523
|
data: {
|
|
1524
|
+
...(options.deliveryChecks ? {
|
|
1525
|
+
repair: { hasFixer: Boolean(options.adapters?.fixer), allowedPaths: options.allowedPaths ?? null },
|
|
1526
|
+
} : {}),
|
|
1509
1527
|
workflowRef: workflow.name,
|
|
1510
1528
|
workflowDigest,
|
|
1511
1529
|
base: workspace.base,
|
|
@@ -1574,6 +1592,10 @@ export function assertRunConfiguration(state, options) {
|
|
|
1574
1592
|
if (!state.run) throw new OrchestratorError("run has no creation record");
|
|
1575
1593
|
if (options.workId && options.workId !== state.run.work)
|
|
1576
1594
|
throw new OrchestratorError("run belongs to a different work");
|
|
1595
|
+
if (state.run.repair && (
|
|
1596
|
+
canonicalJson(state.run.repair.allowedPaths) !== canonicalJson(options.allowedPaths ?? null) ||
|
|
1597
|
+
state.run.repair.hasFixer !== Boolean(options.adapters?.fixer)
|
|
1598
|
+
)) throw new OrchestratorError("repair configuration changed since the run was created; refusing to resume");
|
|
1577
1599
|
const { workflowDigest } = options;
|
|
1578
1600
|
if (
|
|
1579
1601
|
state.run.deliveryChecks &&
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Read-only repository/config discovery shared by single- and multi-repo status.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { parseYamlSubset } from "../engine/yaml-subset.js";
|
|
6
|
+
|
|
7
|
+
export const pathLabel = (cwd, path) => relative(cwd, path) || ".";
|
|
8
|
+
export const shellArg = (value) => /^[a-zA-Z0-9_./:-]+$/.test(value)
|
|
9
|
+
? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
10
|
+
export const directory = (path) => {
|
|
11
|
+
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Require an actual checkout root, not a directory inside a parent checkout.
|
|
15
|
+
export function repositoryRoot(path, cache = new Map()) {
|
|
16
|
+
const real = realpathSync(path);
|
|
17
|
+
if (cache.has(real)) {
|
|
18
|
+
const cached = cache.get(real);
|
|
19
|
+
if (cached instanceof Error) throw cached;
|
|
20
|
+
return cached;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const top = execFileSync("git", ["-C", real, "rev-parse", "--show-toplevel"], {
|
|
24
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
25
|
+
}).trim();
|
|
26
|
+
if (realpathSync(top) !== real) throw new Error("not a git repository root");
|
|
27
|
+
cache.set(real, real);
|
|
28
|
+
return real;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
cache.set(real, error);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function workConfigs(repoRoot, workId, warnings = []) {
|
|
36
|
+
const dir = join(repoRoot, "delivery", "work", workId);
|
|
37
|
+
if (!directory(dir)) return [];
|
|
38
|
+
return readdirSync(dir).sort().filter((name) => /^run-config.*\.ya?ml$/.test(name)).map((name) => {
|
|
39
|
+
const path = join(dir, name);
|
|
40
|
+
try {
|
|
41
|
+
const doc = parseYamlSubset(readFileSync(path, "utf8"));
|
|
42
|
+
if (typeof doc.repo !== "string" || !doc.repo.trim()) throw new Error("repo must be a non-empty path");
|
|
43
|
+
const target = resolve(dirname(path), doc.repo);
|
|
44
|
+
return { path, target, release: Object.hasOwn(doc, "release") };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
warnings.push(`${path}: ${error.message.replaceAll("\n", " ")}`);
|
|
47
|
+
return { path, error: true, release: null };
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function configTargets(configs, warnings, roots = new Map()) {
|
|
53
|
+
return configs.map((config) => {
|
|
54
|
+
if (!config.target) return config;
|
|
55
|
+
try { return { ...config, repo: repositoryRoot(config.target, roots) }; }
|
|
56
|
+
catch (error) {
|
|
57
|
+
warnings.push(`${config.path}: ${config.target}: ${error.message.replaceAll("\n", " ")}`);
|
|
58
|
+
return { ...config, error: true };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function discoverRepos(repoRoot, warnings, roots = new Map()) {
|
|
64
|
+
const repos = new Set([repoRoot]);
|
|
65
|
+
const workRoot = join(repoRoot, "delivery", "work");
|
|
66
|
+
if (directory(workRoot)) {
|
|
67
|
+
for (const id of readdirSync(workRoot).sort()) {
|
|
68
|
+
for (const config of configTargets(workConfigs(repoRoot, id, warnings), warnings, roots)) {
|
|
69
|
+
if (config.repo) repos.add(config.repo);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const name of readdirSync(repoRoot).sort()) {
|
|
74
|
+
const child = join(repoRoot, name);
|
|
75
|
+
if (!directory(join(child, "delivery", "work"))) continue;
|
|
76
|
+
try { repos.add(repositoryRoot(child, roots)); } catch { /* only discover git roots */ }
|
|
77
|
+
}
|
|
78
|
+
return [...repos];
|
|
79
|
+
}
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import { listInbox } from "./decisions.js";
|
|
16
|
+
import { configTargets, directory, discoverRepos, pathLabel, shellArg, workConfigs } from "./overview-repos.js";
|
|
15
17
|
|
|
16
18
|
import { EventLedger } from "../storage/event-ledger.js";
|
|
17
19
|
import { latestAdjudications, readFindingsAccount } from "./findings.js";
|
|
18
20
|
import { nextReply } from "./notify.js";
|
|
19
21
|
import { readReleases } from "./release.js";
|
|
22
|
+
import { describeLiveness } from "./liveness.js";
|
|
20
23
|
import { computeWorkCost, renderWorkCost } from "./work-cost.js";
|
|
21
24
|
|
|
22
25
|
function sha256File(path) {
|
|
@@ -64,27 +67,35 @@ export function artifactStatus(workDir, decisions, artifact) {
|
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
function isAncestor(repoRoot, sha, ref) {
|
|
70
|
+
function isAncestor(repoRoot, sha, ref, context) {
|
|
71
|
+
const key = JSON.stringify([repoRoot, sha, ref]);
|
|
72
|
+
if (context.ancestry.has(key)) return context.ancestry.get(key);
|
|
68
73
|
try {
|
|
69
74
|
execFileSync(
|
|
70
75
|
"git",
|
|
71
76
|
["-C", repoRoot, "merge-base", "--is-ancestor", sha, ref],
|
|
72
77
|
{ stdio: "ignore" },
|
|
73
78
|
);
|
|
79
|
+
context.ancestry.set(key, true);
|
|
74
80
|
return true;
|
|
75
81
|
} catch {
|
|
82
|
+
context.ancestry.set(key, false);
|
|
76
83
|
return false;
|
|
77
84
|
}
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
function headRef(repoRoot) {
|
|
87
|
+
function headRef(repoRoot, context) {
|
|
88
|
+
if (context.heads.has(repoRoot)) return context.heads.get(repoRoot);
|
|
81
89
|
try {
|
|
82
|
-
|
|
90
|
+
const ref = execFileSync(
|
|
83
91
|
"git",
|
|
84
92
|
["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
85
93
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
86
94
|
).trim();
|
|
95
|
+
context.heads.set(repoRoot, ref);
|
|
96
|
+
return ref;
|
|
87
97
|
} catch {
|
|
98
|
+
context.heads.set(repoRoot, "HEAD");
|
|
88
99
|
return "HEAD";
|
|
89
100
|
}
|
|
90
101
|
}
|
|
@@ -154,30 +165,64 @@ export function runsFor(repoRoot, workId) {
|
|
|
154
165
|
);
|
|
155
166
|
}
|
|
156
167
|
|
|
168
|
+
// Per status invocation: known local roots need no validation subprocess,
|
|
169
|
+
// and forwarded works share repository facts with the later target scan.
|
|
170
|
+
function overviewContext(repoRoot) {
|
|
171
|
+
return { roots: new Map([[realpathSync(repoRoot), realpathSync(repoRoot)]]), heads: new Map(), ancestry: new Map() };
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
export function computeOverview(
|
|
158
175
|
repoRoot,
|
|
159
|
-
{ work = null, repoLabel = "." } = {},
|
|
176
|
+
{ work = null, repoLabel: defaultRepoLabel = ".", cwd = null, followTargets = true, warnings = [], configsOverride = null, context = overviewContext(repoRoot), skipWorks = new Set(), localCompatibility = false } = {},
|
|
160
177
|
) {
|
|
161
178
|
const workRoot = join(repoRoot, "delivery", "work");
|
|
162
179
|
const rows = [];
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
const workIds = new Set(directory(workRoot) ? readdirSync(workRoot).filter((id) => directory(join(workRoot, id))) : []);
|
|
181
|
+
const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
|
|
182
|
+
if (directory(runsDir)) {
|
|
183
|
+
for (const id of readdirSync(runsDir)) {
|
|
184
|
+
const file = join(runsDir, id, "events.jsonl");
|
|
185
|
+
if (!existsSync(file)) continue;
|
|
186
|
+
const run = EventLedger.open(file).state.run;
|
|
187
|
+
if (run?.work) workIds.add(run.work);
|
|
188
|
+
}
|
|
165
189
|
}
|
|
166
|
-
const mainRef = headRef(repoRoot);
|
|
167
|
-
for
|
|
168
|
-
|
|
190
|
+
const mainRef = headRef(repoRoot, context);
|
|
191
|
+
// Resolve once; do not spawn another Git process for each local work.
|
|
192
|
+
const currentRoot = realpathSync(repoRoot);
|
|
193
|
+
for (const workId of [...workIds].sort()) {
|
|
194
|
+
if ((work && workId !== work) || skipWorks.has(workId)) {
|
|
169
195
|
continue;
|
|
170
196
|
}
|
|
171
197
|
const workDir = join(workRoot, workId);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
|
|
198
|
+
const configs = configsOverride ?? workConfigs(repoRoot, workId, warnings);
|
|
199
|
+
const resolved = followTargets ? configTargets(configs, warnings, context.roots) : configs;
|
|
200
|
+
const owner = followTargets ? resolved.find((item) => item.repo && item.repo !== currentRoot &&
|
|
201
|
+
(directory(join(item.repo, "delivery", "work", workId)) || runsFor(item.repo, workId).length)) : null;
|
|
202
|
+
const usable = resolved.filter((item) => !item.error);
|
|
203
|
+
const selected = owner ?? usable[0] ?? resolved[0] ?? null;
|
|
204
|
+
const applicable = selected?.repo ? resolved.filter((item) => item.repo === selected.repo) : usable;
|
|
205
|
+
const local = localCompatibility && (!selected?.target || selected.repo === currentRoot);
|
|
206
|
+
const config = (local ? null : applicable.find((item) => item.release)) ?? selected;
|
|
207
|
+
if (owner) {
|
|
208
|
+
const targetRows = computeOverview(config.repo, {
|
|
209
|
+
work: workId, repoLabel: shellArg(pathLabel(cwd ?? process.cwd(), config.repo)),
|
|
210
|
+
cwd: cwd ?? process.cwd(), followTargets: false, warnings, configsOverride: applicable, context,
|
|
211
|
+
});
|
|
212
|
+
if (targetRows.length) {
|
|
213
|
+
rows.push({ ...targetRows[0], repo: config.repo, repoLabel: pathLabel(cwd ?? process.cwd(), config.repo) });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
180
216
|
}
|
|
217
|
+
// Cross-repo work still belongs here until the target has records.
|
|
218
|
+
// Its commands need a copyable owner path, not the legacy local label.
|
|
219
|
+
const repoLabel = followTargets && config?.target && config.repo !== currentRoot
|
|
220
|
+
? shellArg(pathLabel(cwd ?? process.cwd(), repoRoot)) : defaultRepoLabel;
|
|
221
|
+
const pathFor = (item) =>
|
|
222
|
+
shellArg(cwd && !local ? pathLabel(cwd, item.path) : `delivery/work/${workId}/${item.path.split("/").at(-1)}`);
|
|
223
|
+
const configPath = config ? pathFor(config) : "<run-config.yaml>";
|
|
224
|
+
const runs = runsFor(repoRoot, workId);
|
|
225
|
+
if (!configs.length && !runs.length && !["work.md", "intent.md", "plan.md", "decisions.jsonl", "runs"].some((name) => existsSync(join(workDir, name)))) continue;
|
|
181
226
|
const decisions = readJsonl(join(workDir, "decisions.jsonl"));
|
|
182
227
|
const unified = existsSync(join(workDir, "work.md"));
|
|
183
228
|
const intent = artifactStatus(
|
|
@@ -195,7 +240,6 @@ export function computeOverview(
|
|
|
195
240
|
(row.severity === "P0" || row.severity === "P1") &&
|
|
196
241
|
!adjudicated.has(row.fingerprint),
|
|
197
242
|
).length;
|
|
198
|
-
const runs = runsFor(repoRoot, workId);
|
|
199
243
|
const live = runs.filter((run) => run.status !== "SUPERSEDED");
|
|
200
244
|
const latest = live[live.length - 1] ?? null;
|
|
201
245
|
// "Merged" is a fact about any candidate of the work, not only the
|
|
@@ -207,7 +251,7 @@ export function computeOverview(
|
|
|
207
251
|
.reverse()
|
|
208
252
|
.find(
|
|
209
253
|
(run) =>
|
|
210
|
-
run.candidate && isAncestor(repoRoot, run.candidate, mainRef),
|
|
254
|
+
run.candidate && isAncestor(repoRoot, run.candidate, mainRef, context),
|
|
211
255
|
) ?? null;
|
|
212
256
|
const merged = Boolean(mergedRun);
|
|
213
257
|
const RELEASE_STEPS = ["preflight", "apply-readback", "observe"];
|
|
@@ -238,7 +282,10 @@ export function computeOverview(
|
|
|
238
282
|
? closure.subject.result
|
|
239
283
|
: "see decisions.jsonl";
|
|
240
284
|
next = `${stage.toLowerCase()} @ ${closure.ts ?? "?"}: ${result.slice(0, 160)}`;
|
|
241
|
-
} else if (!intent.exists) {
|
|
285
|
+
} else if (!intent.exists && (!latest || directory(workDir))) {
|
|
286
|
+
// A work directory without a work description stays NO_INTENT even
|
|
287
|
+
// when it has runs, as in 4.0.0; only a work known solely from
|
|
288
|
+
// runtime ledgers (no directory here) shows its run state.
|
|
242
289
|
stage = "NO_INTENT";
|
|
243
290
|
next = `write delivery/work/${workId}/work.md (goal, scope, acceptance, implementation plan)`;
|
|
244
291
|
} else if (!latest) {
|
|
@@ -263,12 +310,9 @@ export function computeOverview(
|
|
|
263
310
|
next = `buildbeat accept --repo ${repoLabel} --work ${workId} --artifact ${artifacts} --by <you>${plan.stale ? ` # ${unified ? "work.md" : "plan"} changed since acceptance` : ""}`;
|
|
264
311
|
} else {
|
|
265
312
|
stage = "READY_TO_RUN";
|
|
266
|
-
const configs = readdirSync(workDir).filter((name) =>
|
|
267
|
-
/^run-config.*\.ya?ml$/.test(name),
|
|
268
|
-
);
|
|
269
313
|
next =
|
|
270
314
|
configs.length > 0
|
|
271
|
-
? `buildbeat run --config
|
|
315
|
+
? `buildbeat run --config ${configPath}`
|
|
272
316
|
: `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`;
|
|
273
317
|
}
|
|
274
318
|
} else if (latest.status === "RUNNING") {
|
|
@@ -280,9 +324,9 @@ export function computeOverview(
|
|
|
280
324
|
? "MERGE_DECISION"
|
|
281
325
|
: "WAITING_HUMAN";
|
|
282
326
|
const replies = latest.state
|
|
283
|
-
? nextReply({ repoLabel, state: latest.state })
|
|
327
|
+
? nextReply({ repoLabel, state: latest.state, repoRoot })
|
|
284
328
|
: [];
|
|
285
|
-
next = replies[0] ?? `buildbeat
|
|
329
|
+
next = replies[0] ?? `buildbeat status --repo ${repoLabel} --work ${workId}`;
|
|
286
330
|
} else if (latest.status === "SUCCEEDED" && isReleaseLane(latest)) {
|
|
287
331
|
// A release-readback lane that reached wait-close and was approved is
|
|
288
332
|
// a closed release window, not "nothing to merge".
|
|
@@ -291,19 +335,23 @@ export function computeOverview(
|
|
|
291
335
|
} else if (merged) {
|
|
292
336
|
stage = "MERGED";
|
|
293
337
|
// After the merge: the person releases, the project's readback proves
|
|
294
|
-
// it, and the window closes on a passing readback.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
338
|
+
// it, and the window closes on a passing readback. The readback lives
|
|
339
|
+
// in whichever run config has a release: section; when every config
|
|
340
|
+
// was read and none has one, `buildbeat release` could only fail, so
|
|
341
|
+
// the merge is the end of the work. Unreadable or absent configs keep
|
|
342
|
+
// the generic hint.
|
|
343
|
+
const releaseConfig = configs.find((item) => item.release) ?? null;
|
|
344
|
+
const releasePath = releaseConfig ? pathFor(releaseConfig) : configPath;
|
|
345
|
+
const noReleaseStep =
|
|
346
|
+
configs.length > 0 && !releaseConfig && !configs.some((item) => item.error);
|
|
301
347
|
const close = `buildbeat decide --repo ${repoLabel} --work ${workId} --action close --result <what was released> --by <you>`;
|
|
302
348
|
next = !readback
|
|
303
|
-
?
|
|
349
|
+
? noReleaseStep
|
|
350
|
+
? "merged; no release readback configured, nothing left to do (add a release: section to the run config to record a readback and close the window)"
|
|
351
|
+
: `candidate ${mergedRun.candidate.slice(0, 7)} (${mergedRun.id}) is on ${mainRef}; release it (a human action), then buildbeat release --config ${releasePath}; then ${close}`
|
|
304
352
|
: readback.status === "passed"
|
|
305
353
|
? `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 ${
|
|
354
|
+
: `latest readback failed @ ${readback.ts} (exit ${readback.exitCode}); fix the release, then buildbeat release --config ${releasePath} again`;
|
|
307
355
|
if (latest.status !== "SUCCEEDED") {
|
|
308
356
|
next += ` # latest run ${latest.id} ended ${latest.status} after the merge`;
|
|
309
357
|
}
|
|
@@ -315,11 +363,13 @@ export function computeOverview(
|
|
|
315
363
|
} else {
|
|
316
364
|
stage = `STOPPED_${latest.status}`;
|
|
317
365
|
next = plan.accepted
|
|
318
|
-
? `decide: retry (buildbeat run --config
|
|
366
|
+
? `decide: retry (buildbeat run --config ${configPath} --new) or close the work`
|
|
319
367
|
: `${unified ? "work.md" : "plan"} not accepted (${plan.exists ? "draft" : "missing"}); fix that before another run`;
|
|
320
368
|
}
|
|
321
369
|
rows.push({
|
|
322
370
|
work: workId,
|
|
371
|
+
...(config ? { config: config.path, hasRelease: configs.some((item) => item.release) ? true : configs.some((item) => item.error) ? null : false } : { hasRelease: false }),
|
|
372
|
+
...(config?.target && (!config.repo || config.repo !== currentRoot) && followTargets ? { targetRepo: config.repo ?? config.target, targetLabel: pathLabel(cwd ?? process.cwd(), config.repo ?? config.target) } : {}),
|
|
323
373
|
stage,
|
|
324
374
|
intent,
|
|
325
375
|
plan,
|
|
@@ -358,7 +408,7 @@ export function computeOverview(
|
|
|
358
408
|
},
|
|
359
409
|
}
|
|
360
410
|
: {}),
|
|
361
|
-
next,
|
|
411
|
+
next: local ? next : next.replaceAll("--config <run-config.yaml>", `--config ${configPath}`),
|
|
362
412
|
});
|
|
363
413
|
}
|
|
364
414
|
return rows;
|
|
@@ -381,6 +431,8 @@ export function renderOverview(rows) {
|
|
|
381
431
|
const lines = [];
|
|
382
432
|
for (const row of rows) {
|
|
383
433
|
lines.push(`${row.work} ${row.stage}`);
|
|
434
|
+
if (row.repo) lines.push(` repo: ${row.repoLabel ?? row.repo}`);
|
|
435
|
+
if (row.targetRepo) lines.push(` runs in: ${row.targetLabel ?? row.targetRepo}`);
|
|
384
436
|
const parts =
|
|
385
437
|
row.workArtifact === "work"
|
|
386
438
|
? [`work.md ${mark(row.plan)}`, `runs ${row.runs}`]
|
|
@@ -429,3 +481,54 @@ export function renderOverview(rows) {
|
|
|
429
481
|
}
|
|
430
482
|
return lines.join("\n");
|
|
431
483
|
}
|
|
484
|
+
|
|
485
|
+
// The main repo's configuration remains the command source; all state and
|
|
486
|
+
// decisions come from the checkout that owns the work. Only the main repo's
|
|
487
|
+
// pointers are followed, so targets cannot recursively expand this view.
|
|
488
|
+
export function computeRepositoryOverview(repoRoot, { work = null, allRepos = false, cwd = process.cwd(), localRepoLabel = shellArg(pathLabel(cwd, repoRoot)) } = {}) {
|
|
489
|
+
repoRoot = realpathSync(repoRoot);
|
|
490
|
+
const context = overviewContext(repoRoot);
|
|
491
|
+
const warnings = [];
|
|
492
|
+
const roots = allRepos ? discoverRepos(repoRoot, warnings, context.roots) : [repoRoot];
|
|
493
|
+
const primary = computeOverview(repoRoot, { work, cwd, repoLabel: allRepos ? shellArg(pathLabel(cwd, repoRoot)) : localRepoLabel, warnings, context, localCompatibility: !allRepos });
|
|
494
|
+
const groups = new Map(roots.map((repo) => [repo, new Map()]));
|
|
495
|
+
for (const row of primary) {
|
|
496
|
+
const owner = row.repo ?? repoRoot;
|
|
497
|
+
if (!groups.has(owner)) groups.set(owner, new Map());
|
|
498
|
+
groups.get(owner).set(row.work, row);
|
|
499
|
+
}
|
|
500
|
+
if (allRepos) {
|
|
501
|
+
for (const root of roots.filter((root) => root !== repoRoot)) {
|
|
502
|
+
for (const row of computeOverview(root, { work, cwd, followTargets: false, warnings, context, skipWorks: new Set(groups.get(root).keys()), repoLabel: shellArg(pathLabel(cwd, root)) })) {
|
|
503
|
+
if (!groups.get(root).has(row.work)) groups.get(root).set(row.work, row);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
const pending = [];
|
|
508
|
+
const repos = [];
|
|
509
|
+
for (const [repo, byWork] of groups) {
|
|
510
|
+
const works = [...byWork.values()].sort((a, b) => a.work.localeCompare(b.work)).map((row) => ({
|
|
511
|
+
...row, repo, repoLabel: pathLabel(cwd, repo),
|
|
512
|
+
...(row.targetRepo ? { targetLabel: pathLabel(cwd, row.targetRepo) } : {}),
|
|
513
|
+
}));
|
|
514
|
+
const settled = { total: 0, closed: 0, cancelled: 0, merged: 0 };
|
|
515
|
+
for (const row of works) {
|
|
516
|
+
if (allRepos && row.stage === "RUNNING" && row.latest?.source === "runtime") {
|
|
517
|
+
const ledger = EventLedger.open(join(repo, ".buildbeat", "runtime", "runs", row.latest.id, "events.jsonl"));
|
|
518
|
+
const live = describeLiveness({ repoRoot: repo, runId: row.latest.id, ledger });
|
|
519
|
+
if (live.inFlight?.stalled) row.stage = "STALLED";
|
|
520
|
+
}
|
|
521
|
+
const kind = row.stage === "CLOSED" ? "closed" : row.stage === "CANCELLED" ? "cancelled" : row.stage === "MERGED" && row.hasRelease === false ? "merged" : null;
|
|
522
|
+
row.settled = Boolean(kind);
|
|
523
|
+
if (kind) { settled[kind]++; settled.total++; }
|
|
524
|
+
}
|
|
525
|
+
repos.push({ repo, works, settled });
|
|
526
|
+
for (const row of listInbox(repo)) {
|
|
527
|
+
if (work && row.work !== work && !row.corrupted) continue;
|
|
528
|
+
if (!allRepos && repo !== repoRoot && !byWork.has(row.work) && !row.corrupted) continue;
|
|
529
|
+
if (repo === repoRoot && primary.some((item) => item.work === row.work && item.repo && item.repo !== repoRoot)) continue;
|
|
530
|
+
pending.push({ ...row, repo, ...(byWork.get(row.work)?.config ? { config: byWork.get(row.work).config } : {}) });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return { repos, pending, warnings: [...new Set(warnings)] };
|
|
534
|
+
}
|
package/templates/v2/AGENTS.md
CHANGED
|
@@ -6,7 +6,8 @@ One Work owns one user-visible result. Continue safe authorized work to its
|
|
|
6
6
|
acceptance condition; intermediate commits are progress, not completion.
|
|
7
7
|
|
|
8
8
|
Use accept once the user has accepted the scope, run to execute/continue, status
|
|
9
|
-
to inspect
|
|
9
|
+
to inspect (add --all-repos in a main repository to include its code repositories),
|
|
10
|
+
decide for a real human decision, and check for diagnostics. Keep
|
|
10
11
|
candidate/evidence/approval binding, allowedPaths, read-only review, and failure
|
|
11
12
|
budgets. Do not impersonate a human approval or silently weaken requirements.
|
|
12
13
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# BuildBeat v2 run 配置样板。拷到 delivery/work/<WORK-ID>/run-config.yaml 后改 work / run / allowedPaths / workers。
|
|
2
2
|
# 路径相对本文件解析。严格 YAML 子集:只有块列表与块映射(列表项可与键同缩进),行内只允许空的 [] / {},无锚点,注释必须独占一行。
|
|
3
|
-
# 起跑前:buildbeat
|
|
3
|
+
# 起跑前:buildbeat check --config <本文件>;起跑:buildbeat run --config <本文件>
|
|
4
4
|
repo: ../../..
|
|
5
5
|
work: WORK-X
|
|
6
|
-
#
|
|
6
|
+
# 家族名;run 自动编成 RUN-X-01/02…,--new 开新一轮
|
|
7
7
|
run: RUN-X
|
|
8
8
|
# builder / fixer 只能改这些目录;越界改动不成为候选
|
|
9
9
|
allowedPaths:
|