@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 +1 -1
- package/src/botbuddy-release-repair.json +1 -1
- package/src/docker-hygiene.mjs +200 -22
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.5.1","source_identity":"
|
|
1
|
+
{"schema_version":1,"source_version":"1.5.1","source_identity":"7bee97ef5e6148138f44fc770322b2bfffe059e2187b6ab7970ab98548f73763"}
|
package/src/docker-hygiene.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
506
|
-
const
|
|
507
|
-
? inspectInBatches(runDocker, selector, "container",
|
|
508
|
-
: [];
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
? inspectInBatches(runDocker, selector, "network",
|
|
512
|
-
: [];
|
|
513
|
-
return {
|
|
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
|
|
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
|
}
|