@botbuddy/cli 1.2.3 → 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/bin/botbuddy.mjs +5 -1
- package/package.json +1 -1
- package/src/agent-credential-store.mjs +208 -0
- package/src/api.mjs +40 -0
- package/src/auth.mjs +169 -70
- package/src/auth.test.mjs +404 -0
- package/src/codex-bridge.mjs +2 -1
- package/src/commands.mjs +206 -30
- package/src/config.mjs +5 -1
- package/src/discovery.mjs +141 -0
- package/src/discovery.test.mjs +195 -0
- package/src/locks.mjs +154 -0
- package/src/locks.test.mjs +60 -0
- package/src/oauth-loopback.mjs +228 -0
- package/src/profile-bootstrap.mjs +104 -0
- package/src/profile-bootstrap.test.mjs +205 -0
- package/src/publish-equal.mjs +207 -0
- package/src/publish-equal.test.mjs +176 -0
- package/src/publish-workflow.test.mjs +122 -0
- package/src/quiet-runner.mjs +134 -0
- package/src/quiet-runner.test.mjs +109 -0
- package/src/run.mjs +239 -0
- package/src/run.test.mjs +173 -0
- package/src/stack.mjs +964 -0
- package/src/stack.test.mjs +434 -0
- package/src/wait-core.mjs +1266 -0
- package/src/wait-profile.mjs +84 -0
- package/src/wait-profile.test.mjs +30 -0
- package/src/wait.mjs +727 -0
- package/src/wait.test.mjs +266 -0
package/src/stack.mjs
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
// BOT-1220 — `botbuddy stack`: one command to request, inspect, extend, and
|
|
2
|
+
// release a short-lived batch-scoped local Supabase/Docker stack lease (BOT-1218),
|
|
3
|
+
// mirroring the shape and discipline of `scripts/bb-wait.mjs`:
|
|
4
|
+
//
|
|
5
|
+
// botbuddy stack up → request a lease; park zero-poll if the host is
|
|
6
|
+
// saturated; return an ACTIVE lease receipt.
|
|
7
|
+
// botbuddy stack status <id> → the lease's current state + connection.
|
|
8
|
+
// botbuddy stack touch <id> → bump the idle clock (before a long silent step).
|
|
9
|
+
// botbuddy stack done <id> → release (`done`): reap the stack.
|
|
10
|
+
//
|
|
11
|
+
// The canonical batch flow is: stack up → test → break → fix → test → stack done.
|
|
12
|
+
//
|
|
13
|
+
// Design contract (matches bb-wait):
|
|
14
|
+
// * Dependency-free (Node stdlib only) so `npx @botbuddy/cli stack` cold-starts fast.
|
|
15
|
+
// * Exactly ONE single-line JSON receipt to stdout; all diagnostics go to stderr.
|
|
16
|
+
// * Receipt ≤ 10 KB (documented, --receipt-max-bytes overridable).
|
|
17
|
+
// * Documented, meaningful exit codes.
|
|
18
|
+
// * The CLI never executes docker itself on the primary path — the out-of-repo
|
|
19
|
+
// BotBuddy Helper provisions/tears down; `up` just coordinates and (zero-poll)
|
|
20
|
+
// waits for the lease to reach `active`. `--local-exec` is a LOUD, explicitly
|
|
21
|
+
// labelled fallback for when no Helper is available: it runs `supabase start`/
|
|
22
|
+
// `stop` itself and self-activates/finalizes via the fallback MCP tools.
|
|
23
|
+
|
|
24
|
+
import { spawn, spawnSync } from "child_process";
|
|
25
|
+
import { readFileSync, realpathSync } from "fs";
|
|
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";
|
|
30
|
+
import { callToolJson } from "./api.mjs";
|
|
31
|
+
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
|
+
import { bold, dim, yellow } from "./utils.mjs";
|
|
33
|
+
|
|
34
|
+
export const STACK_SCHEMA_VERSION = 1;
|
|
35
|
+
export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
|
|
36
|
+
export const DEFAULT_TIMEOUT_SEC = 3600;
|
|
37
|
+
|
|
38
|
+
// Meaningful, documented exit codes (mirrors bb-wait's taxonomy).
|
|
39
|
+
export const EXIT = Object.freeze({
|
|
40
|
+
OK: 0, // active lease held (or a successful status/touch/done)
|
|
41
|
+
QUEUED: 0, // --no-wait: parked in the queue and exited (see receipt.queued)
|
|
42
|
+
TIMEOUT: 2, // --timeout elapsed while parked for capacity
|
|
43
|
+
AUTH: 3, // not authenticated / token expired / unauthorized
|
|
44
|
+
INVALID: 4, // bad arguments / undeterminable slot
|
|
45
|
+
BACKEND: 5, // server/RPC/transport error, or the coordination request failed
|
|
46
|
+
LEASE_FAILED: 6, // the lease was reaped / provision failed instead of going active
|
|
47
|
+
INTERNAL: 7, // unexpected local error
|
|
48
|
+
CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
|
|
52
|
+
|
|
53
|
+
${bold("USAGE")}
|
|
54
|
+
botbuddy stack up [options] Request a lease; park if the host is full; hold it active
|
|
55
|
+
botbuddy stack status <lease_id> Show a lease's state + connection
|
|
56
|
+
botbuddy stack touch <lease_id> Bump the idle clock so the reaper doesn't stop it
|
|
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
|
|
60
|
+
|
|
61
|
+
${bold("up OPTIONS")}
|
|
62
|
+
--slot <slot> Stable stack identity (BOT-1186): the shared CLI stack DB port
|
|
63
|
+
(e.g. 56322) or "<repo>-<ticket-slug>". Derived from --repo/--ticket
|
|
64
|
+
when omitted. "default" is rejected.
|
|
65
|
+
--host <host_key> Pin to a canonical host. Omit to auto-select a beacon-fresh host.
|
|
66
|
+
--repo <repo> Repository the batch is for (e.g. botbuddy-web).
|
|
67
|
+
--ticket <BOT-123> Ticket the batch is for (also used to derive the slot).
|
|
68
|
+
--stack-path <relative> Stack directory inside the registered worktree (default: .).
|
|
69
|
+
--purpose <text> Free-text purpose recorded on the lease.
|
|
70
|
+
--idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
|
|
71
|
+
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
72
|
+
--no-wait If the host is full, print the queue position and exit (don't park).
|
|
73
|
+
--local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
|
|
74
|
+
|
|
75
|
+
${bold("done OPTIONS")}
|
|
76
|
+
--stop Keep volumes (cheap re-provision next batch). Default: destroy.
|
|
77
|
+
--local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
|
|
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
|
+
|
|
88
|
+
${bold("GLOBAL")}
|
|
89
|
+
--json Pretty-print the receipt (default is one compact JSON line).
|
|
90
|
+
--receipt-max-bytes N Receipt size cap (default ${DEFAULT_RECEIPT_MAX_BYTES}).
|
|
91
|
+
|
|
92
|
+
${bold("EXIT CODES")}
|
|
93
|
+
0 ok/held (or queued+--no-wait) 2 park timed out 3 not authenticated
|
|
94
|
+
4 invalid arguments 5 backend/coordination 6 lease failed/reaped
|
|
95
|
+
7 internal error 8 cleanup/reap proof failed
|
|
96
|
+
|
|
97
|
+
${bold("EXAMPLE")}
|
|
98
|
+
# request a per-worktree stack for this batch, run a lane against it, then reap it
|
|
99
|
+
botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
|
|
100
|
+
|
|
101
|
+
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Derive the event-stream base URL from the MCP server URL. */
|
|
104
|
+
export function eventStreamBase(serverUrl = SERVER_URL) {
|
|
105
|
+
return serverUrl.replace(/\/mcp-server\/?$/, "/event-stream");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Slugify a ticket key / repo fragment into a slot-safe token. */
|
|
109
|
+
function slug(s) {
|
|
110
|
+
return String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Derive the BOT-1186 slot (stable stack identity). Precedence:
|
|
115
|
+
* 1. explicit --slot
|
|
116
|
+
* 2. "<repo>-<ticket>" when both are given
|
|
117
|
+
* 3. BB_STACK_SLOT env
|
|
118
|
+
* "default" is rejected (one slot per host can't represent shared + N worktree stacks).
|
|
119
|
+
* Throws a caller-facing Error when a slot can't be determined.
|
|
120
|
+
*/
|
|
121
|
+
export function deriveSlot({ slot, repo, ticket } = {}, env = process.env) {
|
|
122
|
+
let s = slot != null && String(slot).trim() !== "" ? String(slot).trim()
|
|
123
|
+
: (repo && ticket ? `${slug(repo)}-${slug(ticket)}` : null);
|
|
124
|
+
if (!s && env.BB_STACK_SLOT) s = String(env.BB_STACK_SLOT).trim();
|
|
125
|
+
if (!s) {
|
|
126
|
+
throw new Error("cannot determine a stack slot — pass --slot <port|repo-ticket>, or --repo and --ticket");
|
|
127
|
+
}
|
|
128
|
+
if (s.toLowerCase() === "default") {
|
|
129
|
+
throw new Error('slot "default" is rejected — use the shared CLI stack DB port (e.g. 56322) or "<repo>-<ticket-slug>"');
|
|
130
|
+
}
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse `botbuddy stack` argv into { command, leaseId, opts, errors }. Pure — no I/O.
|
|
136
|
+
* `errors` non-empty ⇒ the caller emits an INVALID receipt and exits 4.
|
|
137
|
+
*/
|
|
138
|
+
export function parseStackArgs(argv) {
|
|
139
|
+
const errors = [];
|
|
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);
|
|
144
|
+
const opts = {
|
|
145
|
+
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
146
|
+
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
|
|
147
|
+
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
148
|
+
connectionFile: null, noWait: false, localExec: false,
|
|
149
|
+
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
150
|
+
};
|
|
151
|
+
const positionals = [];
|
|
152
|
+
const need = (name, v) => { if (v === undefined) { errors.push(`${name} needs a value`); return false; } return true; };
|
|
153
|
+
for (let i = 0; i < rest.length; i++) {
|
|
154
|
+
const a = rest[i];
|
|
155
|
+
switch (a) {
|
|
156
|
+
case "--slot": if (need(a, rest[i + 1])) opts.slot = rest[++i]; break;
|
|
157
|
+
case "--host": if (need(a, rest[i + 1])) opts.host = rest[++i]; break;
|
|
158
|
+
case "--repo": if (need(a, rest[i + 1])) opts.repo = rest[++i]; break;
|
|
159
|
+
case "--ticket": if (need(a, rest[i + 1])) opts.ticket = rest[++i]; break;
|
|
160
|
+
case "--ticket-url": if (need(a, rest[i + 1])) opts.ticketUrl = rest[++i]; break;
|
|
161
|
+
case "--pr": if (need(a, rest[i + 1])) opts.prId = rest[++i]; break;
|
|
162
|
+
case "--pr-url": if (need(a, rest[i + 1])) opts.prUrl = rest[++i]; break;
|
|
163
|
+
case "--purpose": if (need(a, rest[i + 1])) opts.purpose = rest[++i]; break;
|
|
164
|
+
case "--stack-path": if (need(a, rest[i + 1])) opts.stackPath = rest[++i]; break;
|
|
165
|
+
case "--idle-ttl": {
|
|
166
|
+
if (need(a, rest[i + 1])) {
|
|
167
|
+
const n = Number(rest[++i]);
|
|
168
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--idle-ttl must be a positive integer number of seconds");
|
|
169
|
+
else opts.idleTtl = n;
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case "--timeout": {
|
|
174
|
+
if (need(a, rest[i + 1])) {
|
|
175
|
+
const n = Number(rest[++i]);
|
|
176
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--timeout must be a positive integer number of seconds");
|
|
177
|
+
else opts.timeout = n;
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
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;
|
|
206
|
+
case "--receipt-max-bytes": {
|
|
207
|
+
if (need(a, rest[i + 1])) {
|
|
208
|
+
const n = Number(rest[++i]);
|
|
209
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 512) errors.push("--receipt-max-bytes must be an integer >= 512");
|
|
210
|
+
else opts.receiptMaxBytes = n;
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "--no-wait": opts.noWait = true; break;
|
|
215
|
+
case "--local-exec": opts.localExec = true; break;
|
|
216
|
+
case "--stop": opts.disposition = "stop"; break;
|
|
217
|
+
case "--destroy": opts.disposition = "destroy"; break;
|
|
218
|
+
case "--json": opts.json = true; break;
|
|
219
|
+
default:
|
|
220
|
+
if (a.startsWith("-")) errors.push(`unknown option: ${a}`);
|
|
221
|
+
else positionals.push(a);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (typeof opts.stackPath !== "string" || !opts.stackPath.trim() || opts.stackPath.startsWith("/") ||
|
|
225
|
+
opts.stackPath.split("/").some((part) => part === "..")) {
|
|
226
|
+
errors.push("--stack-path must be a non-empty relative path without '..'");
|
|
227
|
+
}
|
|
228
|
+
const leaseId = positionals[0] ?? null;
|
|
229
|
+
if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
|
|
230
|
+
errors.push(`${command} needs a <lease_id>`);
|
|
231
|
+
}
|
|
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 };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Build the canonical receipt object (single JSON line to stdout). */
|
|
246
|
+
export function buildReceipt(fields) {
|
|
247
|
+
return { schema_version: STACK_SCHEMA_VERSION, ...fields };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Clamp a receipt under maxBytes: the connection block (the only unbounded field)
|
|
252
|
+
* collapses to a pointer first, then a hard fallback. lease_id/state/exit_code/outcome
|
|
253
|
+
* are always preserved so a truncated receipt is still actionable.
|
|
254
|
+
*/
|
|
255
|
+
export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
|
|
256
|
+
const enc = (o) => Buffer.byteLength(JSON.stringify(o), "utf8");
|
|
257
|
+
if (enc(receipt) <= maxBytes) return receipt;
|
|
258
|
+
const r = { ...receipt };
|
|
259
|
+
if (r.connection !== undefined) r.connection = { connection_truncated: true };
|
|
260
|
+
if (enc(r) <= maxBytes) return r;
|
|
261
|
+
return {
|
|
262
|
+
schema_version: r.schema_version, command: r.command, outcome: r.outcome,
|
|
263
|
+
lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
|
|
264
|
+
error: r.error ?? null, truncated: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolve a LOCAL Supabase API origin to pin into the `supabase start` environment
|
|
270
|
+
* (BOT-903 / Codex P1): without `VITE_SUPABASE_URL` pinned, config.toml's
|
|
271
|
+
* `env(VITE_SUPABASE_URL)` falls back to the repo's `.env` PRODUCTION origin, so the
|
|
272
|
+
* "disposable" local edge runtime would address `https://api.bot-buddy.ai`. Precedence:
|
|
273
|
+
* 1. an already-exported local (`127.0.0.1`/`localhost`) `VITE_SUPABASE_URL`;
|
|
274
|
+
* 2. `http://127.0.0.1:<[api] port>` read from `./supabase/config.toml`.
|
|
275
|
+
* Returns null when neither is available — the caller then REFUSES to run `supabase
|
|
276
|
+
* start` rather than risk crossing into production.
|
|
277
|
+
*/
|
|
278
|
+
export function resolveLocalSupabaseUrl(env = process.env, cwd = process.cwd()) {
|
|
279
|
+
const cur = env.VITE_SUPABASE_URL;
|
|
280
|
+
if (cur && /(127\.0\.0\.1|localhost)/.test(cur)) return cur;
|
|
281
|
+
try {
|
|
282
|
+
const toml = readFileSync(`${cwd}/supabase/config.toml`, "utf8");
|
|
283
|
+
// The [api] section's `port = NNNNN` (stop at the next section header).
|
|
284
|
+
const section = /\[api\]([\s\S]*?)(\n\[|$)/.exec(toml);
|
|
285
|
+
const m = section && /\bport\s*=\s*(\d+)/.exec(section[1]);
|
|
286
|
+
if (m) return `http://127.0.0.1:${m[1]}`;
|
|
287
|
+
} catch { /* no config.toml here */ }
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Resolve the requested stack directory once, before it leaves the coding
|
|
292
|
+
* machine. This closes both `..` and symlink escapes; the server separately
|
|
293
|
+
* verifies the resulting root is a registered worktree on the selected host. */
|
|
294
|
+
export function resolveStackPath(cwd = process.cwd(), stackPath = ".") {
|
|
295
|
+
if (typeof stackPath !== "string" || !stackPath || isAbsolute(stackPath) || stackPath.split("/").some((part) => part === "..")) {
|
|
296
|
+
throw new Error("--stack-path must be a non-empty relative path without '..'");
|
|
297
|
+
}
|
|
298
|
+
const root = realpathSync(cwd);
|
|
299
|
+
const target = realpathSync(`${root}/${stackPath}`);
|
|
300
|
+
const inside = relative(root, target);
|
|
301
|
+
if (inside === ".." || inside.startsWith(`..${process.platform === "win32" ? "\\\\" : "/"}`)) {
|
|
302
|
+
throw new Error("--stack-path resolves outside the current worktree");
|
|
303
|
+
}
|
|
304
|
+
return { worktreeRoot: root, stackPath: inside || "." };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Parse `supabase status -o json` (or the plain key/value fallback) into a connection block. */
|
|
308
|
+
export function parseSupabaseStatus(text) {
|
|
309
|
+
const t = String(text || "");
|
|
310
|
+
try {
|
|
311
|
+
const j = JSON.parse(t);
|
|
312
|
+
return {
|
|
313
|
+
api_url: j.API_URL ?? j.api_url ?? null,
|
|
314
|
+
db_url: j.DB_URL ?? j.db_url ?? null,
|
|
315
|
+
anon_key: j.ANON_KEY ?? j.anon_key ?? j.PUBLISHABLE_KEY ?? null,
|
|
316
|
+
service_role_key: j.SERVICE_ROLE_KEY ?? j.service_role_key ?? null,
|
|
317
|
+
};
|
|
318
|
+
} catch {
|
|
319
|
+
const grab = (re) => { const m = re.exec(t); return m ? m[1].trim() : null; };
|
|
320
|
+
const conn = {
|
|
321
|
+
api_url: grab(/API URL:\s*(\S+)/i),
|
|
322
|
+
db_url: grab(/DB URL:\s*(\S+)/i),
|
|
323
|
+
anon_key: grab(/anon key:\s*(\S+)/i),
|
|
324
|
+
service_role_key: grab(/service_role key:\s*(\S+)/i),
|
|
325
|
+
};
|
|
326
|
+
return conn;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── runtime (network / process) ──────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
function stackAuthHeader() {
|
|
333
|
+
const cfg = getConfig();
|
|
334
|
+
if (cfg.access_token) {
|
|
335
|
+
if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) return null;
|
|
336
|
+
return { Authorization: `Bearer ${cfg.access_token}`, "x-agent-api-key": cfg.api_key || "" };
|
|
337
|
+
}
|
|
338
|
+
if (cfg.api_key) return { Authorization: `Bearer ${cfg.api_key}`, "x-agent-api-key": cfg.api_key };
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function parseSseFrames(buffer) {
|
|
343
|
+
const frames = [];
|
|
344
|
+
let idx, remaining = buffer;
|
|
345
|
+
while ((idx = remaining.indexOf("\n\n")) !== -1) {
|
|
346
|
+
const block = remaining.slice(0, idx);
|
|
347
|
+
remaining = remaining.slice(idx + 2);
|
|
348
|
+
const dataLines = [];
|
|
349
|
+
for (const line of block.split("\n")) {
|
|
350
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
351
|
+
const colon = line.indexOf(":");
|
|
352
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
353
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
354
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
355
|
+
if (field === "data") dataLines.push(value);
|
|
356
|
+
}
|
|
357
|
+
if (dataLines.length) frames.push({ data: dataLines.join("\n") });
|
|
358
|
+
}
|
|
359
|
+
return { frames, rest: remaining };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Register a wait_session for this lease (so it shows on /waits) + return {cursorStart}. Best-effort. */
|
|
363
|
+
async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl = fetch) {
|
|
364
|
+
const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
|
|
365
|
+
const res = await fetchImpl(`${eventStreamBase()}`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
368
|
+
body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
|
|
369
|
+
signal,
|
|
370
|
+
});
|
|
371
|
+
if (!res.ok) throw new Error(`register responded ${res.status}`);
|
|
372
|
+
const body = await res.json();
|
|
373
|
+
return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Zero-poll wait for `lease:<leaseId>` to reach a state satisfying `isDone(state)`.
|
|
378
|
+
* Registers a wait_session (visible on /waits), then blocks on the event-stream SSE.
|
|
379
|
+
* Resolves { woke, state } on a matching frame, { timeout:true } on --timeout,
|
|
380
|
+
* { failed, state } if the lease was reaped, or { error } on a transport failure.
|
|
381
|
+
*/
|
|
382
|
+
export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth, signal = null, deadlineMs = null }, fetchImpl = fetch) {
|
|
383
|
+
const absoluteDeadlineMs = deadlineMs ?? Date.now() + timeoutSec * 1000;
|
|
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");
|
|
415
|
+
try {
|
|
416
|
+
({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
|
|
419
|
+
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
420
|
+
}
|
|
421
|
+
const url = new URL(eventStreamBase());
|
|
422
|
+
url.searchParams.set("tables", "agent_signal_events");
|
|
423
|
+
if (cursorStart != null) url.searchParams.set("since", String(cursorStart));
|
|
424
|
+
if (waitSessionId) url.searchParams.set("wait_session", waitSessionId);
|
|
425
|
+
url.searchParams.set("heartbeat", "1");
|
|
426
|
+
let res;
|
|
427
|
+
try {
|
|
428
|
+
res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
429
|
+
} catch (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");
|
|
432
|
+
}
|
|
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");
|
|
435
|
+
const decoder = new TextDecoder();
|
|
436
|
+
let buf = "";
|
|
437
|
+
try {
|
|
438
|
+
for await (const chunk of res.body) {
|
|
439
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
440
|
+
const { frames, rest } = parseSseFrames(buf);
|
|
441
|
+
buf = rest;
|
|
442
|
+
for (const f of frames) {
|
|
443
|
+
let sig;
|
|
444
|
+
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
445
|
+
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
446
|
+
const st = sig.payload?.state ?? null;
|
|
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"); }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch (err) {
|
|
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);
|
|
462
|
+
}
|
|
463
|
+
return finish({ timeout: true }, "timeout");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const isActive = (s) => s === "active";
|
|
467
|
+
const nonQueued = (s) => s != null && s !== "queued";
|
|
468
|
+
const isReaped = (s) => s === "reaping" || s === "reaped";
|
|
469
|
+
|
|
470
|
+
/** LOUD local-exec fallback: bring a stack up in the cwd via the Supabase CLI. */
|
|
471
|
+
function localProvision() {
|
|
472
|
+
// Pin a LOCAL origin so `supabase start` never bakes the repo's prod origin into the
|
|
473
|
+
// edge runtime (BOT-903 / Codex P1). Refuse rather than risk crossing into production.
|
|
474
|
+
const url = resolveLocalSupabaseUrl();
|
|
475
|
+
if (!url) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
"refusing --local-exec: cannot determine a LOCAL Supabase API origin (no supabase/config.toml [api] port " +
|
|
478
|
+
"in this directory, and VITE_SUPABASE_URL is not a 127.0.0.1/localhost origin). `supabase start` would bake " +
|
|
479
|
+
"the repository's PRODUCTION origin into the edge runtime — run from a repo with supabase/config.toml, or " +
|
|
480
|
+
"export VITE_SUPABASE_URL=http://127.0.0.1:<port> first.",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const spawnEnv = { ...process.env, VITE_SUPABASE_URL: url };
|
|
484
|
+
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in this worktree (VITE_SUPABASE_URL=${url}).\n`);
|
|
485
|
+
const start = spawnSync("supabase", ["start", "--workdir", process.cwd()], { encoding: "utf8", env: spawnEnv });
|
|
486
|
+
if (start.status !== 0) {
|
|
487
|
+
throw new Error(`supabase start failed (${start.status}): ${(start.stderr || start.stdout || "").slice(0, 400)}`);
|
|
488
|
+
}
|
|
489
|
+
const status = spawnSync("supabase", ["status", "-o", "json"], { encoding: "utf8", env: spawnEnv });
|
|
490
|
+
return parseSupabaseStatus(status.stdout || "");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** LOUD local-exec fallback: tear the stack down in the cwd. Returns true iff it succeeded. */
|
|
494
|
+
function localTeardown() {
|
|
495
|
+
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in this worktree.\n`);
|
|
496
|
+
const res = spawnSync("supabase", ["stop", "--workdir", process.cwd()], { encoding: "utf8" });
|
|
497
|
+
if (res.status !== 0) {
|
|
498
|
+
process.stderr.write(`${yellow("⚠")} stack: supabase stop returned ${res.status}: ${(res.stderr || res.stdout || "").slice(0, 300)}\n`);
|
|
499
|
+
}
|
|
500
|
+
return res.status === 0;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function emit(receipt, opts, code) {
|
|
504
|
+
const out = truncateReceipt({ ...receipt, exit_code: code }, opts?.receiptMaxBytes ?? DEFAULT_RECEIPT_MAX_BYTES);
|
|
505
|
+
process.stdout.write((opts?.json ? JSON.stringify(out, null, 2) : JSON.stringify(out)) + "\n");
|
|
506
|
+
return code;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── command orchestration ────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
async function cmdUp(opts) {
|
|
512
|
+
let slot;
|
|
513
|
+
try { slot = deriveSlot(opts); } catch (e) {
|
|
514
|
+
return emit(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
|
|
515
|
+
}
|
|
516
|
+
const auth = stackAuthHeader();
|
|
517
|
+
if (!auth) return emit(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
518
|
+
let execution;
|
|
519
|
+
try { execution = resolveStackPath(process.cwd(), opts.stackPath); } catch (e) {
|
|
520
|
+
return emit(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const req = await callToolJson("request_stack_lease", {
|
|
524
|
+
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
525
|
+
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
526
|
+
pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
527
|
+
purpose: opts.purpose || undefined, idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
528
|
+
stack_path: execution.stackPath,
|
|
529
|
+
worktree_root: execution.worktreeRoot,
|
|
530
|
+
});
|
|
531
|
+
if (!req.ok) {
|
|
532
|
+
return req.auth
|
|
533
|
+
? emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
|
|
534
|
+
: emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
|
|
535
|
+
}
|
|
536
|
+
const d = req.data;
|
|
537
|
+
if (!d.success) {
|
|
538
|
+
return emit(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
|
|
539
|
+
}
|
|
540
|
+
let leaseId = d.lease_id;
|
|
541
|
+
let state = d.state;
|
|
542
|
+
|
|
543
|
+
if (state === "queued") {
|
|
544
|
+
if (opts.noWait) {
|
|
545
|
+
return emit(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
|
|
546
|
+
}
|
|
547
|
+
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
548
|
+
const parked = await waitForLease(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
|
|
549
|
+
if (parked.timeout) return emit(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
|
|
550
|
+
if (parked.auth) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
551
|
+
if (parked.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
552
|
+
state = parked.state || "provisioning";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
|
|
556
|
+
if (state !== "active") {
|
|
557
|
+
if (opts.localExec) {
|
|
558
|
+
let conn;
|
|
559
|
+
try { conn = localProvision(); } catch (e) {
|
|
560
|
+
return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
561
|
+
}
|
|
562
|
+
const act = await callToolJson("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
563
|
+
if (!act.ok || !act.data?.success) {
|
|
564
|
+
return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
const active = await waitForLease(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
|
|
568
|
+
if (active.timeout) return emit(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `provisioning did not reach active in ${opts.timeout}s (Helper may be down — retry with --local-exec)` }), opts, EXIT.TIMEOUT);
|
|
569
|
+
if (active.failed) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
|
|
570
|
+
if (active.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Authoritative final read (connection block, current state).
|
|
575
|
+
const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
|
|
576
|
+
const g = got.ok && got.data?.success ? got.data : null;
|
|
577
|
+
if (!g || g.state !== "active") {
|
|
578
|
+
return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
|
|
579
|
+
}
|
|
580
|
+
return emit(buildReceipt({
|
|
581
|
+
command: "up", outcome: "active", lease_id: leaseId, state: "active",
|
|
582
|
+
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
583
|
+
idle_ttl_secs: g.idle_ttl_secs, resource_name: g.resource_name,
|
|
584
|
+
}), opts, EXIT.OK);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function cmdStatus(leaseId, opts) {
|
|
588
|
+
const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
|
|
589
|
+
if (!got.ok) return got.auth
|
|
590
|
+
? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
|
|
591
|
+
: emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
592
|
+
const g = got.data;
|
|
593
|
+
if (!g.success) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
|
|
594
|
+
return emit(buildReceipt({
|
|
595
|
+
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
596
|
+
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
597
|
+
queue_position: g.queue_position, idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
598
|
+
}), opts, EXIT.OK);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function cmdTouch(leaseId, opts) {
|
|
602
|
+
const r = await callToolJson("touch_stack_lease", { lease_id: leaseId });
|
|
603
|
+
if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
604
|
+
if (!r.data.success) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
605
|
+
return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function cmdDone(leaseId, opts) {
|
|
609
|
+
const r = await callToolJson("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
|
|
610
|
+
if (!r.ok) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
611
|
+
if (!r.data.success) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
612
|
+
let state = r.data.state;
|
|
613
|
+
if (opts.localExec && state === "reaping") {
|
|
614
|
+
// Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
|
|
615
|
+
// unavailable, etc.) must NOT finalize — finalize frees the slot lock and promotes
|
|
616
|
+
// the next queued lease, which would collide with containers still running on this
|
|
617
|
+
// slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
|
|
618
|
+
// the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
|
|
619
|
+
if (!localTeardown()) {
|
|
620
|
+
return emit(buildReceipt({
|
|
621
|
+
command: "done", outcome: "error", lease_id: leaseId, state,
|
|
622
|
+
error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
|
|
623
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
624
|
+
}
|
|
625
|
+
const fin = await callToolJson("finalize_stack_lease", { lease_id: leaseId });
|
|
626
|
+
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
627
|
+
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
628
|
+
}
|
|
629
|
+
return emit(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition }), opts, EXIT.OK);
|
|
630
|
+
}
|
|
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
|
+
|
|
934
|
+
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|
|
935
|
+
export async function cmdStack(argv) {
|
|
936
|
+
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
937
|
+
process.stdout.write(STACK_HELP + "\n");
|
|
938
|
+
return EXIT.OK;
|
|
939
|
+
}
|
|
940
|
+
const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
|
|
941
|
+
if (errors.length) {
|
|
942
|
+
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
|
943
|
+
const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
|
|
944
|
+
process.exitCode = code;
|
|
945
|
+
return code;
|
|
946
|
+
}
|
|
947
|
+
let code;
|
|
948
|
+
try {
|
|
949
|
+
switch (command) {
|
|
950
|
+
case "up": code = await cmdUp(opts); break;
|
|
951
|
+
case "status": code = await cmdStatus(leaseId, opts); break;
|
|
952
|
+
case "touch": code = await cmdTouch(leaseId, opts); break;
|
|
953
|
+
case "done": code = await cmdDone(leaseId, opts); break;
|
|
954
|
+
case "run": code = await cmdRun(opts, childArgv); break;
|
|
955
|
+
default:
|
|
956
|
+
process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
|
|
957
|
+
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
|
|
958
|
+
}
|
|
959
|
+
} catch (err) {
|
|
960
|
+
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `internal: ${err?.message ?? err}` }), opts, EXIT.INTERNAL);
|
|
961
|
+
}
|
|
962
|
+
process.exitCode = code;
|
|
963
|
+
return code;
|
|
964
|
+
}
|