@botbuddy/cli 1.59.7 → 1.59.9

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/wait-core.mjs CHANGED
@@ -80,6 +80,7 @@ function managedAllocationFailureDetail(signal, condition) {
80
80
  // own-session terminal branch because its wait_session_id differs) would otherwise match
81
81
  // that raw event condition and exit an unrelated wait as an ordinary match (Codex P2).
82
82
  export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
83
+ const HOST_APPROVAL_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
83
84
 
84
85
  // The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
85
86
  // public.agent_signal_type enum is these PLUS the control types above
@@ -99,6 +100,11 @@ export const SIGNAL_TYPES = Object.freeze([
99
100
  "snapshot",
100
101
  "ticket_unblocked",
101
102
  "stack_lease",
103
+ // BOT-2324: emitted only once the exact public host approval predicate is
104
+ // true (owner-bound active settings + policy + live tenant subscription).
105
+ // It is intentionally consumed through the existing raw `event:` escape
106
+ // hatch rather than adding another parallel wait grammar.
107
+ "host_approval",
102
108
  ]);
103
109
 
104
110
  const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
@@ -281,9 +287,10 @@ const VALIDATORS = {
281
287
  // BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run or PR
282
288
  // (they must already exist at arm time) and wakes on the first terminal CI run for
283
289
  // the repo delivered AFTER the wait was armed — the post-merge / cron train case.
284
- // Optional branch=/workflow= filter that first run; branch is valid only with
285
- // scope=next. A workflow also pins a level-triggered sha= recovery to one
286
- // canonical workflow identity.
290
+ // Optional branch=/workflow=/workflow_id=/workflow_path= filters narrow that
291
+ // first run; branch and stable workflow identities are valid only with
292
+ // scope=next. The legacy display-name filter also pins a level-triggered
293
+ // sha= recovery to one historical workflow.
287
294
  // BOT-1685 — scope=next also accepts an optional sha= FILTER (distinct from the
288
295
  // level-triggered sha= selector): a wait armed BEFORE re-dispatching a run for a
289
296
  // known commit then wakes on the NEXT terminal run for THAT commit, instead of
@@ -303,9 +310,13 @@ const VALIDATORS = {
303
310
  }
304
311
  const hasBranchFilter = p.branch !== undefined;
305
312
  const hasWorkflowFilter = p.workflow !== undefined;
313
+ const hasWorkflowIdFilter = p.workflow_id !== undefined;
314
+ const hasWorkflowPathFilter = p.workflow_path !== undefined;
306
315
  if (p.scope !== undefined) {
307
316
  if (p.scope === "latest") {
308
- if (hasBranchFilter || hasWorkflowFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
317
+ if (hasBranchFilter || hasWorkflowFilter || hasWorkflowIdFilter || hasWorkflowPathFilter) {
318
+ throw new Error("ci branch=/workflow=/workflow_id=/workflow_path= are only valid with scope=next");
319
+ }
309
320
  if (p.pr === undefined || !/^\d+$/.test(String(p.pr).trim())) {
310
321
  throw new Error("ci scope=latest needs pr=<number>");
311
322
  }
@@ -323,12 +334,23 @@ const VALIDATORS = {
323
334
  if (v === "") throw new Error(`ci ${key}= must be non-empty`);
324
335
  return v;
325
336
  };
326
- return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), sha: filter("sha"), pr: null };
337
+ const workflowId = filter("workflow_id");
338
+ const workflowPath = filter("workflow_path");
339
+ return {
340
+ repo,
341
+ scope: "next",
342
+ branch: filter("branch"),
343
+ workflow: filter("workflow"),
344
+ ...(workflowId == null ? {} : { workflowId }),
345
+ ...(workflowPath == null ? {} : { workflowPath }),
346
+ sha: filter("sha"),
347
+ pr: null,
348
+ };
327
349
  }
328
350
  throw new Error("ci scope must be 'latest' or 'next' (or use run_id=/sha=)");
329
351
  }
330
- if (hasBranchFilter || (hasWorkflowFilter && p.sha === undefined)) {
331
- throw new Error("ci branch= is only valid with scope=next; workflow= also accepts an exact sha selector");
352
+ if (hasBranchFilter || hasWorkflowIdFilter || hasWorkflowPathFilter || (hasWorkflowFilter && p.sha === undefined)) {
353
+ throw new Error("ci branch=/workflow_id=/workflow_path= are only valid with scope=next; legacy workflow= also accepts an exact sha selector");
332
354
  }
333
355
  if (p.run_id !== undefined) {
334
356
  if (!String(p.run_id).trim()) throw new Error("ci run_id must be non-empty");
@@ -337,7 +359,13 @@ const VALIDATORS = {
337
359
  if (!String(p.sha).trim()) throw new Error("ci sha must be non-empty");
338
360
  const workflow = p.workflow === undefined ? null : String(p.workflow).trim();
339
361
  if (p.workflow !== undefined && workflow === "") throw new Error("ci workflow= must be non-empty");
340
- return { repo, scope: "sha", sha: String(p.sha).trim(), workflow, pr: p.pr != null ? String(p.pr).trim() : null };
362
+ return {
363
+ repo,
364
+ scope: "sha",
365
+ sha: String(p.sha).trim(),
366
+ workflow,
367
+ pr: p.pr != null ? String(p.pr).trim() : null,
368
+ };
341
369
  },
342
370
  "staging-green"(p) {
343
371
  const repo = p.repo != null ? String(p.repo).trim() : null;
@@ -406,7 +434,15 @@ const VALIDATORS = {
406
434
  if (!SIGNAL_TYPES.includes(p.type)) {
407
435
  throw new Error(`event type must be a known signal_type (${SIGNAL_TYPES.join(", ")})`);
408
436
  }
409
- return { type: p.type, subject: p.subject !== undefined ? String(p.subject) : null };
437
+ const subject = p.subject !== undefined ? String(p.subject).trim() : null;
438
+ // BOT-2324: unlike the generic raw escape hatch, this approval condition
439
+ // has one narrow public identity grammar. The service independently checks
440
+ // it for direct JSON callers; keeping the CLI equally strict prevents an
441
+ // impossible-to-match wait from being armed locally.
442
+ if (p.type === "host_approval" && (!subject || !HOST_APPROVAL_HOST_KEY_RE.test(subject))) {
443
+ throw new Error("event:type=host_approval needs subject=<canonical bbh- host key>");
444
+ }
445
+ return { type: p.type, subject };
410
446
  },
411
447
  };
412
448
 
@@ -999,7 +1035,7 @@ export function formatUnknownWorkflowGuidance(detail, candidates) {
999
1035
  const named = Array.isArray(candidates) ? candidates.filter((c) => typeof c === "string" && c.trim() !== "") : [];
1000
1036
  if (named.length === 0) return lines;
1001
1037
  lines.push("closest workflow names this repo has actually reported:");
1002
- for (const name of named) lines.push(` workflow=${name}`);
1038
+ for (const name of named) lines.push(` ${name.includes("=") ? name : `workflow=${name}`}`);
1003
1039
  return lines;
1004
1040
  }
1005
1041
 
@@ -1138,16 +1174,20 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1138
1174
  if (params.scope === "run_id") return String(signal.payload?.run_id) === params.runId;
1139
1175
  if (params.scope === "sha") {
1140
1176
  if (signal.payload?.head_sha !== params.sha) return false;
1141
- if (params.workflow == null) return true;
1142
- const workflow = signal.payload?.workflow_name;
1143
- return typeof workflow === "string" && workflow.trim().toLowerCase() === params.workflow.trim().toLowerCase();
1177
+ if (params.workflow != null) {
1178
+ const workflow = signal.payload?.workflow_name;
1179
+ if (typeof workflow !== "string" || workflow.trim().toLowerCase() !== params.workflow.trim().toLowerCase()) return false;
1180
+ }
1181
+ if (params.workflowId != null && String(signal.payload?.workflow_id ?? "").trim() !== params.workflowId) return false;
1182
+ if (params.workflowPath != null && String(signal.payload?.workflow_path ?? "").trim() !== params.workflowPath) return false;
1183
+ return true;
1144
1184
  }
1145
1185
  if (params.scope === "next") {
1146
1186
  // BOT-1507 — live-only: the first TERMINAL run for the repo. Never keys on
1147
1187
  // subject_key (it is run:<id> for post-merge runs and owner/repo#n for PR
1148
1188
  // runs — both are legitimate wakes); a null/empty conclusion is an
1149
1189
  // in-progress signal and never terminal. Optional branch/workflow filters:
1150
- // branch is exact, workflow is the GitHub display name (case/space-insensitive).
1190
+ // branch/path/id are exact; the display name is case/space-insensitive.
1151
1191
  const conclusion = signal.payload?.conclusion;
1152
1192
  if (typeof conclusion !== "string" || conclusion.trim() === "") return false;
1153
1193
  // BOT-1685: an optional sha filter pins scope=next to a commit, so a
@@ -1160,6 +1200,8 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1160
1200
  return false;
1161
1201
  }
1162
1202
  }
1203
+ if (params.workflowId != null && String(signal.payload?.workflow_id ?? "").trim() !== params.workflowId) return false;
1204
+ if (params.workflowPath != null && String(signal.payload?.workflow_path ?? "").trim() !== params.workflowPath) return false;
1163
1205
  return true;
1164
1206
  }
1165
1207
  // scope=latest: the PR's run (by subject owner/repo#pr or payload pr_number).
@@ -1237,10 +1279,13 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
1237
1279
  // wake the wrong workspace. This centralizes what BOT-1247's `unblocked` matcher did
1238
1280
  // per-type (that per-type check stays as belt-and-braces). Tenantless signals
1239
1281
  // (host-shared lock_availability/container_capacity, owner/recipient-scoped
1240
- // chat/pr_state/test_run/guidance) are NEVER blocked — they keep today's semantics
1241
- // for any session (AC-5). `sessionTenant === undefined` opts a caller out of the
1242
- // central guard: a pure unit test asserting a per-type matcher in isolation. The CLI
1243
- // always threads the server-resolved tenant, so the guard is live for every real wait.
1282
+ // chat/pr_state/test_run/guidance) are NEVER blocked — they keep today's semantics
1283
+ // for any session (AC-5). `host_approval` is the deliberate exception: it is
1284
+ // owner-visible AND tenant-bound, so the relay stamps scope=owner_tenant and we
1285
+ // retain this tenant fence after its owner gate. `sessionTenant === undefined`
1286
+ // opts a caller out of the central guard: a pure unit test asserting a per-type
1287
+ // matcher in isolation. The CLI always threads the server-resolved tenant, so
1288
+ // the guard is live for every real wait.
1244
1289
  export function signalTenantBlocked(signal, sessionTenant) {
1245
1290
  if (sessionTenant === undefined) return false;
1246
1291
  if (signal.tenant_id == null) return false;
package/src/wait.mjs CHANGED
@@ -153,7 +153,10 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
153
153
  Wakes only after a complete non-vacuous workflow success;
154
154
  manual resolutions, overrides, skipped, and neutral runs do not match.
155
155
  event:type=<signal_type>[,subject=<key>]
156
- raw signal-spine match (escape hatch)
156
+ raw signal-spine match (escape hatch). Host setup uses
157
+ event:type=host_approval,subject=bbh-<24 lowercase hex>;
158
+ it wakes only for the authenticated owner's one bound
159
+ tenant after current server-side approval revalidation.
157
160
 
158
161
  OPTIONS
159
162
  --any wake on the first matching condition (default; only mode in v1)