@botbuddy/cli 1.58.1 → 1.59.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/package.json +1 -1
- package/src/pw/session.mjs +7 -2
- package/src/run.mjs +5 -3
- package/src/stack-lease-correlation.mjs +31 -0
- package/src/stack-run-outcome.mjs +164 -0
- package/src/stack.mjs +367 -18
- package/src/wait-core.mjs +66 -0
- package/src/wait.mjs +9 -6
package/package.json
CHANGED
package/src/pw/session.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// recorded. Typed/filled values, @ENV secrets, query strings, fragments, raw
|
|
14
14
|
// Playwright output and error text never reach state, outbox or the server.
|
|
15
15
|
import { createHash, randomUUID } from "node:crypto";
|
|
16
|
+
import { stackLeaseIdFromEnv } from "../stack-lease-correlation.mjs";
|
|
16
17
|
import { chmod, link, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
17
18
|
import { homedir } from "node:os";
|
|
18
19
|
import { dirname, join } from "node:path";
|
|
@@ -330,7 +331,7 @@ function actionView(action) {
|
|
|
330
331
|
|
|
331
332
|
// ── Lane tracker ─────────────────────────────────────────────────────────────
|
|
332
333
|
|
|
333
|
-
function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null }) {
|
|
334
|
+
function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null, env = process.env }) {
|
|
334
335
|
const statePath = sessionStatePath({ root, tenant, repository, host, lane });
|
|
335
336
|
const stateLock = `${statePath}.lock`;
|
|
336
337
|
const execLock = `${statePath}.exec.lock`;
|
|
@@ -446,6 +447,9 @@ function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, de
|
|
|
446
447
|
if (git.branch && git.branch !== "HEAD") context.branch = String(git.branch);
|
|
447
448
|
if (git.prNumber !== null && git.prNumber !== undefined && git.prNumber !== "") context.pr = String(git.prNumber);
|
|
448
449
|
if (git.sha) context.sha = String(git.sha);
|
|
450
|
+
// BOT-2467: a browser session inside `bb stack run` is linked to that lease.
|
|
451
|
+
const leaseId = stackLeaseIdFromEnv(env);
|
|
452
|
+
if (leaseId) context.correlations = { stack_lease_id: leaseId };
|
|
449
453
|
const state = {
|
|
450
454
|
schema_version: 1, tenant, repository, host, lane: String(lane), producer_kind: PRODUCER_KIND, attempt: 1,
|
|
451
455
|
source_run_id: randomUUID(), status: "active", environment, context, started_at: startedAt, next_sequence: 0,
|
|
@@ -966,10 +970,11 @@ export function createBrowserTelemetryResolver({
|
|
|
966
970
|
now = () => new Date(), monotonic = () => performance.now(), gitInfo = () => ({}), spawnHeartbeat = null,
|
|
967
971
|
onAppended = null, // test seam: observe each durable append (e.g. to simulate a crash right after it)
|
|
968
972
|
onStateWrite = null, // test seam: inject a state-write failure
|
|
973
|
+
env = process.env, // BOT-2467: where BOTBUDDY_STACK_LEASE_ID is read from
|
|
969
974
|
} = {}) {
|
|
970
975
|
let outboxPromise = null;
|
|
971
976
|
const getOutbox = () => (outboxPromise ??= createTelemetryOutbox({ root: join(root, "telemetry"), tenant, repository, producerVersion, now }));
|
|
972
|
-
const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite });
|
|
977
|
+
const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite, env });
|
|
973
978
|
return {
|
|
974
979
|
async resolve({ host, lane }) { return { tracked: true, tracker: trackerFor({ host, lane }) }; },
|
|
975
980
|
async forHeartbeat(info) { return trackerFor({ host: info.host, lane: info.lane }); },
|
package/src/run.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// mechanism, never the owner of a child process.
|
|
8
8
|
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-correlation.mjs";
|
|
10
11
|
import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
12
13
|
import { homedir } from "node:os";
|
|
@@ -105,7 +106,7 @@ function signalExitCode(signal) {
|
|
|
105
106
|
// runner. It has exactly one durable pre-spawn boundary (the outbox start
|
|
106
107
|
// event), then returns the child verdict even when terminal delivery degrades.
|
|
107
108
|
export async function launchForegroundRun(argv, {
|
|
108
|
-
cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process,
|
|
109
|
+
cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process, env = process.env,
|
|
109
110
|
} = {}) {
|
|
110
111
|
const { opts, command, errors } = parseRunArgs(argv);
|
|
111
112
|
if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
|
|
@@ -138,13 +139,14 @@ export async function launchForegroundRun(argv, {
|
|
|
138
139
|
).catch(() => {});
|
|
139
140
|
}
|
|
140
141
|
const runId = randomUUID();
|
|
141
|
-
|
|
142
|
+
// BOT-2467: a run inside `bb stack run` is linked to that lease (uuid-validated).
|
|
143
|
+
const context = withStackLeaseCorrelation({
|
|
142
144
|
repo: identity.repository,
|
|
143
145
|
environment: opts.environment,
|
|
144
146
|
runner: identity.appSlug,
|
|
145
147
|
phase: opts.kind === "wait" ? "waiting" : "testing",
|
|
146
148
|
health: "uninstrumented",
|
|
147
|
-
};
|
|
149
|
+
}, stackLeaseIdFromEnv(env));
|
|
148
150
|
const base = {
|
|
149
151
|
schema_version: 1,
|
|
150
152
|
producer_kind: opts.kind,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// BOT-2467 — link an execution to the `bb stack run` lease it ran under.
|
|
2
|
+
//
|
|
3
|
+
// `bb stack run` exports BOTBUDDY_STACK_LEASE_ID to its child. Every execution
|
|
4
|
+
// producer (lefthook lane telemetry, `bb run`, `bb pw`) stamps it on the lifecycle
|
|
5
|
+
// context as `correlations.stack_lease_id`, which execution-ingest stores on
|
|
6
|
+
// `execution_runs.stack_lease_id`. Only a UUID is forwarded; anything else is
|
|
7
|
+
// dropped (never sent for the server to reject the whole event). Dependency-free.
|
|
8
|
+
|
|
9
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
|
|
11
|
+
/** The lease id when it is a UUID (lower-cased), else null. */
|
|
12
|
+
export function validStackLeaseId(value) {
|
|
13
|
+
return typeof value === "string" && UUID_RE.test(value) ? value.toLowerCase() : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The lease this process runs under, from `$BOTBUDDY_STACK_LEASE_ID`, or null. */
|
|
17
|
+
export function stackLeaseIdFromEnv(env = process.env) {
|
|
18
|
+
return validStackLeaseId(env?.BOTBUDDY_STACK_LEASE_ID);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `context` with `correlations.stack_lease_id` set to `leaseId` (other correlations
|
|
23
|
+
* kept). Returns `context` unchanged when the id is not a UUID.
|
|
24
|
+
*/
|
|
25
|
+
export function withStackLeaseCorrelation(context, leaseId) {
|
|
26
|
+
const id = validStackLeaseId(leaseId);
|
|
27
|
+
if (!id) return context;
|
|
28
|
+
const correlations = context?.correlations && typeof context.correlations === "object" && !Array.isArray(context.correlations)
|
|
29
|
+
? context.correlations : {};
|
|
30
|
+
return { ...context, correlations: { ...correlations, stack_lease_id: id } };
|
|
31
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// BOT-2467 — one secret-free outcome record per `bb stack run`.
|
|
2
|
+
//
|
|
3
|
+
// When a push goes red under `bb stack run` the stdout receipt already says whether
|
|
4
|
+
// the stack broke (a lease/transport/auth failure) or the wrapped command failed
|
|
5
|
+
// (`child_exit_code` decided the exit). Nothing persisted that split. After the
|
|
6
|
+
// receipt is on stdout, `cmdRun` hands the receipt fields to `reportStackRunOutcome`,
|
|
7
|
+
// which makes ONE best-effort `record_stack_run_outcome` MCP call with the same
|
|
8
|
+
// credential the lease calls used. It is capped at 2 s and every failure (timeout,
|
|
9
|
+
// 4xx/5xx, unknown tool on an older server, a thrown error) is a silent drop: the
|
|
10
|
+
// receipt and the exit code are never touched.
|
|
11
|
+
//
|
|
12
|
+
// The payload is an allowlist built here — never the receipt itself — so no token,
|
|
13
|
+
// path, hostname, env value, argv or command output can reach the server. The
|
|
14
|
+
// server re-validates the same allowlist (supabase/functions/_shared/lib/stackRunOutcome.ts).
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { VERSION } from "./version.mjs";
|
|
18
|
+
import { validStackLeaseId } from "./stack-lease-correlation.mjs";
|
|
19
|
+
|
|
20
|
+
export { validStackLeaseId };
|
|
21
|
+
|
|
22
|
+
export const STACK_RUN_OUTCOME_TOOL = "record_stack_run_outcome";
|
|
23
|
+
export const STACK_RUN_OUTCOME_TIMEOUT_MS = 2000;
|
|
24
|
+
/** `=off` disables the record (the CLI test suites set it; nothing else should). */
|
|
25
|
+
export const STACK_RUN_OUTCOME_OPT_OUT_ENV = "BOTBUDDY_STACK_RUN_OUTCOME";
|
|
26
|
+
export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation"]);
|
|
27
|
+
|
|
28
|
+
const CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
29
|
+
const TOKEN_RE = /^[a-z0-9_-]{1,64}$/;
|
|
30
|
+
const LANE_RE = /^[A-Za-z0-9:_.-]{1,128}$/;
|
|
31
|
+
const TICKET_RE = /^[A-Za-z][A-Za-z0-9]*-\d{1,9}$/;
|
|
32
|
+
const REPO_RE = /^[A-Za-z0-9._/-]{1,128}$/;
|
|
33
|
+
const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
|
|
34
|
+
|
|
35
|
+
// Mirrors stack.mjs EXIT (kept local so this module stays dependency-free for tests).
|
|
36
|
+
const EXIT_TIMEOUT = 2;
|
|
37
|
+
const EXIT_AUTH = 3;
|
|
38
|
+
const EXIT_INVALID = 4;
|
|
39
|
+
const EXIT_BACKEND = 5;
|
|
40
|
+
const EXIT_LEASE_FAILED = 6;
|
|
41
|
+
const EXIT_INTERNAL = 7;
|
|
42
|
+
const EXIT_CLEANUP_FAILED = 8;
|
|
43
|
+
const SIGNAL_EXITS = new Set([129, 130, 143]);
|
|
44
|
+
// Typed codes that are the platform's fault whatever the exit code says.
|
|
45
|
+
const PLATFORM_CODES = new Set(["TUNNEL_LOST", "LEASE_PREEMPTED", "HOST_CAPACITY_SOURCE_STALE"]);
|
|
46
|
+
|
|
47
|
+
function boundedCode(value) {
|
|
48
|
+
return typeof value === "string" && CODE_RE.test(value) ? value : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Classify one `bb stack run` terminal from its receipt fields (`runReceiptFields`
|
|
53
|
+
* output, or the argument-error receipt) and its exit code. Pure.
|
|
54
|
+
*
|
|
55
|
+
* script — the lease was active and the child's exit decided the result
|
|
56
|
+
* (`--hard-ttl` stop → code HARD_TTL)
|
|
57
|
+
* platform — the stack, its transport, the credential or the backend failed
|
|
58
|
+
* interrupted — a signal ended the run (operator Ctrl-C / parent kill)
|
|
59
|
+
* invocation — exit 4: bad arguments or a refused request before any lease
|
|
60
|
+
*/
|
|
61
|
+
export function classifyStackRunOutcome(fields = {}, exitCode) {
|
|
62
|
+
const code = boundedCode(fields.code);
|
|
63
|
+
const child = Number.isInteger(fields.child_exit_code) ? fields.child_exit_code : null;
|
|
64
|
+
const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
|
|
65
|
+
if (outcome === "interrupted") return { category: "interrupted", code };
|
|
66
|
+
if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
|
|
67
|
+
if (fields.fenced === true) return { category: "platform", code: code ?? "LEASE_LOST" };
|
|
68
|
+
if (outcome === "completed" && exitCode === 0) return { category: "script", code: null };
|
|
69
|
+
if (outcome === "failed" && code == null && child != null) {
|
|
70
|
+
if (SIGNAL_EXITS.has(exitCode)) return { category: "interrupted", code: null };
|
|
71
|
+
// The child's exit decided the push; an unproven teardown on top of it is still
|
|
72
|
+
// named (CLEANUP_FAILED) so the platform half is never silently lost.
|
|
73
|
+
if (exitCode === child) return { category: "script", code: fields.cleanup === false ? "CLEANUP_FAILED" : null };
|
|
74
|
+
if (exitCode === EXIT_TIMEOUT) return { category: "script", code: "HARD_TTL" };
|
|
75
|
+
if (exitCode === EXIT_CLEANUP_FAILED) return { category: "platform", code: "CLEANUP_FAILED" };
|
|
76
|
+
}
|
|
77
|
+
if (exitCode === EXIT_INVALID) return { category: "invocation", code: code ?? "INVALID_ARGUMENTS" };
|
|
78
|
+
if (SIGNAL_EXITS.has(exitCode) && code == null) return { category: "interrupted", code: null };
|
|
79
|
+
switch (exitCode) {
|
|
80
|
+
// An admitted lease whose stack never reached active is a provisioning failure, not
|
|
81
|
+
// a capacity shortage (`timeout_phase` comes from stackRunOutcomeFields).
|
|
82
|
+
case EXIT_TIMEOUT: return { category: "platform", code: code ?? (fields.timeout_phase === "provision" ? "PROVISION_TIMEOUT" : "CAPACITY_TIMEOUT") };
|
|
83
|
+
case EXIT_AUTH: return { category: "platform", code: code ?? "AUTH" };
|
|
84
|
+
case EXIT_BACKEND: return { category: "platform", code: code ?? "BACKEND_UNREACHABLE" };
|
|
85
|
+
case EXIT_LEASE_FAILED: return { category: "platform", code: code ?? "LEASE_FAILED" };
|
|
86
|
+
case EXIT_CLEANUP_FAILED: return { category: "platform", code: "CLEANUP_FAILED" };
|
|
87
|
+
case EXIT_INTERNAL: return { category: "platform", code: code ?? "INTERNAL" };
|
|
88
|
+
default: return { category: "platform", code: code ?? "UNKNOWN" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The lane id from `$BOTBUDDY_LANE_SOURCE_RUN_ID` (`lefthook:<invocation>:<lane>`), or null. */
|
|
93
|
+
export function laneFromSourceRunId(value) {
|
|
94
|
+
if (typeof value !== "string" || !value.startsWith("lefthook:")) return null;
|
|
95
|
+
const parts = value.split(":");
|
|
96
|
+
if (parts.length < 3) return null;
|
|
97
|
+
const lane = parts.slice(2).join(":");
|
|
98
|
+
return LANE_RE.test(lane) ? lane : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const pick = (value, re) => (typeof value === "string" && re.test(value) ? value : null);
|
|
102
|
+
const exitInt = (value) => (Number.isInteger(value) && value >= 0 && value <= 255 ? value : null);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The allowlisted payload for one run. Every field is bounded and shape-checked here
|
|
106
|
+
* so the call carries nothing the server would refuse — and nothing secret.
|
|
107
|
+
*/
|
|
108
|
+
export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = randomUUID(), opts = {}, env = process.env, durationMs = null }) {
|
|
109
|
+
const { category, code } = classifyStackRunOutcome(fields, exitCode);
|
|
110
|
+
return {
|
|
111
|
+
run_id: runId,
|
|
112
|
+
lease_id: validStackLeaseId(fields.lease_id),
|
|
113
|
+
outcome: pick(fields.outcome, TOKEN_RE) ?? "unknown",
|
|
114
|
+
code,
|
|
115
|
+
category,
|
|
116
|
+
exit_code: exitInt(exitCode) ?? 255,
|
|
117
|
+
child_exit_code: exitInt(fields.child_exit_code),
|
|
118
|
+
park_reason: pick(fields.park_reason, TOKEN_RE),
|
|
119
|
+
execution_mode: pick(fields.execution_mode, TOKEN_RE),
|
|
120
|
+
transport: pick(fields.transport, TOKEN_RE),
|
|
121
|
+
fenced: fields.fenced === true,
|
|
122
|
+
lane: laneFromSourceRunId(env.BOTBUDDY_LANE_SOURCE_RUN_ID),
|
|
123
|
+
ticket: pick(opts.ticket, TICKET_RE)?.toUpperCase() ?? null,
|
|
124
|
+
repo: pick(opts.repo, REPO_RE),
|
|
125
|
+
duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? Math.round(durationMs) : null,
|
|
126
|
+
cli_version: pick(VERSION, VERSION_RE) ?? "unknown",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Best-effort record. `channel` is `{ auth, callTool }` from the lifecycle (the
|
|
132
|
+
* credential the lease calls used); without one there is nothing to authenticate
|
|
133
|
+
* with and nothing is sent. Never throws; resolves within `timeoutMs`.
|
|
134
|
+
*/
|
|
135
|
+
export async function reportStackRunOutcome({ fields, exitCode, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
|
|
136
|
+
// Test suites opt out so a spawned real CLI never records a fixture run.
|
|
137
|
+
if (env?.[STACK_RUN_OUTCOME_OPT_OUT_ENV] === "off") return { sent: false, reason: "disabled" };
|
|
138
|
+
if (!channel?.auth || typeof channel.callTool !== "function") return { sent: false, reason: "no_credential" };
|
|
139
|
+
let payload;
|
|
140
|
+
try {
|
|
141
|
+
payload = buildStackRunOutcomePayload({ fields, exitCode, opts, env, durationMs });
|
|
142
|
+
} catch {
|
|
143
|
+
return { sent: false, reason: "payload_error" };
|
|
144
|
+
}
|
|
145
|
+
const ac = new AbortController();
|
|
146
|
+
let timer;
|
|
147
|
+
const TIMED_OUT = Symbol("stack-run-outcome-timeout");
|
|
148
|
+
const timeout = new Promise((resolve) => {
|
|
149
|
+
// Referenced on purpose: a never-settling call holds no handle of its own, and an
|
|
150
|
+
// unref'd timer would let the event loop end before the cap resolves the race.
|
|
151
|
+
// `finally` clears it, so a fast answer never waits on it.
|
|
152
|
+
timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, timeoutMs);
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
const call = Promise.resolve().then(() => channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal }));
|
|
156
|
+
const result = await Promise.race([call.catch(() => null), timeout]);
|
|
157
|
+
if (result === TIMED_OUT) return { sent: false, reason: "timeout", payload };
|
|
158
|
+
return { sent: Boolean(result?.ok && !result?.isError), reason: result?.ok ? null : "refused", payload };
|
|
159
|
+
} catch {
|
|
160
|
+
return { sent: false, reason: "error", payload };
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/stack.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validate
|
|
|
51
51
|
import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
|
|
52
52
|
import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
|
|
53
53
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
54
|
+
import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
|
|
54
55
|
|
|
55
56
|
export const STACK_SCHEMA_VERSION = 1;
|
|
56
57
|
|
|
@@ -133,6 +134,8 @@ ${bold("up OPTIONS")}
|
|
|
133
134
|
deadline (exit 2) and a failed wait (its own exit code) never retry.
|
|
134
135
|
--local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
|
|
135
136
|
stack and self-activate. Refused at the worktree root (shared stack).
|
|
137
|
+
--keep-volumes LOCAL-EXEC only: preserve a reaped stack's volumes; refuses when the
|
|
138
|
+
latest signed Helper receipt requires a one-time recovery stop.
|
|
136
139
|
--provision-timeout N LOCAL-EXEC: seconds 'supabase start' may run (default ${LOCAL_EXEC_PROVISION_TIMEOUT_SEC}). On
|
|
137
140
|
timeout up runs 'stack done --local-exec' itself and exits 6 with
|
|
138
141
|
park_reason provision_timeout (BOT-2154). The server also fails a local-exec
|
|
@@ -327,7 +330,7 @@ export function parseStackArgs(argv) {
|
|
|
327
330
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
328
331
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
|
|
329
332
|
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
330
|
-
connectionFile: null, noWait: false, claimOnly: false, localExec: false, waitCapacity: null, holdOnTimeout: null,
|
|
333
|
+
connectionFile: null, noWait: false, claimOnly: false, localExec: false, keepVolumes: false, waitCapacity: null, holdOnTimeout: null,
|
|
331
334
|
dockerContext: null, dockerEndpoint: null,
|
|
332
335
|
// BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
|
|
333
336
|
execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
|
|
@@ -452,6 +455,7 @@ export function parseStackArgs(argv) {
|
|
|
452
455
|
case "--no-wait": opts.noWait = true; break;
|
|
453
456
|
case "--claim-only": opts.claimOnly = true; break;
|
|
454
457
|
case "--local-exec": opts.localExec = true; break;
|
|
458
|
+
case "--keep-volumes": opts.keepVolumes = true; break;
|
|
455
459
|
case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
|
|
456
460
|
case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
|
|
457
461
|
case "--stop": opts.disposition = "stop"; break;
|
|
@@ -519,6 +523,9 @@ export function parseStackArgs(argv) {
|
|
|
519
523
|
if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
|
|
520
524
|
errors.push("--claim-only is valid only with `stack up --local-exec`");
|
|
521
525
|
}
|
|
526
|
+
if (opts.keepVolumes && (command !== "up" || !opts.localExec)) {
|
|
527
|
+
errors.push("--keep-volumes is valid only with `stack up --local-exec`");
|
|
528
|
+
}
|
|
522
529
|
if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
|
|
523
530
|
errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
|
|
524
531
|
}
|
|
@@ -1396,13 +1403,13 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1396
1403
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
1397
1404
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
1398
1405
|
const st = sig.payload?.state ?? null;
|
|
1399
|
-
if (isFailed(st)) {
|
|
1406
|
+
if (isFailed(st, sig.payload)) {
|
|
1400
1407
|
// BOT-2013: carry the server's reap_reason so the receipt says WHY the lease is gone.
|
|
1401
1408
|
const reapReason = typeof sig.payload?.reap_reason === "string" ? sig.payload.reap_reason : null;
|
|
1402
1409
|
decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
|
|
1403
1410
|
break frameLoop;
|
|
1404
1411
|
}
|
|
1405
|
-
if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
1412
|
+
if (isDone(st, sig.payload)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
1406
1413
|
}
|
|
1407
1414
|
}
|
|
1408
1415
|
} catch (err) {
|
|
@@ -1602,8 +1609,8 @@ export async function parkOnLease(leaseId, isDone, isFailed, {
|
|
|
1602
1609
|
// Codex #1168 P1: an authenticated read of the lease proves the outage is over, so a later,
|
|
1603
1610
|
// independent rotation starts a fresh auth budget instead of inheriting this one.
|
|
1604
1611
|
if (state != null) recovered();
|
|
1605
|
-
if (state != null && isFailed(state)) return done({ failed: true, state, ...(read?.reap_reason ? { reap_reason: read.reap_reason } : {}) });
|
|
1606
|
-
if (state != null && isDone(state)) return done({ woke: true, state });
|
|
1612
|
+
if (state != null && isFailed(state, read)) return done({ failed: true, state, ...(read?.reap_reason ? { reap_reason: read.reap_reason } : {}) });
|
|
1613
|
+
if (state != null && isDone(state, read)) return done({ woke: true, state });
|
|
1607
1614
|
}
|
|
1608
1615
|
}
|
|
1609
1616
|
}
|
|
@@ -2096,6 +2103,60 @@ export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawn
|
|
|
2096
2103
|
return labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log);
|
|
2097
2104
|
}
|
|
2098
2105
|
|
|
2106
|
+
/** BOT-2398: a prior signed `volumes_reusable=false` receipt permits one exact,
|
|
2107
|
+
* bounded Supabase recovery stop before a local-exec start. This deliberately
|
|
2108
|
+
* has no label-scoped fallback: the signed Helper receipt authorizes only this
|
|
2109
|
+
* Supabase project on this pinned daemon, and any uncertainty leaves the lease
|
|
2110
|
+
* fenced without starting. */
|
|
2111
|
+
export async function localVolumeRecoveryStop(opts, execution, dockerTarget, {
|
|
2112
|
+
targetCheck = runLocalExecTargetCheck,
|
|
2113
|
+
spawn = spawnSync,
|
|
2114
|
+
readProjectId = projectIdUnder,
|
|
2115
|
+
acquireLock = acquireStackLock,
|
|
2116
|
+
lockHeld = false,
|
|
2117
|
+
env = process.env,
|
|
2118
|
+
} = {}) {
|
|
2119
|
+
if (!opts?.localExec || !ADMITTED_DOCKER_VALIDATIONS.has(dockerTarget?.validation) ||
|
|
2120
|
+
!dockerTarget?.resolved_endpoint || !dockerTarget?.server_id) return false;
|
|
2121
|
+
const stackDir = stackDirFor(execution);
|
|
2122
|
+
const projectId = readProjectId(stackDir);
|
|
2123
|
+
if (typeof projectId !== "string" || !/^[a-z0-9][a-z0-9_-]{0,62}$/.test(projectId)) return false;
|
|
2124
|
+
let lock;
|
|
2125
|
+
if (!lockHeld) {
|
|
2126
|
+
try { lock = await acquireLock(lockPathForProject(projectId), { timeoutMs: 0 }); }
|
|
2127
|
+
catch { return false; }
|
|
2128
|
+
}
|
|
2129
|
+
try {
|
|
2130
|
+
const currentTarget = async () => {
|
|
2131
|
+
const checked = await targetCheck(opts);
|
|
2132
|
+
return checked?.exitCode === 0 ? dockerTargetFromPreflight(checked.receipt) : null;
|
|
2133
|
+
};
|
|
2134
|
+
const before = await currentTarget();
|
|
2135
|
+
if (!dockerTargetsMatch(dockerTarget, before)) return false;
|
|
2136
|
+
const dockerEnv = dockerEnvForTarget(dockerTarget, env);
|
|
2137
|
+
const stop = spawn("supabase", ["stop", "--workdir", stackDir, "--no-backup"], {
|
|
2138
|
+
encoding: "utf8", env: dockerEnv, timeout: 120_000, killSignal: "SIGTERM",
|
|
2139
|
+
stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024,
|
|
2140
|
+
});
|
|
2141
|
+
if (stop?.error || stop?.status !== 0) return false;
|
|
2142
|
+
const after = await currentTarget();
|
|
2143
|
+
if (!dockerTargetsMatch(dockerTarget, after)) return false;
|
|
2144
|
+
const listProjectResources = (args) => spawn("docker", args, {
|
|
2145
|
+
encoding: "utf8", env: dockerEnv, timeout: 30_000, killSignal: "SIGTERM",
|
|
2146
|
+
stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1024 * 1024,
|
|
2147
|
+
});
|
|
2148
|
+
const containers = listProjectResources(["ps", "-aq", "--filter", `label=com.supabase.cli.project=${projectId}`]);
|
|
2149
|
+
if (containers?.error || containers?.status !== 0 || String(containers.stdout || "").trim()) return false;
|
|
2150
|
+
const volumes = listProjectResources(["volume", "ls", "-q", "--filter", `label=com.supabase.cli.project=${projectId}`]);
|
|
2151
|
+
if (volumes?.error || volumes?.status !== 0 || String(volumes.stdout || "").trim()) return false;
|
|
2152
|
+
const networks = listProjectResources(["network", "ls", "-q", "--filter", `label=com.supabase.cli.project=${projectId}`]);
|
|
2153
|
+
if (networks?.error || networks?.status !== 0 || String(networks.stdout || "").trim()) return false;
|
|
2154
|
+
return true;
|
|
2155
|
+
} finally {
|
|
2156
|
+
lock?.release?.();
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2099
2160
|
/** BOT-2001: remove exactly one Supabase project's containers, network and (for `destroy`)
|
|
2100
2161
|
* volumes by the CLI's project label; true iff a re-list finds NONE of the resource classes
|
|
2101
2162
|
* this disposition removes (Codex #1012: a busy volume or network fails closed too). */
|
|
@@ -2146,9 +2207,17 @@ export async function cmdUp(opts, {
|
|
|
2146
2207
|
callTool = callToolJson,
|
|
2147
2208
|
authProvider = stackAuthHeader,
|
|
2148
2209
|
localProvisionFn = localProvision,
|
|
2210
|
+
// BOT-2398: exactly one fixed `supabase stop --no-backup` against the
|
|
2211
|
+
// validated stack directory/engine, only when a signed prior reap requires it.
|
|
2212
|
+
// Keep the fourth argument: the caller holds the project lock across this
|
|
2213
|
+
// stop and the immediately following start, so the recovery primitive must
|
|
2214
|
+
// not try to acquire the same lock recursively.
|
|
2215
|
+
recoveryStopFn = localVolumeRecoveryStop,
|
|
2149
2216
|
// BOT-2154: the self-release after a timed-out local start (`stack done --local-exec`).
|
|
2150
2217
|
localDoneFn = null,
|
|
2151
2218
|
waitFn = waitForLease,
|
|
2219
|
+
// BOT-2398 focused seam; production uses the existing lease SSE park below.
|
|
2220
|
+
waitHandoffFn = null,
|
|
2152
2221
|
emitResult = emit,
|
|
2153
2222
|
machineUuidFn = machineUuid,
|
|
2154
2223
|
assertIsolated = validateLocalExecTarget,
|
|
@@ -2459,10 +2528,181 @@ export async function cmdUp(opts, {
|
|
|
2459
2528
|
const reservedProjectId = projectIdUnder(stackDirFor(execution));
|
|
2460
2529
|
const reservedConnection = connectionWithDockerTarget(
|
|
2461
2530
|
reservedProjectId ? { project_id: reservedProjectId } : {}, localDockerTarget);
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2531
|
+
let volumeRecoveryRequired = false;
|
|
2532
|
+
let provisionReserved = false;
|
|
2533
|
+
if (d.volume_recovery_protocol === 1) {
|
|
2534
|
+
const priorRead = await call("get_stack_lease", { lease_id: leaseId });
|
|
2535
|
+
const current = priorRead?.ok && priorRead.data?.success ? priorRead.data : null;
|
|
2536
|
+
const exactCurrentContext = current && current.state === "provisioning" &&
|
|
2537
|
+
current.host_key === d.host_key && current.slot === slot &&
|
|
2538
|
+
current.worktree_root === execution.worktreeRoot && current.stack_path === execution.stackPath &&
|
|
2539
|
+
current.execution_mode === "local_exec";
|
|
2540
|
+
if (!exactCurrentContext) {
|
|
2541
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2542
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
2543
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2544
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
2545
|
+
return emitResult(buildReceipt({
|
|
2546
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_CONTEXT_UNVERIFIED", lease_id: leaseId, slot,
|
|
2547
|
+
error: "could not verify the exact local-exec host/slot/worktree before checking volume-recovery state",
|
|
2548
|
+
lease_cancellation: leaseCancellation,
|
|
2549
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2550
|
+
}
|
|
2551
|
+
const hasPriorSource = Object.prototype.hasOwnProperty.call(current, "prior_volume_source_lease_id");
|
|
2552
|
+
const hasPriorDecision = Object.prototype.hasOwnProperty.call(current, "prior_volumes_reusable");
|
|
2553
|
+
const priorSource = current.prior_volume_source_lease_id;
|
|
2554
|
+
if (!hasPriorSource || !hasPriorDecision ||
|
|
2555
|
+
(priorSource !== null && (typeof priorSource !== "string" || !priorSource.trim()))) {
|
|
2556
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2557
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
2558
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2559
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
2560
|
+
return emitResult(buildReceipt({
|
|
2561
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_HISTORY_UNVERIFIED", lease_id: leaseId, slot,
|
|
2562
|
+
error: "the server omitted or malformed prior local-exec history; no recovery or start was attempted",
|
|
2563
|
+
lease_cancellation: leaseCancellation,
|
|
2564
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2565
|
+
}
|
|
2566
|
+
const reuseState = current.prior_volumes_reusable;
|
|
2567
|
+
if (reuseState !== undefined && reuseState !== null && typeof reuseState !== "boolean") {
|
|
2568
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2569
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
2570
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2571
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
2572
|
+
return emitResult(buildReceipt({
|
|
2573
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_STATE_INVALID", lease_id: leaseId, slot,
|
|
2574
|
+
error: "the server returned an invalid prior volume-reuse state; no recovery or start was attempted",
|
|
2575
|
+
lease_cancellation: leaseCancellation,
|
|
2576
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2577
|
+
}
|
|
2578
|
+
if (priorSource !== null && reuseState !== true && reuseState !== false) {
|
|
2579
|
+
// The Helper handoff is bound to the already-reserved ordinary
|
|
2580
|
+
// provision job. Reserve it before enqueueing the distinct control
|
|
2581
|
+
// action; neither job may race the local start.
|
|
2582
|
+
const handoffReservation = await call("reserve_stack_lease", {
|
|
2583
|
+
lease_id: leaseId, connection: reservedConnection,
|
|
2584
|
+
});
|
|
2585
|
+
if (!handoffReservation.ok || handoffReservation.data?.success !== true) {
|
|
2586
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", code: "PREDECESSOR_HANDOFF_RESERVATION_FAILED",
|
|
2587
|
+
lease_id: leaseId, slot, error: "the local provision job could not be reserved before requesting the Helper fence handoff; local start was not attempted" }),
|
|
2588
|
+
opts, EXIT.LEASE_FAILED);
|
|
2589
|
+
}
|
|
2590
|
+
provisionReserved = true;
|
|
2591
|
+
const requestedHandoff = await call("request_stack_lease_predecessor_handoff", { lease_id: leaseId });
|
|
2592
|
+
if (!requestedHandoff.ok || requestedHandoff.data?.success !== true ||
|
|
2593
|
+
requestedHandoff.data?.handoff_required !== true ||
|
|
2594
|
+
typeof requestedHandoff.data?.handoff_job_id !== "string" ||
|
|
2595
|
+
!requestedHandoff.data.handoff_job_id.trim()) {
|
|
2596
|
+
const cancelled = requestedHandoff.ok && requestedHandoff.data?.success === true &&
|
|
2597
|
+
requestedHandoff.data?.handoff_required === false
|
|
2598
|
+
? await call("cancel_unclaimed_stack_lease", { lease_id: leaseId }) : null;
|
|
2599
|
+
const leaseCancellation = cancelled
|
|
2600
|
+
? (cancelled.ok && cancelled.data?.success
|
|
2601
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2602
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" })
|
|
2603
|
+
: null;
|
|
2604
|
+
return emitResult(buildReceipt({
|
|
2605
|
+
command: "up", outcome: "refused", code: "PREDECESSOR_HANDOFF_UNAVAILABLE", lease_id: leaseId, slot,
|
|
2606
|
+
error: "the owner-authenticated Helper could not prove an accepted exact schema-1 predecessor handoff; refusing recovery stop and local start",
|
|
2607
|
+
...(leaseCancellation ? { lease_cancellation: leaseCancellation } : {}),
|
|
2608
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2609
|
+
}
|
|
2610
|
+
const handoffJobID = requestedHandoff.data.handoff_job_id;
|
|
2611
|
+
const isAcceptedHandoff = (value) => value?.state === "provisioning" &&
|
|
2612
|
+
value?.predecessor_handoff_job_id === handoffJobID &&
|
|
2613
|
+
value?.predecessor_handoff_status === "completed" &&
|
|
2614
|
+
value?.predecessor_handoff_outcome === "succeeded";
|
|
2615
|
+
const isFailedHandoff = (value) => value?.state === "provisioning" &&
|
|
2616
|
+
value?.predecessor_handoff_job_id === handoffJobID &&
|
|
2617
|
+
["failed", "stale"].includes(value?.predecessor_handoff_status);
|
|
2618
|
+
let handoffState = await call("get_stack_lease", { lease_id: leaseId });
|
|
2619
|
+
if (!handoffState.ok || handoffState.data?.success !== true ||
|
|
2620
|
+
(handoffState.data.predecessor_handoff_job_id != null &&
|
|
2621
|
+
handoffState.data.predecessor_handoff_job_id !== handoffJobID)) {
|
|
2622
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", code: "PREDECESSOR_HANDOFF_STATE_UNVERIFIED",
|
|
2623
|
+
lease_id: leaseId, slot, error: "the exact Helper handoff job status could not be verified; local start was not attempted" }),
|
|
2624
|
+
opts, EXIT.LEASE_FAILED);
|
|
2625
|
+
}
|
|
2626
|
+
if (!isAcceptedHandoff(handoffState.data) && !isFailedHandoff(handoffState.data)) {
|
|
2627
|
+
const waited = waitHandoffFn
|
|
2628
|
+
? await waitHandoffFn(leaseId, handoffJobID)
|
|
2629
|
+
: await resilientWait(
|
|
2630
|
+
(leaseState, payload) => leaseState === "provisioning" &&
|
|
2631
|
+
payload?.predecessor_handoff_job_id === handoffJobID &&
|
|
2632
|
+
payload?.predecessor_handoff_status === "completed" &&
|
|
2633
|
+
payload?.predecessor_handoff_outcome === "succeeded",
|
|
2634
|
+
(leaseState, payload) => leaseState === "reap_failed" ||
|
|
2635
|
+
(leaseState === "provisioning" && payload?.predecessor_handoff_job_id === handoffJobID &&
|
|
2636
|
+
["failed", "stale"].includes(payload?.predecessor_handoff_status)),
|
|
2637
|
+
);
|
|
2638
|
+
if (!waited?.woke) {
|
|
2639
|
+
return emitResult(buildReceipt({ command: "up", outcome: waited?.timeout ? "timeout" : "error",
|
|
2640
|
+
code: waited?.timeout ? "PREDECESSOR_HANDOFF_TIMEOUT" : "PREDECESSOR_HANDOFF_FAILED",
|
|
2641
|
+
lease_id: leaseId, slot, error: "the Helper predecessor handoff did not reach accepted success; recovery stop and local start were not attempted" }),
|
|
2642
|
+
opts, waited?.timeout ? EXIT.TIMEOUT : EXIT.LEASE_FAILED);
|
|
2643
|
+
}
|
|
2644
|
+
handoffState = await call("get_stack_lease", { lease_id: leaseId });
|
|
2645
|
+
}
|
|
2646
|
+
if (!handoffState.ok || !isAcceptedHandoff(handoffState.data)) {
|
|
2647
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", code: "PREDECESSOR_HANDOFF_FAILED",
|
|
2648
|
+
lease_id: leaseId, slot, error: "the exact Helper handoff has no accepted successful terminal receipt; recovery stop and local start were not attempted" }),
|
|
2649
|
+
opts, EXIT.LEASE_FAILED);
|
|
2650
|
+
}
|
|
2651
|
+
// The signed Helper acknowledgement rebinds exact local custody,
|
|
2652
|
+
// but schema 1 did not record whether old volumes were reusable. A
|
|
2653
|
+
// recovery stop would be destructive, so honour --keep-volumes before
|
|
2654
|
+
// it can be attempted.
|
|
2655
|
+
if (opts.keepVolumes) {
|
|
2656
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2657
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
2658
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2659
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
2660
|
+
return emitResult(buildReceipt({
|
|
2661
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_REQUIRED", lease_id: leaseId, slot,
|
|
2662
|
+
error: "the accepted predecessor handoff has no reusable-volume decision; --keep-volumes prevents the required recovery stop",
|
|
2663
|
+
lease_cancellation: leaseCancellation,
|
|
2664
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2665
|
+
}
|
|
2666
|
+
// Run the established bounded no-backup stop under the project lock
|
|
2667
|
+
// before starting this exact successor.
|
|
2668
|
+
volumeRecoveryRequired = true;
|
|
2669
|
+
}
|
|
2670
|
+
if (reuseState === false || reuseState === true) {
|
|
2671
|
+
const priorProject = current.prior_project_id;
|
|
2672
|
+
const priorTarget = current.prior_docker_target;
|
|
2673
|
+
const exactPriorTarget = priorSource !== null && priorProject === reservedProjectId &&
|
|
2674
|
+
dockerTargetsMatch(priorTarget, localDockerTarget);
|
|
2675
|
+
if (!exactPriorTarget) {
|
|
2676
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2677
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
2678
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2679
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
2680
|
+
return emitResult(buildReceipt({
|
|
2681
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_TARGET_MISMATCH", lease_id: leaseId, slot,
|
|
2682
|
+
error: "the prior signed non-reusable-volume receipt is for a different or incomplete Docker target/project; refusing to start",
|
|
2683
|
+
lease_cancellation: leaseCancellation,
|
|
2684
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2685
|
+
}
|
|
2686
|
+
if (reuseState === false && opts.keepVolumes) {
|
|
2687
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2688
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
2689
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2690
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
2691
|
+
return emitResult(buildReceipt({
|
|
2692
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_REQUIRED", lease_id: leaseId, slot,
|
|
2693
|
+
error: "the exact prior Helper receipt marked volumes non-reusable; --keep-volumes prevents the required recovery stop",
|
|
2694
|
+
lease_cancellation: leaseCancellation,
|
|
2695
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2696
|
+
}
|
|
2697
|
+
volumeRecoveryRequired = reuseState === false;
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
const reserved = provisionReserved
|
|
2701
|
+
? { ok: true, data: { success: true, reserved: true } }
|
|
2702
|
+
: await call("reserve_stack_lease", {
|
|
2703
|
+
lease_id: leaseId,
|
|
2704
|
+
connection: reservedConnection,
|
|
2705
|
+
});
|
|
2466
2706
|
if (!reserved.ok || !reserved.data?.success) {
|
|
2467
2707
|
// A Helper can win the reservation race by taking the queued provision
|
|
2468
2708
|
// job between `request_stack_lease` and `reserve_stack_lease`. That is NOT
|
|
@@ -2504,10 +2744,46 @@ export async function cmdUp(opts, {
|
|
|
2504
2744
|
}), opts, EXIT.LEASE_FAILED);
|
|
2505
2745
|
}
|
|
2506
2746
|
} else {
|
|
2747
|
+
let recoveryLock = null;
|
|
2748
|
+
if (volumeRecoveryRequired) {
|
|
2749
|
+
if (opts.keepVolumes) {
|
|
2750
|
+
const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
2751
|
+
return emitResult(buildReceipt({
|
|
2752
|
+
command: "up", outcome: "refused", code: "VOLUME_RECOVERY_REQUIRED", lease_id: leaseId, slot,
|
|
2753
|
+
error: "the exact predecessor handoff requires a recovery stop; --keep-volumes prevents the required recovery",
|
|
2754
|
+
lease_cancellation: cancelled.ok && cancelled.data?.success
|
|
2755
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
2756
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" },
|
|
2757
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2758
|
+
}
|
|
2759
|
+
if (typeof reservedProjectId !== "string" || !/^[a-z0-9][a-z0-9_-]{0,62}$/.test(reservedProjectId)) {
|
|
2760
|
+
return emitResult(buildReceipt({
|
|
2761
|
+
command: "up", outcome: "error", code: "VOLUME_RECOVERY_PROJECT_UNVERIFIED", lease_id: leaseId, slot,
|
|
2762
|
+
error: "the isolated stack project id is unavailable; the recovery stop and supabase start were not attempted",
|
|
2763
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2764
|
+
}
|
|
2765
|
+
try { recoveryLock = await acquireStackLock(lockPathForProject(reservedProjectId), { timeoutMs: 0 }); }
|
|
2766
|
+
catch {
|
|
2767
|
+
return emitResult(buildReceipt({
|
|
2768
|
+
command: "up", outcome: "error", code: "VOLUME_RECOVERY_PROJECT_BUSY", lease_id: leaseId, slot,
|
|
2769
|
+
error: "the isolated stack project lock is held; the recovery stop and supabase start were not attempted",
|
|
2770
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2771
|
+
}
|
|
2772
|
+
let recovered = false;
|
|
2773
|
+
try { recovered = await recoveryStopFn(opts, execution, localDockerTarget, { lockHeld: true }) === true; } catch { recovered = false; }
|
|
2774
|
+
if (!recovered) {
|
|
2775
|
+
recoveryLock.release();
|
|
2776
|
+
return emitResult(buildReceipt({
|
|
2777
|
+
command: "up", outcome: "error", code: "VOLUME_RECOVERY_STOP_FAILED", lease_id: leaseId, slot,
|
|
2778
|
+
error: "the exact bounded `supabase stop --no-backup` recovery failed; the reserved lease remains fenced and `supabase start` was not attempted",
|
|
2779
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2507
2782
|
let conn;
|
|
2508
2783
|
try {
|
|
2509
2784
|
conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
|
|
2510
2785
|
} catch (e) {
|
|
2786
|
+
recoveryLock?.release();
|
|
2511
2787
|
if (e?.code === "PROVISION_TIMEOUT") {
|
|
2512
2788
|
// BOT-2154: a bounded start that ran out of time. Release the lease ourselves —
|
|
2513
2789
|
// `stack done --local-exec` stops whatever the start left behind and reaps the lease
|
|
@@ -2532,6 +2808,7 @@ export async function cmdUp(opts, {
|
|
|
2532
2808
|
// review); do NOT cancel.
|
|
2533
2809
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
2534
2810
|
}
|
|
2811
|
+
recoveryLock?.release();
|
|
2535
2812
|
const act = await call("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
2536
2813
|
if (!act.ok || !act.data?.success) {
|
|
2537
2814
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
@@ -2690,6 +2967,8 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2690
2967
|
if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
2691
2968
|
const call = rpc.call;
|
|
2692
2969
|
let dockerTarget = null;
|
|
2970
|
+
let expectedDockerTarget = null;
|
|
2971
|
+
let provisionedProjectId = null;
|
|
2693
2972
|
let legacyTargetProof = null;
|
|
2694
2973
|
let teardownDir = process.cwd();
|
|
2695
2974
|
let leaseBefore = null;
|
|
@@ -2750,6 +3029,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2750
3029
|
// old lease would still finalize. Refuse on project drift (the provisioned project_id is
|
|
2751
3030
|
// persisted on the lease connection at `up`).
|
|
2752
3031
|
const provisionedProject = current.data.connection?.project_id;
|
|
3032
|
+
provisionedProjectId = provisionedProject ?? null;
|
|
2753
3033
|
if (provisionedProject) {
|
|
2754
3034
|
const currentProject = projectIdUnder(teardownDir);
|
|
2755
3035
|
if (currentProject && currentProject !== provisionedProject) {
|
|
@@ -2762,6 +3042,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2762
3042
|
}
|
|
2763
3043
|
}
|
|
2764
3044
|
const expected = current.data.connection?.botbuddy_docker_target;
|
|
3045
|
+
expectedDockerTarget = expected ?? null;
|
|
2765
3046
|
// BOT-1711 (Codex R13 P1): a modern-target lease that lacks a persisted provisioned
|
|
2766
3047
|
// project_id was NOT provisioned by BOT-1711+ local-exec (e.g. a pre-1.32 lease that
|
|
2767
3048
|
// recorded a non-root stack_path but actually started the worktree ROOT). Its recorded
|
|
@@ -2876,7 +3157,17 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2876
3157
|
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.",
|
|
2877
3158
|
}), opts, EXIT.LEASE_FAILED);
|
|
2878
3159
|
}
|
|
2879
|
-
|
|
3160
|
+
// BOT-2398: a successful owner-local teardown is itself an authenticated
|
|
3161
|
+
// custody receipt. Carry the exact provisioned project, Docker daemon, and
|
|
3162
|
+
// disposition so the server can persist a reusable decision without waiting
|
|
3163
|
+
// for a Helper reap receipt that will never exist on this path.
|
|
3164
|
+
const localExecCompletion = opts.localExec && expectedDockerTarget && provisionedProjectId
|
|
3165
|
+
? { project_id: provisionedProjectId, docker_target: dockerTarget, disposition }
|
|
3166
|
+
: undefined;
|
|
3167
|
+
const fin = await call("finalize_stack_lease", {
|
|
3168
|
+
lease_id: leaseId,
|
|
3169
|
+
...(localExecCompletion ? { local_exec_completion: localExecCompletion } : {}),
|
|
3170
|
+
});
|
|
2880
3171
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
2881
3172
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
2882
3173
|
}
|
|
@@ -3061,9 +3352,17 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
3061
3352
|
const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
|
|
3062
3353
|
const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
|
|
3063
3354
|
const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
|
|
3064
|
-
|
|
3355
|
+
const final = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
|
|
3356
|
+
// BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
|
|
3357
|
+
if (final && typeof final === "object" && incidentBox.outcomeChannel) {
|
|
3358
|
+
Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
|
|
3359
|
+
}
|
|
3360
|
+
return final;
|
|
3065
3361
|
}
|
|
3066
3362
|
|
|
3363
|
+
/** BOT-2467: where a lifecycle result carries its outcome-record channel. */
|
|
3364
|
+
export const OUTCOME_CHANNEL = Symbol.for("botbuddy.stackRunOutcomeChannel");
|
|
3365
|
+
|
|
3067
3366
|
async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
3068
3367
|
// BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
|
|
3069
3368
|
// long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
|
|
@@ -3083,6 +3382,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3083
3382
|
refresh: refreshAuth,
|
|
3084
3383
|
auth: initialAuth,
|
|
3085
3384
|
});
|
|
3385
|
+
// BOT-2467: cmdRun records the run's outcome with the credential this run ended on.
|
|
3386
|
+
incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
|
|
3086
3387
|
const api = adapters.api ?? {
|
|
3087
3388
|
request: (args) => rpc.call("request_stack_lease", args),
|
|
3088
3389
|
get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
|
|
@@ -3713,7 +4014,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3713
4014
|
}
|
|
3714
4015
|
if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
|
|
3715
4016
|
const cleanup = await requestCleanup();
|
|
3716
|
-
|
|
4017
|
+
// BOT-2467: `timeoutPhase` is for the outcome record only (never on the receipt):
|
|
4018
|
+
// an admitted lease whose stack never started is not a capacity shortage.
|
|
4019
|
+
return { exitCode: EXIT.TIMEOUT, outcome: "timeout", timeoutPhase: "provision", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
3717
4020
|
}
|
|
3718
4021
|
}
|
|
3719
4022
|
if (active?.failed || active?.error || active?.auth) {
|
|
@@ -3969,13 +4272,54 @@ export function runReceiptFields(result) {
|
|
|
3969
4272
|
};
|
|
3970
4273
|
}
|
|
3971
4274
|
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
4275
|
+
/**
|
|
4276
|
+
* `stack run`: the lifecycle, its one receipt, then (BOT-2467) one best-effort
|
|
4277
|
+
* `record_stack_run_outcome` call carrying the platform-vs-script classification.
|
|
4278
|
+
* The record runs strictly AFTER the receipt is on stdout and can never change it
|
|
4279
|
+
* or the returned exit code.
|
|
4280
|
+
*/
|
|
4281
|
+
export async function cmdRun(opts, childArgv, {
|
|
4282
|
+
lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
|
|
4283
|
+
} = {}) {
|
|
4284
|
+
const startedAt = now();
|
|
4285
|
+
const result = await lifecycle(opts, childArgv);
|
|
4286
|
+
const fields = runReceiptFields(result);
|
|
4287
|
+
const code = emit(buildReceipt(fields), opts, result.exitCode);
|
|
4288
|
+
try {
|
|
4289
|
+
await report({ fields: stackRunOutcomeFields(result), exitCode: code, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
|
|
4290
|
+
} catch { /* best-effort: the receipt and exit code are already final */ }
|
|
4291
|
+
return code;
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
/**
|
|
4295
|
+
* BOT-2467: the credential an argument-refused `stack run` can record its outcome
|
|
4296
|
+
* with — env/flag/cached session only. Never self-heals (no mint, no Keychain read)
|
|
4297
|
+
* on a fail-fast path.
|
|
4298
|
+
*/
|
|
4299
|
+
/**
|
|
4300
|
+
* BOT-2467: the fields the outcome classifier reads — the receipt's, plus facts the
|
|
4301
|
+
* receipt deliberately does not carry (which wait a timeout ended). Never emitted.
|
|
4302
|
+
*/
|
|
4303
|
+
export function stackRunOutcomeFields(result) {
|
|
4304
|
+
return { ...runReceiptFields(result), ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}) };
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
async function parseErrorOutcomeChannel(argv, { resolveCredential = resolveAgentSessionCredential, callTool = callToolJson } = {}) {
|
|
4308
|
+
try {
|
|
4309
|
+
// Only this command's own options: a credential-looking flag in the child argv
|
|
4310
|
+
// (after `--`) is the child's, never ours.
|
|
4311
|
+
const end = argv.indexOf("--");
|
|
4312
|
+
const ownArgv = end === -1 ? argv : argv.slice(0, end);
|
|
4313
|
+
const credential = await resolveCredential({ argv: ownArgv, env: process.env, cwd: process.cwd(), allowSelfHeal: false });
|
|
4314
|
+
if (typeof credential?.token !== "string" || !AGENT_KEY_RE.test(credential.token)) return null;
|
|
4315
|
+
return { auth: { "x-agent-api-key": credential.token }, callTool };
|
|
4316
|
+
} catch {
|
|
4317
|
+
return null;
|
|
4318
|
+
}
|
|
3975
4319
|
}
|
|
3976
4320
|
|
|
3977
4321
|
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|
|
3978
|
-
export async function cmdStack(argv) {
|
|
4322
|
+
export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeChannel = parseErrorOutcomeChannel } = {}) {
|
|
3979
4323
|
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
3980
4324
|
process.stdout.write(STACK_HELP + "\n");
|
|
3981
4325
|
return EXIT.OK;
|
|
@@ -3983,7 +4327,12 @@ export async function cmdStack(argv) {
|
|
|
3983
4327
|
const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
|
|
3984
4328
|
if (errors.length) {
|
|
3985
4329
|
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
|
3986
|
-
const
|
|
4330
|
+
const fields = { command: command || "?", outcome: "error", error: errors[0] };
|
|
4331
|
+
const code = emit(buildReceipt(fields), opts, EXIT.INVALID);
|
|
4332
|
+
// BOT-2467: a refused `stack run` is recorded as an `invocation` outcome (no lease).
|
|
4333
|
+
if (command === "run") {
|
|
4334
|
+
try { await report({ fields, exitCode: code, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
|
|
4335
|
+
}
|
|
3987
4336
|
process.exitCode = code;
|
|
3988
4337
|
return code;
|
|
3989
4338
|
}
|
|
@@ -4001,7 +4350,7 @@ export async function cmdStack(argv) {
|
|
|
4001
4350
|
case "status": code = await cmdStatus(leaseId, opts); break;
|
|
4002
4351
|
case "touch": code = await cmdTouch(leaseId, opts); break;
|
|
4003
4352
|
case "done": code = await cmdDone(leaseId, opts); break;
|
|
4004
|
-
case "run": code = await cmdRun(opts, childArgv); break;
|
|
4353
|
+
case "run": code = await cmdRun(opts, childArgv, { report }); break;
|
|
4005
4354
|
default:
|
|
4006
4355
|
process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
|
|
4007
4356
|
code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
|
package/src/wait-core.mjs
CHANGED
|
@@ -568,6 +568,72 @@ export function parseConditions(specs) {
|
|
|
568
568
|
return { conditions, errors };
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// BOT-2467: a secret-free description of grammar failures, for the
|
|
573
|
+
// `wait_setup_error` sensor event. Per failed spec: the attempted condition TYPE
|
|
574
|
+
// (only when it is a known type or within edit distance 2 of one — else
|
|
575
|
+
// "unknown") and a parse_error category derived from the validator's message.
|
|
576
|
+
// Never the raw spec, a param name or a param value.
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
export const CONDITION_PARSE_ERRORS = Object.freeze([
|
|
580
|
+
"unknown_type", "missing_param", "bad_param_value", "unknown_param", "param_conflict",
|
|
581
|
+
"condition_conflict", "empty", "syntax",
|
|
582
|
+
]);
|
|
583
|
+
const MAX_DESCRIBED_CONDITION_ERRORS = 16;
|
|
584
|
+
const TYPE_TOKEN_RE = /^[a-z0-9_-]{1,32}$/;
|
|
585
|
+
|
|
586
|
+
function editDistanceAtMost(a, b, limit) {
|
|
587
|
+
if (Math.abs(a.length - b.length) > limit) return false;
|
|
588
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
589
|
+
for (let i = 1; i <= a.length; i++) {
|
|
590
|
+
const row = [i];
|
|
591
|
+
let best = i;
|
|
592
|
+
for (let j = 1; j <= b.length; j++) {
|
|
593
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
594
|
+
best = Math.min(best, row[j]);
|
|
595
|
+
}
|
|
596
|
+
if (best > limit) return false;
|
|
597
|
+
prev = row;
|
|
598
|
+
}
|
|
599
|
+
return prev[b.length] <= limit;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function attemptedConditionType(spec) {
|
|
603
|
+
const raw = String(spec ?? "");
|
|
604
|
+
const colon = raw.indexOf(":");
|
|
605
|
+
const token = (colon === -1 ? raw : raw.slice(0, colon)).trim();
|
|
606
|
+
if (Object.prototype.hasOwnProperty.call(VALIDATORS, token)) return token;
|
|
607
|
+
const lower = token.toLowerCase();
|
|
608
|
+
if (!TYPE_TOKEN_RE.test(lower)) return "unknown";
|
|
609
|
+
return Object.keys(VALIDATORS).some((known) => editDistanceAtMost(lower, known, 2)) ? lower : "unknown";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function conditionParseError(error) {
|
|
613
|
+
const spec = String(error?.spec ?? "");
|
|
614
|
+
const message = String(error?.message ?? "");
|
|
615
|
+
if (spec.trim() === "" || /at least one condition is required/.test(message)) return "empty";
|
|
616
|
+
if (/^unknown condition type/.test(message)) return "unknown_type";
|
|
617
|
+
if (/must be the only condition/.test(message)) return "condition_conflict";
|
|
618
|
+
if (/exactly one of|only valid with|takes no /.test(message)) return "param_conflict";
|
|
619
|
+
if (/unknown key|does not accept|accepts only/.test(message)) return "unknown_param";
|
|
620
|
+
if (/\bneeds\b/.test(message)) return "missing_param";
|
|
621
|
+
if (/\bmust\b/.test(message)) return "bad_param_value";
|
|
622
|
+
return "syntax";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* `parseConditions(...).errors` → `[{ attempted_type, parse_error }]`, one per failed
|
|
627
|
+
* spec in order, at most 16. Pure; safe to send (no spec text, names or values).
|
|
628
|
+
*/
|
|
629
|
+
export function describeConditionErrors(errors) {
|
|
630
|
+
if (!Array.isArray(errors)) return [];
|
|
631
|
+
return errors.slice(0, MAX_DESCRIBED_CONDITION_ERRORS).map((error) => {
|
|
632
|
+
const parseError = conditionParseError(error);
|
|
633
|
+
return { attempted_type: parseError === "empty" ? "unknown" : attemptedConditionType(error?.spec), parse_error: parseError };
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
571
637
|
// ---------------------------------------------------------------------------
|
|
572
638
|
// SSE frame parsing — split-safe, keepalive-aware, multi-line data.
|
|
573
639
|
// ---------------------------------------------------------------------------
|
package/src/wait.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Usage: botbuddy wait [--any] <condition>... [options]
|
|
14
14
|
// Run botbuddy wait --help for the condition grammar.
|
|
15
15
|
|
|
16
|
-
import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
|
|
16
|
+
import { EXIT, describeConditionErrors, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
|
|
17
17
|
import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -915,7 +915,7 @@ export const RECOVERY = Object.freeze({
|
|
|
915
915
|
// only error code, exit code, condition TYPES, auth path, profile name, cli version.
|
|
916
916
|
const SENSOR_TELEMETRY_TIMEOUT_MS = 2000;
|
|
917
917
|
|
|
918
|
-
async function reportSetupError(opts, { error, exitCode, conditions }) {
|
|
918
|
+
async function reportSetupError(opts, { error, exitCode, conditions, conditionErrors = null }) {
|
|
919
919
|
// Never on the session-token path. A bb_agent_/bb_sess_ SESSION token cannot
|
|
920
920
|
// authenticate at sensor-ingest (edgeAuth resolves only PATs / agent keys), so it
|
|
921
921
|
// would only 401. Discriminate by the credential's SOURCE, not its shape: a
|
|
@@ -939,7 +939,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
|
|
|
939
939
|
timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, SENSOR_TELEMETRY_TIMEOUT_MS);
|
|
940
940
|
});
|
|
941
941
|
try {
|
|
942
|
-
await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions }, ac.signal), timeout]);
|
|
942
|
+
await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions, conditionErrors }, ac.signal), timeout]);
|
|
943
943
|
} catch {
|
|
944
944
|
// Best-effort: an AbortError (2 s timeout) or any transport/lookup error drops.
|
|
945
945
|
} finally {
|
|
@@ -947,7 +947,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
|
|
|
947
947
|
}
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
-
async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal) {
|
|
950
|
+
async function sendSetupErrorEvent(opts, { error, exitCode, conditions, conditionErrors }, signal) {
|
|
951
951
|
// Resolve a bearer WITHOUT ever touching the Keychain. This telemetry is
|
|
952
952
|
// best-effort and runs on fail-fast paths right before process.exit; a Keychain
|
|
953
953
|
// read (readProfileCredential → `security find-generic-password`) can block/prompt
|
|
@@ -994,6 +994,7 @@ async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal
|
|
|
994
994
|
error,
|
|
995
995
|
exit_code: exitCode,
|
|
996
996
|
condition_types: conditionTypes,
|
|
997
|
+
...(Array.isArray(conditionErrors) && conditionErrors.length ? { condition_errors: conditionErrors } : {}),
|
|
997
998
|
auth_path: opts.token ? "explicit_token" : "binding",
|
|
998
999
|
profile: null,
|
|
999
1000
|
cli_version: VERSION,
|
|
@@ -1577,8 +1578,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1577
1578
|
process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
|
|
1578
1579
|
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
|
|
1579
1580
|
// BOT-1593: condition_types is [] on a grammar failure (the parsed set is
|
|
1580
|
-
// partial/empty and is caller data regardless).
|
|
1581
|
-
|
|
1581
|
+
// partial/empty and is caller data regardless). BOT-2467: instead, each failed
|
|
1582
|
+
// spec is described by its attempted TYPE and a parse_error category — never
|
|
1583
|
+
// the spec text, a param name or a value.
|
|
1584
|
+
await reportSetupError(opts, { error: "invalid_conditions", exitCode: EXIT.INVALID, conditions: [], conditionErrors: describeConditionErrors(errors) });
|
|
1582
1585
|
process.exit(EXIT.INVALID);
|
|
1583
1586
|
}
|
|
1584
1587
|
|