@botbuddy/cli 1.32.3 → 1.33.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,1032 @@
1
+ // BOT-1798 — the ISOLATED stack directory a Helper-backed `bb stack run` leases.
2
+ //
3
+ // A lease's `stack_path` is where the BotBuddy Helper runs `supabase start`
4
+ // (`<worktree_root>/<stack_path>`). At the default `"."` that is the worktree
5
+ // ROOT, whose committed `supabase/config.toml` declares the SHARED canonical
6
+ // project (`xymjddoobrfnsmawmtbc` for botbuddy-web, `hhukfannuankqtswiqdq` for
7
+ // supplyspark-ent). The Helper then collides with — and clobbers the edge
8
+ // bind-mount of — the developer's already-running shared stack: `supabase status`
9
+ // reports no local API URL, provisioning fails (`invalidStatus`), and the lease
10
+ // cannot be reaped because its recorded teardown target IS the shared stack.
11
+ // Observed in production 2026-09-21: zero `stack run` leases activated in 36h.
12
+ //
13
+ // BOT-1711 closed the same hole for `stack up --local-exec` by REFUSING `"."`.
14
+ // `run` cannot just refuse: nothing (BotBuddy or Supply Guard) passes
15
+ // `--stack-path` for it. So `run` materializes its own isolated stack instead —
16
+ // a gitignored directory inside the worktree holding the worktree's own
17
+ // `supabase/` tree with a DISTINCT, ticket-derived `project_id` and a fully
18
+ // remapped port block. Same shape as the hermetic deno lane
19
+ // (`scripts/run-deno-hermetic.mjs` + `scripts/lib/hermetic-config.mjs`) and
20
+ // Supply Guard's `scripts/local-supabase-isolated.sh`, ported here because the
21
+ // published CLI cannot import repo scripts and stays Node-stdlib only.
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { cpSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
+ import { homedir, hostname } from "node:os";
25
+ import { basename, join } from "node:path";
26
+ import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
27
+
28
+ /** Where materialized leased stacks live inside the worktree (gitignored). */
29
+ export const LEASED_STACKS_DIR = ".botbuddy/stacks";
30
+
31
+ // Port bands already spoken for on this host (docs/docker-capacity.md → "What
32
+ // counts against the budget"): 41xxx/42xxx per-worktree preview + lighthouse,
33
+ // 43xxx–53xxx hermetic deno lane, 54xxx supplyspark-ent shared, 55xxx another
34
+ // tenant, 56320–56329 + 56683 the BB shared canonical stack, and 56321 + n×10
35
+ // (n ≤ 399, i.e. up to ~60330) Supply Guard's per-ticket blocks. A leased stack
36
+ // therefore gets a contiguous block above all of them, and every block stays a
37
+ // valid TCP port (61000 + 200×20 − 1 = 64999).
38
+ export const LEASED_PORT_BASE = 61000;
39
+ /** Ports reserved per leased stack (the repo declares 10; the rest is headroom). */
40
+ export const LEASED_PORT_STRIDE = 20;
41
+ /** Distinct blocks available. Two worktree+slot pairs CAN hash to one block; as
42
+ * with the hermetic/e2e ports that is a loud `supabase start` port-bind failure,
43
+ * never a silently shared stack. */
44
+ export const LEASED_PORT_SLOTS = 200;
45
+
46
+ /** How long a leased stack directory must have been untouched before another run
47
+ * may collect it. Comfortably above the reaper's default 1800 s idle TTL, so a
48
+ * batch whose owner process died but whose child/containers are still finishing is
49
+ * never collected out from under itself (BOT-1798, Codex #928 R5). */
50
+ export const LEASED_STACK_GC_MIN_AGE_MS = 2 * 60 * 60 * 1000;
51
+ /** Ownership + retention metadata each materialized stack carries (BOT-1798). */
52
+ export const LEASED_STACK_MARKER = ".bb-stack.json";
53
+ /** HOST-scoped port-block reservations (BOT-1798, Codex #928 R13). Worktrees are
54
+ * private to each other, but the ports are the machine's — a reservation visible
55
+ * only inside one worktree lets two worktrees bind the same block. This lives
56
+ * beside `~/.botbuddy/machine-id`, the existing machine-scoped state. */
57
+ export const LEASED_PORT_REGISTRY_DIRNAME = "leased-port-blocks";
58
+ /** The host-scoped mutex that serializes "reclaim a stale claim, then take a
59
+ * block" (BOT-1798, Codex #928 R16). Without it two runs can both read the same
60
+ * expired claim, and the slower one's delete wipes the winner's NEW claim. */
61
+ export const LEASED_ALLOCATION_LOCK = ".allocate.lock";
62
+ /** A held allocation lock older than this is assumed abandoned (the critical
63
+ * section is a few file operations). */
64
+ const LEASED_ALLOCATION_LOCK_STALE_MS = 60 * 1000;
65
+ export function defaultPortRegistryDir() {
66
+ return join(homedir(), ".botbuddy", LEASED_PORT_REGISTRY_DIRNAME);
67
+ }
68
+ /** Grace added to a batch's own idle TTL before its directory may be collected:
69
+ * the reaper stops an idle stack at the TTL, and the teardown itself takes time. */
70
+ const LEASED_STACK_RETENTION_GRACE_MS = 30 * 60 * 1000;
71
+ /** The idle TTL a lease gets when the caller names none (`bb stack up --idle-ttl`). */
72
+ const DEFAULT_IDLE_TTL_SECS = 1800;
73
+
74
+
75
+ // TOML decimal integers may carry a leading sign (`+56321`); radix forms may not.
76
+ // A negative value is rejected downstream by tomlIntToDecimal (BOT-1711 Codex R15).
77
+ const TOML_INT_PORT = "(0[xX][0-9A-Fa-f_]+|0[oO][0-7_]+|0[bB][01_]+|[+-]?[0-9][0-9_]*)";
78
+
79
+ /** Normalize any valid TOML integer literal to its decimal string: decimal (with `_`
80
+ * separators), or `0x`/`0o`/`0b` radix forms. Returns null for a non-integer. Without
81
+ * this a hex/octal port (`0xdc01` == 56321) or a separated one (`56_321`) would parse as
82
+ * a truncated value and bypass the port-collision checks while Supabase binds the full
83
+ * port (BOT-1711 Codex R11/R14). */
84
+ export function tomlIntToDecimal(token) {
85
+ const cleaned = String(token).replace(/_/g, "");
86
+ const n = Number(cleaned);
87
+ return Number.isInteger(n) && n >= 0 ? String(n) : null;
88
+ }
89
+
90
+ /** Every port a `config.toml` allocates, as a SECTION-QUALIFIED map `"<section>.<key>" ->
91
+ * "<port>"` (e.g. `api.port`, `db.shadow_port`, `inbucket.pop3_port`). Section-qualified so
92
+ * the SAME `port` key under [api]/[db]/[studio]/[inbucket] stays distinct, which lets a
93
+ * target be checked for BOTH completeness (declares every port the root does) and
94
+ * disjointness (shares no port value). Comments are not configuration; both the
95
+ * `[section]` + bare-key form and the dotted `api.port = N` form are recognized
96
+ * (BOT-1711 R16). */
97
+ export function portMapInConfig(toml) {
98
+ const map = new Map();
99
+ let section = "";
100
+ const bare = new RegExp(`^((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
101
+ const dotted = new RegExp(`^([A-Za-z0-9_]+)\\.((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
102
+ for (const raw of String(toml).split(/\r?\n/)) {
103
+ const line = raw.trim();
104
+ if (line.startsWith("#")) continue; // comments are not configuration
105
+ const sec = /^\[([^\]]+)\]/.exec(line);
106
+ if (sec) { section = sec[1].trim(); continue; }
107
+ const dot = dotted.exec(line);
108
+ if (dot) {
109
+ const dec = tomlIntToDecimal(dot[3]);
110
+ if (dec != null) map.set(`${dot[1]}.${dot[2]}`, dec);
111
+ continue;
112
+ }
113
+ const m = bare.exec(line);
114
+ if (m) {
115
+ const dec = tomlIntToDecimal(m[2]);
116
+ if (dec != null) map.set(`${section}.${m[1]}`, dec);
117
+ }
118
+ }
119
+ return map;
120
+ }
121
+
122
+ /** Lowercase-alphanumeric tail of a ticket/slot, e.g. `BOT-1798` → `bot1798`. */
123
+ function identityTag(ticket, slot) {
124
+ const source = String(ticket ?? "").trim() || String(slot ?? "").trim();
125
+ const cleaned = source.toLowerCase().replace(/[^a-z0-9]/g, "");
126
+ return cleaned.slice(-10) || "stack";
127
+ }
128
+
129
+ /**
130
+ * The identity of one batch's isolated stack:
131
+ * * `projectId` — the Docker/Supabase project name. Ticket-derived so
132
+ * `docker ps` ownership stays decidable (docs/docker-capacity.md rule 3),
133
+ * hash-suffixed so two worktrees on one ticket never share containers, and
134
+ * Supabase-safe (lowercase alphanumeric, leading letter, ≤20 chars).
135
+ * * `portBase` — the first port of this stack's contiguous block.
136
+ *
137
+ * `nonce` scopes the identity to ONE invocation (BOT-1798, Codex #928 R5). A
138
+ * per-slot identity was reachable by a second run as soon as the first run's
139
+ * PROCESS died — its detached child, containers and server lease can outlive it,
140
+ * and the file lock then reads as reclaimable — so the refresh could swap the
141
+ * `supabase/functions` tree under a batch that was still running. A run's lease
142
+ * is released with `destroy`, so a stable identity bought no volume reuse anyway.
143
+ * Omit `nonce` for a deterministic per-slot identity (tests, diagnostics).
144
+ */
145
+ export function leasedStackIdentity({ worktreeRoot, slot, ticket, nonce } = {}) {
146
+ // JSON-encode the parts so no path/slot/nonce combination can alias another
147
+ // (an unambiguous, escape-free separator).
148
+ const digest = createHash("sha256").update(JSON.stringify([worktreeRoot ?? "", slot ?? "", nonce ?? ""])).digest("hex");
149
+ const projectId = `bb${identityTag(ticket, slot)}${digest.slice(0, 6)}`.slice(0, 20);
150
+ const portBase = LEASED_PORT_BASE + (parseInt(digest.slice(8, 16), 16) % LEASED_PORT_SLOTS) * LEASED_PORT_STRIDE;
151
+ return { projectId, portBase, dirName: projectId };
152
+ }
153
+
154
+ const PROJECT_ID_RE = /^[a-z][a-z0-9]{0,19}$/;
155
+
156
+ /**
157
+ * The worktree's own config with exactly THREE things changed: `project_id`,
158
+ * every declared host port, and the edge runtime's own public origin. Everything
159
+ * else — `[functions.*] verify_jwt`, `[auth.hook.*]`, every other
160
+ * `[edge_runtime.secrets]` env() interpolation, storage limits, comments —
161
+ * survives byte-for-byte, or the leased stack exercises a different contract
162
+ * than the shared one.
163
+ *
164
+ * Ports are assigned by first appearance into the stack's own block, so a port
165
+ * the config adds later is remapped automatically instead of silently falling
166
+ * back to Supabase's default (which would collide with every other stack).
167
+ *
168
+ * The origin pin (BOT-1798, Codex #928 R3 P1) is the leased equivalent of
169
+ * `resolveIsolatedStackApiUrl` on the `--local-exec` path. The committed config
170
+ * resolves the edge runtime's `VITE_SUPABASE_URL` through `env()` because a local
171
+ * `supabase start` exports the stack's Kong origin into the shell (BOT-903) — but
172
+ * the HELPER starts this stack with ITS environment, where that variable is
173
+ * absent, points at production, or points at another stack. The edge runtime
174
+ * would then emit foreign public URLs from inside the leased stack. The
175
+ * materialized config is disposable and is never used for `functions deploy`, so
176
+ * the safe answer is to pin the literal origin of its own remapped `[api] port`.
177
+ */
178
+ export function rewriteLeasedStackConfig(configText, { projectId, portBase } = {}) {
179
+ if (!PROJECT_ID_RE.test(String(projectId ?? ""))) {
180
+ throw new Error(`rewriteLeasedStackConfig: projectId "${projectId}" must be lowercase alphanumeric, start with a letter, and be ≤20 characters`);
181
+ }
182
+ if (!Number.isInteger(portBase)) throw new Error("rewriteLeasedStackConfig: portBase must be an integer");
183
+
184
+ const bare = new RegExp(`^(\\s*)((?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
185
+ const dotted = new RegExp(`^(\\s*)([A-Za-z0-9_]+\\.(?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
186
+ const ports = new Map();
187
+ const assign = (key) => {
188
+ if (!ports.has(key)) {
189
+ if (ports.size >= LEASED_PORT_STRIDE) {
190
+ throw new Error(
191
+ `refusing to materialize an isolated leased stack: supabase/config.toml declares more than ${LEASED_PORT_STRIDE} host ports ` +
192
+ `(at "${key}") — the leased port block cannot hold them all, and reusing a port would collide inside the stack. ` +
193
+ "Widen LEASED_PORT_STRIDE (and the band in docs/docker-capacity.md) before adding more services.",
194
+ );
195
+ }
196
+ ports.set(key, portBase + ports.size);
197
+ }
198
+ return ports.get(key);
199
+ };
200
+
201
+ let section = "";
202
+ let sawProjectId = false;
203
+ const out = [];
204
+ for (const line of String(configText).split(/\r?\n/)) {
205
+ const trimmed = line.trim();
206
+ if (trimmed.startsWith("#")) { out.push(line); continue; }
207
+ const sec = /^\s*\[([^\]]+)\]/.exec(line);
208
+ if (sec) { section = sec[1].trim(); out.push(line); continue; }
209
+ // project_id is a root key: it precedes every section header.
210
+ if (!section && /^\s*project_id\s*=/.test(line)) {
211
+ out.push(`project_id = "${projectId}"`);
212
+ sawProjectId = true;
213
+ continue;
214
+ }
215
+ const dot = dotted.exec(line);
216
+ if (dot && tomlIntToDecimal(dot[4]) != null) {
217
+ out.push(`${dot[1]}${dot[2]}${dot[3]}${assign(dot[2])}${dot[5]}`);
218
+ continue;
219
+ }
220
+ const m = bare.exec(line);
221
+ if (m && tomlIntToDecimal(m[4]) != null) {
222
+ out.push(`${m[1]}${m[2]}${m[3]}${assign(`${section}.${m[2]}`)}${m[5]}`);
223
+ continue;
224
+ }
225
+ out.push(line);
226
+ }
227
+ // Pin the edge origin AFTER the port pass: [edge_runtime.secrets] may precede
228
+ // [api] in the file, so the leased api port is only known once every line is seen.
229
+ const apiPort = ports.get("api.port");
230
+ let pinned = 0;
231
+ let sawEdgeOrigin = false;
232
+ let pinSection = "";
233
+ for (let i = 0; i < out.length; i++) {
234
+ const line = out[i];
235
+ if (line.trim().startsWith("#")) continue;
236
+ const sec = /^\s*\[([^\]]+)\]/.exec(line);
237
+ if (sec) { pinSection = sec[1].trim(); continue; }
238
+ if (pinSection !== "edge_runtime.secrets") continue;
239
+ const m = /^(\s*)VITE_SUPABASE_URL(\s*=\s*).*$/.exec(line);
240
+ if (!m) continue;
241
+ sawEdgeOrigin = true;
242
+ if (apiPort === undefined) break;
243
+ out[i] = `${m[1]}VITE_SUPABASE_URL${m[2]}"http://127.0.0.1:${apiPort}"`;
244
+ pinned++;
245
+ }
246
+ if (sawEdgeOrigin && apiPort === undefined) {
247
+ throw new Error(
248
+ "refusing to materialize an isolated leased stack: its supabase/config.toml sets the edge runtime's " +
249
+ "VITE_SUPABASE_URL but declares no [api] port, so the stack's own origin cannot be pinned — the Helper " +
250
+ "would start an edge runtime that emits another stack's (or production's) public URLs.",
251
+ );
252
+ }
253
+ if (!sawProjectId) {
254
+ throw new Error(
255
+ "refusing to materialize an isolated leased stack: the worktree's supabase/config.toml declares no root project_id, " +
256
+ "so the leased stack cannot be given an identity distinct from the shared canonical project.",
257
+ );
258
+ }
259
+ return { text: out.join("\n"), ports, pinnedEdgeOrigin: pinned > 0 };
260
+ }
261
+
262
+ /** The literal `[edge_runtime.secrets] VITE_SUPABASE_URL` a config declares, or
263
+ * null when it declares none. Comments are not configuration. */
264
+ export function edgeOriginInConfig(toml) {
265
+ let section = "";
266
+ for (const raw of String(toml).split(/\r?\n/)) {
267
+ const line = raw.trim();
268
+ if (line.startsWith("#")) continue;
269
+ const sec = /^\[([^\]]+)\]/.exec(line);
270
+ if (sec) { section = sec[1].trim(); continue; }
271
+ if (section !== "edge_runtime.secrets") continue;
272
+ const m = /^VITE_SUPABASE_URL\s*=\s*(.*)$/.exec(line);
273
+ if (m) return m[1].trim().replace(/^["']|["']$/g, "");
274
+ }
275
+ return null;
276
+ }
277
+
278
+ /**
279
+ * FAIL CLOSED before anything is leased: prove the written config is actually
280
+ * isolated from the worktree root's. Mirrors the BOT-1711 invariant
281
+ * (`assertIsolatedLocalExecTarget`) so a materializer bug can never hand the
282
+ * Helper the shared canonical stack.
283
+ */
284
+ export function assertLeasedStackIsolated(rootConfigText, stackConfigText) {
285
+ const rootProject = projectIdFromConfig(rootConfigText);
286
+ const stackProject = projectIdFromConfig(stackConfigText);
287
+ // FAIL CLOSED (Codex #928 R17, mirroring BOT-1711 R6 P1): with no readable root
288
+ // identity, isolation from the shared canonical project cannot be proven — and
289
+ // its containers may well be running.
290
+ if (!rootProject) {
291
+ throw new Error(
292
+ "refusing to lease a stack: the worktree's supabase/config.toml declares no readable project_id, so isolation from the " +
293
+ "shared canonical project cannot be proven (its containers may still be running).",
294
+ );
295
+ }
296
+ if (!stackProject || stackProject === rootProject) {
297
+ throw new Error(
298
+ `refusing to lease a materialized stack whose project_id ("${stackProject ?? "<none>"}") is not distinct from the worktree's ` +
299
+ "shared canonical project — `supabase start` there would operate the shared dev stack.",
300
+ );
301
+ }
302
+ const rootPorts = portMapInConfig(rootConfigText);
303
+ const stackPorts = portMapInConfig(stackConfigText);
304
+ const missing = [...rootPorts.keys()].filter((k) => !stackPorts.has(k));
305
+ if (missing.length) {
306
+ throw new Error(`refusing to lease a materialized stack that omits port(s) the shared stack allocates (${missing.join(", ")}) — an omitted port falls back to Supabase's default and collides with other stacks.`);
307
+ }
308
+ const values = [...stackPorts.values()];
309
+ const dupes = [...new Set(values.filter((v, i) => values.indexOf(v) !== i))];
310
+ if (dupes.length) {
311
+ throw new Error(`refusing to lease a materialized stack that assigns the same port to multiple services (${dupes.join(", ")}).`);
312
+ }
313
+ const rootValues = new Set(rootPorts.values());
314
+ const shared = [...new Set(values)].filter((v) => rootValues.has(v));
315
+ if (shared.length) {
316
+ throw new Error(`refusing to lease a materialized stack that reuses the shared stack's port(s) ${shared.join(", ")}.`);
317
+ }
318
+ // The edge runtime must emit THIS stack's public URLs (BOT-1798, Codex #928 R9).
319
+ // A config that leaves `env(VITE_SUPABASE_URL)` in place — the repo default,
320
+ // because a local `supabase start` exports the origin into the shell — resolves
321
+ // against the HELPER's environment: missing, production, or another stack.
322
+ const edgeOrigin = edgeOriginInConfig(stackConfigText);
323
+ // Omitting it is not a way out either (Codex #928 R10): this repo's edge runtime
324
+ // is handed its public origin through that section and fails closed without it,
325
+ // so a leased stack must declare it whenever the worktree's own config does.
326
+ if (edgeOrigin === null && edgeOriginInConfig(rootConfigText) !== null) {
327
+ throw new Error(
328
+ "refusing to lease a stack that declares no [edge_runtime.secrets] VITE_SUPABASE_URL while the worktree's config does — " +
329
+ "the leased edge runtime would start without the public origin it fails closed on. Declare it, pinned to that stack's own api port.",
330
+ );
331
+ }
332
+ if (edgeOrigin !== null) {
333
+ const apiPort = stackPorts.get("api.port");
334
+ const expected = apiPort === undefined ? null : `http://127.0.0.1:${apiPort}`;
335
+ if (expected === null || edgeOrigin !== expected) {
336
+ throw new Error(
337
+ `refusing to lease a stack whose edge runtime origin is "${edgeOrigin}": it must be pinned to that stack's OWN api ` +
338
+ `port (${expected ?? "which its config does not declare"}), or the Helper's environment decides — and the leased ` +
339
+ "edge runtime emits another stack's (or production's) public URLs.",
340
+ );
341
+ }
342
+ }
343
+ }
344
+
345
+ // Supabase CLI scratch state is per-stack: never copied IN from the worktree, and
346
+ // never pruned OUT of the leased stack (it belongs to that stack's own lifecycle).
347
+ const SKIP_ENTRIES = new Set([".branches", ".temp"]);
348
+
349
+ /**
350
+ * Is a stack directory still CLAIMED by its batch? `acquireStackLock` writes its
351
+ * lock file once and never refreshes it (the heartbeat goes to the server), so a
352
+ * batch that runs for hours has an ancient lock file — its age proves nothing
353
+ * (BOT-1798, Codex #928 R6). Only the recorded owner's liveness does. Anything
354
+ * unprovable — an unreadable payload, a claim recorded by another machine — counts
355
+ * as claimed, because wrongly deleting a live batch's tree is far worse than
356
+ * leaving a directory behind.
357
+ */
358
+ function stackDirIsClaimed(projectId, { read, alive, host, marker = null }) {
359
+ // The marker's own owner is a second, independent claim: lock files live in the
360
+ // system temp directory, which the OS may sweep while a long batch is running.
361
+ if (marker && Number.isInteger(marker.owner_pid) && marker.owner_pid > 0
362
+ && (!marker.host || marker.host === host()) && alive(marker.owner_pid)) {
363
+ return true;
364
+ }
365
+ let raw;
366
+ try {
367
+ raw = read(lockPathForProject(projectId), "utf8");
368
+ } catch {
369
+ return false; // no lock file at all: nothing claims this directory
370
+ }
371
+ let holder;
372
+ try {
373
+ holder = JSON.parse(raw);
374
+ } catch {
375
+ return true; // a corrupt lock is not proof the batch is gone
376
+ }
377
+ if (!holder || !Number.isInteger(holder.pid) || holder.pid <= 0) return true;
378
+ if (holder.host && holder.host !== host()) return true; // another machine's claim
379
+ return alive(holder.pid);
380
+ }
381
+
382
+ /** Does this PID exist? EPERM means it exists and is simply not ours to signal. */
383
+ function pidIsAlive(pid) {
384
+ try {
385
+ process.kill(pid, 0);
386
+ return true;
387
+ } catch (error) {
388
+ return error?.code === "EPERM";
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Does `dirName` belong to THIS ticket's family of materialized stacks?
394
+ *
395
+ * A prefix test is not enough (BOT-1798, Codex #928 R7): `BOT-179` prefixes
396
+ * `BOT-1798`, so `bbbot1798<hash>` would read as a `BOT-179` stack and a run for
397
+ * the shorter ticket could collect the longer one's live directory. The name must
398
+ * be the family prefix followed by exactly the generated hex suffix — and when the
399
+ * directory carries an ownership marker, that marker is authoritative.
400
+ */
401
+ function isOwnStackFamily(dirName, { ticket, slot, marker }) {
402
+ // Compare NORMALIZED ticket keys (Codex #928 R11): `bot-1798` and `BOT-1798` are
403
+ // the same ticket, and the slot a previous batch happened to use is not part of
404
+ // ownership — requiring it left full supabase/ copies permanently uncollectable.
405
+ if (marker && (marker.ticket != null || marker.slot != null)) {
406
+ return identityTag(marker.ticket, marker.slot) === identityTag(ticket, slot);
407
+ }
408
+ return new RegExp(`^bb${identityTag(ticket, slot)}[0-9a-f]{1,6}$`).test(dirName);
409
+ }
410
+
411
+ /**
412
+ * Collect this ticket's long-finished stack directories (BOT-1798, Codex #928 R5/R6).
413
+ * Per-invocation directories would otherwise accumulate, but a batch that is still
414
+ * running must never be collected — so a directory is removed only when it is both
415
+ * older than `LEASED_STACK_GC_MIN_AGE_MS` (well past the reaper's idle TTL, so a
416
+ * child that outlived its dead owner cannot still be using it) AND unclaimed by a
417
+ * live lock owner. Best-effort throughout: collection never blocks provisioning.
418
+ */
419
+ function collectFinishedStacks(stacksDir, { keep, ticket, slot, readdir, remove, stat, now, read, alive, host }) {
420
+ let entries;
421
+ try { entries = readdir(stacksDir); } catch { return []; }
422
+ const collected = [];
423
+ for (const entry of entries) {
424
+ if (entry === keep) continue;
425
+ try {
426
+ const marker = readStackMarker(join(stacksDir, entry), read);
427
+ // A marker states how long its batch must be kept, so an EXPIRED, unclaimed
428
+ // stack is provably finished whoever ran it — and one-off tickets would
429
+ // otherwise leave full supabase/ copies behind forever (Codex #928 R12).
430
+ // Without a marker nothing is provable, so stay inside this ticket's family
431
+ // and fall back to the conservative default age.
432
+ const retainUntil = Number.isFinite(marker?.retain_until) ? marker.retain_until : null;
433
+ if (retainUntil === null && !isOwnStackFamily(entry, { ticket, slot, marker })) continue;
434
+ if (now() < (retainUntil ?? stat(join(stacksDir, entry)).mtimeMs + LEASED_STACK_GC_MIN_AGE_MS)) continue;
435
+ if (stackDirIsClaimed(entry, { read, alive, host, marker })) continue;
436
+ remove(join(stacksDir, entry), { recursive: true, force: true });
437
+ collected.push(entry);
438
+ } catch { /* a racing collector or a permission fault is not fatal */ }
439
+ }
440
+ return collected;
441
+ }
442
+
443
+ /**
444
+ * Push this stack's retention deadline forward on a lease heartbeat (BOT-1798,
445
+ * Codex #928 R8). Anchoring retention to creation time was not enough: a batch
446
+ * that outlives its deadline and THEN loses its owner process still holds a live
447
+ * lease, child and containers for another idle-TTL window, and a later run would
448
+ * have collected the tree it is bind-mounting. The deadline therefore tracks the
449
+ * lease's last activity, exactly as the server's reaper does.
450
+ *
451
+ * Returns false (never throws) when there is no marker to refresh — an explicitly
452
+ * prepared `--stack-path` is the caller's to manage, not ours to annotate.
453
+ */
454
+ export function refreshLeasedStackRetention(stackDir, { idleTtlSecs, now = Date.now, read = readFileSync, write = writeFileSync, rename = renameSync, portRegistryDir = null } = {}) {
455
+ const marker = readStackMarker(stackDir, read);
456
+ if (!marker) return false;
457
+ const ttlMs = Math.max(0, Number.isFinite(idleTtlSecs) ? idleTtlSecs : DEFAULT_IDLE_TTL_SECS) * 1000;
458
+ const at = now();
459
+ const renewed = JSON.stringify({
460
+ ...marker,
461
+ last_seen_at: at,
462
+ retain_until: at + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
463
+ });
464
+ try {
465
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), renewed, { write, rename });
466
+ } catch {
467
+ return false; // a best-effort extension must never fence a healthy batch
468
+ }
469
+ // Renew the host-scoped port reservation on the same beat, or another worktree
470
+ // would eventually read this live batch's block as free.
471
+ if (Number.isInteger(marker.port_base)) {
472
+ try {
473
+ // Codex #928 R23 (P2): a heartbeat can still be in flight when its own batch
474
+ // exits — cleanup releases (or an unrelated retry reclaims and re-reserves)
475
+ // this same block before the stale call reaches here. Verify the registry's
476
+ // CURRENT claim is still ours (by invocation, like releaseLeasedStackClaim)
477
+ // before overwriting it; otherwise this recreates a finished batch's claim,
478
+ // or worse, clobbers a different live invocation's claim with our stale
479
+ // metadata, making its fresh reservation look owned by our dead PID.
480
+ const claimPath = portClaimPath(portRegistryDir ?? defaultPortRegistryDir(), marker.port_base);
481
+ const owner = marker.invocation_token ?? marker.project_id;
482
+ let current = null;
483
+ try { current = JSON.parse(read(claimPath, "utf8")); } catch { current = null; }
484
+ if (current && (current.invocation_token ?? current.project_id) === owner) {
485
+ atomicWrite(claimPath, renewed, { write, rename });
486
+ }
487
+ } catch { /* the reservation is advisory; never fence a healthy batch */ }
488
+ }
489
+ return true;
490
+ }
491
+
492
+ /** This stack's ownership/retention marker, or null when it has none. */
493
+ function readStackMarker(stackDir, read) {
494
+ try {
495
+ const parsed = JSON.parse(read(join(stackDir, LEASED_STACK_MARKER), "utf8"));
496
+ return parsed && typeof parsed === "object" ? parsed : null;
497
+ } catch {
498
+ return null;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * The port blocks this worktree's other leased stacks still hold (BOT-1798, Codex
504
+ * #928 R12). A per-invocation nonce spreads identities but says nothing about the
505
+ * 200 available blocks, so two concurrent runs could hash to the SAME ports: the
506
+ * server lease is keyed by the ticket slot, not the block, so nothing serializes
507
+ * them and the second Helper fails on a bind error after provisioning starts.
508
+ * A block counts as taken while its stack is retained or its lock owner is alive.
509
+ */
510
+ function occupiedPortBlocks(registryDir, { keep, readdir, read, now, alive, host, stale = null }) {
511
+ const taken = new Map(); // portBase -> { projectId, createdAt }
512
+ let entries;
513
+ try { entries = readdir(registryDir); } catch { return taken; }
514
+ for (const entry of entries) {
515
+ if (!entry.endsWith(".json")) continue;
516
+ let claim;
517
+ try { claim = JSON.parse(read(join(registryDir, entry), "utf8")); } catch { continue; }
518
+ const portBase = Number(claim?.port_base);
519
+ if (!Number.isInteger(portBase)) continue;
520
+ const retained = Number.isFinite(claim.retain_until) && now() < claim.retain_until;
521
+ const ownerAlive = Number.isInteger(claim.owner_pid) && claim.owner_pid > 0
522
+ && (!claim.host || claim.host === host()) && alive(claim.owner_pid);
523
+ if (!retained && !ownerAlive) {
524
+ // A reservation whose batch is provably finished is not a claim — reclaim it
525
+ // even when it shares `keep`'s project id (Codex #928 R23): `keep` exempts a
526
+ // STILL-LIVE claim we already hold from looking like a peer's, not an expired
527
+ // one from a past, dead invocation of the same project. Checked before the
528
+ // `keep` exemption below, or an expired same-project claim is invisible to
529
+ // both `taken` and `stale` forever — never a collision, but never reclaimed
530
+ // either, wedging any caller that needs this EXACT block (reservePortBlocks'
531
+ // explicit --stack-path ports have nowhere else to go). It is NOT deleted
532
+ // here: removal happens under the allocation lock, so a peer's fresh claim can
533
+ // never be wiped by a decision made before it existed.
534
+ stale?.add(portBase);
535
+ continue;
536
+ }
537
+ if (claim.project_id === keep) continue; // a live claim we already hold: not a peer's
538
+ const previous = taken.get(portBase);
539
+ const createdAt = Number.isFinite(claim.created_at) ? claim.created_at : 0;
540
+ if (!previous || createdAt < previous.createdAt) taken.set(portBase, { projectId: claim.project_id ?? entry, createdAt });
541
+ }
542
+ return taken;
543
+ }
544
+
545
+ /** Overwrite a file atomically (BOT-1798, Codex #928 R15): a crash mid-write must
546
+ * never leave a truncated marker, because an unparseable marker downgrades a live
547
+ * stack to "no recorded retention" and invites the collector to delete it. */
548
+ function atomicWrite(path, data, { write, rename }) {
549
+ const tmp = `${path}.${process.pid}.tmp`;
550
+ write(tmp, data);
551
+ rename(tmp, path);
552
+ }
553
+
554
+ /** Block this thread for `ms` without async: the allocation critical section is
555
+ * a handful of file operations, and materialization is synchronous. */
556
+ function sleepSync(ms) {
557
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* fall through */ }
558
+ }
559
+
560
+ /**
561
+ * Run `fn` holding the host-scoped allocation lock (BOT-1798, Codex #928 R16).
562
+ * Taken by exclusive create; a lock whose owner is dead — or that is older than
563
+ * the critical section could possibly be — is stolen, so a crash never wedges the
564
+ * machine's port allocation.
565
+ */
566
+ function withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep = sleepSync }, fn) {
567
+ const lockPath = join(registryDir, LEASED_ALLOCATION_LOCK);
568
+ const guardPath = `${lockPath}.reclaim`;
569
+ const token = randomUUID();
570
+ const deadline = now() + 30_000;
571
+ for (;;) {
572
+ try {
573
+ writeExclusive(lockPath, JSON.stringify({ pid: process.pid, host: host(), at: now(), owner_token: token }));
574
+ break;
575
+ } catch (error) {
576
+ if (error?.code !== "EEXIST") throw error;
577
+ let raw = null;
578
+ try { raw = read(lockPath, "utf8"); } catch { raw = null; }
579
+ if (raw === null) continue; // it vanished: try to create again
580
+ let holder = null;
581
+ try { holder = JSON.parse(raw); } catch { /* corrupt: treat as abandoned */ }
582
+ const abandoned = !holder
583
+ || !Number.isInteger(holder.pid)
584
+ || (holder.host && holder.host !== host())
585
+ || !alive(holder.pid)
586
+ || now() - (Number(holder.at) || 0) > LEASED_ALLOCATION_LOCK_STALE_MS;
587
+ if (abandoned) {
588
+ // Serialize the STEAL itself and delete only the exact record we inspected
589
+ // (Codex #928 R18): two stealers could otherwise both read the abandoned
590
+ // lock and the slower one would delete the winner's replacement, putting
591
+ // both inside the critical section.
592
+ let guarded = false;
593
+ try {
594
+ writeExclusive(guardPath, JSON.stringify({ pid: process.pid, at: now(), owner_token: token }));
595
+ guarded = true;
596
+ } catch (guardError) {
597
+ if (guardError?.code !== "EEXIST") throw guardError;
598
+ // The guard itself can be abandoned (it is held for two file ops), but it
599
+ // is taken over with the same generation check as the lock (Codex #928
600
+ // R22): deleting it unconditionally would remove a peer's REPLACEMENT and
601
+ // admit two allocators at once.
602
+ let guardRaw = null;
603
+ try { guardRaw = read(guardPath, "utf8"); } catch { guardRaw = null; }
604
+ let guardHolder = null;
605
+ if (guardRaw !== null) {
606
+ try { guardHolder = JSON.parse(guardRaw); } catch { /* corrupt */ }
607
+ if (!guardHolder || now() - (Number(guardHolder.at) || 0) > LEASED_ALLOCATION_LOCK_STALE_MS) {
608
+ let current = null;
609
+ try { current = read(guardPath, "utf8"); } catch { current = null; }
610
+ if (current === guardRaw) {
611
+ try { remove(guardPath, { force: true }); } catch { /* a peer may have won */ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+ if (guarded) {
617
+ try {
618
+ let current = null;
619
+ try { current = read(lockPath, "utf8"); } catch { current = null; }
620
+ if (current === raw) {
621
+ try { remove(lockPath, { force: true }); } catch { /* vanished already */ }
622
+ }
623
+ } finally {
624
+ // Only ever unlink OUR OWN guard.
625
+ try {
626
+ const mine = JSON.parse(read(guardPath, "utf8"));
627
+ if (mine?.owner_token === token) remove(guardPath, { force: true });
628
+ } catch { /* gone, corrupt, or superseded */ }
629
+ }
630
+ continue; // we just cleared the lock ourselves: worth an immediate retry
631
+ }
632
+ // Codex #928 R23 (P2): a peer's OWN reclaim guard is live (or merely looked
633
+ // fresh a moment ago) — we made no progress this iteration. Fall through to
634
+ // the SAME deadline check + backoff as the "lock is held" case below instead
635
+ // of looping straight back to the top: skipping both, as this branch used to,
636
+ // spins at full CPU re-reading the guard until it separately goes stale,
637
+ // which can take up to LEASED_ALLOCATION_LOCK_STALE_MS regardless of this
638
+ // call's own 30s deadline.
639
+ }
640
+ if (now() > deadline) {
641
+ throw new Error(
642
+ "refusing to materialize an isolated leased stack: another run has held the host's port-allocation lock for 30s. " +
643
+ "Retry once that batch has started.",
644
+ );
645
+ }
646
+ sleep(50);
647
+ }
648
+ }
649
+ try {
650
+ return fn();
651
+ } finally {
652
+ // Only ever unlink OUR OWN lock: a stealer may already have replaced it.
653
+ try {
654
+ const current = JSON.parse(read(lockPath, "utf8"));
655
+ if (current?.owner_token === token) remove(lockPath, { force: true });
656
+ } catch { /* gone, corrupt, or superseded: never unlink blindly */ }
657
+ }
658
+ }
659
+
660
+ /**
661
+ * The host blocks a set of declared ports occupies. Blocks are `LEASED_PORT_STRIDE`
662
+ * wide across the WHOLE port space (Codex #928 R19): the leased band is aligned to
663
+ * that stride, so materialized stacks key exactly as before, and a hand-prepared
664
+ * stack outside the band — two of which can just as easily reuse 57000 — is
665
+ * reserved on the same host registry instead of silently unserialized.
666
+ */
667
+ export function blocksForPorts(ports) {
668
+ const blocks = new Set();
669
+ for (const value of ports) {
670
+ const port = Number(value);
671
+ if (!Number.isInteger(port) || port <= 0) continue;
672
+ blocks.add(Math.floor(port / LEASED_PORT_STRIDE) * LEASED_PORT_STRIDE);
673
+ }
674
+ return [...blocks].sort((a, b) => a - b);
675
+ }
676
+
677
+ /**
678
+ * Give back the host port block a materialized batch holds, the moment its stack
679
+ * is provably gone (BOT-1798, Codex #928 R19). Every invocation takes a NEW
680
+ * identity, so holding claims for their full retention after a proven reap would
681
+ * let a couple of hundred successful sequential runs exhaust the machine while
682
+ * nothing is running. Ownership-checked and best-effort: a claim that now belongs
683
+ * to another batch is never unlinked. Returns true when a claim was released.
684
+ */
685
+ export function releaseLeasedStackClaim(stackDir, { registryDir = defaultPortRegistryDir(), io = {} } = {}) {
686
+ const read = io.readFile ?? readFileSync;
687
+ const remove = io.remove ?? rmSync;
688
+ const marker = readStackMarker(stackDir, read);
689
+ if (!marker || !Number.isInteger(marker.port_base)) return false;
690
+ const path = portClaimPath(registryDir, marker.port_base);
691
+ let claim = null;
692
+ try { claim = JSON.parse(read(path, "utf8")); } catch { return false; }
693
+ const owner = marker.invocation_token ?? marker.project_id;
694
+ if (!claim || (claim.invocation_token ?? claim.project_id) !== owner) return false;
695
+ try {
696
+ remove(path, { force: true });
697
+ return true;
698
+ } catch {
699
+ return false;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Reserve the blocks an EXPLICIT `--stack-path` occupies (BOT-1798, Codex #928
705
+ * R18). Its project lock only serializes runs that share a project id; two
706
+ * prepared directories with different ids but overlapping ports — or one
707
+ * overlapping a materialized stack — would still collide on the host. Returns a
708
+ * `release()`; throws (code `stack_ports_busy`) when a live peer holds a block.
709
+ */
710
+ export function reservePortBlocks(ports, { registryDir = defaultPortRegistryDir(), owner, io = {} } = {}) {
711
+ const read = io.readFile ?? readFileSync;
712
+ const write = io.writeFile ?? writeFileSync;
713
+ const mkdir = io.mkdir ?? mkdirSync;
714
+ const readdir = io.readdir ?? readdirSync;
715
+ const remove = io.remove ?? rmSync;
716
+ const now = io.now ?? Date.now;
717
+ const alive = io.alive ?? pidIsAlive;
718
+ const host = io.hostname ?? hostname;
719
+ const sleep = io.sleep ?? sleepSync;
720
+ const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
721
+ const blocks = blocksForPorts(ports);
722
+ // Identity is not ownership (Codex #928 R21): a RETRY of a prepared path reuses
723
+ // its project id, and a claim retained because the previous attempt's reap was
724
+ // unproven must not be overwritten by it. Claims therefore carry a token unique
725
+ // to one invocation.
726
+ const invocationToken = owner?.invocationToken ?? randomUUID();
727
+ if (blocks.length === 0) return { blocks: [], invocationToken, release: () => {}, renew: () => false };
728
+
729
+ mkdir(registryDir, { recursive: true });
730
+ const claimed = [];
731
+ const record = (block, at = now(), ttlSecs = owner?.idleTtlSecs) => JSON.stringify({
732
+ schema: 1,
733
+ project_id: owner?.projectId ?? null,
734
+ invocation_token: invocationToken,
735
+ ticket: owner?.ticket == null ? null : String(owner.ticket),
736
+ slot: owner?.slot == null ? null : String(owner.slot),
737
+ stack_dir: owner?.stackDir ?? null,
738
+ port_base: block,
739
+ created_at: at,
740
+ retain_until: at + Math.max(LEASED_STACK_GC_MIN_AGE_MS, (Number(ttlSecs) || DEFAULT_IDLE_TTL_SECS) * 1000 + LEASED_STACK_RETENTION_GRACE_MS),
741
+ owner_pid: process.pid,
742
+ host: host(),
743
+ });
744
+ try {
745
+ withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
746
+ const stale = new Set();
747
+ const taken = occupiedPortBlocks(registryDir, { keep: owner?.projectId ?? null, readdir, read, now, alive, host, stale });
748
+ for (const block of stale) {
749
+ try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
750
+ }
751
+ for (const block of blocks) {
752
+ if (taken.has(block)) {
753
+ const error = new Error(
754
+ `refusing to lease this stack: its port block ${block}-${block + LEASED_PORT_STRIDE - 1} is already reserved by ` +
755
+ `another live batch (${taken.get(block).projectId}). Wait for it to finish, or prepare the stack on a free block.`,
756
+ );
757
+ error.code = "stack_ports_busy";
758
+ throw error;
759
+ }
760
+ try {
761
+ writeExclusive(portClaimPath(registryDir, block), record(block));
762
+ } catch (error) {
763
+ if (error?.code !== "EEXIST") throw error;
764
+ // Only THIS invocation's own reservation is not a peer.
765
+ let existing = null;
766
+ try { existing = JSON.parse(read(portClaimPath(registryDir, block), "utf8")); } catch { /* unreadable */ }
767
+ if (existing && existing.invocation_token === invocationToken) write(portClaimPath(registryDir, block), record(block));
768
+ else {
769
+ const busy = new Error(`refusing to lease this stack: port block ${block} was claimed by another run.`);
770
+ busy.code = "stack_ports_busy";
771
+ throw busy;
772
+ }
773
+ }
774
+ claimed.push(block);
775
+ }
776
+ });
777
+ } catch (error) {
778
+ for (const block of claimed) {
779
+ try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
780
+ }
781
+ throw error;
782
+ }
783
+ return {
784
+ blocks: claimed,
785
+ invocationToken,
786
+ /** Keep the reservation alive while the batch runs (Codex #928 R21): a
787
+ * prepared `--stack-path` has no directory marker, so nothing else renews it,
788
+ * and an aged-out claim could be reclaimed under a still-live stack. */
789
+ renew: ({ idleTtlSecs, now: at = now } = {}) => {
790
+ let renewed = false;
791
+ for (const block of claimed) {
792
+ try {
793
+ const current = JSON.parse(read(portClaimPath(registryDir, block), "utf8"));
794
+ if (current?.invocation_token !== invocationToken) continue;
795
+ write(portClaimPath(registryDir, block), record(block, at(), idleTtlSecs ?? owner?.idleTtlSecs));
796
+ renewed = true;
797
+ } catch { /* advisory: never fence a healthy batch */ }
798
+ }
799
+ return renewed;
800
+ },
801
+ release: () => {
802
+ for (const block of claimed) {
803
+ try {
804
+ const current = JSON.parse(read(portClaimPath(registryDir, block), "utf8"));
805
+ if (current?.invocation_token === invocationToken) remove(portClaimPath(registryDir, block), { force: true });
806
+ } catch { /* gone or superseded: never unlink blindly */ }
807
+ }
808
+ },
809
+ };
810
+ }
811
+
812
+ /** The host-scoped reservation file for a block. */
813
+ function portClaimPath(registryDir, portBase) {
814
+ return join(registryDir, `${portBase}.json`);
815
+ }
816
+
817
+ /** The first free block at or after the identity's own hash-derived one. */
818
+ function selectPortBase(preferred, taken) {
819
+ const start = Math.floor((preferred - LEASED_PORT_BASE) / LEASED_PORT_STRIDE);
820
+ for (let i = 0; i < LEASED_PORT_SLOTS; i++) {
821
+ const candidate = LEASED_PORT_BASE + ((start + i) % LEASED_PORT_SLOTS) * LEASED_PORT_STRIDE;
822
+ if (!taken.has(candidate)) return candidate;
823
+ }
824
+ throw new Error(
825
+ `refusing to materialize an isolated leased stack: all ${LEASED_PORT_SLOTS} leased port blocks ` +
826
+ `(${LEASED_PORT_BASE}-${LEASED_PORT_BASE + LEASED_PORT_SLOTS * LEASED_PORT_STRIDE - 1}) are held by live or retained stacks. ` +
827
+ "Finish or reap a batch before starting another.",
828
+ );
829
+ }
830
+
831
+ /**
832
+ * Refuse a materialization target that is not literally inside the worktree
833
+ * (BOT-1798, Codex #928 R4 P1). The refresh prunes and copies BEFORE
834
+ * `resolveStackPath` could notice an escape, so a stale or hostile symlink at
835
+ * `.botbuddy/`, `.botbuddy/stacks/`, `<project_id>/` or its `supabase/` would
836
+ * make `prune()` delete data OUTSIDE the worktree. Every existing component of
837
+ * the path must therefore resolve to itself; the first one that does not is
838
+ * fatal, and a component that does not exist yet ends the walk (nothing below it
839
+ * can exist either). `worktreeRoot` is already canonical (realpath of the cwd).
840
+ */
841
+ function assertCanonicalTarget(worktreeRoot, relPath, realpath) {
842
+ let expected = worktreeRoot;
843
+ for (const part of relPath.split("/")) {
844
+ expected = join(expected, part);
845
+ let resolved;
846
+ try {
847
+ resolved = realpath(expected);
848
+ } catch (error) {
849
+ if (error?.code === "ENOENT") return; // not created yet
850
+ throw error;
851
+ }
852
+ if (resolved !== expected) {
853
+ throw new Error(
854
+ `refusing to materialize an isolated leased stack: ${expected} is a symlink (it resolves to ${resolved}), so refreshing ` +
855
+ "the stack would delete and rewrite files outside this worktree. Remove that link and re-run.",
856
+ );
857
+ }
858
+ }
859
+ }
860
+
861
+ /** Delete everything the refresh is about to replace, so a file removed from the
862
+ * worktree cannot linger in the leased stack (and be applied by `supabase start`).
863
+ * The stack's own CLI scratch state (SKIP_ENTRIES) is left alone. A not-yet-created
864
+ * directory is simply nothing to prune. */
865
+ function prune(stackSupabaseDir, { readdir, remove }) {
866
+ let entries;
867
+ try { entries = readdir(stackSupabaseDir); } catch { return; }
868
+ for (const entry of entries) {
869
+ if (SKIP_ENTRIES.has(entry)) continue;
870
+ remove(join(stackSupabaseDir, entry), { recursive: true, force: true });
871
+ }
872
+ }
873
+
874
+ /**
875
+ * Materialize (or refresh) the isolated stack directory for this run and return
876
+ * its path RELATIVE to the worktree root — never `"."`. The directory belongs to
877
+ * ONE invocation (see `nonce` on `leasedStackIdentity`), so a concurrent or
878
+ * crashed batch's tree is never rewritten; this ticket's long-finished
879
+ * directories are collected on the way past.
880
+ *
881
+ * The worktree's whole `supabase/` tree is copied, not symlinked: the Helper's
882
+ * edge-runtime container bind-mounts `<stack_path>/supabase/functions`, and a
883
+ * symlink that resolves on the host does not resolve inside Docker (the same
884
+ * reason Supply Guard's isolated-stack script materializes function sources).
885
+ * Copying every run also keeps the leased stack in step with the worktree — and
886
+ * the copy is a REPLACE (rsync --delete semantics, like the hermetic lane), so a
887
+ * migration or function deleted in the worktree is not resurrected by the leased
888
+ * stack. Only the Supabase CLI's own scratch state survives the refresh.
889
+ */
890
+ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs } = {}, io = {}) {
891
+ const read = io.readFile ?? readFileSync;
892
+ const write = io.writeFile ?? writeFileSync;
893
+ const mkdir = io.mkdir ?? mkdirSync;
894
+ const copy = io.copy ?? cpSync;
895
+ const readdir = io.readdir ?? readdirSync;
896
+ const remove = io.remove ?? rmSync;
897
+ const stat = io.stat ?? statSync;
898
+ const now = io.now ?? Date.now;
899
+ const rename = io.rename ?? renameSync;
900
+ const resolveRoot = io.realpath ?? realpathSync;
901
+
902
+ const worktreeRoot = resolveRoot(cwd);
903
+ const sourceDir = join(worktreeRoot, "supabase");
904
+ const configPath = join(sourceDir, "config.toml");
905
+ let rootConfig;
906
+ try {
907
+ rootConfig = read(configPath, "utf8");
908
+ } catch (error) {
909
+ throw new Error(
910
+ `cannot materialize an isolated leased stack: ${configPath} is missing or unreadable (${error?.code ?? error?.message ?? error}). ` +
911
+ "`bb stack run` derives its own stack from the worktree's supabase/config.toml; it never leases the worktree root, " +
912
+ "because the Helper would start the SHARED canonical project there.",
913
+ );
914
+ }
915
+
916
+ const alive = io.alive ?? pidIsAlive;
917
+ const host = io.hostname ?? hostname;
918
+ const sleep = io.sleep ?? sleepSync;
919
+ const identity = leasedStackIdentity({ worktreeRoot, slot, ticket, nonce });
920
+ const stackPath = `${LEASED_STACKS_DIR}/${identity.dirName}`;
921
+ const stackDir = join(worktreeRoot, stackPath);
922
+ const stacksDir = join(worktreeRoot, LEASED_STACKS_DIR);
923
+
924
+ collectFinishedStacks(stacksDir, {
925
+ keep: identity.dirName, ticket, slot, readdir, remove, stat, now, read, alive, host,
926
+ });
927
+ // Prove the target is really inside this worktree BEFORE pruning or copying.
928
+ assertCanonicalTarget(worktreeRoot, `${stackPath}/supabase`, resolveRoot);
929
+ let dirExisted = true;
930
+ try { stat(stackDir); } catch { dirExisted = false; }
931
+ mkdir(stackDir, { recursive: true });
932
+ assertCanonicalTarget(worktreeRoot, `${stackPath}/supabase`, resolveRoot);
933
+
934
+ // Claim a port block no live sibling holds, then re-scan: another run can pick
935
+ // the same free block at the same instant, and the tie-break moves exactly one
936
+ // of us. The claim is published as a marker before the copy, so a concurrent
937
+ // scanner can see it.
938
+ const createdAt = now();
939
+ const ttlMs = Math.max(0, Number.isFinite(idleTtlSecs) ? idleTtlSecs : DEFAULT_IDLE_TTL_SECS) * 1000;
940
+ // With a nonce the token IS the invocation; without one the identity is the
941
+ // stable per-slot one, so the project id is exactly as specific (and keeps a
942
+ // refresh idempotent).
943
+ const invocationToken = nonce ? String(nonce) : identity.projectId;
944
+ const marker = (portBase) => JSON.stringify({
945
+ schema: 1,
946
+ project_id: identity.projectId,
947
+ invocation_token: invocationToken,
948
+ ticket: ticket == null ? null : String(ticket),
949
+ slot: slot == null ? null : String(slot),
950
+ port_base: portBase,
951
+ stack_dir: stackDir,
952
+ created_at: createdAt,
953
+ retain_until: createdAt + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
954
+ owner_pid: process.pid,
955
+ host: host(),
956
+ });
957
+ const registryDir = io.portRegistryDir ?? defaultPortRegistryDir();
958
+ const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
959
+ mkdir(registryDir, { recursive: true });
960
+ // Claim the block by EXCLUSIVE CREATE (Codex #928 R14) inside the host's
961
+ // allocation lock (Codex #928 R16): the scan only REPORTS stale reservations, and
962
+ // reclaiming one plus taking the block happen as one serialized step, so a
963
+ // slower peer can never delete the winner's fresh claim.
964
+ const portBase = withAllocationLock(registryDir, { writeExclusive, read, remove, now, alive, host, sleep }, () => {
965
+ let preferred = identity.portBase;
966
+ for (let attempt = 0; attempt < LEASED_PORT_SLOTS; attempt++) {
967
+ const stale = new Set();
968
+ const taken = occupiedPortBlocks(registryDir, { keep: identity.projectId, readdir, read, now, alive, host, stale });
969
+ // Every claim creation happens under this lock, so a reservation that read as
970
+ // finished a moment ago cannot have become live since: sweeping them here
971
+ // releases the machine's blocks without ever racing a peer's fresh claim.
972
+ for (const block of stale) {
973
+ try { remove(portClaimPath(registryDir, block), { force: true }); } catch { /* best effort */ }
974
+ }
975
+ const candidate = selectPortBase(preferred, taken);
976
+ try {
977
+ writeExclusive(portClaimPath(registryDir, candidate), marker(candidate));
978
+ return candidate;
979
+ } catch (error) {
980
+ if (error?.code !== "EEXIST") throw error;
981
+ // Our OWN previous claim (a re-materialization of this same batch) is not a
982
+ // peer: take it back rather than drifting onto a new block every refresh.
983
+ let existing = null;
984
+ try { existing = JSON.parse(read(portClaimPath(registryDir, candidate), "utf8")); } catch { /* unreadable: treat as a peer */ }
985
+ // Ownership is the INVOCATION, not the project id (Codex #928 R22): two
986
+ // nonces can in principle truncate to the same project suffix, and the
987
+ // earlier run's Helper may still be using that stack.
988
+ if (existing?.invocation_token === invocationToken) {
989
+ write(portClaimPath(registryDir, candidate), marker(candidate));
990
+ return candidate;
991
+ }
992
+ preferred = candidate + LEASED_PORT_STRIDE; // a peer won this one
993
+ }
994
+ }
995
+ return null;
996
+ });
997
+ if (portBase === null) {
998
+ throw new Error(
999
+ `refusing to materialize an isolated leased stack: every leased port block was claimed by another run while this one ` +
1000
+ "was choosing. Retry once the host has fewer concurrent batches.",
1001
+ );
1002
+ }
1003
+ // From here on the block is RESERVED: any failure must give it back, or a
1004
+ // retrying start would take another one and eventually exhaust the host
1005
+ // (Codex #928 R15). The half-built directory goes too, unless it predates us.
1006
+ try {
1007
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase), { write, rename });
1008
+
1009
+ const { text } = rewriteLeasedStackConfig(rootConfig, { projectId: identity.projectId, portBase });
1010
+ assertLeasedStackIsolated(rootConfig, text);
1011
+
1012
+ prune(join(stackDir, "supabase"), { readdir, remove });
1013
+ copy(sourceDir, join(stackDir, "supabase"), {
1014
+ recursive: true,
1015
+ force: true,
1016
+ filter: (source) => !SKIP_ENTRIES.has(basename(source)),
1017
+ });
1018
+ write(join(stackDir, "supabase", "config.toml"), text);
1019
+ // Re-state the claim after the copy: the marker records who owns this directory,
1020
+ // which port block it holds, and how long a later run must keep it (Codex #928
1021
+ // R7/R12). It lives beside `supabase/`, which the refresh replaces wholesale.
1022
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase), { write, rename });
1023
+ atomicWrite(portClaimPath(registryDir, portBase), marker(portBase), { write, rename });
1024
+ return stackPath;
1025
+ } catch (error) {
1026
+ try { remove(portClaimPath(registryDir, portBase), { force: true }); } catch { /* best effort */ }
1027
+ if (!dirExisted) {
1028
+ try { remove(stackDir, { recursive: true, force: true }); } catch { /* best effort */ }
1029
+ }
1030
+ throw error;
1031
+ }
1032
+ }