@botbuddy/cli 1.8.3 → 1.8.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/package.json +1 -1
- package/src/botbuddy-release-repair.json +1 -0
- package/src/docker-hygiene.mjs +6 -0
- package/src/machine-id.mjs +70 -0
- package/src/wait-core.mjs +46 -5
- package/src/wait.mjs +7 -1
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema_version":1,"source_version":"1.8.3","source_identity":"a3c85408868d913b2ab265f910ab062916c1c394895cec71cae0f79fc99f5a6c"}
|
package/src/docker-hygiene.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { hostname } from "node:os";
|
|
9
9
|
import { callToolJson } from "./api.mjs";
|
|
10
10
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
11
|
+
import { machineUuid } from "./machine-id.mjs";
|
|
11
12
|
|
|
12
13
|
export const SCHEMA_VERSION = 1;
|
|
13
14
|
export const DEFAULT_PROJECTED_ENDPOINTS = 10;
|
|
@@ -1118,6 +1119,7 @@ async function runDockerCommandWithBotBuddyLock(argv, {
|
|
|
1118
1119
|
runWorkflow = runDockerWorkflow,
|
|
1119
1120
|
workflowOptions = {},
|
|
1120
1121
|
machineHost = hostname(),
|
|
1122
|
+
machineId = machineUuid(),
|
|
1121
1123
|
monotonicNow = Date.now,
|
|
1122
1124
|
} = {}) {
|
|
1123
1125
|
const parsed = parseDockerArgs(argv);
|
|
@@ -1131,6 +1133,10 @@ async function runDockerCommandWithBotBuddyLock(argv, {
|
|
|
1131
1133
|
host: machineHost,
|
|
1132
1134
|
slot: parsed.opts.lockSlot,
|
|
1133
1135
|
mode: "lock",
|
|
1136
|
+
// BOT-1239: report THIS machine's unique id so the supabase_local stack lock states
|
|
1137
|
+
// which physical machine holds it (host is a hostname two machines can share). Only
|
|
1138
|
+
// sent when the probe found one — a NULL degrades to hostname grouping.
|
|
1139
|
+
...(machineId ? { machine_uuid: machineId } : {}),
|
|
1134
1140
|
ticket_id: parsed.opts.ticket,
|
|
1135
1141
|
no_pr_reason: "local OrbStack hygiene",
|
|
1136
1142
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// BOT-1239 — the machine-UNIQUE identity of THIS physical machine.
|
|
2
|
+
//
|
|
3
|
+
// A hostname is not machine-unique (two machines can both be "ubuntu"), so a
|
|
4
|
+
// supabase_local lock keyed only on hostname cannot be attributed to the machine
|
|
5
|
+
// that holds it. This reads the same machine-unique token a BotBuddy Helper attests
|
|
6
|
+
// at enrollment (container_hosts.hardware_uuid) so a lock the CLI acquires reports the
|
|
7
|
+
// SAME identity — letting /docker merge a machine's Helper with its own stack lock and
|
|
8
|
+
// never with a foreign machine's under a shared hostname.
|
|
9
|
+
//
|
|
10
|
+
// * macOS : IOPlatformUUID from `ioreg -rd1 -c IOPlatformExpertDevice` (what the
|
|
11
|
+
// Helper attests, so the two identities match for one machine).
|
|
12
|
+
// * Linux : /etc/machine-id (falling back to /var/lib/dbus/machine-id).
|
|
13
|
+
// * else / on any error : null — the lock then degrades to hostname grouping, never
|
|
14
|
+
// a wrong merge. This must NEVER throw or block a lock acquisition.
|
|
15
|
+
//
|
|
16
|
+
// `BOTBUDDY_MACHINE_UUID` overrides the probe (explicit operator control / tests).
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
const IOREG_UUID_RE = /"IOPlatformUUID"\s*=\s*"([^"]+)"/;
|
|
21
|
+
|
|
22
|
+
export function readMachineUuid({
|
|
23
|
+
platform = process.platform,
|
|
24
|
+
env = process.env,
|
|
25
|
+
exec = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: 4000 }),
|
|
26
|
+
readFile = (p) => readFileSync(p, "utf8"),
|
|
27
|
+
} = {}) {
|
|
28
|
+
try {
|
|
29
|
+
const override = (env.BOTBUDDY_MACHINE_UUID || "").trim();
|
|
30
|
+
if (override) return override;
|
|
31
|
+
|
|
32
|
+
if (platform === "darwin") {
|
|
33
|
+
const out = exec("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
34
|
+
if (out && out.status === 0 && typeof out.stdout === "string") {
|
|
35
|
+
const m = out.stdout.match(IOREG_UUID_RE);
|
|
36
|
+
const uuid = m && m[1] ? m[1].trim() : "";
|
|
37
|
+
return uuid || null;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (platform === "linux") {
|
|
43
|
+
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
44
|
+
try {
|
|
45
|
+
const id = String(readFile(path) || "").trim();
|
|
46
|
+
if (id) return id;
|
|
47
|
+
} catch { /* try the next source */ }
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
} catch {
|
|
54
|
+
// A machine id is a best-effort disambiguator; never let it fail an acquire.
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let _cached;
|
|
60
|
+
let _cachedComputed = false;
|
|
61
|
+
|
|
62
|
+
// Cached, zero-argument accessor for the real process. Tests call readMachineUuid
|
|
63
|
+
// directly with injected dependencies instead of hitting this cache.
|
|
64
|
+
export function machineUuid() {
|
|
65
|
+
if (!_cachedComputed) {
|
|
66
|
+
_cached = readMachineUuid();
|
|
67
|
+
_cachedComputed = true;
|
|
68
|
+
}
|
|
69
|
+
return _cached;
|
|
70
|
+
}
|
package/src/wait-core.mjs
CHANGED
|
@@ -196,20 +196,44 @@ const VALIDATORS = {
|
|
|
196
196
|
// BOT-1066 — ci: wake when a PR's CI reaches a terminal conclusion. Exactly one
|
|
197
197
|
// selector: scope=latest (follows the PR's latest run — needs pr=), run_id=<id>
|
|
198
198
|
// (pinned to one run), or sha=<sha> (pinned to a commit). repo= is always required.
|
|
199
|
+
// BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run, sha,
|
|
200
|
+
// or PR (they must all already exist at arm time) and wakes on the first terminal
|
|
201
|
+
// CI run for the repo delivered AFTER the wait was armed — the post-merge / cron
|
|
202
|
+
// train case. Optional branch=/workflow= filter that first run; both are valid
|
|
203
|
+
// ONLY with scope=next (a run_id/sha/latest wait already names its run).
|
|
199
204
|
ci(p) {
|
|
200
205
|
const repo = p.repo != null ? String(p.repo).trim() : null;
|
|
201
206
|
if (!repo || !repo.includes("/")) throw new Error("ci needs repo=<owner/repo>");
|
|
202
207
|
const selectors = ["scope", "run_id", "sha"].filter((k) => p[k] !== undefined);
|
|
203
208
|
if (selectors.length !== 1) {
|
|
204
|
-
throw new Error("ci needs exactly one of scope=latest | run_id=<id> | sha=<sha>");
|
|
209
|
+
throw new Error("ci needs exactly one of scope=latest|next | run_id=<id> | sha=<sha>");
|
|
205
210
|
}
|
|
211
|
+
const hasFilter = p.branch !== undefined || p.workflow !== undefined;
|
|
206
212
|
if (p.scope !== undefined) {
|
|
207
|
-
if (p.scope
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
if (p.scope === "latest") {
|
|
214
|
+
if (hasFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
|
|
215
|
+
if (p.pr === undefined || !/^\d+$/.test(String(p.pr).trim())) {
|
|
216
|
+
throw new Error("ci scope=latest needs pr=<number>");
|
|
217
|
+
}
|
|
218
|
+
return { repo, scope: "latest", pr: String(p.pr).trim() };
|
|
219
|
+
}
|
|
220
|
+
if (p.scope === "next") {
|
|
221
|
+
// A future run has no PR head to follow — pr= is meaningless (and run_id/sha
|
|
222
|
+
// are already excluded by the single-selector check above).
|
|
223
|
+
if (p.pr !== undefined) {
|
|
224
|
+
throw new Error("ci scope=next takes no pr= (it targets a future run, not a PR head)");
|
|
225
|
+
}
|
|
226
|
+
const filter = (key) => {
|
|
227
|
+
if (p[key] === undefined) return null;
|
|
228
|
+
const v = String(p[key]).trim();
|
|
229
|
+
if (v === "") throw new Error(`ci ${key}= must be non-empty`);
|
|
230
|
+
return v;
|
|
231
|
+
};
|
|
232
|
+
return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), pr: null };
|
|
210
233
|
}
|
|
211
|
-
|
|
234
|
+
throw new Error("ci scope must be 'latest' or 'next' (or use run_id=/sha=)");
|
|
212
235
|
}
|
|
236
|
+
if (hasFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
|
|
213
237
|
if (p.run_id !== undefined) {
|
|
214
238
|
if (!String(p.run_id).trim()) throw new Error("ci run_id must be non-empty");
|
|
215
239
|
return { repo, scope: "run_id", runId: String(p.run_id).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
|
|
@@ -679,6 +703,23 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
679
703
|
if (String(signal.payload?.repo ?? "").toLowerCase() !== params.repo.toLowerCase()) return false;
|
|
680
704
|
if (params.scope === "run_id") return String(signal.payload?.run_id) === params.runId;
|
|
681
705
|
if (params.scope === "sha") return signal.payload?.head_sha === params.sha;
|
|
706
|
+
if (params.scope === "next") {
|
|
707
|
+
// BOT-1507 — live-only: the first TERMINAL run for the repo. Never keys on
|
|
708
|
+
// subject_key (it is run:<id> for post-merge runs and owner/repo#n for PR
|
|
709
|
+
// runs — both are legitimate wakes); a null/empty conclusion is an
|
|
710
|
+
// in-progress signal and never terminal. Optional branch/workflow filters:
|
|
711
|
+
// branch is exact, workflow is the GitHub display name (case/space-insensitive).
|
|
712
|
+
const conclusion = signal.payload?.conclusion;
|
|
713
|
+
if (typeof conclusion !== "string" || conclusion.trim() === "") return false;
|
|
714
|
+
if (params.branch != null && signal.payload?.head_branch !== params.branch) return false;
|
|
715
|
+
if (params.workflow != null) {
|
|
716
|
+
const wf = signal.payload?.workflow_name;
|
|
717
|
+
if (typeof wf !== "string" || wf.trim().toLowerCase() !== params.workflow.trim().toLowerCase()) {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
682
723
|
// scope=latest: the PR's run (by subject owner/repo#pr or payload pr_number).
|
|
683
724
|
return signal.subject_key === `${params.repo}#${params.pr}` ||
|
|
684
725
|
(signal.payload?.pr_number != null && String(signal.payload.pr_number) === params.pr);
|
package/src/wait.mjs
CHANGED
|
@@ -75,10 +75,16 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
75
75
|
Host-shared; a silently-dead host is detected server-side
|
|
76
76
|
(capacity_source_stale) via a host beacon — the client
|
|
77
77
|
grace (default 900s) is a backstop, no longer the only guard.
|
|
78
|
-
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
|
|
78
|
+
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
|
|
79
|
+
| scope=next[,branch=<name>][,workflow=<name>]}
|
|
79
80
|
a PR's CI reaching a terminal conclusion (owner-scoped);
|
|
80
81
|
GitHub Actions or an external check_suite provider.
|
|
81
82
|
A repo with no CI feed is rejected (no_signal_source).
|
|
83
|
+
scope=next is LIVE-ONLY: it wakes on the first terminal
|
|
84
|
+
run delivered AFTER arm (post-merge / cron / scheduled),
|
|
85
|
+
never replays a finished run, and needs no run_id/sha/pr.
|
|
86
|
+
Optional branch= (exact) / workflow= (GitHub workflow
|
|
87
|
+
DISPLAY name, case-insensitive) narrow that first run.
|
|
82
88
|
staging-green:repo=<owner/repo> verified automatic recovery of an enabled staging gate.
|
|
83
89
|
Wakes only after a complete non-vacuous workflow success;
|
|
84
90
|
manual resolutions, overrides, skipped, and neutral runs do not match.
|