@botbuddy/cli 1.2.3 → 1.4.1
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 +39 -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 +572 -0
- package/src/stack.test.mjs +196 -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,572 @@
|
|
|
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 { spawnSync } from "child_process";
|
|
25
|
+
import { readFileSync, realpathSync } from "fs";
|
|
26
|
+
import { join, relative, isAbsolute } from "path";
|
|
27
|
+
import { callToolJson } from "./api.mjs";
|
|
28
|
+
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
29
|
+
import { bold, dim, yellow } from "./utils.mjs";
|
|
30
|
+
|
|
31
|
+
export const STACK_SCHEMA_VERSION = 1;
|
|
32
|
+
export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
|
|
33
|
+
export const DEFAULT_TIMEOUT_SEC = 3600;
|
|
34
|
+
|
|
35
|
+
// Meaningful, documented exit codes (mirrors bb-wait's taxonomy).
|
|
36
|
+
export const EXIT = Object.freeze({
|
|
37
|
+
OK: 0, // active lease held (or a successful status/touch/done)
|
|
38
|
+
QUEUED: 0, // --no-wait: parked in the queue and exited (see receipt.queued)
|
|
39
|
+
TIMEOUT: 2, // --timeout elapsed while parked for capacity
|
|
40
|
+
AUTH: 3, // not authenticated / token expired / unauthorized
|
|
41
|
+
INVALID: 4, // bad arguments / undeterminable slot
|
|
42
|
+
BACKEND: 5, // server/RPC/transport error, or the coordination request failed
|
|
43
|
+
LEASE_FAILED: 6, // the lease was reaped / provision failed instead of going active
|
|
44
|
+
INTERNAL: 7, // unexpected local error
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
|
|
48
|
+
|
|
49
|
+
${bold("USAGE")}
|
|
50
|
+
botbuddy stack up [options] Request a lease; park if the host is full; hold it active
|
|
51
|
+
botbuddy stack status <lease_id> Show a lease's state + connection
|
|
52
|
+
botbuddy stack touch <lease_id> Bump the idle clock so the reaper doesn't stop it
|
|
53
|
+
botbuddy stack done <lease_id> Release (done): tear the stack down
|
|
54
|
+
|
|
55
|
+
${bold("up OPTIONS")}
|
|
56
|
+
--slot <slot> Stable stack identity (BOT-1186): the shared CLI stack DB port
|
|
57
|
+
(e.g. 56322) or "<repo>-<ticket-slug>". Derived from --repo/--ticket
|
|
58
|
+
when omitted. "default" is rejected.
|
|
59
|
+
--host <host_key> Pin to a canonical host. Omit to auto-select a beacon-fresh host.
|
|
60
|
+
--repo <repo> Repository the batch is for (e.g. botbuddy-web).
|
|
61
|
+
--ticket <BOT-123> Ticket the batch is for (also used to derive the slot).
|
|
62
|
+
--stack-path <relative> Stack directory inside the registered worktree (default: .).
|
|
63
|
+
--purpose <text> Free-text purpose recorded on the lease.
|
|
64
|
+
--idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
|
|
65
|
+
--timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
|
|
66
|
+
--no-wait If the host is full, print the queue position and exit (don't park).
|
|
67
|
+
--local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
|
|
68
|
+
|
|
69
|
+
${bold("done OPTIONS")}
|
|
70
|
+
--stop Keep volumes (cheap re-provision next batch). Default: destroy.
|
|
71
|
+
--local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
|
|
72
|
+
|
|
73
|
+
${bold("GLOBAL")}
|
|
74
|
+
--json Pretty-print the receipt (default is one compact JSON line).
|
|
75
|
+
--receipt-max-bytes N Receipt size cap (default ${DEFAULT_RECEIPT_MAX_BYTES}).
|
|
76
|
+
|
|
77
|
+
${bold("EXIT CODES")}
|
|
78
|
+
0 ok/held (or queued+--no-wait) 2 park timed out 3 not authenticated
|
|
79
|
+
4 invalid arguments 5 backend/coordination 6 lease failed/reaped
|
|
80
|
+
7 internal error
|
|
81
|
+
|
|
82
|
+
${bold("EXAMPLE")}
|
|
83
|
+
# request a per-worktree stack for this batch, run a lane against it, then reap it
|
|
84
|
+
id=$(botbuddy stack up --repo botbuddy-web --ticket BOT-1220 | jq -r .lease_id)
|
|
85
|
+
pnpm test:integration
|
|
86
|
+
botbuddy stack done "$id"`;
|
|
87
|
+
|
|
88
|
+
// ── pure helpers (unit-tested) ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Derive the event-stream base URL from the MCP server URL. */
|
|
91
|
+
export function eventStreamBase(serverUrl = SERVER_URL) {
|
|
92
|
+
return serverUrl.replace(/\/mcp-server\/?$/, "/event-stream");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Slugify a ticket key / repo fragment into a slot-safe token. */
|
|
96
|
+
function slug(s) {
|
|
97
|
+
return String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Derive the BOT-1186 slot (stable stack identity). Precedence:
|
|
102
|
+
* 1. explicit --slot
|
|
103
|
+
* 2. "<repo>-<ticket>" when both are given
|
|
104
|
+
* 3. BB_STACK_SLOT env
|
|
105
|
+
* "default" is rejected (one slot per host can't represent shared + N worktree stacks).
|
|
106
|
+
* Throws a caller-facing Error when a slot can't be determined.
|
|
107
|
+
*/
|
|
108
|
+
export function deriveSlot({ slot, repo, ticket } = {}, env = process.env) {
|
|
109
|
+
let s = slot != null && String(slot).trim() !== "" ? String(slot).trim()
|
|
110
|
+
: (repo && ticket ? `${slug(repo)}-${slug(ticket)}` : null);
|
|
111
|
+
if (!s && env.BB_STACK_SLOT) s = String(env.BB_STACK_SLOT).trim();
|
|
112
|
+
if (!s) {
|
|
113
|
+
throw new Error("cannot determine a stack slot — pass --slot <port|repo-ticket>, or --repo and --ticket");
|
|
114
|
+
}
|
|
115
|
+
if (s.toLowerCase() === "default") {
|
|
116
|
+
throw new Error('slot "default" is rejected — use the shared CLI stack DB port (e.g. 56322) or "<repo>-<ticket-slug>"');
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse `botbuddy stack` argv into { command, leaseId, opts, errors }. Pure — no I/O.
|
|
123
|
+
* `errors` non-empty ⇒ the caller emits an INVALID receipt and exits 4.
|
|
124
|
+
*/
|
|
125
|
+
export function parseStackArgs(argv) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
const [command, ...rest] = argv;
|
|
128
|
+
const opts = {
|
|
129
|
+
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
130
|
+
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
|
|
131
|
+
timeout: DEFAULT_TIMEOUT_SEC, noWait: false, localExec: false,
|
|
132
|
+
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
133
|
+
};
|
|
134
|
+
const positionals = [];
|
|
135
|
+
const need = (name, v) => { if (v === undefined) { errors.push(`${name} needs a value`); return false; } return true; };
|
|
136
|
+
for (let i = 0; i < rest.length; i++) {
|
|
137
|
+
const a = rest[i];
|
|
138
|
+
switch (a) {
|
|
139
|
+
case "--slot": if (need(a, rest[i + 1])) opts.slot = rest[++i]; break;
|
|
140
|
+
case "--host": if (need(a, rest[i + 1])) opts.host = rest[++i]; break;
|
|
141
|
+
case "--repo": if (need(a, rest[i + 1])) opts.repo = rest[++i]; break;
|
|
142
|
+
case "--ticket": if (need(a, rest[i + 1])) opts.ticket = rest[++i]; break;
|
|
143
|
+
case "--ticket-url": if (need(a, rest[i + 1])) opts.ticketUrl = rest[++i]; break;
|
|
144
|
+
case "--pr": if (need(a, rest[i + 1])) opts.prId = rest[++i]; break;
|
|
145
|
+
case "--pr-url": if (need(a, rest[i + 1])) opts.prUrl = rest[++i]; break;
|
|
146
|
+
case "--purpose": if (need(a, rest[i + 1])) opts.purpose = rest[++i]; break;
|
|
147
|
+
case "--stack-path": if (need(a, rest[i + 1])) opts.stackPath = rest[++i]; break;
|
|
148
|
+
case "--idle-ttl": {
|
|
149
|
+
if (need(a, rest[i + 1])) {
|
|
150
|
+
const n = Number(rest[++i]);
|
|
151
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--idle-ttl must be a positive integer number of seconds");
|
|
152
|
+
else opts.idleTtl = n;
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "--timeout": {
|
|
157
|
+
if (need(a, rest[i + 1])) {
|
|
158
|
+
const n = Number(rest[++i]);
|
|
159
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--timeout must be a positive integer number of seconds");
|
|
160
|
+
else opts.timeout = n;
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case "--receipt-max-bytes": {
|
|
165
|
+
if (need(a, rest[i + 1])) {
|
|
166
|
+
const n = Number(rest[++i]);
|
|
167
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 512) errors.push("--receipt-max-bytes must be an integer >= 512");
|
|
168
|
+
else opts.receiptMaxBytes = n;
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "--no-wait": opts.noWait = true; break;
|
|
173
|
+
case "--local-exec": opts.localExec = true; break;
|
|
174
|
+
case "--stop": opts.disposition = "stop"; break;
|
|
175
|
+
case "--destroy": opts.disposition = "destroy"; break;
|
|
176
|
+
case "--json": opts.json = true; break;
|
|
177
|
+
default:
|
|
178
|
+
if (a.startsWith("-")) errors.push(`unknown option: ${a}`);
|
|
179
|
+
else positionals.push(a);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (typeof opts.stackPath !== "string" || !opts.stackPath.trim() || opts.stackPath.startsWith("/") ||
|
|
183
|
+
opts.stackPath.split("/").some((part) => part === "..")) {
|
|
184
|
+
errors.push("--stack-path must be a non-empty relative path without '..'");
|
|
185
|
+
}
|
|
186
|
+
const leaseId = positionals[0] ?? null;
|
|
187
|
+
if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
|
|
188
|
+
errors.push(`${command} needs a <lease_id>`);
|
|
189
|
+
}
|
|
190
|
+
return { command, leaseId, opts, errors };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Build the canonical receipt object (single JSON line to stdout). */
|
|
194
|
+
export function buildReceipt(fields) {
|
|
195
|
+
return { schema_version: STACK_SCHEMA_VERSION, ...fields };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Clamp a receipt under maxBytes: the connection block (the only unbounded field)
|
|
200
|
+
* collapses to a pointer first, then a hard fallback. lease_id/state/exit_code/outcome
|
|
201
|
+
* are always preserved so a truncated receipt is still actionable.
|
|
202
|
+
*/
|
|
203
|
+
export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
|
|
204
|
+
const enc = (o) => Buffer.byteLength(JSON.stringify(o), "utf8");
|
|
205
|
+
if (enc(receipt) <= maxBytes) return receipt;
|
|
206
|
+
const r = { ...receipt };
|
|
207
|
+
if (r.connection !== undefined) r.connection = { connection_truncated: true };
|
|
208
|
+
if (enc(r) <= maxBytes) return r;
|
|
209
|
+
return {
|
|
210
|
+
schema_version: r.schema_version, command: r.command, outcome: r.outcome,
|
|
211
|
+
lease_id: r.lease_id ?? null, state: r.state ?? null, exit_code: r.exit_code,
|
|
212
|
+
error: r.error ?? null, truncated: true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve a LOCAL Supabase API origin to pin into the `supabase start` environment
|
|
218
|
+
* (BOT-903 / Codex P1): without `VITE_SUPABASE_URL` pinned, config.toml's
|
|
219
|
+
* `env(VITE_SUPABASE_URL)` falls back to the repo's `.env` PRODUCTION origin, so the
|
|
220
|
+
* "disposable" local edge runtime would address `https://api.bot-buddy.ai`. Precedence:
|
|
221
|
+
* 1. an already-exported local (`127.0.0.1`/`localhost`) `VITE_SUPABASE_URL`;
|
|
222
|
+
* 2. `http://127.0.0.1:<[api] port>` read from `./supabase/config.toml`.
|
|
223
|
+
* Returns null when neither is available — the caller then REFUSES to run `supabase
|
|
224
|
+
* start` rather than risk crossing into production.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveLocalSupabaseUrl(env = process.env, cwd = process.cwd()) {
|
|
227
|
+
const cur = env.VITE_SUPABASE_URL;
|
|
228
|
+
if (cur && /(127\.0\.0\.1|localhost)/.test(cur)) return cur;
|
|
229
|
+
try {
|
|
230
|
+
const toml = readFileSync(`${cwd}/supabase/config.toml`, "utf8");
|
|
231
|
+
// The [api] section's `port = NNNNN` (stop at the next section header).
|
|
232
|
+
const section = /\[api\]([\s\S]*?)(\n\[|$)/.exec(toml);
|
|
233
|
+
const m = section && /\bport\s*=\s*(\d+)/.exec(section[1]);
|
|
234
|
+
if (m) return `http://127.0.0.1:${m[1]}`;
|
|
235
|
+
} catch { /* no config.toml here */ }
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Resolve the requested stack directory once, before it leaves the coding
|
|
240
|
+
* machine. This closes both `..` and symlink escapes; the server separately
|
|
241
|
+
* verifies the resulting root is a registered worktree on the selected host. */
|
|
242
|
+
export function resolveStackPath(cwd = process.cwd(), stackPath = ".") {
|
|
243
|
+
if (typeof stackPath !== "string" || !stackPath || isAbsolute(stackPath) || stackPath.split("/").some((part) => part === "..")) {
|
|
244
|
+
throw new Error("--stack-path must be a non-empty relative path without '..'");
|
|
245
|
+
}
|
|
246
|
+
const root = realpathSync(cwd);
|
|
247
|
+
const target = realpathSync(`${root}/${stackPath}`);
|
|
248
|
+
const inside = relative(root, target);
|
|
249
|
+
if (inside === ".." || inside.startsWith(`..${process.platform === "win32" ? "\\\\" : "/"}`)) {
|
|
250
|
+
throw new Error("--stack-path resolves outside the current worktree");
|
|
251
|
+
}
|
|
252
|
+
return { worktreeRoot: root, stackPath: inside || "." };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Parse `supabase status -o json` (or the plain key/value fallback) into a connection block. */
|
|
256
|
+
export function parseSupabaseStatus(text) {
|
|
257
|
+
const t = String(text || "");
|
|
258
|
+
try {
|
|
259
|
+
const j = JSON.parse(t);
|
|
260
|
+
return {
|
|
261
|
+
api_url: j.API_URL ?? j.api_url ?? null,
|
|
262
|
+
db_url: j.DB_URL ?? j.db_url ?? null,
|
|
263
|
+
anon_key: j.ANON_KEY ?? j.anon_key ?? j.PUBLISHABLE_KEY ?? null,
|
|
264
|
+
service_role_key: j.SERVICE_ROLE_KEY ?? j.service_role_key ?? null,
|
|
265
|
+
};
|
|
266
|
+
} catch {
|
|
267
|
+
const grab = (re) => { const m = re.exec(t); return m ? m[1].trim() : null; };
|
|
268
|
+
const conn = {
|
|
269
|
+
api_url: grab(/API URL:\s*(\S+)/i),
|
|
270
|
+
db_url: grab(/DB URL:\s*(\S+)/i),
|
|
271
|
+
anon_key: grab(/anon key:\s*(\S+)/i),
|
|
272
|
+
service_role_key: grab(/service_role key:\s*(\S+)/i),
|
|
273
|
+
};
|
|
274
|
+
return conn;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── runtime (network / process) ──────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
function stackAuthHeader() {
|
|
281
|
+
const cfg = getConfig();
|
|
282
|
+
if (cfg.access_token) {
|
|
283
|
+
if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) return null;
|
|
284
|
+
return { Authorization: `Bearer ${cfg.access_token}`, "x-agent-api-key": cfg.api_key || "" };
|
|
285
|
+
}
|
|
286
|
+
if (cfg.api_key) return { Authorization: `Bearer ${cfg.api_key}`, "x-agent-api-key": cfg.api_key };
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseSseFrames(buffer) {
|
|
291
|
+
const frames = [];
|
|
292
|
+
let idx, remaining = buffer;
|
|
293
|
+
while ((idx = remaining.indexOf("\n\n")) !== -1) {
|
|
294
|
+
const block = remaining.slice(0, idx);
|
|
295
|
+
remaining = remaining.slice(idx + 2);
|
|
296
|
+
const dataLines = [];
|
|
297
|
+
for (const line of block.split("\n")) {
|
|
298
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
299
|
+
const colon = line.indexOf(":");
|
|
300
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
301
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
302
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
303
|
+
if (field === "data") dataLines.push(value);
|
|
304
|
+
}
|
|
305
|
+
if (dataLines.length) frames.push({ data: dataLines.join("\n") });
|
|
306
|
+
}
|
|
307
|
+
return { frames, rest: remaining };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 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) {
|
|
312
|
+
const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
|
|
313
|
+
const res = await fetchImpl(`${eventStreamBase()}`, {
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
316
|
+
body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
|
|
317
|
+
});
|
|
318
|
+
if (!res.ok) throw new Error(`register responded ${res.status}`);
|
|
319
|
+
const body = await res.json();
|
|
320
|
+
return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Zero-poll wait for `lease:<leaseId>` to reach a state satisfying `isDone(state)`.
|
|
325
|
+
* Registers a wait_session (visible on /waits), then blocks on the event-stream SSE.
|
|
326
|
+
* Resolves { woke, state } on a matching frame, { timeout:true } on --timeout,
|
|
327
|
+
* { failed, state } if the lease was reaped, or { error } on a transport failure.
|
|
328
|
+
*/
|
|
329
|
+
async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fetchImpl = fetch) {
|
|
330
|
+
let cursorStart = null;
|
|
331
|
+
try {
|
|
332
|
+
({ cursorStart } = await registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl));
|
|
333
|
+
} catch (err) {
|
|
334
|
+
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
335
|
+
}
|
|
336
|
+
const url = new URL(eventStreamBase());
|
|
337
|
+
url.searchParams.set("tables", "agent_signal_events");
|
|
338
|
+
if (cursorStart != null) url.searchParams.set("since", String(cursorStart));
|
|
339
|
+
url.searchParams.set("heartbeat", "1");
|
|
340
|
+
const ac = new AbortController();
|
|
341
|
+
const timer = setTimeout(() => ac.abort("timeout"), timeoutSec * 1000);
|
|
342
|
+
let res;
|
|
343
|
+
try {
|
|
344
|
+
res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
345
|
+
} catch (err) {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
if (ac.signal.aborted) return { timeout: true };
|
|
348
|
+
return { error: `sse connect: ${err?.message ?? err}` };
|
|
349
|
+
}
|
|
350
|
+
if (res.status === 401 || res.status === 403) { clearTimeout(timer); return { auth: true }; }
|
|
351
|
+
if (!res.ok || !res.body) { clearTimeout(timer); return { error: `sse responded ${res.status}` }; }
|
|
352
|
+
const decoder = new TextDecoder();
|
|
353
|
+
let buf = "";
|
|
354
|
+
try {
|
|
355
|
+
for await (const chunk of res.body) {
|
|
356
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
357
|
+
const { frames, rest } = parseSseFrames(buf);
|
|
358
|
+
buf = rest;
|
|
359
|
+
for (const f of frames) {
|
|
360
|
+
let sig;
|
|
361
|
+
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
362
|
+
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
363
|
+
const st = sig.payload?.state ?? null;
|
|
364
|
+
if (isFailed(st)) { clearTimeout(timer); ac.abort(); return { failed: true, state: st }; }
|
|
365
|
+
if (isDone(st)) { clearTimeout(timer); ac.abort(); return { woke: true, state: st }; }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
if (ac.signal.aborted) return { timeout: true };
|
|
371
|
+
return { error: `sse stream: ${err?.message ?? err}` };
|
|
372
|
+
}
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
return { timeout: true };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const isActive = (s) => s === "active";
|
|
378
|
+
const nonQueued = (s) => s != null && s !== "queued";
|
|
379
|
+
const isReaped = (s) => s === "reaping" || s === "reaped";
|
|
380
|
+
|
|
381
|
+
/** LOUD local-exec fallback: bring a stack up in the cwd via the Supabase CLI. */
|
|
382
|
+
function localProvision() {
|
|
383
|
+
// Pin a LOCAL origin so `supabase start` never bakes the repo's prod origin into the
|
|
384
|
+
// edge runtime (BOT-903 / Codex P1). Refuse rather than risk crossing into production.
|
|
385
|
+
const url = resolveLocalSupabaseUrl();
|
|
386
|
+
if (!url) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
"refusing --local-exec: cannot determine a LOCAL Supabase API origin (no supabase/config.toml [api] port " +
|
|
389
|
+
"in this directory, and VITE_SUPABASE_URL is not a 127.0.0.1/localhost origin). `supabase start` would bake " +
|
|
390
|
+
"the repository's PRODUCTION origin into the edge runtime — run from a repo with supabase/config.toml, or " +
|
|
391
|
+
"export VITE_SUPABASE_URL=http://127.0.0.1:<port> first.",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const spawnEnv = { ...process.env, VITE_SUPABASE_URL: url };
|
|
395
|
+
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in this worktree (VITE_SUPABASE_URL=${url}).\n`);
|
|
396
|
+
const start = spawnSync("supabase", ["start", "--workdir", process.cwd()], { encoding: "utf8", env: spawnEnv });
|
|
397
|
+
if (start.status !== 0) {
|
|
398
|
+
throw new Error(`supabase start failed (${start.status}): ${(start.stderr || start.stdout || "").slice(0, 400)}`);
|
|
399
|
+
}
|
|
400
|
+
const status = spawnSync("supabase", ["status", "-o", "json"], { encoding: "utf8", env: spawnEnv });
|
|
401
|
+
return parseSupabaseStatus(status.stdout || "");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** LOUD local-exec fallback: tear the stack down in the cwd. Returns true iff it succeeded. */
|
|
405
|
+
function localTeardown() {
|
|
406
|
+
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in this worktree.\n`);
|
|
407
|
+
const res = spawnSync("supabase", ["stop", "--workdir", process.cwd()], { encoding: "utf8" });
|
|
408
|
+
if (res.status !== 0) {
|
|
409
|
+
process.stderr.write(`${yellow("⚠")} stack: supabase stop returned ${res.status}: ${(res.stderr || res.stdout || "").slice(0, 300)}\n`);
|
|
410
|
+
}
|
|
411
|
+
return res.status === 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function emit(receipt, opts, code) {
|
|
415
|
+
const out = truncateReceipt({ ...receipt, exit_code: code }, opts?.receiptMaxBytes ?? DEFAULT_RECEIPT_MAX_BYTES);
|
|
416
|
+
process.stdout.write((opts?.json ? JSON.stringify(out, null, 2) : JSON.stringify(out)) + "\n");
|
|
417
|
+
return code;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── command orchestration ────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
async function cmdUp(opts) {
|
|
423
|
+
let slot;
|
|
424
|
+
try { slot = deriveSlot(opts); } catch (e) {
|
|
425
|
+
return emit(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
|
|
426
|
+
}
|
|
427
|
+
const auth = stackAuthHeader();
|
|
428
|
+
if (!auth) return emit(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
429
|
+
let execution;
|
|
430
|
+
try { execution = resolveStackPath(process.cwd(), opts.stackPath); } catch (e) {
|
|
431
|
+
return emit(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const req = await callToolJson("request_stack_lease", {
|
|
435
|
+
slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
|
|
436
|
+
ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
|
|
437
|
+
pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
|
|
438
|
+
purpose: opts.purpose || undefined, idle_ttl_secs: opts.idleTtl ?? undefined,
|
|
439
|
+
stack_path: execution.stackPath,
|
|
440
|
+
worktree_root: execution.worktreeRoot,
|
|
441
|
+
});
|
|
442
|
+
if (!req.ok) {
|
|
443
|
+
return req.auth
|
|
444
|
+
? emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
|
|
445
|
+
: emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
|
|
446
|
+
}
|
|
447
|
+
const d = req.data;
|
|
448
|
+
if (!d.success) {
|
|
449
|
+
return emit(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
|
|
450
|
+
}
|
|
451
|
+
let leaseId = d.lease_id;
|
|
452
|
+
let state = d.state;
|
|
453
|
+
|
|
454
|
+
if (state === "queued") {
|
|
455
|
+
if (opts.noWait) {
|
|
456
|
+
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);
|
|
457
|
+
}
|
|
458
|
+
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
459
|
+
const parked = await waitForLease(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
|
|
460
|
+
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);
|
|
461
|
+
if (parked.auth) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
462
|
+
if (parked.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
463
|
+
state = parked.state || "provisioning";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
|
|
467
|
+
if (state !== "active") {
|
|
468
|
+
if (opts.localExec) {
|
|
469
|
+
let conn;
|
|
470
|
+
try { conn = localProvision(); } catch (e) {
|
|
471
|
+
return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
472
|
+
}
|
|
473
|
+
const act = await callToolJson("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
474
|
+
if (!act.ok || !act.data?.success) {
|
|
475
|
+
return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
const active = await waitForLease(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
|
|
479
|
+
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);
|
|
480
|
+
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);
|
|
481
|
+
if (active.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Authoritative final read (connection block, current state).
|
|
486
|
+
const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
|
|
487
|
+
const g = got.ok && got.data?.success ? got.data : null;
|
|
488
|
+
if (!g || g.state !== "active") {
|
|
489
|
+
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);
|
|
490
|
+
}
|
|
491
|
+
return emit(buildReceipt({
|
|
492
|
+
command: "up", outcome: "active", lease_id: leaseId, state: "active",
|
|
493
|
+
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
494
|
+
idle_ttl_secs: g.idle_ttl_secs, resource_name: g.resource_name,
|
|
495
|
+
}), opts, EXIT.OK);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function cmdStatus(leaseId, opts) {
|
|
499
|
+
const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
|
|
500
|
+
if (!got.ok) return got.auth
|
|
501
|
+
? emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.AUTH)
|
|
502
|
+
: emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
503
|
+
const g = got.data;
|
|
504
|
+
if (!g.success) return emit(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
|
|
505
|
+
return emit(buildReceipt({
|
|
506
|
+
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
507
|
+
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
508
|
+
queue_position: g.queue_position, idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
509
|
+
}), opts, EXIT.OK);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function cmdTouch(leaseId, opts) {
|
|
513
|
+
const r = await callToolJson("touch_stack_lease", { lease_id: leaseId });
|
|
514
|
+
if (!r.ok) return emit(buildReceipt({ command: "touch", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
515
|
+
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);
|
|
516
|
+
return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function cmdDone(leaseId, opts) {
|
|
520
|
+
const r = await callToolJson("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
|
|
521
|
+
if (!r.ok) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
522
|
+
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);
|
|
523
|
+
let state = r.data.state;
|
|
524
|
+
if (opts.localExec && state === "reaping") {
|
|
525
|
+
// Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
|
|
526
|
+
// unavailable, etc.) must NOT finalize — finalize frees the slot lock and promotes
|
|
527
|
+
// the next queued lease, which would collide with containers still running on this
|
|
528
|
+
// slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
|
|
529
|
+
// the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
|
|
530
|
+
if (!localTeardown()) {
|
|
531
|
+
return emit(buildReceipt({
|
|
532
|
+
command: "done", outcome: "error", lease_id: leaseId, state,
|
|
533
|
+
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.",
|
|
534
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
535
|
+
}
|
|
536
|
+
const fin = await callToolJson("finalize_stack_lease", { lease_id: leaseId });
|
|
537
|
+
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
538
|
+
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
539
|
+
}
|
|
540
|
+
return emit(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition }), opts, EXIT.OK);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|
|
544
|
+
export async function cmdStack(argv) {
|
|
545
|
+
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
546
|
+
process.stdout.write(STACK_HELP + "\n");
|
|
547
|
+
return EXIT.OK;
|
|
548
|
+
}
|
|
549
|
+
const { command, leaseId, opts, errors } = parseStackArgs(argv);
|
|
550
|
+
if (errors.length) {
|
|
551
|
+
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
|
552
|
+
const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
|
|
553
|
+
process.exitCode = code;
|
|
554
|
+
return code;
|
|
555
|
+
}
|
|
556
|
+
let code;
|
|
557
|
+
try {
|
|
558
|
+
switch (command) {
|
|
559
|
+
case "up": code = await cmdUp(opts); break;
|
|
560
|
+
case "status": code = await cmdStatus(leaseId, opts); break;
|
|
561
|
+
case "touch": code = await cmdTouch(leaseId, opts); break;
|
|
562
|
+
case "done": code = await cmdDone(leaseId, opts); break;
|
|
563
|
+
default:
|
|
564
|
+
process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
|
|
565
|
+
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `internal: ${err?.message ?? err}` }), opts, EXIT.INTERNAL);
|
|
569
|
+
}
|
|
570
|
+
process.exitCode = code;
|
|
571
|
+
return code;
|
|
572
|
+
}
|