@botbuddy/cli 1.4.1 → 1.5.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.
@@ -0,0 +1,1062 @@
1
+ // BOT-1405 — dry-run-first OrbStack/Supabase hygiene.
2
+ //
3
+ // This module deliberately uses Docker's narrow object removal commands. It
4
+ // never constructs a prune, volume, force-remove, or context-switch command.
5
+ // Every daemon operation carries an explicit --context or --host selector.
6
+ import { spawnSync } from "node:child_process";
7
+ import { readFileSync } from "node:fs";
8
+ import { hostname } from "node:os";
9
+ import { callToolJson } from "./api.mjs";
10
+ import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
11
+
12
+ export const SCHEMA_VERSION = 1;
13
+ export const DEFAULT_PROJECTED_ENDPOINTS = 10;
14
+ export const DEFAULT_WARN_PRESSURE = 192;
15
+ export const DEFAULT_FAIL_PRESSURE = 224;
16
+ const INSPECT_BATCH_SIZE = 10;
17
+ const MAX_APPLY_BUDGET_MS = 5 * 60 * 1000;
18
+ const MAX_DOCKER_COMMAND_MS = 30 * 1000;
19
+ const LOCK_EXPIRY_RESERVE_MS = 60 * 1000;
20
+
21
+ export const EXIT = Object.freeze({
22
+ OK: 0,
23
+ PRESSURE: 2,
24
+ INVALID: 4,
25
+ DOCKER: 5,
26
+ DELETE_FAILED: 6,
27
+ UNSUPPORTED: 7,
28
+ });
29
+
30
+ export const DOCKER_HELP = `botbuddy docker — safe local OrbStack/Supabase hygiene
31
+
32
+ USAGE
33
+ botbuddy docker hygiene (--context <name> | --endpoint <uri>) [--ticket <BOT|ENT-N>] [--json]
34
+ botbuddy docker hygiene (--context <name> | --endpoint <uri>) --apply
35
+ --ticket <BOT|ENT-N> --lock-slot <stable-slot>
36
+ --candidate-id <id> [--candidate-id <id> ...] [--json]
37
+ botbuddy docker preflight (--context <name> | --endpoint <uri>) [options] [--json]
38
+
39
+ COMMANDS
40
+ hygiene Inventory stopped Supabase containers and empty
41
+ supabase_network_* bridge networks. This is a dry-run unless
42
+ --apply is explicitly present.
43
+ preflight Read-only network-pressure check for a prospective managed stack.
44
+
45
+ REQUIRED SELECTOR
46
+ --context <name> Explicit Docker context, e.g. orbstack
47
+ --endpoint <uri> Explicit Docker endpoint, e.g.
48
+ unix:///Users/me/.orbstack/run/docker.sock
49
+
50
+ Exactly one selector is required. The target must identify itself as
51
+ OrbStack; the ambient Docker context and DOCKER_HOST are never trusted.
52
+
53
+ HYGIENE OPTIONS
54
+ --ticket <BOT|ENT-N> Scope discovery/apply to the owning ticket's project.
55
+ --lock-slot <slot> Stable supabase_local slot used by the stack lifecycle.
56
+ Apply acquires it on this machine before Docker inventory.
57
+ --candidate-id <id> Exact reviewed dry-run candidate to apply; repeatable.
58
+ --apply Delete only the ticket-owned reviewed IDs after revalidation.
59
+ Without it, no mutation.
60
+ --json Emit exactly one compact machine-readable receipt.
61
+
62
+ PREFLIGHT OPTIONS
63
+ --projected-endpoints <n> Expected endpoints in the new stack (default ${DEFAULT_PROJECTED_ENDPOINTS})
64
+ --warn-pressure <n> Warn threshold (default ${DEFAULT_WARN_PRESSURE})
65
+ --fail-pressure <n> Refuse threshold (default ${DEFAULT_FAIL_PRESSURE})
66
+ --json Emit exactly one compact machine-readable receipt.
67
+
68
+ SAFETY
69
+ • Apply requires a ticket that matches each candidate's Docker project label.
70
+ • Apply must run from a Git worktree branch carrying that same ticket.
71
+ • Apply first holds the same project file lock as repository test lanes, then
72
+ holds the matching BotBuddy supabase_local lock for the whole transaction.
73
+ • Reentrant lock grants are refused; cleanup is bounded to five minutes and
74
+ stops at least one minute before the advertised lock expiry.
75
+ • Apply requires the exact candidate IDs copied from the reviewed dry run.
76
+ • Removes only stopped, project-labelled Supabase containers in inactive stacks.
77
+ • Removes only empty supabase_network_* bridge networks in inactive stacks.
78
+ • Re-inspects every candidate immediately before deletion.
79
+ • Never force-removes a resource, never runs a prune command, and never removes volumes.
80
+
81
+ EXAMPLES
82
+ botbuddy docker hygiene --context orbstack --ticket BOT-1405 --json
83
+ botbuddy docker hygiene --context orbstack --apply --ticket BOT-1405 \\
84
+ --lock-slot botbuddy-web-bot-1405 \\
85
+ --candidate-id 0123456789abcdef
86
+ botbuddy docker preflight --context orbstack --json`;
87
+
88
+ const BUILTIN_NETWORKS = new Set(["bridge", "host", "none"]);
89
+ // `created` is treated as active because Docker Compose may have created a
90
+ // project's resources before its first container reaches running state.
91
+ const ACTIVE_STATES = new Set(["created", "running", "paused", "restarting", "removing"]);
92
+
93
+ function positiveInteger(value, option, errors, { allowZero = false } = {}) {
94
+ const parsed = Number(value);
95
+ if (!Number.isInteger(parsed) || parsed < (allowZero ? 0 : 1)) {
96
+ errors.push(`${option} must be ${allowZero ? "a non-negative" : "a positive"} integer`);
97
+ return null;
98
+ }
99
+ return parsed;
100
+ }
101
+
102
+ export function parseDockerArgs(argv) {
103
+ const [command, ...rest] = argv;
104
+ const errors = [];
105
+ const opts = {
106
+ context: null,
107
+ endpoint: null,
108
+ ticket: null,
109
+ lockSlot: null,
110
+ candidateIds: [],
111
+ apply: false,
112
+ json: false,
113
+ projectedEndpoints: DEFAULT_PROJECTED_ENDPOINTS,
114
+ warnPressure: DEFAULT_WARN_PRESSURE,
115
+ failPressure: DEFAULT_FAIL_PRESSURE,
116
+ };
117
+
118
+ const needValue = (option, index) => {
119
+ const value = rest[index + 1];
120
+ if (value === undefined || value.startsWith("--")) {
121
+ errors.push(`${option} needs a value`);
122
+ return null;
123
+ }
124
+ return value;
125
+ };
126
+
127
+ for (let i = 0; i < rest.length; i += 1) {
128
+ const arg = rest[i];
129
+ switch (arg) {
130
+ case "--context": {
131
+ const value = needValue(arg, i);
132
+ if (value !== null) {
133
+ opts.context = value;
134
+ i += 1;
135
+ }
136
+ break;
137
+ }
138
+ case "--endpoint": {
139
+ const value = needValue(arg, i);
140
+ if (value !== null) {
141
+ opts.endpoint = value;
142
+ i += 1;
143
+ }
144
+ break;
145
+ }
146
+ case "--ticket": {
147
+ const value = needValue(arg, i);
148
+ if (value !== null) {
149
+ opts.ticket = value.toUpperCase();
150
+ i += 1;
151
+ }
152
+ break;
153
+ }
154
+ case "--candidate-id": {
155
+ const value = needValue(arg, i);
156
+ if (value !== null) {
157
+ opts.candidateIds.push(value);
158
+ i += 1;
159
+ }
160
+ break;
161
+ }
162
+ case "--lock-slot": {
163
+ const value = needValue(arg, i);
164
+ if (value !== null) {
165
+ opts.lockSlot = value;
166
+ i += 1;
167
+ }
168
+ break;
169
+ }
170
+ case "--projected-endpoints": {
171
+ const value = needValue(arg, i);
172
+ if (value !== null) {
173
+ opts.projectedEndpoints = positiveInteger(value, arg, errors, { allowZero: true });
174
+ i += 1;
175
+ }
176
+ break;
177
+ }
178
+ case "--warn-pressure": {
179
+ const value = needValue(arg, i);
180
+ if (value !== null) {
181
+ opts.warnPressure = positiveInteger(value, arg, errors);
182
+ i += 1;
183
+ }
184
+ break;
185
+ }
186
+ case "--fail-pressure": {
187
+ const value = needValue(arg, i);
188
+ if (value !== null) {
189
+ opts.failPressure = positiveInteger(value, arg, errors);
190
+ i += 1;
191
+ }
192
+ break;
193
+ }
194
+ case "--apply": opts.apply = true; break;
195
+ case "--json": opts.json = true; break;
196
+ case "--help":
197
+ case "-h":
198
+ break;
199
+ default:
200
+ errors.push(`unknown option: ${arg}`);
201
+ }
202
+ }
203
+
204
+ if (!command || !["hygiene", "preflight", "help"].includes(command)) {
205
+ errors.push(`unknown docker command: ${command ?? "(missing)"}`);
206
+ }
207
+ if (command !== "help") {
208
+ if (Boolean(opts.context) === Boolean(opts.endpoint)) {
209
+ errors.push("exactly one of --context <name> or --endpoint <uri> is required");
210
+ }
211
+ if (command === "preflight" && opts.apply) errors.push("--apply is not valid for the read-only preflight command");
212
+ if (opts.ticket && !/^(BOT|ENT)-[1-9][0-9]*$/.test(opts.ticket)) {
213
+ errors.push("--ticket must be a BOT-N or ENT-N identifier");
214
+ }
215
+ if (new Set(opts.candidateIds).size !== opts.candidateIds.length) {
216
+ errors.push("--candidate-id values must be unique");
217
+ }
218
+ if (command === "preflight" && (opts.ticket || opts.lockSlot || opts.candidateIds.length > 0)) {
219
+ errors.push("--ticket, --lock-slot, and --candidate-id are not valid for preflight");
220
+ }
221
+ if (command === "hygiene" && !opts.apply && opts.lockSlot) errors.push("--lock-slot is valid only together with --apply");
222
+ if (command === "hygiene" && !opts.apply && opts.candidateIds.length > 0) {
223
+ errors.push("--candidate-id is valid only together with --apply");
224
+ }
225
+ if (command === "hygiene" && opts.apply) {
226
+ if (!opts.ticket) errors.push("--apply requires --ticket <BOT|ENT-N> as cleanup authority");
227
+ if (!opts.lockSlot || opts.lockSlot === "default") errors.push("--apply requires a non-default --lock-slot from the owning stack lifecycle");
228
+ if (opts.candidateIds.length === 0) errors.push("--apply requires at least one exact --candidate-id from the reviewed dry run");
229
+ }
230
+ if (opts.warnPressure !== null && opts.failPressure !== null && opts.failPressure <= opts.warnPressure) {
231
+ errors.push("--fail-pressure must be greater than --warn-pressure");
232
+ }
233
+ }
234
+
235
+ return { command, opts, errors };
236
+ }
237
+
238
+ function defaultDockerRunner(args, { timeoutMs = MAX_DOCKER_COMMAND_MS } = {}) {
239
+ const env = { ...process.env };
240
+ delete env.DOCKER_CONTEXT;
241
+ delete env.DOCKER_HOST;
242
+ return spawnSync("docker", args, {
243
+ encoding: "utf8",
244
+ env,
245
+ stdio: ["ignore", "pipe", "pipe"],
246
+ maxBuffer: 4 * 1024 * 1024,
247
+ timeout: timeoutMs,
248
+ });
249
+ }
250
+
251
+ function deadlineBoundDockerRunner(runDocker, deadlineAt, monotonicNow) {
252
+ let budgetExpired = false;
253
+ return (args) => {
254
+ if (budgetExpired) {
255
+ return { status: null, stdout: "", stderr: "lock safety budget expired before Docker access" };
256
+ }
257
+ const remainingMs = deadlineAt - monotonicNow();
258
+ if (remainingMs <= 0) {
259
+ budgetExpired = true;
260
+ return { status: null, stdout: "", stderr: "lock safety budget expired before Docker access" };
261
+ }
262
+ const result = runDocker(args, { timeoutMs: Math.max(1, Math.min(MAX_DOCKER_COMMAND_MS, remainingMs)) });
263
+ if (monotonicNow() > deadlineAt) {
264
+ budgetExpired = true;
265
+ // A successful exact-ID removal is irreversible. Preserve its stdout/status
266
+ // so the receipt remains truthful, then let the workflow record the deletion
267
+ // and stop before issuing another Docker command.
268
+ if (!result?.error && result?.status === 0) {
269
+ return { ...result, budgetExpiredAfterSuccess: true };
270
+ }
271
+ return { status: null, stdout: "", stderr: "lock safety budget expired during Docker access" };
272
+ }
273
+ return result;
274
+ };
275
+ }
276
+
277
+ function defaultBranchResolver() {
278
+ const result = spawnSync("git", ["branch", "--show-current"], {
279
+ encoding: "utf8",
280
+ stdio: ["ignore", "pipe", "pipe"],
281
+ maxBuffer: 64 * 1024,
282
+ });
283
+ if (result?.error || result?.status !== 0) {
284
+ throw new Error("apply must run from the owning ticket worktree (unable to resolve the current Git branch)");
285
+ }
286
+ const branch = String(result.stdout || "").trim();
287
+ if (!branch) throw new Error("apply must run from the owning ticket worktree (detached or empty Git branch)");
288
+ return branch;
289
+ }
290
+
291
+ function boundedArgv(args) {
292
+ if (args.length <= 10) return args.join(" ");
293
+ return `${args.slice(0, 10).join(" ")} … (${args.length - 10} more arguments)`;
294
+ }
295
+
296
+ function commandFailure(args, result) {
297
+ const detail = String(result?.stderr || result?.error?.message || "Docker command failed")
298
+ .trim()
299
+ .replace(/\s+/g, " ")
300
+ .slice(0, 500);
301
+ return new Error(`docker ${boundedArgv(args)} failed (exit ${result?.status ?? "spawn"})${detail ? `: ${detail}` : ""}`);
302
+ }
303
+
304
+ function runChecked(runDocker, args) {
305
+ const result = runDocker(args);
306
+ if (result?.error || result?.status !== 0) throw commandFailure(args, result);
307
+ return String(result.stdout || "");
308
+ }
309
+
310
+ function parseJson(text, label) {
311
+ try {
312
+ return JSON.parse(text);
313
+ } catch {
314
+ throw new Error(`${label} returned malformed JSON`);
315
+ }
316
+ }
317
+
318
+ function selectorArgs(opts) {
319
+ return opts.context ? ["--context", opts.context] : ["--host", opts.endpoint];
320
+ }
321
+
322
+ function selected(runDocker, selector, args) {
323
+ return runChecked(runDocker, [...selector, ...args]);
324
+ }
325
+
326
+ function nonEmptyLines(text) {
327
+ return text.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
328
+ }
329
+
330
+ function inspectInBatches(runDocker, selector, kind, ids, extraArgs = []) {
331
+ 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);
340
+ }
341
+ return resources;
342
+ }
343
+
344
+ function projectIdentity(resource) {
345
+ const labels = resource?.Config?.Labels || resource?.Labels || {};
346
+ const value = labels["com.supabase.cli.project"] || labels["com.docker.compose.project"];
347
+ return typeof value === "string" && value.trim() ? value.trim() : null;
348
+ }
349
+
350
+ export function projectMatchesTicket(project, ticket) {
351
+ if (typeof project !== "string" || typeof ticket !== "string") return false;
352
+ const match = /^(BOT|ENT)-([1-9][0-9]*)$/.exec(ticket.toUpperCase());
353
+ if (!match) return false;
354
+ const [, team, number] = match;
355
+ const prefixes = team === "BOT" ? ["bot"] : ["ent", "sgent"];
356
+ const parts = project.toLowerCase().split(/[-_]+/).filter(Boolean);
357
+ return parts.some((part, index) => prefixes.some((prefix) => (
358
+ part === `${prefix}${number}` || (part === prefix && parts[index + 1] === number)
359
+ )));
360
+ }
361
+
362
+ export function branchMatchesTicket(branch, ticket) {
363
+ if (typeof branch !== "string" || typeof ticket !== "string") return false;
364
+ const match = /^(BOT|ENT)-([1-9][0-9]*)$/.exec(ticket.toUpperCase());
365
+ if (!match) return false;
366
+ const [, team, number] = match;
367
+ const prefix = team.toLowerCase();
368
+ const parts = branch.toLowerCase().split(/[/_-]+/).filter(Boolean);
369
+ return parts.some((part, index) => part === `${prefix}${number}`
370
+ || (part === prefix && parts[index + 1] === number));
371
+ }
372
+
373
+ export function lockSlotForProject(project, ticket) {
374
+ if (!projectMatchesTicket(project, ticket)) return null;
375
+ return ticket.toUpperCase().startsWith("ENT-") ? `supplyspark-ent-${project}` : project;
376
+ }
377
+
378
+ function containerName(resource) {
379
+ return String(resource?.Name || "").replace(/^\//, "");
380
+ }
381
+
382
+ function isActiveContainer(resource) {
383
+ const state = resource?.State || {};
384
+ return Boolean(state.Running || state.Paused || state.Restarting || state.Removing)
385
+ || ACTIVE_STATES.has(String(state.Status || "").toLowerCase());
386
+ }
387
+
388
+ function resourceSort(left, right) {
389
+ const typeOrder = { container: 0, network: 1 };
390
+ return (typeOrder[left.type] ?? 9) - (typeOrder[right.type] ?? 9)
391
+ || String(left.name).localeCompare(String(right.name))
392
+ || String(left.id).localeCompare(String(right.id));
393
+ }
394
+
395
+ function skippedResource(type, resource, reason, extra = {}) {
396
+ return {
397
+ type,
398
+ id: String(resource?.Id || ""),
399
+ name: type === "container" ? containerName(resource) : String(resource?.Name || ""),
400
+ project: projectIdentity(resource),
401
+ reason,
402
+ ...extra,
403
+ };
404
+ }
405
+
406
+ export function classifyInventory({ containers = [], networks = [] } = {}) {
407
+ const activeProjects = new Set();
408
+ for (const resource of containers) {
409
+ const name = containerName(resource);
410
+ const project = projectIdentity(resource);
411
+ if (name.startsWith("supabase_") && project && isActiveContainer(resource)) activeProjects.add(project);
412
+ }
413
+
414
+ const candidates = [];
415
+ const skipped = [];
416
+ let supabaseContainers = 0;
417
+
418
+ for (const resource of containers) {
419
+ const name = containerName(resource);
420
+ if (!name.startsWith("supabase_")) continue;
421
+ supabaseContainers += 1;
422
+ if (isActiveContainer(resource)) continue;
423
+ const project = projectIdentity(resource);
424
+ if (!project) {
425
+ skipped.push(skippedResource("container", resource, "missing_project_identity"));
426
+ continue;
427
+ }
428
+ if (activeProjects.has(project)) {
429
+ skipped.push(skippedResource("container", resource, "active_stack"));
430
+ continue;
431
+ }
432
+ candidates.push({
433
+ type: "container",
434
+ id: String(resource.Id),
435
+ name,
436
+ project,
437
+ state: String(resource?.State?.Status || "stopped"),
438
+ reclaimable_bytes: Number.isFinite(Number(resource.SizeRw)) ? Number(resource.SizeRw) : 0,
439
+ });
440
+ }
441
+
442
+ let supabaseNetworks = 0;
443
+ let userBridgeNetworks = 0;
444
+ let attachedEndpoints = 0;
445
+ for (const resource of networks) {
446
+ const name = String(resource?.Name || "");
447
+ const driver = String(resource?.Driver || "");
448
+ const attachments = resource?.Containers && typeof resource.Containers === "object"
449
+ ? Object.keys(resource.Containers).length
450
+ : 0;
451
+ if (driver === "bridge" && !BUILTIN_NETWORKS.has(name)) {
452
+ userBridgeNetworks += 1;
453
+ attachedEndpoints += attachments;
454
+ }
455
+ if (!name.startsWith("supabase_network_")) continue;
456
+ supabaseNetworks += 1;
457
+ if (BUILTIN_NETWORKS.has(name)) {
458
+ skipped.push(skippedResource("network", resource, "built_in_network", { driver, attached_containers: attachments }));
459
+ continue;
460
+ }
461
+ if (driver !== "bridge") {
462
+ skipped.push(skippedResource("network", resource, "non_bridge_network", { driver, attached_containers: attachments }));
463
+ continue;
464
+ }
465
+ const project = projectIdentity(resource);
466
+ if (!project) {
467
+ skipped.push(skippedResource("network", resource, "missing_project_identity", { driver, attached_containers: attachments }));
468
+ continue;
469
+ }
470
+ if (attachments > 0) {
471
+ skipped.push(skippedResource("network", resource, "attached_containers", { driver, attached_containers: attachments }));
472
+ continue;
473
+ }
474
+ if (activeProjects.has(project)) {
475
+ skipped.push(skippedResource("network", resource, "active_stack", { driver, attached_containers: 0 }));
476
+ continue;
477
+ }
478
+ candidates.push({
479
+ type: "network",
480
+ id: String(resource.Id),
481
+ name,
482
+ project,
483
+ driver,
484
+ attached_containers: 0,
485
+ reclaimable_bytes: 0,
486
+ });
487
+ }
488
+
489
+ return {
490
+ active_projects: [...activeProjects].sort(),
491
+ candidates: candidates.sort(resourceSort),
492
+ skipped: skipped.sort(resourceSort),
493
+ counts: {
494
+ containers: containers.length,
495
+ networks: networks.length,
496
+ supabase_containers: supabaseContainers,
497
+ supabase_networks: supabaseNetworks,
498
+ user_bridge_networks: userBridgeNetworks,
499
+ attached_endpoints: attachedEndpoints,
500
+ },
501
+ };
502
+ }
503
+
504
+ 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 };
514
+ }
515
+
516
+ function validateOrbStack(runDocker, opts) {
517
+ let resolvedEndpoint = opts.endpoint;
518
+ if (opts.context) {
519
+ const inspected = parseJson(runChecked(runDocker, ["context", "inspect", opts.context]), "docker context inspect");
520
+ const context = Array.isArray(inspected) ? inspected[0] : inspected;
521
+ resolvedEndpoint = context?.Endpoints?.docker?.Host || null;
522
+ if (!resolvedEndpoint) throw new Error(`Docker context ${opts.context} has no Docker endpoint`);
523
+ }
524
+
525
+ const selector = selectorArgs(opts);
526
+ const info = parseJson(selected(runDocker, selector, ["info", "--format", "{{json .}}"]), "docker info");
527
+ const osIsOrbStack = info?.OperatingSystem === "OrbStack";
528
+ const identity = `${info?.KernelVersion || ""} ${info?.Name || ""} ${info?.ServerVersion || ""}`;
529
+ if (!osIsOrbStack || !/orbstack/i.test(identity)) {
530
+ throw new Error(`Docker target is not OrbStack (OperatingSystem=${info?.OperatingSystem || "unknown"}, kernel=${info?.KernelVersion || "unknown"})`);
531
+ }
532
+
533
+ return {
534
+ selector,
535
+ receipt: {
536
+ requested: opts.context ? { type: "context", value: opts.context } : { type: "endpoint", value: opts.endpoint },
537
+ resolved_endpoint: resolvedEndpoint,
538
+ validation: "orbstack",
539
+ server: {
540
+ id: info.ID || null,
541
+ name: info.Name || null,
542
+ version: info.ServerVersion || null,
543
+ operating_system: info.OperatingSystem || null,
544
+ kernel_version: info.KernelVersion || null,
545
+ },
546
+ },
547
+ };
548
+ }
549
+
550
+ export function evaluatePressure({
551
+ bridgeNetworks,
552
+ attachedEndpoints,
553
+ projectedEndpoints = DEFAULT_PROJECTED_ENDPOINTS,
554
+ warn = DEFAULT_WARN_PRESSURE,
555
+ fail = DEFAULT_FAIL_PRESSURE,
556
+ }) {
557
+ const current = Number(bridgeNetworks) + (2 * Number(attachedEndpoints));
558
+ const projected = current + 1 + (2 * Number(projectedEndpoints));
559
+ return {
560
+ metric: "conservative_network_pressure_units",
561
+ formula: "bridge_networks + (2 * attached_endpoints)",
562
+ current_pressure_units: current,
563
+ projected_pressure_units: projected,
564
+ projected_endpoints: Number(projectedEndpoints),
565
+ warn_pressure: Number(warn),
566
+ fail_pressure: Number(fail),
567
+ status: projected >= fail ? "fail" : projected >= warn ? "warn" : "ok",
568
+ note: "Conservative pressure proxy; OrbStack does not expose a kernel interface ceiling through the Docker API.",
569
+ };
570
+ }
571
+
572
+ function baseReceipt(command, opts, now) {
573
+ return {
574
+ schema_version: SCHEMA_VERSION,
575
+ command,
576
+ outcome: "ok",
577
+ apply: command === "hygiene" && opts.apply,
578
+ timestamp: now().toISOString(),
579
+ context: null,
580
+ inventory: null,
581
+ active_projects: [],
582
+ candidates: [],
583
+ candidate_reclaimable_bytes: 0,
584
+ deleted: [],
585
+ reclaimed_space_bytes: 0,
586
+ skipped: [],
587
+ pressure: null,
588
+ warnings: [],
589
+ errors: [],
590
+ recommendation: null,
591
+ authority: {
592
+ ticket: opts.ticket,
593
+ worktree_branch: null,
594
+ reviewed_candidate_ids: [...opts.candidateIds],
595
+ file_lock: null,
596
+ stack_lock: null,
597
+ },
598
+ };
599
+ }
600
+
601
+ function addSkipOnce(receipt, item) {
602
+ if (!receipt.skipped.some((existing) => existing.type === item.type && existing.id === item.id && existing.reason === item.reason)) {
603
+ receipt.skipped.push(item);
604
+ receipt.skipped.sort(resourceSort);
605
+ }
606
+ }
607
+
608
+ function recommendationFor(opts, apply = false, candidates = []) {
609
+ const selector = opts.context ? `--context ${opts.context}` : `--endpoint ${opts.endpoint}`;
610
+ const ticket = opts.ticket ? ` --ticket ${opts.ticket}` : "";
611
+ if (!apply) return `botbuddy docker hygiene ${selector}${ticket}`;
612
+ if (!opts.ticket) return `botbuddy docker hygiene ${selector} --ticket <BOT-or-ENT-ticket>`;
613
+ const projects = [...new Set(candidates.map((item) => item.project))];
614
+ const lockSlot = projects.length === 1 ? lockSlotForProject(projects[0], opts.ticket) : null;
615
+ const ids = candidates.map((item) => ` --candidate-id ${item.id}`).join("");
616
+ return ids
617
+ ? `botbuddy docker hygiene ${selector} --apply --ticket ${opts.ticket} --lock-slot ${lockSlot || "<owning-stack-slot>"}${ids}`
618
+ : `No ticket-owned candidates remain for ${opts.ticket}; do not run apply.`;
619
+ }
620
+
621
+ export function runDockerWorkflow(argv, {
622
+ runDocker = defaultDockerRunner,
623
+ resolveBranch = defaultBranchResolver,
624
+ lockAuthority = null,
625
+ deadlineAt = null,
626
+ monotonicNow = Date.now,
627
+ platform = process.platform,
628
+ now = () => new Date(),
629
+ } = {}) {
630
+ const parsed = parseDockerArgs(argv);
631
+ const receipt = baseReceipt(parsed.command || "unknown", parsed.opts, now);
632
+
633
+ if (parsed.errors.length > 0) {
634
+ receipt.outcome = "error";
635
+ receipt.errors = parsed.errors;
636
+ return { exitCode: EXIT.INVALID, receipt, json: parsed.opts.json };
637
+ }
638
+ if (platform !== "darwin") {
639
+ receipt.outcome = "error";
640
+ receipt.errors = [`unsupported platform: ${platform}; OrbStack hygiene is macOS-only`];
641
+ return { exitCode: EXIT.UNSUPPORTED, receipt, json: parsed.opts.json };
642
+ }
643
+ if (parsed.opts.apply) {
644
+ try {
645
+ const branch = resolveBranch();
646
+ receipt.authority.worktree_branch = branch;
647
+ if (!branchMatchesTicket(branch, parsed.opts.ticket)) {
648
+ throw new Error(`current branch ${branch} does not own ${parsed.opts.ticket}; run apply from that ticket's worktree`);
649
+ }
650
+ } catch (error) {
651
+ receipt.outcome = "refused";
652
+ receipt.errors = [error.message];
653
+ return { exitCode: EXIT.INVALID, receipt, json: parsed.opts.json };
654
+ }
655
+ if (!lockAuthority?.held || lockAuthority.slot !== parsed.opts.lockSlot) {
656
+ receipt.outcome = "refused";
657
+ receipt.errors = ["apply requires a confirmed BotBuddy supabase_local lock for --lock-slot before Docker inventory"];
658
+ return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
659
+ }
660
+ receipt.authority.stack_lock = {
661
+ host: lockAuthority.host,
662
+ slot: lockAuthority.slot,
663
+ resource_id: lockAuthority.resourceId || null,
664
+ resource_name: lockAuthority.resourceName || null,
665
+ granted_seconds_remaining: lockAuthority.secondsRemaining || null,
666
+ apply_budget_ms: lockAuthority.applyBudgetMs || null,
667
+ held: true,
668
+ };
669
+ deadlineAt ??= monotonicNow() + MAX_APPLY_BUDGET_MS;
670
+ runDocker = deadlineBoundDockerRunner(runDocker, deadlineAt, monotonicNow);
671
+ }
672
+
673
+ let validated;
674
+ let inventory;
675
+ try {
676
+ validated = validateOrbStack(runDocker, parsed.opts);
677
+ receipt.context = validated.receipt;
678
+ inventory = classifyInventory(readInventory(runDocker, validated.selector));
679
+ receipt.recommendation = recommendationFor(parsed.opts);
680
+ } catch (error) {
681
+ receipt.outcome = "error";
682
+ receipt.errors = [error.message];
683
+ return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
684
+ }
685
+
686
+ receipt.inventory = inventory.counts;
687
+ receipt.active_projects = inventory.active_projects;
688
+ receipt.skipped = [...inventory.skipped];
689
+
690
+ const ticketCandidates = parsed.opts.ticket
691
+ ? inventory.candidates.filter((item) => projectMatchesTicket(item.project, parsed.opts.ticket))
692
+ : inventory.candidates;
693
+ if (parsed.opts.ticket) {
694
+ for (const item of inventory.candidates) {
695
+ if (!projectMatchesTicket(item.project, parsed.opts.ticket)) {
696
+ addSkipOnce(receipt, { ...item, reason: "ticket_mismatch" });
697
+ }
698
+ }
699
+ }
700
+ receipt.candidates = ticketCandidates;
701
+ receipt.candidate_reclaimable_bytes = ticketCandidates.reduce((total, item) => total + item.reclaimable_bytes, 0);
702
+
703
+ if (parsed.command === "preflight") {
704
+ receipt.pressure = evaluatePressure({
705
+ bridgeNetworks: inventory.counts.user_bridge_networks,
706
+ attachedEndpoints: inventory.counts.attached_endpoints,
707
+ projectedEndpoints: parsed.opts.projectedEndpoints,
708
+ warn: parsed.opts.warnPressure,
709
+ fail: parsed.opts.failPressure,
710
+ });
711
+ if (receipt.pressure.status === "warn") {
712
+ receipt.outcome = "warn";
713
+ receipt.warnings.push("Projected OrbStack network pressure is at or above the warning threshold; run dry-run hygiene before starting another stack.");
714
+ } else if (receipt.pressure.status === "fail") {
715
+ receipt.outcome = "refused";
716
+ receipt.errors.push("Projected OrbStack network pressure is at or above the refusal threshold; cleanup or release an owning stack before start.");
717
+ return { exitCode: EXIT.PRESSURE, receipt, json: parsed.opts.json };
718
+ }
719
+ return { exitCode: EXIT.OK, receipt, json: parsed.opts.json };
720
+ }
721
+
722
+ if (!parsed.opts.apply) {
723
+ receipt.outcome = "dry_run";
724
+ receipt.recommendation = recommendationFor(parsed.opts, true, ticketCandidates);
725
+ return { exitCode: EXIT.OK, receipt, json: parsed.opts.json };
726
+ }
727
+
728
+ const requestedCandidates = [];
729
+ for (const id of parsed.opts.candidateIds) {
730
+ const discovered = inventory.candidates.find((item) => item.id === id);
731
+ if (!discovered) {
732
+ receipt.errors.push(`candidate ${id} is not currently eligible; run a fresh ticket-scoped dry run`);
733
+ continue;
734
+ }
735
+ if (!projectMatchesTicket(discovered.project, parsed.opts.ticket)) {
736
+ receipt.errors.push(`candidate ${id} project ${discovered.project} does not belong to ${parsed.opts.ticket}`);
737
+ continue;
738
+ }
739
+ const expectedSlot = lockSlotForProject(discovered.project, parsed.opts.ticket);
740
+ if (parsed.opts.lockSlot !== expectedSlot) {
741
+ receipt.errors.push(`candidate ${id} project ${discovered.project} requires lock slot ${expectedSlot}, not ${parsed.opts.lockSlot}`);
742
+ continue;
743
+ }
744
+ requestedCandidates.push(discovered);
745
+ }
746
+ if (receipt.errors.length > 0) {
747
+ receipt.outcome = "refused";
748
+ return { exitCode: EXIT.INVALID, receipt, json: parsed.opts.json };
749
+ }
750
+
751
+ for (const candidate of requestedCandidates) {
752
+ let fresh;
753
+ try {
754
+ fresh = classifyInventory(readInventory(runDocker, validated.selector));
755
+ } catch (error) {
756
+ receipt.errors.push(`revalidation failed before ${candidate.type} ${candidate.id}: ${error.message}`);
757
+ addSkipOnce(receipt, { ...candidate, reason: "revalidation_failed" });
758
+ continue;
759
+ }
760
+ const revalidated = fresh.candidates.find((item) => item.type === candidate.type
761
+ && item.id === candidate.id
762
+ && projectMatchesTicket(item.project, parsed.opts.ticket));
763
+ if (!revalidated) {
764
+ const refusal = fresh.skipped.find((item) => item.type === candidate.type && item.id === candidate.id);
765
+ const skipped = refusal || { ...candidate, reason: "disappeared" };
766
+ addSkipOnce(receipt, skipped);
767
+ receipt.errors.push(`candidate ${candidate.id} refused during revalidation: ${skipped.reason}`);
768
+ continue;
769
+ }
770
+
771
+ const args = candidate.type === "container"
772
+ ? [...validated.selector, "container", "rm", candidate.id]
773
+ : [...validated.selector, "network", "rm", candidate.id];
774
+ const result = runDocker(args);
775
+ if (result?.error || result?.status !== 0) {
776
+ const error = commandFailure(args, result);
777
+ receipt.errors.push(error.message);
778
+ addSkipOnce(receipt, { ...candidate, reason: "delete_failed" });
779
+ continue;
780
+ }
781
+ receipt.deleted.push({
782
+ type: candidate.type,
783
+ id: candidate.id,
784
+ name: candidate.name,
785
+ project: candidate.project,
786
+ reclaimed_bytes: candidate.reclaimable_bytes,
787
+ });
788
+ receipt.reclaimed_space_bytes += candidate.reclaimable_bytes;
789
+ if (result.budgetExpiredAfterSuccess) {
790
+ receipt.errors.push(`lock safety budget expired after successful ${candidate.type} deletion ${candidate.id}; deletion evidence was preserved and further Docker access was stopped`);
791
+ break;
792
+ }
793
+ }
794
+
795
+ if (receipt.errors.length > 0) {
796
+ receipt.outcome = receipt.deleted.length > 0 ? "partial_failure" : "refused";
797
+ return { exitCode: EXIT.DELETE_FAILED, receipt, json: parsed.opts.json };
798
+ }
799
+ receipt.outcome = "applied";
800
+ receipt.recommendation = "Cleanup complete; run botbuddy docker preflight with the same explicit selector before starting the next managed stack.";
801
+ return { exitCode: EXIT.OK, receipt, json: parsed.opts.json };
802
+ }
803
+
804
+ export function formatBytes(bytes) {
805
+ const value = Number(bytes) || 0;
806
+ if (value < 1024) return `${value} B`;
807
+ const units = ["KiB", "MiB", "GiB", "TiB"];
808
+ let current = value;
809
+ let unit = -1;
810
+ do {
811
+ current /= 1024;
812
+ unit += 1;
813
+ } while (current >= 1024 && unit < units.length - 1);
814
+ return `${current.toFixed(current >= 10 ? 1 : 2)} ${units[unit]}`;
815
+ }
816
+
817
+ export function formatHumanReceipt(receipt) {
818
+ const context = receipt.context
819
+ ? `${receipt.context.requested.type}=${receipt.context.requested.value} → ${receipt.context.resolved_endpoint}`
820
+ : "not validated";
821
+ const lines = [
822
+ `[botbuddy docker] ${receipt.command}: ${receipt.outcome}`,
823
+ `Context: ${context}`,
824
+ ];
825
+ if (receipt.context?.server) {
826
+ lines.push(`Server: ${receipt.context.server.operating_system} ${receipt.context.server.version || "unknown"} (${receipt.context.server.kernel_version || "unknown kernel"})`);
827
+ }
828
+ if (receipt.inventory) {
829
+ lines.push(`Inventory: ${receipt.inventory.supabase_containers} Supabase containers; ${receipt.inventory.supabase_networks} Supabase networks; active projects: ${receipt.active_projects.join(", ") || "none"}`);
830
+ }
831
+ lines.push(receipt.authority?.ticket
832
+ ? `Authority: ticket=${receipt.authority.ticket}; branch=${receipt.authority.worktree_branch || "dry-run only"}; reviewed IDs=${receipt.authority.reviewed_candidate_ids.join(", ") || "none (dry run)"}`
833
+ : "Authority: detection-only; pass an owning ticket before selecting apply IDs");
834
+ if (receipt.authority?.stack_lock) {
835
+ lines.push(`Stack lock: host=${receipt.authority.stack_lock.host}; slot=${receipt.authority.stack_lock.slot}; granted=${receipt.authority.stack_lock.granted_seconds_remaining || "unknown"}s; budget=${receipt.authority.stack_lock.apply_budget_ms || "unknown"}ms; released=${receipt.authority.stack_lock.released === true}`);
836
+ }
837
+ lines.push(`Candidates (${receipt.candidates.length}, potential ${formatBytes(receipt.candidate_reclaimable_bytes)}):`);
838
+ for (const item of receipt.candidates) lines.push(` ${item.type} ${item.id} ${item.name} project=${item.project}`);
839
+ if (receipt.candidates.length === 0) lines.push(" none");
840
+ lines.push(`Deleted (${receipt.deleted.length}, reclaimed ${formatBytes(receipt.reclaimed_space_bytes)}):`);
841
+ for (const item of receipt.deleted) lines.push(` ${item.type} ${item.id} ${item.name}`);
842
+ if (receipt.deleted.length === 0) lines.push(" none");
843
+ lines.push(`Skipped (${receipt.skipped.length}):`);
844
+ for (const item of receipt.skipped) lines.push(` ${item.type} ${item.id} ${item.name} — ${item.reason}`);
845
+ if (receipt.skipped.length === 0) lines.push(" none");
846
+ if (receipt.pressure) {
847
+ lines.push(`Pressure: ${receipt.pressure.projected_pressure_units} projected (${receipt.pressure.status}; warn ${receipt.pressure.warn_pressure}, fail ${receipt.pressure.fail_pressure})`);
848
+ }
849
+ for (const warning of receipt.warnings) lines.push(`Warning: ${warning}`);
850
+ for (const error of receipt.errors) lines.push(`Error: ${error}`);
851
+ if (receipt.recommendation) lines.push(`Next: ${receipt.recommendation}`);
852
+ return lines.join("\n");
853
+ }
854
+
855
+ export function cmdDocker(argv) {
856
+ if (argv.length === 0 || argv[0] === "help" || argv.includes("--help") || argv.includes("-h")) {
857
+ console.log(DOCKER_HELP);
858
+ return;
859
+ }
860
+ return runDockerCommand(argv).then((result) => {
861
+ if (result.json) console.log(JSON.stringify(result.receipt));
862
+ else console.log(formatHumanReceipt(result.receipt));
863
+ if (result.exitCode !== EXIT.OK) process.exitCode = result.exitCode;
864
+ });
865
+ }
866
+
867
+ function refusedResult(argv, message, exitCode = EXIT.DOCKER, now = () => new Date()) {
868
+ const parsed = parseDockerArgs(argv);
869
+ const receipt = baseReceipt(parsed.command || "unknown", parsed.opts, now);
870
+ receipt.outcome = "refused";
871
+ receipt.errors = [message];
872
+ return { exitCode, receipt, json: parsed.opts.json };
873
+ }
874
+
875
+ function lockIdentity(data) {
876
+ const raw = typeof data?.raw === "string" ? data.raw : typeof data?.message === "string" ? data.message : "";
877
+ const resourceId = raw.match(/\[resource_id: ([0-9a-f-]+)\]/i)?.[1] || data?.resource_id || null;
878
+ const resourceLockId = data?.resource_lock_id || null;
879
+ const resourceName = raw.match(/\(resource: ([^)]+)\)/)?.[1] || data?.resource_name || data?.resource || null;
880
+ return { resourceId, resourceLockId, resourceName };
881
+ }
882
+
883
+ function lockGrantTiming(data) {
884
+ const raw = typeof data?.raw === "string" ? data.raw : typeof data?.message === "string" ? data.message : "";
885
+ const secondsRemaining = Number(data?.seconds_remaining ?? raw.match(/~([0-9]+)s\)/i)?.[1]);
886
+ return {
887
+ alreadyHeld: data?.already_held === true || /already held/i.test(raw),
888
+ secondsRemaining: Number.isFinite(secondsRemaining) && secondsRemaining > 0 ? secondsRemaining : null,
889
+ };
890
+ }
891
+
892
+ function resolveLocalProjectId() {
893
+ const config = readFileSync("supabase/config.toml", "utf8");
894
+ const projectId = projectIdFromConfig(config);
895
+ if (!projectId) throw new Error("supabase/config.toml has no project_id");
896
+ return projectId;
897
+ }
898
+
899
+ export async function runDockerCommand(argv, options = {}) {
900
+ const parsed = parseDockerArgs(argv);
901
+ const runWorkflow = options.runWorkflow ?? runDockerWorkflow;
902
+ const workflowOptions = options.workflowOptions ?? {};
903
+ if (parsed.errors.length > 0 || parsed.command !== "hygiene" || !parsed.opts.apply) {
904
+ return runWorkflow(argv, workflowOptions);
905
+ }
906
+
907
+ const resolveProjectId = options.resolveProjectId ?? resolveLocalProjectId;
908
+ const fileLockPathForProject = options.fileLockPathForProject ?? lockPathForProject;
909
+ const acquireFileLock = options.acquireFileLock ?? acquireStackLock;
910
+ let projectId;
911
+ let fileLockPath;
912
+ let fileLock;
913
+ try {
914
+ projectId = resolveProjectId();
915
+ if (!projectId) throw new Error("project identity is empty");
916
+ fileLockPath = fileLockPathForProject(projectId);
917
+ fileLock = await acquireFileLock(fileLockPath);
918
+ } catch (error) {
919
+ return refusedResult(argv, `cross-worktree Supabase file lock not acquired: ${error.message}`, EXIT.DOCKER, workflowOptions.now);
920
+ }
921
+
922
+ let result;
923
+ try {
924
+ result = await runDockerCommandWithBotBuddyLock(argv, options);
925
+ if (result?.receipt?.authority) {
926
+ result.receipt.authority.file_lock = { project_id: projectId, path: fileLockPath, held: true, released: false };
927
+ }
928
+ return result;
929
+ } finally {
930
+ fileLock.release();
931
+ if (result?.receipt?.authority?.file_lock) {
932
+ result.receipt.authority.file_lock.held = false;
933
+ result.receipt.authority.file_lock.released = true;
934
+ }
935
+ }
936
+ }
937
+
938
+ async function runDockerCommandWithBotBuddyLock(argv, {
939
+ callTool = callToolJson,
940
+ runWorkflow = runDockerWorkflow,
941
+ workflowOptions = {},
942
+ machineHost = hostname(),
943
+ monotonicNow = Date.now,
944
+ } = {}) {
945
+ const parsed = parseDockerArgs(argv);
946
+ if (parsed.errors.length > 0 || parsed.command !== "hygiene" || !parsed.opts.apply) {
947
+ return runWorkflow(argv, workflowOptions);
948
+ }
949
+
950
+ const acquired = await callTool("acquire_lock", {
951
+ resource_type: "container",
952
+ subtype: "supabase_local",
953
+ host: machineHost,
954
+ slot: parsed.opts.lockSlot,
955
+ mode: "lock",
956
+ ticket_id: parsed.opts.ticket,
957
+ no_pr_reason: "local OrbStack hygiene",
958
+ });
959
+ if (!acquired?.ok || acquired.isError || acquired.data?.success === false) {
960
+ const detail = acquired?.data?.message || acquired?.error || "lock was busy or unavailable";
961
+ const refused = refusedResult(argv, `BotBuddy supabase_local lock not acquired: ${detail}`, EXIT.DOCKER, workflowOptions.now);
962
+ if (acquired?.data?.queued === true) {
963
+ const queuedIdentity = lockIdentity(acquired.data);
964
+ const retireArgs = queuedIdentity.resourceId && queuedIdentity.resourceLockId
965
+ ? { resource_id: queuedIdentity.resourceId, resource_lock_id: queuedIdentity.resourceLockId }
966
+ : null;
967
+ let retired = false;
968
+ let alreadyAbsent = false;
969
+ let retirementError = null;
970
+ if (retireArgs) {
971
+ const retirement = await callTool("retire_queued_lock_attempt", retireArgs);
972
+ const retirementOk = Boolean(retirement?.ok && !retirement.isError && retirement.data?.success !== false);
973
+ retired = retirementOk && retirement.data?.retired === true;
974
+ alreadyAbsent = retirementOk && retirement.data?.retired === false;
975
+ if (!retirementOk) retirementError = retirement?.data?.message || retirement?.error || "unknown retirement error";
976
+ } else {
977
+ retirementError = "busy response did not include an exact resource and queued-ledger identity";
978
+ }
979
+ refused.receipt.authority.queued_lock_attempt = {
980
+ resource_id: queuedIdentity.resourceId,
981
+ resource_lock_id: queuedIdentity.resourceLockId,
982
+ resource_name: queuedIdentity.resourceName,
983
+ retired,
984
+ already_absent: alreadyAbsent,
985
+ };
986
+ if (retirementError) {
987
+ refused.receipt.errors.push(`failed to retire queued lock attempt: ${retirementError}`);
988
+ }
989
+ }
990
+ return refused;
991
+ }
992
+
993
+ const timing = lockGrantTiming(acquired.data);
994
+ if (timing.alreadyHeld) {
995
+ return refusedResult(argv, "BotBuddy supabase_local lock is already held by another process using this agent identity; refusing reentrant cleanup", EXIT.DOCKER, workflowOptions.now);
996
+ }
997
+
998
+ const identity = lockIdentity(acquired.data);
999
+ if (!identity.resourceId && !identity.resourceName) {
1000
+ const fallbackName = `supabase_local:${machineHost}:${parsed.opts.lockSlot}`;
1001
+ const fallbackRelease = await callTool("release_lock", { resource_name: fallbackName });
1002
+ const releaseNote = fallbackRelease?.ok && !fallbackRelease.isError
1003
+ ? "fallback release succeeded"
1004
+ : "fallback release failed; the lock will expire by TTL";
1005
+ return refusedResult(argv, `BotBuddy granted the stack lock without a releasable identity; refusing Docker access (${releaseNote})`, EXIT.DOCKER, workflowOptions.now);
1006
+ }
1007
+ if (!timing.secondsRemaining || timing.secondsRemaining * 1000 <= LOCK_EXPIRY_RESERVE_MS) {
1008
+ const failedTimingRelease = await callTool("release_lock", identity.resourceId
1009
+ ? { resource_id: identity.resourceId }
1010
+ : { resource_name: identity.resourceName });
1011
+ const releaseNote = failedTimingRelease?.ok && !failedTimingRelease.isError
1012
+ ? "release succeeded"
1013
+ : "release failed; the lock will expire by TTL";
1014
+ return refusedResult(argv, `BotBuddy stack lock grant did not provide a safe remaining TTL; refusing Docker access (${releaseNote})`, EXIT.DOCKER, workflowOptions.now);
1015
+ }
1016
+ const applyBudgetMs = Math.min(MAX_APPLY_BUDGET_MS, timing.secondsRemaining * 1000 - LOCK_EXPIRY_RESERVE_MS);
1017
+ const lockAuthority = {
1018
+ held: true,
1019
+ host: machineHost,
1020
+ slot: parsed.opts.lockSlot,
1021
+ secondsRemaining: timing.secondsRemaining,
1022
+ applyBudgetMs,
1023
+ ...identity,
1024
+ };
1025
+
1026
+ let result;
1027
+ let thrown;
1028
+ try {
1029
+ result = runWorkflow(argv, {
1030
+ ...workflowOptions,
1031
+ lockAuthority,
1032
+ deadlineAt: monotonicNow() + applyBudgetMs,
1033
+ monotonicNow,
1034
+ });
1035
+ } catch (error) {
1036
+ thrown = error;
1037
+ }
1038
+ if (result?.receipt?.authority && !result.receipt.authority.stack_lock) {
1039
+ result.receipt.authority.stack_lock = {
1040
+ host: lockAuthority.host,
1041
+ slot: lockAuthority.slot,
1042
+ resource_id: lockAuthority.resourceId || null,
1043
+ resource_name: lockAuthority.resourceName || null,
1044
+ granted_seconds_remaining: lockAuthority.secondsRemaining,
1045
+ apply_budget_ms: lockAuthority.applyBudgetMs,
1046
+ held: true,
1047
+ };
1048
+ }
1049
+ const released = await callTool("release_lock", identity.resourceId
1050
+ ? { resource_id: identity.resourceId }
1051
+ : { resource_name: identity.resourceName });
1052
+ if (thrown) throw thrown;
1053
+ if (!released?.ok || released.isError) {
1054
+ result.receipt.errors.push(`BotBuddy supabase_local lock release failed: ${released?.error || "unknown release error"}`);
1055
+ result.receipt.outcome = result.receipt.deleted.length > 0 ? "partial_failure" : "refused";
1056
+ result.exitCode = EXIT.DELETE_FAILED;
1057
+ } else if (result.receipt.authority?.stack_lock) {
1058
+ result.receipt.authority.stack_lock.held = false;
1059
+ result.receipt.authority.stack_lock.released = true;
1060
+ }
1061
+ return result;
1062
+ }