@botbuddy/cli 1.5.3 → 1.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"schema_version":1,"source_version":"1.5.1","source_identity":"1bc59a77cac3a2df9a24764a6ae8c4df3fc9e002701396f6a97dd2dfa84589c6"}
1
+ {"schema_version":1,"source_version":"1.5.1","source_identity":"7bee97ef5e6148138f44fc770322b2bfffe059e2187b6ab7970ab98548f73763"}
@@ -78,6 +78,11 @@ SAFETY
78
78
  • Re-inspects every candidate immediately before deletion.
79
79
  • Never force-removes a resource, never runs a prune command, and never removes volumes.
80
80
 
81
+ PERSISTENT ORBSTACK GHOSTS
82
+ If an exact ID remains listed but OrbStack cannot inspect it, inventory is
83
+ reported but cleanup refuses. Restart or repair OrbStack, then rerun the
84
+ hygiene dry run with the same explicit selector. Never use broad cleanup.
85
+
81
86
  EXAMPLES
82
87
  botbuddy docker hygiene --context orbstack --ticket BOT-1405 --json
83
88
  botbuddy docker hygiene --context orbstack --apply --ticket BOT-1405 \\
@@ -327,18 +332,143 @@ function nonEmptyLines(text) {
327
332
  return text.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
328
333
  }
329
334
 
330
- function inspectInBatches(runDocker, selector, kind, ids, extraArgs = []) {
335
+ function boundedDockerError(value) {
336
+ return String(value || "Docker command failed").trim().replace(/\s+/g, " ").slice(0, 500);
337
+ }
338
+
339
+ function listInventoryRefs(runDocker, selector, kind, exactId = null) {
340
+ const args = [kind, "ls"];
341
+ if (kind === "container") args.push("--all");
342
+ args.push("--no-trunc");
343
+ if (kind === "container" && !exactId) args.push("--filter", "name=supabase_");
344
+ if (exactId) args.push("--filter", `id=${exactId}`);
345
+ args.push("--format", kind === "container" ? "{{.ID}}\t{{.Names}}" : "{{.ID}}\t{{.Name}}");
346
+
347
+ const refs = nonEmptyLines(selected(runDocker, selector, args)).map((line) => {
348
+ const [rawId, ...rawName] = line.split("\t");
349
+ const id = rawId.trim();
350
+ if (!id) throw new Error(`Docker ${kind} inventory returned an empty ID`);
351
+ return { id, name: rawName.join("\t").trim() };
352
+ });
353
+ const exact = exactId ? refs.filter((ref) => ref.id === exactId) : refs;
354
+ return [...new Map(exact.map((ref) => [ref.id, ref])).values()];
355
+ }
356
+
357
+ function inspectAttempt(runDocker, selector, kind, refs, extraArgs) {
358
+ const ids = refs.map((ref) => ref.id);
359
+ const args = [...selector, kind, "inspect", ...extraArgs, ...ids];
360
+ const result = runDocker(args);
361
+ if (result?.error || result?.status !== 0) {
362
+ return { ok: false, resources: [], error: commandFailure(args, result).message };
363
+ }
364
+
365
+ let inspected;
366
+ try {
367
+ inspected = JSON.parse(String(result.stdout || ""));
368
+ } catch {
369
+ return { ok: false, resources: [], error: `docker ${kind} inspect returned malformed JSON` };
370
+ }
371
+ if (!Array.isArray(inspected)) {
372
+ return { ok: false, resources: [], error: `Docker ${kind} inventory returned an unexpected shape` };
373
+ }
374
+ return { ok: true, resources: inspected, error: null };
375
+ }
376
+
377
+ function exactInspectedResource(attempt, id) {
378
+ if (!attempt.ok || attempt.resources.length !== 1) return null;
379
+ return String(attempt.resources[0]?.Id || "") === id ? attempt.resources[0] : null;
380
+ }
381
+
382
+ function exactInspectError(attempt, kind, id) {
383
+ if (!attempt.ok) return attempt.error;
384
+ return `docker ${kind} inspect did not return the exact requested ID ${id}`;
385
+ }
386
+
387
+ function inspectInBatches(runDocker, selector, kind, refs, extraArgs = []) {
331
388
  const resources = [];
332
- for (let offset = 0; offset < ids.length; offset += INSPECT_BATCH_SIZE) {
333
- const batch = ids.slice(offset, offset + INSPECT_BATCH_SIZE);
334
- const inspected = parseJson(
335
- selected(runDocker, selector, [kind, "inspect", ...extraArgs, ...batch]),
336
- `docker ${kind} inspect`,
337
- );
338
- if (!Array.isArray(inspected)) throw new Error(`Docker ${kind} inventory returned an unexpected shape`);
339
- resources.push(...inspected);
389
+ const skipped = [];
390
+ const errors = [];
391
+ for (let offset = 0; offset < refs.length; offset += INSPECT_BATCH_SIZE) {
392
+ const batch = refs.slice(offset, offset + INSPECT_BATCH_SIZE);
393
+ const attempt = inspectAttempt(runDocker, selector, kind, batch, extraArgs);
394
+ let isolate = batch;
395
+
396
+ if (attempt.ok) {
397
+ const byId = new Map();
398
+ let invalidIdentity = false;
399
+ for (const resource of attempt.resources) {
400
+ const id = String(resource?.Id || "");
401
+ if (!id || byId.has(id) || !batch.some((ref) => ref.id === id)) {
402
+ invalidIdentity = true;
403
+ break;
404
+ }
405
+ byId.set(id, resource);
406
+ }
407
+ if (!invalidIdentity) {
408
+ for (const ref of batch) {
409
+ const resource = byId.get(ref.id);
410
+ if (resource) resources.push(resource);
411
+ }
412
+ isolate = batch.filter((ref) => !byId.has(ref.id));
413
+ }
414
+ }
415
+
416
+ for (const ref of isolate) {
417
+ const isolated = inspectAttempt(runDocker, selector, kind, [ref], extraArgs);
418
+ const isolatedResource = exactInspectedResource(isolated, ref.id);
419
+ if (isolatedResource) {
420
+ resources.push(isolatedResource);
421
+ continue;
422
+ }
423
+
424
+ // Re-list through the same validated selector and require exact full-ID
425
+ // equality. A prefix match or same-name replacement is never authority.
426
+ const relisted = listInventoryRefs(runDocker, selector, kind, ref.id);
427
+ const current = relisted.find((item) => item.id === ref.id);
428
+ if (!current) {
429
+ skipped.push({
430
+ type: kind,
431
+ id: ref.id,
432
+ name: ref.name,
433
+ project: null,
434
+ reason: "disappeared_during_inventory",
435
+ });
436
+ continue;
437
+ }
438
+
439
+ const retried = inspectAttempt(runDocker, selector, kind, [current], extraArgs);
440
+ const retriedResource = exactInspectedResource(retried, ref.id);
441
+ if (retriedResource) {
442
+ resources.push(retriedResource);
443
+ continue;
444
+ }
445
+ // The row can disappear after the first exact re-list but before the
446
+ // retry returns. Confirm it still exists before calling the engine state
447
+ // persistently uninspectable; daemon/list failures still throw closed.
448
+ const finalRelisted = listInventoryRefs(runDocker, selector, kind, ref.id);
449
+ const finalCurrent = finalRelisted.find((item) => item.id === ref.id);
450
+ if (!finalCurrent) {
451
+ skipped.push({
452
+ type: kind,
453
+ id: ref.id,
454
+ name: current.name || ref.name,
455
+ project: null,
456
+ reason: "disappeared_during_inventory",
457
+ });
458
+ continue;
459
+ }
460
+ errors.push({
461
+ type: kind,
462
+ id: ref.id,
463
+ name: finalCurrent.name || current.name || ref.name,
464
+ project: null,
465
+ reason: "uninspectable_during_inventory",
466
+ error: boundedDockerError(exactInspectError(retried, kind, ref.id)
467
+ || exactInspectError(isolated, kind, ref.id)),
468
+ });
469
+ }
340
470
  }
341
- return resources;
471
+ return { resources, skipped, errors };
342
472
  }
343
473
 
344
474
  function projectIdentity(resource) {
@@ -403,7 +533,7 @@ function skippedResource(type, resource, reason, extra = {}) {
403
533
  };
404
534
  }
405
535
 
406
- export function classifyInventory({ containers = [], networks = [] } = {}) {
536
+ export function classifyInventory({ containers = [], networks = [], skipped: discoverySkipped = [], errors = [], listed = {} } = {}) {
407
537
  const activeProjects = new Set();
408
538
  for (const resource of containers) {
409
539
  const name = containerName(resource);
@@ -412,7 +542,7 @@ export function classifyInventory({ containers = [], networks = [] } = {}) {
412
542
  }
413
543
 
414
544
  const candidates = [];
415
- const skipped = [];
545
+ const skipped = [...discoverySkipped];
416
546
  let supabaseContainers = 0;
417
547
 
418
548
  for (const resource of containers) {
@@ -490,9 +620,12 @@ export function classifyInventory({ containers = [], networks = [] } = {}) {
490
620
  active_projects: [...activeProjects].sort(),
491
621
  candidates: candidates.sort(resourceSort),
492
622
  skipped: skipped.sort(resourceSort),
623
+ inventory_errors: [...errors].sort(resourceSort),
493
624
  counts: {
494
625
  containers: containers.length,
495
626
  networks: networks.length,
627
+ listed_containers: listed.containers ?? containers.length,
628
+ listed_networks: listed.networks ?? networks.length,
496
629
  supabase_containers: supabaseContainers,
497
630
  supabase_networks: supabaseNetworks,
498
631
  user_bridge_networks: userBridgeNetworks,
@@ -502,15 +635,21 @@ export function classifyInventory({ containers = [], networks = [] } = {}) {
502
635
  }
503
636
 
504
637
  function readInventory(runDocker, selector) {
505
- const containerIds = nonEmptyLines(selected(runDocker, selector, ["container", "ls", "--all", "--quiet", "--no-trunc", "--filter", "name=supabase_"]));
506
- const containers = containerIds.length
507
- ? inspectInBatches(runDocker, selector, "container", containerIds, ["--size"])
508
- : [];
509
- const networkIds = nonEmptyLines(selected(runDocker, selector, ["network", "ls", "--quiet", "--no-trunc"]));
510
- const networks = networkIds.length
511
- ? inspectInBatches(runDocker, selector, "network", networkIds)
512
- : [];
513
- return { containers, networks };
638
+ const containerRefs = listInventoryRefs(runDocker, selector, "container");
639
+ const containerResult = containerRefs.length
640
+ ? inspectInBatches(runDocker, selector, "container", containerRefs, ["--size"])
641
+ : { resources: [], skipped: [], errors: [] };
642
+ const networkRefs = listInventoryRefs(runDocker, selector, "network");
643
+ const networkResult = networkRefs.length
644
+ ? inspectInBatches(runDocker, selector, "network", networkRefs)
645
+ : { resources: [], skipped: [], errors: [] };
646
+ return {
647
+ containers: containerResult.resources,
648
+ networks: networkResult.resources,
649
+ skipped: [...containerResult.skipped, ...networkResult.skipped],
650
+ errors: [...containerResult.errors, ...networkResult.errors],
651
+ listed: { containers: containerRefs.length, networks: networkRefs.length },
652
+ };
514
653
  }
515
654
 
516
655
  function validateOrbStack(runDocker, opts) {
@@ -556,6 +695,7 @@ export function evaluatePressure({
556
695
  }) {
557
696
  const current = Number(bridgeNetworks) + (2 * Number(attachedEndpoints));
558
697
  const projected = current + 1 + (2 * Number(projectedEndpoints));
698
+ const status = projected >= fail ? "fail" : projected >= warn ? "warn" : "ok";
559
699
  return {
560
700
  metric: "conservative_network_pressure_units",
561
701
  formula: "bridge_networks + (2 * attached_endpoints)",
@@ -564,7 +704,18 @@ export function evaluatePressure({
564
704
  projected_endpoints: Number(projectedEndpoints),
565
705
  warn_pressure: Number(warn),
566
706
  fail_pressure: Number(fail),
567
- status: projected >= fail ? "fail" : projected >= warn ? "warn" : "ok",
707
+ status,
708
+ // BOT-1412: a host-wide interface-pressure refusal is not a per-stack
709
+ // `supabase_local` lock wait. Emit the relay's capacity condition in the
710
+ // machine receipt so callers park on the global policy and receive the
711
+ // eventual capacity signal, rather than retrying Docker locally.
712
+ ...(status === "fail" ? {
713
+ wait: {
714
+ kind: "global_capacity",
715
+ condition: "capacity:policy=host_docker_pressure_v1,slots>=1",
716
+ command: "bb-wait 'capacity:policy=host_docker_pressure_v1,slots>=1'",
717
+ },
718
+ } : {}),
568
719
  note: "Conservative pressure proxy; OrbStack does not expose a kernel interface ceiling through the Docker API.",
569
720
  };
570
721
  }
@@ -584,6 +735,7 @@ function baseReceipt(command, opts, now) {
584
735
  deleted: [],
585
736
  reclaimed_space_bytes: 0,
586
737
  skipped: [],
738
+ inventory_errors: [],
587
739
  pressure: null,
588
740
  warnings: [],
589
741
  errors: [],
@@ -686,6 +838,7 @@ export function runDockerWorkflow(argv, {
686
838
  receipt.inventory = inventory.counts;
687
839
  receipt.active_projects = inventory.active_projects;
688
840
  receipt.skipped = [...inventory.skipped];
841
+ receipt.inventory_errors = [...inventory.inventory_errors];
689
842
 
690
843
  const ticketCandidates = parsed.opts.ticket
691
844
  ? inventory.candidates.filter((item) => projectMatchesTicket(item.project, parsed.opts.ticket))
@@ -700,6 +853,15 @@ export function runDockerWorkflow(argv, {
700
853
  receipt.candidates = ticketCandidates;
701
854
  receipt.candidate_reclaimable_bytes = ticketCandidates.reduce((total, item) => total + item.reclaimable_bytes, 0);
702
855
 
856
+ if (receipt.inventory_errors.length > 0) {
857
+ receipt.outcome = "refused";
858
+ for (const item of receipt.inventory_errors) {
859
+ receipt.errors.push(`${item.type} ${item.id}${item.name ? ` (${item.name})` : ""} remains listed but cannot be authoritatively inspected: ${item.error}`);
860
+ }
861
+ receipt.recommendation = "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup.";
862
+ return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
863
+ }
864
+
703
865
  if (parsed.command === "preflight") {
704
866
  receipt.pressure = evaluatePressure({
705
867
  bridgeNetworks: inventory.counts.user_bridge_networks,
@@ -757,6 +919,17 @@ export function runDockerWorkflow(argv, {
757
919
  addSkipOnce(receipt, { ...candidate, reason: "revalidation_failed" });
758
920
  continue;
759
921
  }
922
+ if (fresh.inventory_errors.length > 0) {
923
+ for (const item of fresh.inventory_errors) {
924
+ if (!receipt.inventory_errors.some((existing) => existing.type === item.type && existing.id === item.id)) {
925
+ receipt.inventory_errors.push(item);
926
+ receipt.errors.push(`revalidation blocked by ${item.type} ${item.id}${item.name ? ` (${item.name})` : ""}: ${item.error}`);
927
+ }
928
+ }
929
+ receipt.recommendation = "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup.";
930
+ addSkipOnce(receipt, { ...candidate, reason: "revalidation_failed" });
931
+ continue;
932
+ }
760
933
  const revalidated = fresh.candidates.find((item) => item.type === candidate.type
761
934
  && item.id === candidate.id
762
935
  && projectMatchesTicket(item.project, parsed.opts.ticket));
@@ -843,6 +1016,11 @@ export function formatHumanReceipt(receipt) {
843
1016
  lines.push(`Skipped (${receipt.skipped.length}):`);
844
1017
  for (const item of receipt.skipped) lines.push(` ${item.type} ${item.id} ${item.name} — ${item.reason}`);
845
1018
  if (receipt.skipped.length === 0) lines.push(" none");
1019
+ lines.push(`Inventory errors (${receipt.inventory_errors?.length || 0}):`);
1020
+ for (const item of receipt.inventory_errors || []) {
1021
+ lines.push(` ${item.type} ${item.id} ${item.name || "(name unknown)"} — ${item.reason}: ${item.error}`);
1022
+ }
1023
+ if (!receipt.inventory_errors?.length) lines.push(" none");
846
1024
  if (receipt.pressure) {
847
1025
  lines.push(`Pressure: ${receipt.pressure.projected_pressure_units} projected (${receipt.pressure.status}; warn ${receipt.pressure.warn_pressure}, fail ${receipt.pressure.fail_pressure})`);
848
1026
  }