@botbuddy/cli 1.4.1 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.mjs +2 -1
- package/src/profile-bootstrap.test.mjs +1 -1
- package/src/stack.mjs +419 -27
- package/src/stack.test.mjs +239 -1
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export async function callTool(toolName, args = {}) {
|
|
|
46
46
|
// * { ok:true, data } — tool result JSON (data.success may still be false)
|
|
47
47
|
// * { ok:false, auth:true } — not authenticated / token expired
|
|
48
48
|
// * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
|
|
49
|
-
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch } = {}) {
|
|
49
|
+
export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal } = {}) {
|
|
50
50
|
const cfg = getConfig();
|
|
51
51
|
let auth;
|
|
52
52
|
if (cfg.access_token) {
|
|
@@ -64,6 +64,7 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch } =
|
|
|
64
64
|
method: "POST",
|
|
65
65
|
headers: { "Content-Type": "application/json", ...auth },
|
|
66
66
|
body: JSON.stringify(body),
|
|
67
|
+
signal,
|
|
67
68
|
});
|
|
68
69
|
} catch (err) {
|
|
69
70
|
return { ok: false, status: null, error: `transport: ${err?.message ?? err}` };
|
|
@@ -13,7 +13,7 @@ test("BOT-1353: the published CLI identifies this profile-bootstrap release", as
|
|
|
13
13
|
cwd: new URL("..", import.meta.url),
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
assert.equal(stdout.trim(), "botbuddy v1.4.
|
|
16
|
+
assert.equal(stdout.trim(), "botbuddy v1.4.2");
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test("BOT-1382: profile --help succeeds and documents the subcommands", async () => {
|
package/src/stack.mjs
CHANGED
|
@@ -21,9 +21,12 @@
|
|
|
21
21
|
// labelled fallback for when no Helper is available: it runs `supabase start`/
|
|
22
22
|
// `stop` itself and self-activates/finalizes via the fallback MCP tools.
|
|
23
23
|
|
|
24
|
-
import { spawnSync } from "child_process";
|
|
24
|
+
import { spawn, spawnSync } from "child_process";
|
|
25
25
|
import { readFileSync, realpathSync } from "fs";
|
|
26
|
-
import {
|
|
26
|
+
import { open, unlink, readFile } from "fs/promises";
|
|
27
|
+
import { tmpdir } from "os";
|
|
28
|
+
import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
29
|
+
import { randomUUID } from "crypto";
|
|
27
30
|
import { callToolJson } from "./api.mjs";
|
|
28
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
29
32
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
@@ -42,6 +45,7 @@ export const EXIT = Object.freeze({
|
|
|
42
45
|
BACKEND: 5, // server/RPC/transport error, or the coordination request failed
|
|
43
46
|
LEASE_FAILED: 6, // the lease was reaped / provision failed instead of going active
|
|
44
47
|
INTERNAL: 7, // unexpected local error
|
|
48
|
+
CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
|
|
45
49
|
});
|
|
46
50
|
|
|
47
51
|
export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
|
|
@@ -51,6 +55,8 @@ ${bold("USAGE")}
|
|
|
51
55
|
botbuddy stack status <lease_id> Show a lease's state + connection
|
|
52
56
|
botbuddy stack touch <lease_id> Bump the idle clock so the reaper doesn't stop it
|
|
53
57
|
botbuddy stack done <lease_id> Release (done): tear the stack down
|
|
58
|
+
botbuddy stack run [options] -- <executable> [args...]
|
|
59
|
+
Own one Helper-backed test/fix batch end-to-end
|
|
54
60
|
|
|
55
61
|
${bold("up OPTIONS")}
|
|
56
62
|
--slot <slot> Stable stack identity (BOT-1186): the shared CLI stack DB port
|
|
@@ -70,6 +76,15 @@ ${bold("done OPTIONS")}
|
|
|
70
76
|
--stop Keep volumes (cheap re-provision next batch). Default: destroy.
|
|
71
77
|
--local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
|
|
72
78
|
|
|
79
|
+
${bold("run OPTIONS")}
|
|
80
|
+
--repo <repo> Required approved repository name.
|
|
81
|
+
--ticket <BOT-123> Required ticket for the batch.
|
|
82
|
+
--provision-timeout N Seconds to wait for a physical Helper provision (default: --timeout).
|
|
83
|
+
--reap-timeout N Seconds to wait for signed physical reap proof (default: 300).
|
|
84
|
+
--connection-file P Optional empty file path for the mode-0600 connection JSON.
|
|
85
|
+
--hard-ttl N Bound the child runtime; timeout requests normal cleanup.
|
|
86
|
+
--local-exec Rejected: stack run never starts Docker directly.
|
|
87
|
+
|
|
73
88
|
${bold("GLOBAL")}
|
|
74
89
|
--json Pretty-print the receipt (default is one compact JSON line).
|
|
75
90
|
--receipt-max-bytes N Receipt size cap (default ${DEFAULT_RECEIPT_MAX_BYTES}).
|
|
@@ -77,13 +92,11 @@ ${bold("GLOBAL")}
|
|
|
77
92
|
${bold("EXIT CODES")}
|
|
78
93
|
0 ok/held (or queued+--no-wait) 2 park timed out 3 not authenticated
|
|
79
94
|
4 invalid arguments 5 backend/coordination 6 lease failed/reaped
|
|
80
|
-
7 internal error
|
|
95
|
+
7 internal error 8 cleanup/reap proof failed
|
|
81
96
|
|
|
82
97
|
${bold("EXAMPLE")}
|
|
83
98
|
# request a per-worktree stack for this batch, run a lane against it, then reap it
|
|
84
|
-
|
|
85
|
-
pnpm test:integration
|
|
86
|
-
botbuddy stack done "$id"`;
|
|
99
|
+
botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
|
|
87
100
|
|
|
88
101
|
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
89
102
|
|
|
@@ -124,11 +137,15 @@ export function deriveSlot({ slot, repo, ticket } = {}, env = process.env) {
|
|
|
124
137
|
*/
|
|
125
138
|
export function parseStackArgs(argv) {
|
|
126
139
|
const errors = [];
|
|
127
|
-
const [command, ...
|
|
140
|
+
const [command, ...rawRest] = argv;
|
|
141
|
+
const divider = rawRest.indexOf("--");
|
|
142
|
+
const rest = divider === -1 ? rawRest : rawRest.slice(0, divider);
|
|
143
|
+
const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
|
|
128
144
|
const opts = {
|
|
129
145
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
130
146
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
|
|
131
|
-
timeout: DEFAULT_TIMEOUT_SEC,
|
|
147
|
+
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
148
|
+
connectionFile: null, noWait: false, localExec: false,
|
|
132
149
|
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
133
150
|
};
|
|
134
151
|
const positionals = [];
|
|
@@ -161,6 +178,31 @@ export function parseStackArgs(argv) {
|
|
|
161
178
|
}
|
|
162
179
|
break;
|
|
163
180
|
}
|
|
181
|
+
case "--provision-timeout": {
|
|
182
|
+
if (need(a, rest[i + 1])) {
|
|
183
|
+
const n = Number(rest[++i]);
|
|
184
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--provision-timeout must be a positive integer number of seconds");
|
|
185
|
+
else opts.provisionTimeout = n;
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "--reap-timeout": {
|
|
190
|
+
if (need(a, rest[i + 1])) {
|
|
191
|
+
const n = Number(rest[++i]);
|
|
192
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--reap-timeout must be a positive integer number of seconds");
|
|
193
|
+
else opts.reapTimeout = n;
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "--hard-ttl": {
|
|
198
|
+
if (need(a, rest[i + 1])) {
|
|
199
|
+
const n = Number(rest[++i]);
|
|
200
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--hard-ttl must be a positive integer number of seconds");
|
|
201
|
+
else opts.hardTtl = n;
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "--connection-file": if (need(a, rest[i + 1])) opts.connectionFile = rest[++i]; break;
|
|
164
206
|
case "--receipt-max-bytes": {
|
|
165
207
|
if (need(a, rest[i + 1])) {
|
|
166
208
|
const n = Number(rest[++i]);
|
|
@@ -187,7 +229,17 @@ export function parseStackArgs(argv) {
|
|
|
187
229
|
if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
|
|
188
230
|
errors.push(`${command} needs a <lease_id>`);
|
|
189
231
|
}
|
|
190
|
-
|
|
232
|
+
if (command === "run") {
|
|
233
|
+
if (!opts.repo) errors.push("run needs --repo <approved-repo>");
|
|
234
|
+
if (!opts.ticket || !/^[A-Z][A-Z0-9]*-\d+$/i.test(opts.ticket)) errors.push("run needs --ticket <BOT-n|ENT-n>");
|
|
235
|
+
if (divider === -1 || childArgv.length === 0) errors.push("run needs an executable after --");
|
|
236
|
+
if (opts.noWait) errors.push("--no-wait is incompatible with run; the command owns its queue wait");
|
|
237
|
+
if (opts.localExec) errors.push("--local-exec is forbidden for run; Helper-backed physical lifecycle is required");
|
|
238
|
+
if (opts.connectionFile && !isAbsolute(opts.connectionFile)) errors.push("--connection-file must be an absolute path");
|
|
239
|
+
} else if (divider !== -1) {
|
|
240
|
+
errors.push("-- <executable> is only valid with stack run");
|
|
241
|
+
}
|
|
242
|
+
return { command, leaseId, opts, errors, childArgv };
|
|
191
243
|
}
|
|
192
244
|
|
|
193
245
|
/** Build the canonical receipt object (single JSON line to stdout). */
|
|
@@ -308,12 +360,13 @@ function parseSseFrames(buffer) {
|
|
|
308
360
|
}
|
|
309
361
|
|
|
310
362
|
/** Register a wait_session for this lease (so it shows on /waits) + return {cursorStart}. Best-effort. */
|
|
311
|
-
async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
|
|
363
|
+
async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl = fetch) {
|
|
312
364
|
const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
|
|
313
365
|
const res = await fetchImpl(`${eventStreamBase()}`, {
|
|
314
366
|
method: "POST",
|
|
315
367
|
headers: { ...auth, "Content-Type": "application/json" },
|
|
316
368
|
body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
|
|
369
|
+
signal,
|
|
317
370
|
});
|
|
318
371
|
if (!res.ok) throw new Error(`register responded ${res.status}`);
|
|
319
372
|
const body = await res.json();
|
|
@@ -326,29 +379,59 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
|
|
|
326
379
|
* Resolves { woke, state } on a matching frame, { timeout:true } on --timeout,
|
|
327
380
|
* { failed, state } if the lease was reaped, or { error } on a transport failure.
|
|
328
381
|
*/
|
|
329
|
-
async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fetchImpl = fetch) {
|
|
382
|
+
export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth, signal = null, deadlineMs = null }, fetchImpl = fetch) {
|
|
383
|
+
const absoluteDeadlineMs = deadlineMs ?? Date.now() + timeoutSec * 1000;
|
|
330
384
|
let cursorStart = null;
|
|
385
|
+
let waitSessionId = null;
|
|
386
|
+
const ac = new AbortController();
|
|
387
|
+
const abortFromParent = () => ac.abort("interrupted");
|
|
388
|
+
if (signal?.aborted) ac.abort("interrupted");
|
|
389
|
+
else signal?.addEventListener?.("abort", abortFromParent, { once: true });
|
|
390
|
+
const timer = setTimeout(() => ac.abort("timeout"), Math.max(0, absoluteDeadlineMs - Date.now()));
|
|
391
|
+
const finish = async (result, status) => {
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
signal?.removeEventListener?.("abort", abortFromParent);
|
|
394
|
+
if (waitSessionId) {
|
|
395
|
+
try {
|
|
396
|
+
const finalizer = new AbortController();
|
|
397
|
+
const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
|
|
398
|
+
try {
|
|
399
|
+
const response = await fetchImpl(eventStreamBase(), {
|
|
400
|
+
method: "POST", headers: { ...auth, "Content-Type": "application/json" },
|
|
401
|
+
body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
|
|
402
|
+
signal: finalizer.signal,
|
|
403
|
+
});
|
|
404
|
+
if (!response.ok) process.stderr.write(`${yellow("⚠")} stack: wait finalization returned ${response.status}\n`);
|
|
405
|
+
} finally {
|
|
406
|
+
clearTimeout(finalizerTimer);
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
process.stderr.write(`${yellow("⚠")} stack: wait finalization failed (${err?.message ?? err})\n`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return result;
|
|
413
|
+
};
|
|
414
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
331
415
|
try {
|
|
332
|
-
({ cursorStart } = await registerLeaseWait(leaseId,
|
|
416
|
+
({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
|
|
333
417
|
} catch (err) {
|
|
418
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
334
419
|
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
335
420
|
}
|
|
336
421
|
const url = new URL(eventStreamBase());
|
|
337
422
|
url.searchParams.set("tables", "agent_signal_events");
|
|
338
423
|
if (cursorStart != null) url.searchParams.set("since", String(cursorStart));
|
|
424
|
+
if (waitSessionId) url.searchParams.set("wait_session", waitSessionId);
|
|
339
425
|
url.searchParams.set("heartbeat", "1");
|
|
340
|
-
const ac = new AbortController();
|
|
341
|
-
const timer = setTimeout(() => ac.abort("timeout"), timeoutSec * 1000);
|
|
342
426
|
let res;
|
|
343
427
|
try {
|
|
344
428
|
res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
345
429
|
} catch (err) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return { error: `sse connect: ${err?.message ?? err}` };
|
|
430
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
431
|
+
return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
|
|
349
432
|
}
|
|
350
|
-
if (res.status === 401 || res.status === 403)
|
|
351
|
-
if (!res.ok || !res.body)
|
|
433
|
+
if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
|
|
434
|
+
if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
|
|
352
435
|
const decoder = new TextDecoder();
|
|
353
436
|
let buf = "";
|
|
354
437
|
try {
|
|
@@ -361,17 +444,23 @@ async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fet
|
|
|
361
444
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
362
445
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
363
446
|
const st = sig.payload?.state ?? null;
|
|
364
|
-
if (isFailed(st)) {
|
|
365
|
-
if (isDone(st)) {
|
|
447
|
+
if (isFailed(st)) { ac.abort(); return finish({ failed: true, state: st }, "error"); }
|
|
448
|
+
if (isDone(st)) { ac.abort(); return finish({ woke: true, state: st }, "matched"); }
|
|
366
449
|
}
|
|
367
450
|
}
|
|
368
451
|
} catch (err) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
452
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
453
|
+
return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
|
|
454
|
+
}
|
|
455
|
+
// A clean relay EOF is not a timeout: the server intentionally closes on
|
|
456
|
+
// scope changes and proxies can recycle idle streams. Re-arm from a fresh
|
|
457
|
+
// cursor until the original deadline, never silently fall back to polling.
|
|
458
|
+
const remainingSec = Math.ceil((absoluteDeadlineMs - Date.now()) / 1000);
|
|
459
|
+
if (remainingSec > 0 && !ac.signal.aborted) {
|
|
460
|
+
await finish({ reconnected: true }, "error");
|
|
461
|
+
return waitForLease(leaseId, isDone, isFailed, { timeoutSec: remainingSec, auth, signal, deadlineMs: absoluteDeadlineMs }, fetchImpl);
|
|
372
462
|
}
|
|
373
|
-
|
|
374
|
-
return { timeout: true };
|
|
463
|
+
return finish({ timeout: true }, "timeout");
|
|
375
464
|
}
|
|
376
465
|
|
|
377
466
|
const isActive = (s) => s === "active";
|
|
@@ -540,13 +629,315 @@ async function cmdDone(leaseId, opts) {
|
|
|
540
629
|
return emit(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition }), opts, EXIT.OK);
|
|
541
630
|
}
|
|
542
631
|
|
|
632
|
+
const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
633
|
+
|
|
634
|
+
function safeConnectionPath(requested, leaseId) {
|
|
635
|
+
if (requested) {
|
|
636
|
+
// `wx` below prevents clobbering; canonicalising the parent prevents a
|
|
637
|
+
// symlinked directory from redirecting the only credential-bearing file.
|
|
638
|
+
const filename = basename(requested);
|
|
639
|
+
if (!filename || filename === "." || filename === "..") throw new Error("--connection-file must name a file");
|
|
640
|
+
return `${realpathSync(dirname(requested))}/${filename}`;
|
|
641
|
+
}
|
|
642
|
+
return `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}.json`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Write credentials atomically without ever putting them into argv, output, or a receipt. */
|
|
646
|
+
async function writeConnectionFile(path, connection) {
|
|
647
|
+
return writePrivateTextFile(path, JSON.stringify(connection));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Write a credential-bearing text artifact (dotenv/TOML/JSON) mode 0600. */
|
|
651
|
+
async function writePrivateTextFile(path, text) {
|
|
652
|
+
const handle = await open(path, "wx", 0o600);
|
|
653
|
+
try {
|
|
654
|
+
await handle.writeFile(text);
|
|
655
|
+
} catch (error) {
|
|
656
|
+
await unlink(path).catch(() => {});
|
|
657
|
+
throw error;
|
|
658
|
+
} finally {
|
|
659
|
+
await handle.close();
|
|
660
|
+
}
|
|
661
|
+
return path;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function childGroupSignal(child, signal) {
|
|
665
|
+
if (!child?.pid) return;
|
|
666
|
+
try { process.kill(-child.pid, signal); }
|
|
667
|
+
catch { try { child.kill(signal); } catch { /* already gone */ } }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function waitForChild(child) {
|
|
671
|
+
return new Promise((resolveChild, rejectChild) => {
|
|
672
|
+
child.once("error", rejectChild);
|
|
673
|
+
child.once("exit", (code, signal) => resolveChild({ code: code ?? (signal ? SIGNAL_EXIT[signal] ?? 1 : 1), signal }));
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function isDenoIntegrationLane(argv) {
|
|
678
|
+
// `test:all` is the supported package wrapper that transitively invokes
|
|
679
|
+
// `test:integration`; both must receive the leased runner configuration.
|
|
680
|
+
return argv.includes("test:integration") || argv.includes("test:integration:coverage") || argv.includes("test:all") || argv.includes("scripts/run-deno-integration.sh");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function requireConnectionText(connection, key) {
|
|
684
|
+
const value = connection?.[key];
|
|
685
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`active stack connection is missing ${key}`);
|
|
686
|
+
return value.trim();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Materialise the established integration runner inputs from a Helper receipt.
|
|
690
|
+
* The runner already understands BB_INTEGRATION_ENV_FILE / BB_STACK_CONFIG;
|
|
691
|
+
* never let its shared-stack defaults silently validate the wrong stack. */
|
|
692
|
+
export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connection, { writePrivate = writePrivateTextFile, removePrivate = (path) => unlink(path).catch(() => {}) } = {}) {
|
|
693
|
+
const apiUrl = requireConnectionText(connection, "api_url");
|
|
694
|
+
const anonKey = requireConnectionText(connection, "anon_key");
|
|
695
|
+
const serviceRoleKey = requireConnectionText(connection, "service_role_key");
|
|
696
|
+
const projectId = requireConnectionText(connection, "project_id");
|
|
697
|
+
const parsedApi = new URL(apiUrl);
|
|
698
|
+
if (!parsedApi.port || !["127.0.0.1", "localhost"].includes(parsedApi.hostname)) {
|
|
699
|
+
throw new Error("active stack connection api_url must be a local host URL with an explicit port");
|
|
700
|
+
}
|
|
701
|
+
const dbPort = Number(connection.db_port ?? new URL(requireConnectionText(connection, "db_url")).port);
|
|
702
|
+
if (!Number.isInteger(dbPort) || dbPort <= 0 || dbPort > 65535) throw new Error("active stack connection is missing a valid db_port");
|
|
703
|
+
|
|
704
|
+
const basePath = `${worktreeRoot}/supabase/functions/_test/integration/.env.integration`;
|
|
705
|
+
const base = await readFile(basePath, "utf8");
|
|
706
|
+
const values = {
|
|
707
|
+
SUPABASE_URL: apiUrl,
|
|
708
|
+
VITE_SUPABASE_URL: apiUrl,
|
|
709
|
+
SUPABASE_ANON_KEY: anonKey,
|
|
710
|
+
VITE_SUPABASE_PUBLISHABLE_KEY: anonKey,
|
|
711
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
712
|
+
INTEGRATION_DB_PORT: String(dbPort),
|
|
713
|
+
};
|
|
714
|
+
const seen = new Set();
|
|
715
|
+
const env = base.split(/\r?\n/).map((line) => {
|
|
716
|
+
const key = /^([A-Z0-9_]+)=/.exec(line)?.[1];
|
|
717
|
+
if (!key || !(key in values)) return line;
|
|
718
|
+
seen.add(key);
|
|
719
|
+
return `${key}=${values[key]}`;
|
|
720
|
+
});
|
|
721
|
+
for (const [key, value] of Object.entries(values)) if (!seen.has(key)) env.push(`${key}=${value}`);
|
|
722
|
+
|
|
723
|
+
const stamp = `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}`;
|
|
724
|
+
const envFile = `${stamp}.env`;
|
|
725
|
+
const stackConfig = `${stamp}.toml`;
|
|
726
|
+
await writePrivate(envFile, env.join("\n"));
|
|
727
|
+
try {
|
|
728
|
+
await writePrivate(stackConfig, `project_id = "${projectId}"\n\n[api]\nport = ${parsedApi.port}\n\n[db]\nport = ${dbPort}\n`);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
await removePrivate(envFile);
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
return { envFile, stackConfig, edgeMountRoot: worktreeRoot };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* The command-owned lifecycle. The optional adapters are intentionally narrow so
|
|
738
|
+
* its queue, heartbeat, process-group, and cleanup contract is spawn-testable
|
|
739
|
+
* without Docker or a live BotBuddy service.
|
|
740
|
+
*/
|
|
741
|
+
export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
742
|
+
const api = adapters.api ?? {
|
|
743
|
+
request: (args) => callToolJson("request_stack_lease", args),
|
|
744
|
+
get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }),
|
|
745
|
+
touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
|
|
746
|
+
release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
|
|
747
|
+
};
|
|
748
|
+
const auth = adapters.auth ?? stackAuthHeader();
|
|
749
|
+
const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
|
|
750
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
|
|
751
|
+
const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
|
|
752
|
+
const writeConnection = adapters.writeConnection ?? writeConnectionFile;
|
|
753
|
+
const removeConnection = adapters.removeConnection ?? ((path) => unlink(path).catch(() => {}));
|
|
754
|
+
const materializeTestConfig = adapters.materializeTestConfig ?? materializeLeasedTestConfig;
|
|
755
|
+
const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
|
|
756
|
+
const signals = adapters.signals ?? process;
|
|
757
|
+
const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
|
|
758
|
+
let leaseId = null;
|
|
759
|
+
let child = null;
|
|
760
|
+
let childResult = null;
|
|
761
|
+
let connectionFile = null;
|
|
762
|
+
let heartbeat = null;
|
|
763
|
+
let hardTimer = null;
|
|
764
|
+
let cleanupResult = null;
|
|
765
|
+
let cleanupPromise = null;
|
|
766
|
+
let leasedTestConfig = null;
|
|
767
|
+
let receivedSignal = null;
|
|
768
|
+
let forcedStopCode = null;
|
|
769
|
+
let fencing = false;
|
|
770
|
+
let activeWaitAbort = null;
|
|
771
|
+
|
|
772
|
+
const requestCleanup = () => {
|
|
773
|
+
// A signal can arrive while the lease-request RPC is in flight. There is
|
|
774
|
+
// nothing to release yet, so do not cache this no-op; once the RPC returns
|
|
775
|
+
// the interruption branch must perform the real signed cleanup.
|
|
776
|
+
if (!leaseId) return Promise.resolve({ ok: true, state: null });
|
|
777
|
+
if (cleanupPromise) return cleanupPromise;
|
|
778
|
+
cleanupPromise = (async () => {
|
|
779
|
+
if (!leaseId) return cleanupResult = { ok: true, state: null };
|
|
780
|
+
const cleanupAbort = new AbortController();
|
|
781
|
+
const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
|
|
782
|
+
let release;
|
|
783
|
+
try {
|
|
784
|
+
release = await api.release(leaseId, cleanupAbort.signal);
|
|
785
|
+
} finally {
|
|
786
|
+
clock.clearTimeout(cleanupTimer);
|
|
787
|
+
}
|
|
788
|
+
if (!release?.ok || !release?.data?.success) {
|
|
789
|
+
return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
|
|
790
|
+
}
|
|
791
|
+
if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
792
|
+
const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth });
|
|
793
|
+
if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
794
|
+
return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
|
|
795
|
+
})();
|
|
796
|
+
return cleanupPromise;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const stopChild = (signal = "SIGTERM", forcedCode = null) => {
|
|
800
|
+
if (!child || childResult) return;
|
|
801
|
+
forcedStopCode ??= forcedCode;
|
|
802
|
+
childGroupSignal(child, signal);
|
|
803
|
+
const kill = clock.setTimeout(() => childGroupSignal(child, "SIGKILL"), 10_000);
|
|
804
|
+
kill.unref?.();
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
const onSignal = (signal) => {
|
|
808
|
+
if (receivedSignal) return;
|
|
809
|
+
receivedSignal = signal;
|
|
810
|
+
activeWaitAbort?.abort("interrupted");
|
|
811
|
+
stopChild(signal);
|
|
812
|
+
// If capacity is still queued/provisioning there is no child to hold open;
|
|
813
|
+
// release immediately so an interrupted invocation cannot later provision.
|
|
814
|
+
if (!child) void requestCleanup();
|
|
815
|
+
};
|
|
816
|
+
const signalHandlers = new Map();
|
|
817
|
+
for (const signal of Object.keys(SIGNAL_EXIT)) {
|
|
818
|
+
const handler = () => onSignal(signal);
|
|
819
|
+
signalHandlers.set(signal, handler);
|
|
820
|
+
signals.on?.(signal, handler);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
try {
|
|
824
|
+
if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
|
|
825
|
+
const slot = deriveSlot(opts);
|
|
826
|
+
const execution = resolveStackPath(process.cwd(), opts.stackPath);
|
|
827
|
+
const request = await api.request({
|
|
828
|
+
slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
|
|
829
|
+
ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
830
|
+
purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
831
|
+
stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
|
|
832
|
+
});
|
|
833
|
+
if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
|
|
834
|
+
if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
|
|
835
|
+
leaseId = request.data.lease_id;
|
|
836
|
+
if (request.data.reused) {
|
|
837
|
+
return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let state = request.data.state;
|
|
841
|
+
if (receivedSignal) {
|
|
842
|
+
const cleanup = await requestCleanup();
|
|
843
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
844
|
+
}
|
|
845
|
+
if (state === "queued") {
|
|
846
|
+
activeWaitAbort = new AbortController();
|
|
847
|
+
const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth, signal: activeWaitAbort.signal });
|
|
848
|
+
activeWaitAbort = null;
|
|
849
|
+
if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
850
|
+
if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
|
|
851
|
+
if (parked?.failed || parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup }; }
|
|
852
|
+
state = parked.state;
|
|
853
|
+
}
|
|
854
|
+
if (state !== "active") {
|
|
855
|
+
activeWaitAbort = new AbortController();
|
|
856
|
+
const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth, signal: activeWaitAbort.signal });
|
|
857
|
+
activeWaitAbort = null;
|
|
858
|
+
if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
|
|
859
|
+
if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
|
|
860
|
+
if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || "lease was reaped before active", cleanup }; }
|
|
861
|
+
}
|
|
862
|
+
const current = await api.get(leaseId);
|
|
863
|
+
if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
|
|
864
|
+
const cleanup = await requestCleanup();
|
|
865
|
+
return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: current?.error || `lease is ${current?.data?.state ?? "unreadable"}, not active with a connection`, cleanup };
|
|
866
|
+
}
|
|
867
|
+
if (receivedSignal) {
|
|
868
|
+
const cleanup = await requestCleanup();
|
|
869
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), current.data.connection);
|
|
873
|
+
if (receivedSignal) {
|
|
874
|
+
const cleanup = await requestCleanup();
|
|
875
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
876
|
+
}
|
|
877
|
+
if (isDenoIntegrationLane(childArgv)) leasedTestConfig = await materializeTestConfig(execution.worktreeRoot, leaseId, current.data.connection);
|
|
878
|
+
if (receivedSignal) {
|
|
879
|
+
const cleanup = await requestCleanup();
|
|
880
|
+
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
881
|
+
}
|
|
882
|
+
const childEnv = {
|
|
883
|
+
...process.env,
|
|
884
|
+
BOTBUDDY_STACK_LEASE_ID: leaseId,
|
|
885
|
+
BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
|
|
886
|
+
...(leasedTestConfig ? {
|
|
887
|
+
BB_INTEGRATION_ENV_FILE: leasedTestConfig.envFile,
|
|
888
|
+
BB_STACK_CONFIG: leasedTestConfig.stackConfig,
|
|
889
|
+
BB_EDGE_MOUNT_ROOT: leasedTestConfig.edgeMountRoot,
|
|
890
|
+
} : {}),
|
|
891
|
+
};
|
|
892
|
+
// resolveStackPath already canonicalised this bounded relative path.
|
|
893
|
+
const childCwd = execution.stackPath === "." ? execution.worktreeRoot : `${execution.worktreeRoot}/${execution.stackPath}`;
|
|
894
|
+
child = startChild(childArgv, childEnv, childCwd);
|
|
895
|
+
const cadenceMs = Math.max(1_000, Math.floor((current.data.idle_ttl_secs ?? opts.idleTtl ?? 1800) * 1000 / 3));
|
|
896
|
+
heartbeat = clock.setInterval(async () => {
|
|
897
|
+
if (fencing || childResult) return;
|
|
898
|
+
const touched = await api.touch(leaseId);
|
|
899
|
+
if (!touched?.ok || !touched?.data?.success) {
|
|
900
|
+
fencing = true;
|
|
901
|
+
stopChild("SIGTERM", EXIT.LEASE_FAILED);
|
|
902
|
+
}
|
|
903
|
+
}, cadenceMs);
|
|
904
|
+
heartbeat.unref?.();
|
|
905
|
+
if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
|
|
906
|
+
hardTimer?.unref?.();
|
|
907
|
+
childResult = await waitForChild(child);
|
|
908
|
+
if (heartbeat) clock.clearInterval(heartbeat);
|
|
909
|
+
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
910
|
+
const cleanup = await requestCleanup();
|
|
911
|
+
const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
|
|
912
|
+
return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
|
|
913
|
+
} catch (error) {
|
|
914
|
+
const cleanup = await requestCleanup();
|
|
915
|
+
return { exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.INTERNAL, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
|
|
916
|
+
} finally {
|
|
917
|
+
if (heartbeat) clock.clearInterval(heartbeat);
|
|
918
|
+
if (hardTimer) clock.clearTimeout(hardTimer);
|
|
919
|
+
if (connectionFile) await removeConnection(connectionFile);
|
|
920
|
+
if (leasedTestConfig) await Promise.all([removeConnection(leasedTestConfig.envFile), removeConnection(leasedTestConfig.stackConfig)]);
|
|
921
|
+
for (const [signal, handler] of signalHandlers) signals.off?.(signal, handler);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function cmdRun(opts, childArgv) {
|
|
926
|
+
const result = await runStackLifecycle(opts, childArgv);
|
|
927
|
+
return emit(buildReceipt({
|
|
928
|
+
command: "run", outcome: result.outcome, lease_id: result.leaseId ?? null,
|
|
929
|
+
child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
|
|
930
|
+
fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
|
|
931
|
+
}), opts, result.exitCode);
|
|
932
|
+
}
|
|
933
|
+
|
|
543
934
|
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|
|
544
935
|
export async function cmdStack(argv) {
|
|
545
936
|
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
546
937
|
process.stdout.write(STACK_HELP + "\n");
|
|
547
938
|
return EXIT.OK;
|
|
548
939
|
}
|
|
549
|
-
const { command, leaseId, opts, errors } = parseStackArgs(argv);
|
|
940
|
+
const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
|
|
550
941
|
if (errors.length) {
|
|
551
942
|
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
|
552
943
|
const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
|
|
@@ -560,6 +951,7 @@ export async function cmdStack(argv) {
|
|
|
560
951
|
case "status": code = await cmdStatus(leaseId, opts); break;
|
|
561
952
|
case "touch": code = await cmdTouch(leaseId, opts); break;
|
|
562
953
|
case "done": code = await cmdDone(leaseId, opts); break;
|
|
954
|
+
case "run": code = await cmdRun(opts, childArgv); break;
|
|
563
955
|
default:
|
|
564
956
|
process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
|
|
565
957
|
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
|
package/src/stack.test.mjs
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import assert from "node:assert/strict";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { dirname, join } from "node:path";
|
|
10
|
-
import { mkdtempSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
11
|
+
import { mkdtempSync, writeFileSync, mkdirSync, realpathSync, readFileSync } from "node:fs";
|
|
11
12
|
import { tmpdir } from "node:os";
|
|
12
13
|
|
|
13
14
|
import {
|
|
14
15
|
EXIT, STACK_HELP, STACK_SCHEMA_VERSION, DEFAULT_RECEIPT_MAX_BYTES,
|
|
15
16
|
deriveSlot, parseStackArgs, buildReceipt, truncateReceipt,
|
|
16
17
|
parseSupabaseStatus, eventStreamBase, resolveLocalSupabaseUrl, resolveStackPath,
|
|
18
|
+
runStackLifecycle, materializeLeasedTestConfig, waitForLease,
|
|
17
19
|
} from "./stack.mjs";
|
|
18
20
|
|
|
19
21
|
const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "botbuddy.mjs");
|
|
@@ -99,6 +101,242 @@ test("parseStackArgs: --stop sets disposition stop (default destroy)", () => {
|
|
|
99
101
|
assert.equal(parseStackArgs(["done", "id"]).opts.disposition, "destroy");
|
|
100
102
|
assert.equal(parseStackArgs(["done", "id", "--stop"]).opts.disposition, "stop");
|
|
101
103
|
});
|
|
104
|
+
test("BOT-1346: run requires an argv boundary and rejects direct Docker fallback", () => {
|
|
105
|
+
assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346"]).errors.some((e) => e.includes("executable")));
|
|
106
|
+
assert.ok(parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--local-exec", "--", "echo", "ok"]).errors.some((e) => e.includes("forbidden")));
|
|
107
|
+
const parsed = parseStackArgs(["run", "--repo", "botbuddy-web", "--ticket", "BOT-1346", "--", "node", "-e", "process.exit(0)"]);
|
|
108
|
+
assert.equal(parsed.errors.length, 0);
|
|
109
|
+
assert.deepEqual(parsed.childArgv, ["node", "-e", "process.exit(0)"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("BOT-1346: a clean lease-stream EOF honours the original deadline", async () => {
|
|
113
|
+
const requests = [];
|
|
114
|
+
const expired = await waitForLease(
|
|
115
|
+
"lease-1346", () => false, () => false,
|
|
116
|
+
{ timeoutSec: 5, deadlineMs: Date.now() - 1, auth: { Authorization: "Bearer test" } },
|
|
117
|
+
async (_url, options = {}) => {
|
|
118
|
+
requests.push(options.method ?? "GET");
|
|
119
|
+
if (options.method === "POST") return new Response(JSON.stringify({ cursor_start: null }), { status: 200 });
|
|
120
|
+
return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { status: 200 });
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(expired, { timeout: true });
|
|
124
|
+
assert.deepEqual(requests, ["POST", "GET"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("BOT-1346: a stalled lease-wait registration is aborted at its deadline", async () => {
|
|
128
|
+
let registrationSignal = null;
|
|
129
|
+
const result = await waitForLease(
|
|
130
|
+
"lease-1346", () => false, () => false,
|
|
131
|
+
{ timeoutSec: 0.02, auth: { Authorization: "Bearer test" } },
|
|
132
|
+
async (_url, options = {}) => {
|
|
133
|
+
registrationSignal = options.signal;
|
|
134
|
+
return await new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }));
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
assert.equal(registrationSignal.aborted, true);
|
|
138
|
+
assert.deepEqual(result, { timeout: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("BOT-1346: stack run owns active → argv child → signed reap and never exposes connection", async () => {
|
|
142
|
+
const calls = [];
|
|
143
|
+
const signals = new EventEmitter();
|
|
144
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4242;
|
|
145
|
+
let written = null; const removed = []; let childCall = null;
|
|
146
|
+
const result = await runStackLifecycle(
|
|
147
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, connectionFile: "/tmp/bot-1346-connection.json" },
|
|
148
|
+
["pnpm", "test:all"],
|
|
149
|
+
{
|
|
150
|
+
auth: { Authorization: "Bearer test" }, signals,
|
|
151
|
+
api: {
|
|
152
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-1346", state: "active" } }),
|
|
153
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", idle_ttl_secs: 30, connection: { db_url: "postgres://secret" } } }),
|
|
154
|
+
touch: async () => ({ ok: true, data: { success: true } }),
|
|
155
|
+
release: async (id) => { calls.push(["release", id]); return { ok: true, data: { success: true, state: "reaping" } }; },
|
|
156
|
+
},
|
|
157
|
+
wait: async (_id, done) => done("reaped") ? { woke: true, state: "reaped" } : { woke: true, state: "active" },
|
|
158
|
+
writeConnection: async (path, connection) => { written = { path, connection }; return path; },
|
|
159
|
+
materializeTestConfig: async () => ({
|
|
160
|
+
envFile: "/tmp/bot-1346-integration.env",
|
|
161
|
+
stackConfig: "/tmp/bot-1346-stack.toml",
|
|
162
|
+
}),
|
|
163
|
+
removeConnection: async (path) => { removed.push(path); },
|
|
164
|
+
startChild: (argv, env, cwd) => { childCall = { argv, env, cwd }; queueMicrotask(() => fakeChild.emit("exit", 7, null)); return fakeChild; },
|
|
165
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
assert.equal(result.exitCode, 7, "successful cleanup must not mask the child failure");
|
|
169
|
+
assert.deepEqual(childCall.argv, ["pnpm", "test:all"], "argv is forwarded without shell parsing");
|
|
170
|
+
assert.equal(childCall.env.BOTBUDDY_STACK_LEASE_ID, "lease-1346");
|
|
171
|
+
assert.equal(childCall.env.BOTBUDDY_STACK_CONNECTION_FILE, written.path);
|
|
172
|
+
assert.equal(childCall.env.BB_INTEGRATION_ENV_FILE, "/tmp/bot-1346-integration.env");
|
|
173
|
+
assert.equal(childCall.env.BB_STACK_CONFIG, "/tmp/bot-1346-stack.toml");
|
|
174
|
+
assert.equal(written.connection.db_url, "postgres://secret");
|
|
175
|
+
assert.deepEqual(removed.sort(), [written.path, "/tmp/bot-1346-integration.env", "/tmp/bot-1346-stack.toml"].sort());
|
|
176
|
+
assert.deepEqual(calls, [["release", "lease-1346"]]);
|
|
177
|
+
assert.doesNotMatch(JSON.stringify(result), /postgres:\/\/secret/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("BOT-1346: materialized integration environment uses the leased database port", async () => {
|
|
181
|
+
const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
|
|
182
|
+
const integrationDir = join(root, "supabase", "functions", "_test", "integration");
|
|
183
|
+
mkdirSync(integrationDir, { recursive: true });
|
|
184
|
+
writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\nINTEGRATION_DB_PORT=56322\n");
|
|
185
|
+
const config = await materializeLeasedTestConfig(root, "lease-port", {
|
|
186
|
+
api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
|
|
187
|
+
});
|
|
188
|
+
assert.match(readFileSync(config.envFile, "utf8"), /^INTEGRATION_DB_PORT=64322$/m);
|
|
189
|
+
assert.match(readFileSync(config.stackConfig, "utf8"), /port = 64322/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("BOT-1346: materialized integration config removes dotenv if its TOML write fails", async () => {
|
|
193
|
+
const root = mkdtempSync(join(tmpdir(), "bb-stack-integration-"));
|
|
194
|
+
const integrationDir = join(root, "supabase", "functions", "_test", "integration");
|
|
195
|
+
mkdirSync(integrationDir, { recursive: true });
|
|
196
|
+
writeFileSync(join(integrationDir, ".env.integration"), "SUPABASE_URL=http://127.0.0.1:56321\n");
|
|
197
|
+
const writes = []; const removed = [];
|
|
198
|
+
await assert.rejects(
|
|
199
|
+
materializeLeasedTestConfig(root, "lease-cleanup", {
|
|
200
|
+
api_url: "http://127.0.0.1:64321", db_port: 64322, anon_key: "anon", service_role_key: "service", project_id: "leased-project",
|
|
201
|
+
}, {
|
|
202
|
+
writePrivate: async (path) => { writes.push(path); if (writes.length === 2) throw new Error("ENOSPC"); },
|
|
203
|
+
removePrivate: async (path) => { removed.push(path); },
|
|
204
|
+
}),
|
|
205
|
+
/ENOSPC/,
|
|
206
|
+
);
|
|
207
|
+
assert.equal(writes.length, 2);
|
|
208
|
+
assert.deepEqual(removed, [writes[0]]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("BOT-1346: queue wait errors retain a failed signed-cleanup receipt", async () => {
|
|
212
|
+
const result = await runStackLifecycle(
|
|
213
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
|
|
214
|
+
["pnpm", "test:integration"],
|
|
215
|
+
{
|
|
216
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
217
|
+
api: {
|
|
218
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queue-error", state: "queued" } }),
|
|
219
|
+
release: async (_leaseId, signal) => {
|
|
220
|
+
assert(signal instanceof AbortSignal, "cleanup release receives the reap deadline signal");
|
|
221
|
+
return { ok: false, error: "signed reap unavailable" };
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
wait: async () => ({ error: "lease stream broke" }),
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
assert.equal(result.exitCode, EXIT.LEASE_FAILED);
|
|
228
|
+
assert.equal(result.cleanup.ok, false);
|
|
229
|
+
assert.match(result.cleanup.error, /signed reap unavailable/);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("BOT-1346: queue timeout receipt retains a failed cleanup result", async () => {
|
|
233
|
+
const result = await runStackLifecycle(
|
|
234
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
|
|
235
|
+
{
|
|
236
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
237
|
+
api: {
|
|
238
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-queued", state: "queued" } }),
|
|
239
|
+
release: async () => ({ ok: false, error: "release rejected" }),
|
|
240
|
+
},
|
|
241
|
+
wait: async () => ({ timeout: true }),
|
|
242
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
assert.equal(result.exitCode, EXIT.TIMEOUT);
|
|
246
|
+
assert.equal(result.cleanup.ok, false);
|
|
247
|
+
assert.match(result.cleanup.error, /release rejected/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("BOT-1346: interruption during lease creation releases the lease once its ID arrives", async () => {
|
|
251
|
+
const signals = new EventEmitter();
|
|
252
|
+
let resolveRequest; let releases = 0;
|
|
253
|
+
const pending = runStackLifecycle(
|
|
254
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["pnpm", "test:integration"],
|
|
255
|
+
{
|
|
256
|
+
auth: { Authorization: "Bearer test" }, signals,
|
|
257
|
+
api: {
|
|
258
|
+
request: () => new Promise((resolve) => { resolveRequest = resolve; }),
|
|
259
|
+
release: async () => { releases++; return { ok: true, data: { success: true, state: "reaping" } }; },
|
|
260
|
+
},
|
|
261
|
+
wait: async () => ({ woke: true, state: "reaped" }),
|
|
262
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
signals.emit("SIGINT");
|
|
266
|
+
resolveRequest({ ok: true, data: { success: true, lease_id: "lease-interrupted-request", state: "queued" } });
|
|
267
|
+
const result = await pending;
|
|
268
|
+
assert.equal(result.exitCode, 130);
|
|
269
|
+
assert.equal(releases, 1);
|
|
270
|
+
assert.equal(result.cleanup.ok, true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("BOT-1346: forced hard TTL cannot turn an incomplete child run into success", async () => {
|
|
274
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4244;
|
|
275
|
+
let hardTimeout;
|
|
276
|
+
const result = await runStackLifecycle(
|
|
277
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5, hardTtl: 1 }, ["node", "-e", "process.exit(0)"],
|
|
278
|
+
{
|
|
279
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
280
|
+
api: {
|
|
281
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-ttl", state: "active" } }),
|
|
282
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
|
|
283
|
+
touch: async () => ({ ok: true, data: { success: true } }),
|
|
284
|
+
release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
|
|
285
|
+
},
|
|
286
|
+
wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
|
|
287
|
+
startChild: () => { queueMicrotask(() => { hardTimeout(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
|
|
288
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: (fn) => { hardTimeout = fn; return { unref() {} }; }, clearTimeout() {} },
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
assert.equal(result.exitCode, EXIT.TIMEOUT);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("BOT-1346: a fenced lease cannot report a gracefully-stopped child as success", async () => {
|
|
295
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4245;
|
|
296
|
+
let heartbeat;
|
|
297
|
+
const result = await runStackLifecycle(
|
|
298
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 }, ["node", "-e", "process.exit(0)"],
|
|
299
|
+
{
|
|
300
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
301
|
+
api: {
|
|
302
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-fenced", state: "active" } }),
|
|
303
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
|
|
304
|
+
touch: async () => ({ ok: false, error: "lease unavailable" }),
|
|
305
|
+
release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
|
|
306
|
+
},
|
|
307
|
+
wait: async () => ({ woke: true, state: "reaped" }), writeConnection: async (path) => path, removeConnection: async () => {},
|
|
308
|
+
startChild: () => { queueMicrotask(async () => { await heartbeat(); fakeChild.emit("exit", 0, null); }); return fakeChild; },
|
|
309
|
+
clock: { setInterval: (fn) => { heartbeat = fn; return { unref() {} }; }, clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
assert.equal(result.exitCode, EXIT.LEASE_FAILED);
|
|
313
|
+
assert.equal(result.fenced, true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("BOT-1346: stack run reports failed signed cleanup after a successful child", async () => {
|
|
317
|
+
const fakeChild = new EventEmitter(); fakeChild.pid = 4243;
|
|
318
|
+
const result = await runStackLifecycle(
|
|
319
|
+
{ repo: "botbuddy-web", ticket: "BOT-1346", stackPath: ".", timeout: 5, reapTimeout: 5 },
|
|
320
|
+
["node", "-e", "process.exit(0)"],
|
|
321
|
+
{
|
|
322
|
+
auth: { Authorization: "Bearer test" }, signals: new EventEmitter(),
|
|
323
|
+
api: {
|
|
324
|
+
request: async () => ({ ok: true, data: { success: true, lease_id: "lease-unreaped", state: "active" } }),
|
|
325
|
+
get: async () => ({ ok: true, data: { success: true, state: "active", connection: {} } }),
|
|
326
|
+
touch: async () => ({ ok: true, data: { success: true } }),
|
|
327
|
+
release: async () => ({ ok: true, data: { success: true, state: "reaping" } }),
|
|
328
|
+
},
|
|
329
|
+
wait: async () => ({ timeout: true }),
|
|
330
|
+
writeConnection: async (path) => path,
|
|
331
|
+
removeConnection: async () => {},
|
|
332
|
+
startChild: () => { queueMicrotask(() => fakeChild.emit("exit", 0, null)); return fakeChild; },
|
|
333
|
+
clock: { setInterval: () => ({ unref() {} }), clearInterval() {}, setTimeout: () => ({ unref() {} }), clearTimeout() {} },
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
assert.equal(result.childExitCode, 0);
|
|
337
|
+
assert.equal(result.exitCode, EXIT.CLEANUP_FAILED);
|
|
338
|
+
assert.equal(result.cleanup.ok, false);
|
|
339
|
+
});
|
|
102
340
|
|
|
103
341
|
// ── receipts ─────────────────────────────────────────────────────────────────
|
|
104
342
|
test("buildReceipt: stamps schema_version", () => {
|