@haiyangbg/buildbeat 3.0.1 → 3.1.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/src/v2/cli/run.js CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { execFileSync, spawn, spawnSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
- import { existsSync, readFileSync } from "node:fs";
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
15
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
 
@@ -52,6 +52,7 @@ import { resumeRun, startRun } from "../runtime/orchestrator.js";
52
52
  import { toRepoRef } from "../runtime/repo-ref.js";
53
53
  import { EventLedger } from "../storage/event-ledger.js";
54
54
  import { acquireLock, listHeldRunLocks, releaseLock } from "../workspace/workspace-manager.js";
55
+ import { checkRunConfigAgainstWorkflow, checkRunConfigShape, RunConfigError } from "./run-config-check.js";
55
56
 
56
57
  const KERNEL = { kind: "kernel", id: "cli" };
57
58
 
@@ -60,7 +61,7 @@ const USAGE = `BuildBeat runtime
60
61
  Usage:
61
62
  buildbeat --version
62
63
  buildbeat start --config <run-config.yaml> [--attempt new]
63
- buildbeat resume --config <run-config.yaml> [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
64
+ buildbeat resume --config <run-config.yaml> [--run <RUN-ID>] [--adopt <sha> --by <name>] # --adopt: hand fix committed in the worktree; skip fix, resume at verify
64
65
  buildbeat status --repo <path> --run <RUN-ID> [--stall-after <minutes>]
65
66
  buildbeat inbox --repo <path>
66
67
  buildbeat overview --repo <path> [--work <WORK-ID>] [--json true]
@@ -256,11 +257,29 @@ function loadRunConfig(flags, command) {
256
257
  }
257
258
  const configPath = resolve(flags.config);
258
259
  const config = parseYamlSubset(readFileSync(configPath, "utf8"));
260
+ // Validate before anything runs and list every problem at once: the
261
+ // workflow does not depend on repo, so its checks run whenever it loads.
262
+ const problems = checkRunConfigShape(config);
259
263
  const configDir = dirname(configPath);
264
+ let workflow = null;
265
+ let workflowPath = null;
266
+ let workflowText = null;
267
+ if (typeof config?.workflow === "string" && config.workflow.trim() !== "") {
268
+ workflowPath = resolve(configDir, config.workflow);
269
+ try {
270
+ workflowText = readFileSync(workflowPath, "utf8");
271
+ workflow = loadWorkflow(workflowPath);
272
+ } catch (error) {
273
+ problems.push(`workflow: cannot load ${config.workflow}: ${error.message}`);
274
+ }
275
+ }
276
+ if (workflow) {
277
+ problems.push(...checkRunConfigAgainstWorkflow(config, workflow));
278
+ }
279
+ if (problems.length > 0) {
280
+ throw new RunConfigError(flags.config, problems);
281
+ }
260
282
  const repoRoot = resolve(configDir, config.repo);
261
- const workflowPath = resolve(configDir, config.workflow);
262
- const workflowText = readFileSync(workflowPath, "utf8");
263
- const workflow = loadWorkflow(workflowPath);
264
283
  const workflowDigest = `sha256:${createHash("sha256").update(workflowText, "utf8").digest("hex")}`;
265
284
 
266
285
  const adapters = {};
@@ -495,7 +514,9 @@ async function commandStart(flags) {
495
514
  const label = repoLabelFor(options.repoRoot);
496
515
  const holders = listHeldRunLocks(options.repoRoot);
497
516
  if (holders.length === 0) {
498
- console.error("blocked by: a stale active-run lock with no run holding it (a killed process?); `gc` clears locks of terminal runs, or remove .buildbeat/runtime/locks/active-run.lock after checking no driver process is alive");
517
+ // A dead owner would already have been reclaimed; the error below
518
+ // names who holds the lock and what to do.
519
+ console.error("blocked by: the active-run lock alone (no run lock beside it); its owner is named below");
499
520
  }
500
521
  for (const holder of holders) {
501
522
  const ledgerPath = join(options.repoRoot, ".buildbeat", "runtime", "runs", holder, "events.jsonl");
@@ -531,8 +552,42 @@ async function commandStart(flags) {
531
552
  await notifyForState(options.repoRoot, repoLabel, ledger.state);
532
553
  }
533
554
 
555
+ // Resolve only from runtime ledgers. Reading candidates does not acquire
556
+ // their locks; resumeRun still owns the lock and freshness checks.
557
+ function resolveResumeRun(repoRoot, family, explicitRun) {
558
+ const pattern = new RegExp(`^${family.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d{2,}$`);
559
+ if (explicitRun !== undefined) {
560
+ if (explicitRun !== family && !pattern.test(explicitRun)) {
561
+ throw new Error(`--run ${explicitRun} is not in run family ${family} of this config`);
562
+ }
563
+ if (!existsSync(ledgerPathFor(repoRoot, explicitRun))) {
564
+ throw new Error(`no ledger for run ${explicitRun}`);
565
+ }
566
+ return explicitRun;
567
+ }
568
+ if (existsSync(ledgerPathFor(repoRoot, family))) {
569
+ return family;
570
+ }
571
+ const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
572
+ const runs = (existsSync(runsDir) ? readdirSync(runsDir) : [])
573
+ .filter((id) => pattern.test(id) && existsSync(ledgerPathFor(repoRoot, id)))
574
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
575
+ .map((id) => ({ id, state: EventLedger.open(ledgerPathFor(repoRoot, id)).state }));
576
+ const open = runs.filter(({ state }) => !state.terminal);
577
+ if (open.length === 1) {
578
+ console.log(`resuming ${open[0].id} (the open run of family ${family})`);
579
+ return open[0].id;
580
+ }
581
+ if (open.length === 0) {
582
+ const latest = runs.at(-1);
583
+ throw new Error(`no open run in family ${family}; ${latest ? `latest run ${latest.id}: ${latest.state.terminal.status}` : "no ledgers found"}; use --run <RUN-ID> to select an existing run explicitly`);
584
+ }
585
+ throw new Error(`multiple open runs in family ${family}: ${open.map(({ id }) => id).join(", ")}; use --run <RUN-ID> to select one`);
586
+ }
587
+
534
588
  async function commandResume(flags) {
535
589
  const options = loadRunConfig(flags, "resume");
590
+ options.runId = resolveResumeRun(options.repoRoot, options.runId, flags.run);
536
591
  if (flags.adopt !== undefined) {
537
592
  const resumeAt = nextStep(options.workflow, "fix", "succeeded") ?? "verify";
538
593
  const adopted = adoptCandidate(options.repoRoot, options.runId, {
@@ -624,7 +679,7 @@ function commandApprove(flags) {
624
679
  if (result.terminal) {
625
680
  console.log("run is terminal: SUCCEEDED (merge itself stays a manual external action)");
626
681
  } else {
627
- console.log("decision recorded; continue with: run.js resume --config <run-config.yaml>");
682
+ console.log(`decision recorded; continue with: buildbeat resume --config <run-config.yaml> --run ${flags.run}`);
628
683
  }
629
684
  }
630
685
 
@@ -954,16 +1009,21 @@ function commandStop(flags) {
954
1009
  throw new Error("stop requires --repo and --run");
955
1010
  }
956
1011
  const repoRoot = resolve(flags.repo);
957
- const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
958
- if (!ledger.state.run) {
1012
+ if (!existsSync(ledgerPathFor(repoRoot, flags.run))) {
959
1013
  throw new Error(`no ledger for run ${flags.run}`);
960
1014
  }
961
- if (ledger.state.terminal) {
962
- console.log(`run already terminal: ${ledger.state.terminal.status}`);
963
- return;
964
- }
1015
+ // Read and decide under the run lock: a ledger read before it may be
1016
+ // stale by the time RUN_TERMINAL is written.
965
1017
  acquireLock(repoRoot, flags.run);
966
1018
  try {
1019
+ const ledger = EventLedger.open(ledgerPathFor(repoRoot, flags.run));
1020
+ if (!ledger.state.run) {
1021
+ throw new Error(`no ledger for run ${flags.run}`);
1022
+ }
1023
+ if (ledger.state.terminal) {
1024
+ console.log(`run already terminal: ${ledger.state.terminal.status}`);
1025
+ return;
1026
+ }
967
1027
  ledger.append({
968
1028
  type: "RUN_TERMINAL",
969
1029
  actor: KERNEL,
@@ -1008,7 +1068,7 @@ function commandGc(flags) {
1008
1068
  if (action.kind === "delete-branch") {
1009
1069
  return `delete branch ${action.branch} (${action.reason})`;
1010
1070
  }
1011
- return "remove stale lock";
1071
+ return action.owner ? "remove active-run lock (owner process is gone)" : "remove stale lock";
1012
1072
  });
1013
1073
  actionable += row.actions.length;
1014
1074
  const keep = row.keep.map((reason) => `keep: ${reason}`);
@@ -114,6 +114,9 @@ export function applyEvent(state, event) {
114
114
  attempts: data.attempt,
115
115
  detail: null,
116
116
  infraAttempts: state.steps[data.step]?.infraAttempts ?? 0,
117
+ // Omit the new key for legacy events to preserve their exact state.
118
+ ...(state.steps[data.step]?.freeAttempts !== undefined
119
+ ? { freeAttempts: state.steps[data.step].freeAttempts } : {}),
117
120
  };
118
121
  next.currentStep = data.step;
119
122
  break;
@@ -132,6 +135,9 @@ export function applyEvent(state, event) {
132
135
  // output, exit 75) is not charged to the step's budget.
133
136
  next.steps[data.step].infraAttempts = (step.infraAttempts ?? 0) + 1;
134
137
  }
138
+ if (data.free === true) {
139
+ next.steps[data.step].freeAttempts = (step.freeAttempts ?? 0) + 1;
140
+ }
135
141
  next.currentStep = null;
136
142
  break;
137
143
  }
@@ -1,8 +1,10 @@
1
1
  // Fail-closed strict YAML subset parser for BuildBeat v2 config files.
2
- // Supports exactly what the official presets need: nested maps, block lists,
3
- // and plain/quoted scalars with space indentation. Everything else — tabs,
4
- // anchors, aliases, tags, block/flow scalars, multi-document streams,
5
- // duplicate keys — is rejected with a line number, never guessed at.
2
+ // Supports exactly what the official presets need: nested maps, block lists
3
+ // (indented under their key or at the key's own indentation), the empty
4
+ // inline [] and {}, and plain/quoted scalars with space indentation.
5
+ // Everything else — tabs, anchors, aliases, tags, block/flow scalars,
6
+ // non-empty inline collections, multi-document streams, duplicate keys — is
7
+ // rejected with a line number and a way to rewrite it, never guessed at.
6
8
 
7
9
  export class YamlSubsetError extends Error {
8
10
  constructor(message, lineNo) {
@@ -13,6 +15,9 @@ export class YamlSubsetError extends Error {
13
15
  }
14
16
 
15
17
  const KEY_PATTERN = /^[A-Za-z0-9_.-]+$/;
18
+ // A list item is a map only when it starts like one: a valid key, then ": "
19
+ // or a colon at the end of the line.
20
+ const MAP_ITEM = /^[A-Za-z0-9_.-]+:( |$)/;
16
21
  const FORBIDDEN_SCALAR_START = ["&", "*", "!", "|", ">", "{", "[", "%", "@", "`"];
17
22
 
18
23
  function parseScalar(raw, lineNo) {
@@ -37,6 +42,14 @@ function parseScalar(raw, lineNo) {
37
42
  }
38
43
  return inner;
39
44
  }
45
+ if (/^\[\s*\]$/.test(text)) return [];
46
+ if (/^\{\s*\}$/.test(text)) return {};
47
+ if (text[0] === "[" || text[0] === "{") {
48
+ throw new YamlSubsetError(
49
+ `inline lists/maps are not supported except [] and {}; write one "- item" per line (or "key: value" per line): ${text}`,
50
+ lineNo,
51
+ );
52
+ }
40
53
  if (FORBIDDEN_SCALAR_START.includes(text[0])) {
41
54
  throw new YamlSubsetError(`unsupported YAML syntax at: ${text}`, lineNo);
42
55
  }
@@ -46,6 +59,8 @@ function parseScalar(raw, lineNo) {
46
59
  if (text === "true") return true;
47
60
  if (text === "false") return false;
48
61
  if (text === "null" || text === "~") return null;
62
+ // Leading zeros are kept as written (007 would otherwise silently be 7).
63
+ if (/^-?0\d+$/.test(text)) return text;
49
64
  if (/^-?\d+$/.test(text)) return Number.parseInt(text, 10);
50
65
  if (/^-?\d+\.\d+$/.test(text)) return Number.parseFloat(text);
51
66
  return text;
@@ -127,9 +142,17 @@ function parseList(lines, start, indent) {
127
142
  }
128
143
  const first = childLines[0].content;
129
144
  const quotedScalar = first.startsWith('"') || first.startsWith("'");
130
- if (childLines.length === 1 && (quotedScalar || !first.includes(":"))) {
145
+ const mapItem = !quotedScalar && MAP_ITEM.test(first);
146
+ if (childLines.length === 1 && !mapItem) {
147
+ // Real YAML reads "echo a: b" as a map; rather than guess, ask for quotes.
148
+ if (!quotedScalar && (first.includes(": ") || first.endsWith(":"))) {
149
+ throw new YamlSubsetError(
150
+ `list item ${JSON.stringify(first)} contains ": "; quote it (- ${JSON.stringify(first)}) or write it as key: value`,
151
+ line.lineNo,
152
+ );
153
+ }
131
154
  result.push(parseScalar(first, childLines[0].lineNo));
132
- } else if (!quotedScalar && first.includes(":")) {
155
+ } else if (mapItem) {
133
156
  result.push(parseMapFromLines(childLines, itemIndent));
134
157
  } else {
135
158
  throw new YamlSubsetError("unsupported list item shape", line.lineNo);
@@ -168,8 +191,19 @@ function parseMap(lines, start, indent) {
168
191
  continue;
169
192
  }
170
193
  const childStart = index + 1;
171
- if (childStart >= lines.length || lines[childStart].indent <= indent) {
172
- throw new YamlSubsetError(`key "${key}" has no value`, line.lineNo);
194
+ const child = lines[childStart];
195
+ // A list may sit at its key's own indentation ("key:" then "- a").
196
+ if (child && child.indent === indent && (child.content === "-" || child.content.startsWith("- "))) {
197
+ const parsed = parseList(lines, childStart, indent);
198
+ result[key] = parsed.value;
199
+ index = parsed.next;
200
+ continue;
201
+ }
202
+ if (!child || child.indent <= indent) {
203
+ throw new YamlSubsetError(
204
+ `key "${key}" has no value: give it a value, write ${key}: [] for an empty list, or indent its items under it`,
205
+ line.lineNo,
206
+ );
173
207
  }
174
208
  const parsed = parseNode(lines, childStart, lines[childStart].indent);
175
209
  result[key] = parsed.value;
@@ -179,7 +213,8 @@ function parseMap(lines, start, indent) {
179
213
  }
180
214
 
181
215
  export function parseYamlSubset(text) {
182
- const lines = toLines(text);
216
+ // A byte-order mark (Windows editors) is not content.
217
+ const lines = toLines(text.replace(/^\uFEFF/, ""));
183
218
  if (lines.length === 0) {
184
219
  throw new YamlSubsetError("empty document");
185
220
  }
@@ -54,20 +54,22 @@ function recordDecisionFile(repoRoot, work, line) {
54
54
  }
55
55
 
56
56
  export function approveRun(repoRoot, runId, { by = "human", transition, ts, policies } = {}) {
57
- const ledger = openWaiting(repoRoot, runId);
58
- const pending = ledger.state.pendingHuman;
59
- if (!transition) {
60
- throw new DecisionError(
61
- `an approval must name its transition explicitly (pending: ${pending.transition})`,
62
- );
63
- }
64
- if (transition !== pending.transition) {
65
- throw new DecisionError(
66
- `transition mismatch: pending is ${pending.transition}, got ${transition}`,
67
- );
68
- }
57
+ // Read, check and write under the run lock: a ledger read before the
58
+ // lock may be stale by the time it is written to.
69
59
  acquireLock(repoRoot, runId);
70
60
  try {
61
+ const ledger = openWaiting(repoRoot, runId);
62
+ const pending = ledger.state.pendingHuman;
63
+ if (!transition) {
64
+ throw new DecisionError(
65
+ `an approval must name its transition explicitly (pending: ${pending.transition})`,
66
+ );
67
+ }
68
+ if (transition !== pending.transition) {
69
+ throw new DecisionError(
70
+ `transition mismatch: pending is ${pending.transition}, got ${transition}`,
71
+ );
72
+ }
71
73
  const bound = ledger.state.workspaces[runId];
72
74
  const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
73
75
  if (!bound || !existsSync(worktreePath)) {
@@ -194,19 +196,19 @@ export function approveRun(repoRoot, runId, { by = "human", transition, ts, poli
194
196
  // fixes). The commit must already be the worktree HEAD: git is read back,
195
197
  // the claim is not trusted.
196
198
  export function adoptCandidate(repoRoot, runId, { sha, by = "human", resumeAt, ts } = {}) {
197
- if (!sha || typeof sha !== "string" || sha.length < 7) {
198
- throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
199
- }
200
- if (!resumeAt) {
201
- throw new DecisionError("adopt requires the step to resume at (resumeAt)");
202
- }
203
- const ledger = openWaiting(repoRoot, runId);
204
- const pending = ledger.state.pendingHuman;
205
- if (pending.kind === "final-decision") {
206
- throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
207
- }
208
199
  acquireLock(repoRoot, runId);
209
200
  try {
201
+ if (!sha || typeof sha !== "string" || sha.length < 7) {
202
+ throw new DecisionError("adopt requires a commit sha (at least 7 characters)");
203
+ }
204
+ if (!resumeAt) {
205
+ throw new DecisionError("adopt requires the step to resume at (resumeAt)");
206
+ }
207
+ const ledger = openWaiting(repoRoot, runId);
208
+ const pending = ledger.state.pendingHuman;
209
+ if (pending.kind === "final-decision") {
210
+ throw new DecisionError("adopt is for a run waiting before fix/verify, not at the merge decision");
211
+ }
210
212
  const bound = ledger.state.workspaces[runId];
211
213
  const worktreePath = bound ? resolveRepoRef(repoRoot, bound.worktreePath) : null;
212
214
  if (!bound || !existsSync(worktreePath)) {
@@ -293,15 +295,15 @@ export function acceptArtifact(repoRoot, workId, artifact, { by = "human", ts }
293
295
  }
294
296
 
295
297
  export function rejectRun(repoRoot, runId, { by = "human", transition, reason, ts } = {}) {
296
- const ledger = openWaiting(repoRoot, runId);
297
- const pending = ledger.state.pendingHuman;
298
- if (transition && transition !== pending.transition) {
299
- throw new DecisionError(
300
- `transition mismatch: pending is ${pending.transition}, got ${transition}`,
301
- );
302
- }
303
298
  acquireLock(repoRoot, runId);
304
299
  try {
300
+ const ledger = openWaiting(repoRoot, runId);
301
+ const pending = ledger.state.pendingHuman;
302
+ if (transition && transition !== pending.transition) {
303
+ throw new DecisionError(
304
+ `transition mismatch: pending is ${pending.transition}, got ${transition}`,
305
+ );
306
+ }
305
307
  const when = ts ?? new Date().toISOString();
306
308
  const decisionRef = `D-${runId}-${ledger.state.decisions.length + 1}`;
307
309
  ledger.append({
@@ -1,5 +1,6 @@
1
1
  // Runtime garbage collection (iteration 08, C3): terminal runs leave a
2
- // worktree, a run/* branch and sometimes a lock behind. Sixteen of them had
2
+ // worktree, a run/* branch and sometimes a lock behind (and a killed driver
3
+ // an active-run lock whose owner is gone). Sixteen of them had
3
4
  // piled up in the deploy campaign before the owner asked for "打扫卫生".
4
5
  //
5
6
  // Rules (fail-closed toward keeping things):
@@ -18,7 +19,7 @@ import { join } from "node:path";
18
19
  import { execFileSync } from "node:child_process";
19
20
 
20
21
  import { EventLedger } from "../storage/event-ledger.js";
21
- import { readback } from "../workspace/workspace-manager.js";
22
+ import { describeLockOwner, inspectLock, readback, reclaimStaleLock } from "../workspace/workspace-manager.js";
22
23
  import { resolveRepoRef } from "./repo-ref.js";
23
24
 
24
25
  function git(cwd, args) {
@@ -64,6 +65,21 @@ export function planGc(repoRoot) {
64
65
  const runsDir = join(repoRoot, ".buildbeat", "runtime", "runs");
65
66
  const locksDir = join(repoRoot, ".buildbeat", "runtime", "locks");
66
67
  const rows = [];
68
+ // The repository-wide lock belongs to no run: reclaimable only when its
69
+ // owner process is provably gone (same host, pid no longer exists).
70
+ const activeLock = join(locksDir, "active-run.lock");
71
+ if (existsSync(activeLock)) {
72
+ const seen = inspectLock(activeLock);
73
+ const row = { run: "(repository)", status: "active-run lock", actions: [], keep: [] };
74
+ if (seen.state === "dead") {
75
+ row.actions.push({ kind: "remove-lock", path: activeLock, owner: seen.owner });
76
+ } else if (seen.state === "unknown") {
77
+ row.keep.push("active-run lock has no owner record (older buildbeat?); remove it by hand once no buildbeat process is running");
78
+ } else {
79
+ row.keep.push(`active-run lock held by ${describeLockOwner(seen.owner)}${seen.state === "foreign-host" ? " (another host)" : " (still running)"}`);
80
+ }
81
+ rows.push(row);
82
+ }
67
83
  if (!existsSync(runsDir)) {
68
84
  return rows;
69
85
  }
@@ -145,7 +161,16 @@ export function applyGc(repoRoot, rows, { force = false } = {}) {
145
161
  const result = { run: row.run, ...action, done: false, error: null };
146
162
  results.push(result);
147
163
  try {
148
- if (action.kind === "remove-lock") {
164
+ if (action.kind === "remove-lock" && action.owner) {
165
+ // Winning the takeover makes this process the only one entitled
166
+ // to the lock; only then is it removed.
167
+ if (reclaimStaleLock(action.path, action.owner)) {
168
+ rmSync(action.path, { recursive: true, force: true });
169
+ result.done = true;
170
+ } else {
171
+ result.error = "lock changed hands since the plan (or is being taken over); left in place";
172
+ }
173
+ } else if (action.kind === "remove-lock") {
149
174
  rmSync(action.path, { recursive: true, force: true });
150
175
  result.done = true;
151
176
  } else if (action.kind === "remove-worktree") {