@botbuddy/cli 1.34.4 → 1.36.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/package.json +1 -1
- package/src/commands.mjs +3 -0
- package/src/pw/args.mjs +14 -4
- package/src/pw/coordinator.mjs +2 -2
- package/src/pw/heartbeat.mjs +31 -0
- package/src/pw/run.mjs +79 -8
- package/src/pw/session.mjs +997 -0
- package/src/pw/telemetry.mjs +111 -10
- package/src/telemetry-config.mjs +9 -4
- package/src/telemetry-delivery.mjs +20 -2
- package/src/telemetry-outbox.mjs +32 -6
- package/src/wait-core.mjs +75 -4
- package/src/wait.mjs +33 -14
package/src/pw/telemetry.mjs
CHANGED
|
@@ -1,14 +1,115 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// BOT-1318 — production wiring for bb pw browser-acceptance telemetry.
|
|
2
|
+
//
|
|
3
|
+
// Replaces the pre-BOT-1318 lane-number spool + close-only record_lane_event
|
|
4
|
+
// rollup. A repository opts in by committing `.botbuddy/config.json` (BOT-1316);
|
|
5
|
+
// once opted in every rule fails closed: the checked-in repository must match
|
|
6
|
+
// origin, the local credential binding must belong to the expected tenant, and
|
|
7
|
+
// that credential is attested once against the server (cached by fingerprint).
|
|
8
|
+
// A repository without the checked-in config stays untracked, exactly as before.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
3
11
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { VERSION } from "../version.mjs";
|
|
14
|
+
import { createTelemetryOutbox } from "../telemetry-outbox.mjs";
|
|
15
|
+
import { deliverExecutionEvent } from "../telemetry-delivery.mjs";
|
|
16
|
+
import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "../telemetry-config.mjs";
|
|
17
|
+
import { defaultGitInfo, findGitRoot } from "../test-lane.mjs";
|
|
18
|
+
import { createBrowserTelemetryResolver } from "./session.mjs";
|
|
19
|
+
import { resolveAgentSessionCredential } from "../agent-session.mjs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
|
|
22
|
+
const HEARTBEAT_ENTRY = fileURLToPath(new URL("./heartbeat.mjs", import.meta.url));
|
|
23
|
+
|
|
24
|
+
export function telemetryRoot(env = process.env) {
|
|
25
|
+
return env.BB_PW_TELEMETRY_ROOT || join(homedir(), ".botbuddy");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Ticket/PR/branch attribution reuses the `botbuddy test` helper (BOT-1510):
|
|
29
|
+
// explicit BB_PW_TICKET_ID / BB_PW_PR_ID win, else the branch's `bot-<n>` segment
|
|
30
|
+
// and `gh pr view`; anything unresolved stays null — never a guess.
|
|
31
|
+
export function gitInfoFor({ cwd, env }) {
|
|
32
|
+
let cached = null;
|
|
33
|
+
return () => {
|
|
34
|
+
if (!cached) {
|
|
35
|
+
const prOverride = env.BB_PW_PR_ID ? Number.parseInt(env.BB_PW_PR_ID, 10) : null;
|
|
36
|
+
cached = defaultGitInfo({ cwd, ticket: env.BB_PW_TICKET_ID || null, pr: Number.isInteger(prOverride) ? prOverride : null });
|
|
37
|
+
}
|
|
38
|
+
return cached;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The session credential the foreground authenticated with (e.g. an explicit
|
|
43
|
+
// --agent-session-token that is neither exported nor cached) is handed to the
|
|
44
|
+
// detached heartbeat through its environment — readable only by the same user,
|
|
45
|
+
// never on the command line — so the heartbeat re-resolves the same principal.
|
|
46
|
+
export function spawnHeartbeatProcess({ cwd, env, sessionCredential = null, spawnImpl = spawn }) {
|
|
47
|
+
return (info) => {
|
|
48
|
+
const childEnv = { ...env, BB_PW_TELEMETRY_ROOT: info.root, ...(sessionCredential ? { BOTBUDDY_AGENT_SESSION_TOKEN: sessionCredential } : {}) };
|
|
49
|
+
const child = spawnImpl(process.execPath, [HEARTBEAT_ENTRY, JSON.stringify({ ...info, cwd })], {
|
|
50
|
+
cwd, detached: true, stdio: "ignore", env: childEnv,
|
|
51
|
+
});
|
|
52
|
+
child.on("error", () => {});
|
|
53
|
+
child.unref();
|
|
54
|
+
return child.pid ?? null;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The worktree's already-resolved session credential (exported or the fresh `bb setup`
|
|
59
|
+
// cache). Never self-heals: the detached heartbeat must not mint a session.
|
|
60
|
+
async function cachedSessionCredential({ env, cwd }) {
|
|
61
|
+
try {
|
|
62
|
+
const credential = await resolveAgentSessionCredential({ argv: [], env, cwd, sessionId: null, flagToken: null, selfHeal: async () => null, warn: () => {} });
|
|
63
|
+
return credential?.token ?? null;
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the verified telemetry identity for this worktree and return a
|
|
69
|
+
* lane-tracker resolver, `{ tracked:false }` when the repository has not opted
|
|
70
|
+
* in, or `{ error }` (fail closed) when the opted-in identity is invalid. The
|
|
71
|
+
* durable mcp_env key is preferred; without one, the session credential the lane
|
|
72
|
+
* gate authenticated with (`sessionCredential`), else the cached session, is used.
|
|
73
|
+
*/
|
|
74
|
+
export async function resolveBrowserTelemetry({
|
|
75
|
+
env = process.env, cwd = process.cwd(), sessionCredential = null,
|
|
76
|
+
identityOptions = {}, resolveSession = cachedSessionCredential, attest = attestTelemetryCredential,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const gitRoot = findGitRoot(cwd);
|
|
79
|
+
if (!gitRoot || !existsSync(join(gitRoot, ".botbuddy", "config.json"))) return { tracked: false };
|
|
80
|
+
let identity;
|
|
81
|
+
try {
|
|
82
|
+
await loadTelemetryLocation(cwd, identityOptions);
|
|
83
|
+
identity = await loadTelemetryIdentity(cwd, { ...identityOptions, fallbackCredential: async () => sessionCredential || (await resolveSession({ env, cwd })) });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { error: `bb-pw: browser telemetry identity rejected (${error?.message ?? "invalid"}); nothing was run. Fix .botbuddy/config.json or the worktree credential binding.` };
|
|
86
|
+
}
|
|
87
|
+
const root = telemetryRoot(env);
|
|
88
|
+
try {
|
|
89
|
+
const outbox = await createTelemetryOutbox({ root: join(root, "telemetry"), tenant: identity.tenant, repository: identity.repository, producerVersion: VERSION });
|
|
90
|
+
await attest(identity, outbox);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { error: `bb-pw: browser telemetry credential rejected (${error?.message ?? "unattested"}); nothing was run.` };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
tracked: true,
|
|
96
|
+
resolver: createBrowserTelemetryResolver({
|
|
97
|
+
root, tenant: identity.tenant, repository: identity.repository, producerVersion: VERSION,
|
|
98
|
+
deliver: (event) => deliverExecutionEvent(event, { credential: identity.credential }),
|
|
99
|
+
gitInfo: gitInfoFor({ cwd, env }),
|
|
100
|
+
spawnHeartbeat: spawnHeartbeatProcess({ cwd, env, sessionCredential }),
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The runPw `deps.telemetry` default: `{ resolve({host, lane}) }`. */
|
|
106
|
+
export function makeTelemetry({ env = process.env, cwd = process.cwd(), sessionCredential = null } = {}) {
|
|
107
|
+
let resolved = null;
|
|
9
108
|
return {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
109
|
+
async resolve({ host, lane }) {
|
|
110
|
+
resolved ??= await resolveBrowserTelemetry({ env, cwd, sessionCredential });
|
|
111
|
+
if (!resolved.tracked) return resolved;
|
|
112
|
+
return resolved.resolver.resolve({ host, lane });
|
|
113
|
+
},
|
|
13
114
|
};
|
|
14
115
|
}
|
package/src/telemetry-config.mjs
CHANGED
|
@@ -36,18 +36,23 @@ export async function repositoryFromRemote(cwd = process.cwd()) {
|
|
|
36
36
|
|
|
37
37
|
export async function verifyTelemetryIdentity(config, {
|
|
38
38
|
cwd = process.cwd(), repositoryFromRemote: remote = repositoryFromRemote, resolveBinding = resolveAgentBinding, call = callToolJson, verifyCredential = false,
|
|
39
|
+
// BOT-1318: a `bb setup`-only worktree holds no mcp_env key, only its cached
|
|
40
|
+
// session token. The durable key still wins; the fallback is used only without it,
|
|
41
|
+
// and is attested against the expected tenant like any other credential.
|
|
42
|
+
fallbackCredential = async () => null,
|
|
39
43
|
} = {}) {
|
|
40
44
|
const repository = await remote(cwd);
|
|
41
45
|
if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
|
|
42
46
|
const binding = await resolveBinding({ cwd });
|
|
43
|
-
if (
|
|
44
|
-
|
|
47
|
+
if (binding?.tenant !== config.expectedTenant) throw new Error("telemetry tenant mismatch with checked-in config");
|
|
48
|
+
const credential = binding.token || (await fallbackCredential()) || null;
|
|
49
|
+
if (!credential) throw new Error("telemetry requires a locally stored tenant-bound credential");
|
|
45
50
|
// A binding file names the intended tenant; it is not proof that a rotated
|
|
46
51
|
// environment credential still belongs there. Ask the server's read-only
|
|
47
52
|
// identity endpoint before allowing a foreground child to run.
|
|
48
53
|
if (verifyCredential) {
|
|
49
54
|
const verified = await call("whoami", {}, {
|
|
50
|
-
auth: { "x-agent-api-key":
|
|
55
|
+
auth: { "x-agent-api-key": credential },
|
|
51
56
|
tenant: config.expectedTenant,
|
|
52
57
|
signal: AbortSignal.timeout(2_000),
|
|
53
58
|
});
|
|
@@ -55,7 +60,7 @@ export async function verifyTelemetryIdentity(config, {
|
|
|
55
60
|
throw new Error("telemetry credential is not attested to the expected tenant");
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
|
-
return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential
|
|
63
|
+
return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential };
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
export async function loadTelemetryLocation(cwd = process.cwd(), {
|
|
@@ -21,11 +21,29 @@ export async function deliverExecutionEvent(event, {
|
|
|
21
21
|
});
|
|
22
22
|
let body = {};
|
|
23
23
|
try { body = await response.json(); } catch { /* typed HTTP status still controls retry */ }
|
|
24
|
-
|
|
24
|
+
// BOT-1318 (Codex R1 P2): carry the canonical execution UUID from the real
|
|
25
|
+
// execution-ingest receipt so producers can report it (never source_run_id).
|
|
26
|
+
if (response.status === 200 || response.status === 202) {
|
|
27
|
+
return {
|
|
28
|
+
accepted: true, receipt: body.receipt ?? "accepted",
|
|
29
|
+
...(typeof body.execution_id === "string" && body.execution_id ? { execution_id: body.execution_id } : {}),
|
|
30
|
+
// BOT-1318 (Codex R13): a terminal outcome on a non-terminal event means the
|
|
31
|
+
// server already ended the run (e.g. reaped as abandoned) — producers reconcile.
|
|
32
|
+
...(typeof body.outcome === "string" && body.outcome ? { outcome: body.outcome } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
25
35
|
// A reaper may have terminalized a run after a successful upload whose
|
|
26
36
|
// response was lost. BOT-1317 correctly refuses a contradictory terminal;
|
|
27
37
|
// retaining that record would otherwise block every later local event.
|
|
28
|
-
|
|
38
|
+
// The 409 carries the canonical outcome and execution id; keep them so the producer
|
|
39
|
+
// reconciles its local terminal to the server's (Codex R27).
|
|
40
|
+
if (response.status === 409 && body.error === "terminal_conflict") {
|
|
41
|
+
return {
|
|
42
|
+
accepted: true, receipt: "terminal_conflict_retired",
|
|
43
|
+
...(typeof body.outcome === "string" && body.outcome ? { outcome: body.outcome } : {}),
|
|
44
|
+
...(typeof body.execution_id === "string" && body.execution_id ? { execution_id: body.execution_id } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
29
47
|
// The queue cannot repair an event rejected by the server's contract.
|
|
30
48
|
// Quarantine these client errors so one malformed historical line does not
|
|
31
49
|
// starve later valid lifecycle events. Authentication is deliberately left
|
package/src/telemetry-outbox.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// append a small, allowlisted lifecycle event before spawning a child; delivery
|
|
4
4
|
// is a separate, idempotent step against execution-ingest.
|
|
5
5
|
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { chmod, link, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
@@ -24,10 +24,26 @@ const CORRELATION_KEYS = new Set([
|
|
|
24
24
|
]);
|
|
25
25
|
const PROGRESS_KEYS = new Set(["completed", "total", "phase", "current_item", "worker", "shard"]);
|
|
26
26
|
const DIAGNOSTIC_KEYS = new Set(["code", "message", "category", "signal", "retryable"]);
|
|
27
|
+
// BOT-1318: the optional, bounded browser-acceptance object. Mirrors the
|
|
28
|
+
// execution-ingest contract; anything else (typed values, raw output, invented
|
|
29
|
+
// metrics) is stripped here rather than shipped for the server to reject.
|
|
30
|
+
const BROWSER_KEYS = new Set(["lane", "host", "action", "summary"]);
|
|
31
|
+
const BROWSER_ACTION_KEYS = new Set(["action_id", "verb", "category", "phase", "started_at", "finished_at", "command_ms", "result", "error_class", "route"]);
|
|
32
|
+
const BROWSER_SUMMARY_KEYS = new Set([
|
|
33
|
+
"actions_total", "actions_succeeded", "actions_failed", "actions_interrupted", "command_ms", "active_ms", "action_span_ms",
|
|
34
|
+
"first_action_at", "last_action_at", "navigations", "screenshots", "snapshots", "interactions", "distinct_routes", "acceptance_verdict",
|
|
35
|
+
]);
|
|
36
|
+
const pick = (value, keys) => Object.fromEntries(Object.entries(value).filter(([key]) => keys.has(key)));
|
|
27
37
|
|
|
28
38
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
39
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
30
40
|
|
|
41
|
+
/** True when a string carries a credential the outbox would redact. */
|
|
42
|
+
export function containsSensitive(value) {
|
|
43
|
+
SENSITIVE.lastIndex = 0;
|
|
44
|
+
return typeof value === "string" && SENSITIVE.test(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
function redactString(value) {
|
|
32
48
|
SENSITIVE.lastIndex = 0;
|
|
33
49
|
return value.replace(SENSITIVE, "[REDACTED]");
|
|
@@ -55,7 +71,7 @@ function eventPayload(event) {
|
|
|
55
71
|
if (!clean || typeof clean !== "object" || Array.isArray(clean)) throw new Error("telemetry event must be an object");
|
|
56
72
|
// execution-ingest is intentionally strict. Keep this client-side allowlist so
|
|
57
73
|
// a reporter cannot accidentally turn arbitrary process state into telemetry.
|
|
58
|
-
const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs"]);
|
|
74
|
+
const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs", "browser"]);
|
|
59
75
|
for (const key of Object.keys(clean)) if (!allowed.has(key)) delete clean[key];
|
|
60
76
|
// Mirror the server contract at the producer boundary. A malformed reporter
|
|
61
77
|
// must not create a permanently retrying record merely because it included
|
|
@@ -69,6 +85,11 @@ function eventPayload(event) {
|
|
|
69
85
|
if (clean.progress && typeof clean.progress === "object" && !Array.isArray(clean.progress)) {
|
|
70
86
|
clean.progress = Object.fromEntries(Object.entries(clean.progress).filter(([key]) => PROGRESS_KEYS.has(key)));
|
|
71
87
|
}
|
|
88
|
+
if (clean.browser && typeof clean.browser === "object" && !Array.isArray(clean.browser)) {
|
|
89
|
+
clean.browser = pick(clean.browser, BROWSER_KEYS);
|
|
90
|
+
if (clean.browser.action && typeof clean.browser.action === "object" && !Array.isArray(clean.browser.action)) clean.browser.action = pick(clean.browser.action, BROWSER_ACTION_KEYS);
|
|
91
|
+
if (clean.browser.summary && typeof clean.browser.summary === "object" && !Array.isArray(clean.browser.summary)) clean.browser.summary = pick(clean.browser.summary, BROWSER_SUMMARY_KEYS);
|
|
92
|
+
}
|
|
72
93
|
if (clean.diagnostic && typeof clean.diagnostic === "object" && !Array.isArray(clean.diagnostic)) {
|
|
73
94
|
clean.diagnostic = Object.fromEntries(Object.entries(clean.diagnostic).filter(([key]) => DIAGNOSTIC_KEYS.has(key)));
|
|
74
95
|
}
|
|
@@ -88,7 +109,7 @@ async function durableWrite(path, value, { append = false } = {}) {
|
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
async function atomicJson(path, value) {
|
|
91
|
-
const temp = `${path}.${process.pid}.${
|
|
112
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
92
113
|
await durableWrite(temp, `${JSON.stringify(value)}\n`);
|
|
93
114
|
await rename(temp, path);
|
|
94
115
|
// Persist the directory entry, not just the file data, before reporting the
|
|
@@ -137,7 +158,7 @@ export async function createTelemetryOutbox({
|
|
|
137
158
|
// Write a complete, fsynced owner record first, then atomically publish
|
|
138
159
|
// it with link(2). A crash can leave an unreferenced temp file but can
|
|
139
160
|
// never strand the shared lock name with missing PID metadata.
|
|
140
|
-
const candidate = `${lockPath}.${process.pid}.${
|
|
161
|
+
const candidate = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
141
162
|
await durableWrite(candidate, JSON.stringify({ pid: process.pid, created_at: now().toISOString() }));
|
|
142
163
|
try { await link(candidate, lockPath); } finally { await unlink(candidate).catch(() => {}); }
|
|
143
164
|
return async () => { try { await unlink(lockPath); } catch { /* a process may have cleaned its own stale lock */ } };
|
|
@@ -257,7 +278,7 @@ export async function createTelemetryOutbox({
|
|
|
257
278
|
}
|
|
258
279
|
offset += Buffer.byteLength(line) + 1;
|
|
259
280
|
}
|
|
260
|
-
const tmp = `${path}.${process.pid}.${
|
|
281
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
261
282
|
await durableWrite(tmp, remaining.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
262
283
|
await rename(tmp, path);
|
|
263
284
|
let dir;
|
|
@@ -266,20 +287,25 @@ export async function createTelemetryOutbox({
|
|
|
266
287
|
} finally { await release(); }
|
|
267
288
|
}
|
|
268
289
|
|
|
269
|
-
|
|
290
|
+
/** `ownerOf(event)` (optional) also counts the queue per owner key, e.g. a browser lane. */
|
|
291
|
+
async function status({ ownerOf = null } = {}) {
|
|
270
292
|
const lines = await readLines();
|
|
271
293
|
let oldest = null;
|
|
272
294
|
let queued = 0;
|
|
295
|
+
const byOwner = {};
|
|
273
296
|
for (const line of lines) {
|
|
274
297
|
try {
|
|
275
298
|
const record = parseRecord(line);
|
|
276
299
|
queued += 1;
|
|
300
|
+
const owner = ownerOf ? ownerOf(record.event) : null;
|
|
301
|
+
if (owner) byOwner[owner] = (byOwner[owner] ?? 0) + 1;
|
|
277
302
|
if (!oldest || record.created_at < oldest) oldest = record.created_at;
|
|
278
303
|
} catch { queued += 1; }
|
|
279
304
|
}
|
|
280
305
|
const persisted = await readStatus();
|
|
281
306
|
return {
|
|
282
307
|
queued_count: queued,
|
|
308
|
+
...(ownerOf ? { queued_by_owner: byOwner } : {}),
|
|
283
309
|
oldest_queued_at: oldest,
|
|
284
310
|
last_successful_delivery_at: persisted.last_successful_delivery_at ?? null,
|
|
285
311
|
last_error_class: persisted.last_error_class ?? null,
|
package/src/wait-core.mjs
CHANGED
|
@@ -132,8 +132,8 @@ const VALIDATORS = {
|
|
|
132
132
|
claim,
|
|
133
133
|
};
|
|
134
134
|
},
|
|
135
|
-
"pr-state": (p) =>
|
|
136
|
-
"pr-review": (p) =>
|
|
135
|
+
"pr-state": (p) => parsePrCondition(p, "pr-state"),
|
|
136
|
+
"pr-review": (p) => parsePrCondition(p, "pr-review"),
|
|
137
137
|
// BOT-1063 — test-run: park on a test_run reaching a terminal status. The
|
|
138
138
|
// spine subject_key is `run:<id>` (mirrors the run:<id> convention noted on
|
|
139
139
|
// agent_signal_events.subject_key), so a condition just names the run id.
|
|
@@ -354,6 +354,46 @@ function parsePrTarget(p, label) {
|
|
|
354
354
|
return { repo, number: number != null && number !== "" ? number : null };
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
// BOT-1975 / BOT-1972 — the pr-state / pr-review key allowlist. Before this an
|
|
358
|
+
// unknown key (a typo'd `shaa=`, or `sha=` / `reviewer=` on a client that did not
|
|
359
|
+
// implement them) was silently discarded and the wait armed WIDER than asked —
|
|
360
|
+
// it then woke on another head's or another reviewer's signal. Now an unknown key
|
|
361
|
+
// refuses at arm (invalid_conditions, exit 4). `reviewer=` is pr-review only.
|
|
362
|
+
const PR_CONDITION_KEYS = Object.freeze({
|
|
363
|
+
"pr-state": Object.freeze(["repo", "pr", "number", "sha"]),
|
|
364
|
+
"pr-review": Object.freeze(["repo", "pr", "number", "sha", "reviewer"]),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// A GitHub login compared case-insensitively, with the App suffix `[bot]` stripped
|
|
368
|
+
// (the webhook reports `chatgpt-codex-connector[bot]`; humans type it either way).
|
|
369
|
+
function normalizeReviewerLogin(login) {
|
|
370
|
+
return String(login).trim().toLowerCase().replace(/\[bot\]$/, "");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parsePrCondition(p, label) {
|
|
374
|
+
const allowed = PR_CONDITION_KEYS[label];
|
|
375
|
+
const unknown = Object.keys(p).filter((key) => !allowed.includes(key));
|
|
376
|
+
if (unknown.length > 0) {
|
|
377
|
+
throw new Error(`${label} unknown key '${unknown[0]}' (allowed: ${allowed.join(", ")})`);
|
|
378
|
+
}
|
|
379
|
+
const out = parsePrTarget(p, label);
|
|
380
|
+
// Optional keys are added ONLY when given, so a plain wait's params (sent to the
|
|
381
|
+
// relay and saved in the local checkpoint) are byte-identical to before.
|
|
382
|
+
if (p.sha !== undefined) {
|
|
383
|
+
const sha = typeof p.sha === "string" ? p.sha.trim().toLowerCase() : "";
|
|
384
|
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) {
|
|
385
|
+
throw new Error(`${label} sha= must be 7–40 hex characters (a commit SHA or its prefix)`);
|
|
386
|
+
}
|
|
387
|
+
out.sha = sha;
|
|
388
|
+
}
|
|
389
|
+
if (p.reviewer !== undefined) {
|
|
390
|
+
const reviewer = typeof p.reviewer === "string" ? normalizeReviewerLogin(p.reviewer) : "";
|
|
391
|
+
if (reviewer === "") throw new Error(`${label} reviewer= must be a GitHub login`);
|
|
392
|
+
out.reviewer = reviewer;
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
357
397
|
// Does a spine subject_key (`owner/repo#number`) satisfy a pr target?
|
|
358
398
|
function prSubjectMatches(subjectKey, params) {
|
|
359
399
|
if (typeof subjectKey !== "string") return false;
|
|
@@ -361,6 +401,35 @@ function prSubjectMatches(subjectKey, params) {
|
|
|
361
401
|
return subjectKey.startsWith(`${params.repo}#`);
|
|
362
402
|
}
|
|
363
403
|
|
|
404
|
+
// Does a commit field on a signal payload start with the condition's sha prefix?
|
|
405
|
+
// A missing / non-string field never matches a sha-scoped condition.
|
|
406
|
+
function shaPrefixMatches(value, sha) {
|
|
407
|
+
return typeof value === "string" && value.trim().toLowerCase().startsWith(sha);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// BOT-1975: optional head scoping for pr-state. pr_state signals carry the PR
|
|
411
|
+
// head as payload.head_ref_oid; a signal for a previous head does not wake.
|
|
412
|
+
function prStateFiltersMatch(signal, params) {
|
|
413
|
+
if (params.sha == null) return true;
|
|
414
|
+
return shaPrefixMatches(signal.payload?.head_ref_oid, params.sha);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// BOT-1975 / BOT-1972: optional commit + reviewer scoping for pr-review. A
|
|
418
|
+
// `review_unavailable` signal (the Codex usage-limit decline) names no commit, so
|
|
419
|
+
// it always passes the sha filter — the waiter must learn no review is coming.
|
|
420
|
+
function prReviewFiltersMatch(signal, params) {
|
|
421
|
+
const payload = signal.payload ?? {};
|
|
422
|
+
if (params.sha != null && payload.transition !== "review_unavailable" &&
|
|
423
|
+
!shaPrefixMatches(payload.commit_sha, params.sha)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if (params.reviewer != null) {
|
|
427
|
+
if (typeof payload.reviewer_login !== "string") return false;
|
|
428
|
+
if (normalizeReviewerLogin(payload.reviewer_login) !== params.reviewer) return false;
|
|
429
|
+
}
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
364
433
|
/**
|
|
365
434
|
* Parse and strictly validate a list of `TYPE:params` condition specs.
|
|
366
435
|
*
|
|
@@ -777,9 +846,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
777
846
|
return signal.subject_key.startsWith(`${params.subtype}:${params.host}:`);
|
|
778
847
|
}
|
|
779
848
|
case "pr-state":
|
|
780
|
-
return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params)
|
|
849
|
+
return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params) &&
|
|
850
|
+
prStateFiltersMatch(signal, params);
|
|
781
851
|
case "pr-review":
|
|
782
|
-
return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params)
|
|
852
|
+
return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params) &&
|
|
853
|
+
prReviewFiltersMatch(signal, params);
|
|
783
854
|
case "test-run":
|
|
784
855
|
return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
|
|
785
856
|
case "guidance":
|
package/src/wait.mjs
CHANGED
|
@@ -71,19 +71,35 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
71
71
|
Must be the ONLY condition (use --timeout to bound it); can't be
|
|
72
72
|
raced against other conditions. Omit claim to just wait for
|
|
73
73
|
availability and acquire it yourself.
|
|
74
|
-
pr-state:repo=<owner/repo>[,pr=<n>]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
pr-state:repo=<owner/repo>[,pr=<n>][,sha=<head>]
|
|
75
|
+
a PR lifecycle or mergeable flip (draft<->ready, merged,
|
|
76
|
+
closed, conflict/clean flip) plus check-rollup, review-
|
|
77
|
+
decision and unresolved-thread-count changes, as projected
|
|
78
|
+
by the PR read model. Omit pr= to wake on ANY of your PRs
|
|
79
|
+
in the repo; or pass pr=<owner/repo#n> in one value. A
|
|
80
|
+
conflict/clean flip sets payload.mergeable_changed. The
|
|
81
|
+
payload carries head_ref_oid; sha=<head> (7-40 hex, a
|
|
82
|
+
prefix is fine) wakes only on signals for that head, so a
|
|
83
|
+
previous head's check result can't wake you. A push alone
|
|
84
|
+
emits nothing — the head rides on the next transition.
|
|
85
|
+
pr-review:repo=<owner/repo>[,pr=<n>][,reviewer=<login>][,sha=<commit>]
|
|
86
|
+
a submitted review, a new review comment, a thread
|
|
87
|
+
resolved/re-opened (payload carries the unresolved-thread
|
|
88
|
+
count), a Codex clean pass (payload.transition=clean_pass)
|
|
89
|
+
or the Codex usage-limit decline (transition=
|
|
90
|
+
review_unavailable — no review is coming).
|
|
82
91
|
Edge-triggered: it wakes only on review activity AFTER
|
|
83
|
-
arm
|
|
84
|
-
bb-wait prints a one-line
|
|
85
|
-
review_decision, age) so a
|
|
86
|
-
"no review yet" — the
|
|
92
|
+
arm, so arm BEFORE requesting the review. If the PR ALREADY
|
|
93
|
+
has an outstanding review at arm, bb-wait prints a one-line
|
|
94
|
+
stderr notice (unresolved count, review_decision, age) so a
|
|
95
|
+
silent park isn't mistaken for "no review yet" — the
|
|
96
|
+
existing review will not re-wake it.
|
|
97
|
+
reviewer=<login> wakes only on that reviewer (case-
|
|
98
|
+
insensitive; a trailing [bot] is ignored). sha=<commit>
|
|
99
|
+
wakes only on verdicts for that commit (submitted reviews
|
|
100
|
+
and clean passes carry commit_sha; thread comments do not,
|
|
101
|
+
so they never match sha=); the usage-limit decline always
|
|
102
|
+
passes sha=. Any other key is refused (exit 4).
|
|
87
103
|
test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
|
|
88
104
|
lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
|
|
89
105
|
becoming active — the zero-poll wake for a parked
|
|
@@ -113,7 +129,8 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
113
129
|
the ranking (first-come). Needs cli >= 1.20.0.
|
|
114
130
|
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
|
|
115
131
|
| scope=next[,sha=<sha>][,branch=<name>][,workflow=<name>]}
|
|
116
|
-
a
|
|
132
|
+
a run's terminal conclusion (owner-scoped) — not a
|
|
133
|
+
PR state or review change (use pr-state / pr-review);
|
|
117
134
|
GitHub Actions or an external check_suite provider.
|
|
118
135
|
A repo with no CI feed is rejected (no_signal_source).
|
|
119
136
|
scope=next is LIVE-ONLY: it wakes on the first terminal
|
|
@@ -154,7 +171,9 @@ AUTH
|
|
|
154
171
|
|
|
155
172
|
OUTPUT
|
|
156
173
|
Exactly one JSON receipt line on stdout at exit, with client semver and
|
|
157
|
-
protocol identity. Diagnostics go to stderr
|
|
174
|
+
protocol identity. Diagnostics go to stderr — including the arm-time notices
|
|
175
|
+
(outstanding review, latest CI run). Do not pipe bb wait through tail: the
|
|
176
|
+
notice is on stderr and you will not see it until the wait exits.
|
|
158
177
|
Exit codes: 0 matched · 2 timeout · 3 auth · 4 invalid conditions ·
|
|
159
178
|
5 backend unavailable · 6 cursor expired · 7 internal ·
|
|
160
179
|
8 superseded (wait replaced server-side). If the receipt has
|