@botbuddy/cli 1.59.0 → 1.59.3
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/stack.mjs +300 -10
- package/src/wait-core.mjs +7 -2
- package/src/wait.mjs +16 -2
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -134,6 +134,8 @@ ${bold("up OPTIONS")}
|
|
|
134
134
|
deadline (exit 2) and a failed wait (its own exit code) never retry.
|
|
135
135
|
--local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
|
|
136
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.
|
|
137
139
|
--provision-timeout N LOCAL-EXEC: seconds 'supabase start' may run (default ${LOCAL_EXEC_PROVISION_TIMEOUT_SEC}). On
|
|
138
140
|
timeout up runs 'stack done --local-exec' itself and exits 6 with
|
|
139
141
|
park_reason provision_timeout (BOT-2154). The server also fails a local-exec
|
|
@@ -328,7 +330,7 @@ export function parseStackArgs(argv) {
|
|
|
328
330
|
slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
|
|
329
331
|
prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".", stackPathExplicit: false,
|
|
330
332
|
timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
|
|
331
|
-
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,
|
|
332
334
|
dockerContext: null, dockerEndpoint: null,
|
|
333
335
|
// BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
|
|
334
336
|
execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
|
|
@@ -453,6 +455,7 @@ export function parseStackArgs(argv) {
|
|
|
453
455
|
case "--no-wait": opts.noWait = true; break;
|
|
454
456
|
case "--claim-only": opts.claimOnly = true; break;
|
|
455
457
|
case "--local-exec": opts.localExec = true; break;
|
|
458
|
+
case "--keep-volumes": opts.keepVolumes = true; break;
|
|
456
459
|
case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
|
|
457
460
|
case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
|
|
458
461
|
case "--stop": opts.disposition = "stop"; break;
|
|
@@ -520,6 +523,9 @@ export function parseStackArgs(argv) {
|
|
|
520
523
|
if (opts.claimOnly && (command !== "up" || !opts.localExec)) {
|
|
521
524
|
errors.push("--claim-only is valid only with `stack up --local-exec`");
|
|
522
525
|
}
|
|
526
|
+
if (opts.keepVolumes && (command !== "up" || !opts.localExec)) {
|
|
527
|
+
errors.push("--keep-volumes is valid only with `stack up --local-exec`");
|
|
528
|
+
}
|
|
523
529
|
if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
|
|
524
530
|
errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
|
|
525
531
|
}
|
|
@@ -1397,13 +1403,13 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
1397
1403
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
1398
1404
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
1399
1405
|
const st = sig.payload?.state ?? null;
|
|
1400
|
-
if (isFailed(st)) {
|
|
1406
|
+
if (isFailed(st, sig.payload)) {
|
|
1401
1407
|
// BOT-2013: carry the server's reap_reason so the receipt says WHY the lease is gone.
|
|
1402
1408
|
const reapReason = typeof sig.payload?.reap_reason === "string" ? sig.payload.reap_reason : null;
|
|
1403
1409
|
decided = { reason: "failed", result: { failed: true, state: st, ...(reapReason ? { reap_reason: reapReason } : {}) }, status: "error" };
|
|
1404
1410
|
break frameLoop;
|
|
1405
1411
|
}
|
|
1406
|
-
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; }
|
|
1407
1413
|
}
|
|
1408
1414
|
}
|
|
1409
1415
|
} catch (err) {
|
|
@@ -1603,8 +1609,8 @@ export async function parkOnLease(leaseId, isDone, isFailed, {
|
|
|
1603
1609
|
// Codex #1168 P1: an authenticated read of the lease proves the outage is over, so a later,
|
|
1604
1610
|
// independent rotation starts a fresh auth budget instead of inheriting this one.
|
|
1605
1611
|
if (state != null) recovered();
|
|
1606
|
-
if (state != null && isFailed(state)) return done({ failed: true, state, ...(read?.reap_reason ? { reap_reason: read.reap_reason } : {}) });
|
|
1607
|
-
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 });
|
|
1608
1614
|
}
|
|
1609
1615
|
}
|
|
1610
1616
|
}
|
|
@@ -2097,6 +2103,60 @@ export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawn
|
|
|
2097
2103
|
return labelScopedTeardown(projectId, disposition, spawn, dockerEnv, log);
|
|
2098
2104
|
}
|
|
2099
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
|
+
|
|
2100
2160
|
/** BOT-2001: remove exactly one Supabase project's containers, network and (for `destroy`)
|
|
2101
2161
|
* volumes by the CLI's project label; true iff a re-list finds NONE of the resource classes
|
|
2102
2162
|
* this disposition removes (Codex #1012: a busy volume or network fails closed too). */
|
|
@@ -2147,9 +2207,17 @@ export async function cmdUp(opts, {
|
|
|
2147
2207
|
callTool = callToolJson,
|
|
2148
2208
|
authProvider = stackAuthHeader,
|
|
2149
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,
|
|
2150
2216
|
// BOT-2154: the self-release after a timed-out local start (`stack done --local-exec`).
|
|
2151
2217
|
localDoneFn = null,
|
|
2152
2218
|
waitFn = waitForLease,
|
|
2219
|
+
// BOT-2398 focused seam; production uses the existing lease SSE park below.
|
|
2220
|
+
waitHandoffFn = null,
|
|
2153
2221
|
emitResult = emit,
|
|
2154
2222
|
machineUuidFn = machineUuid,
|
|
2155
2223
|
assertIsolated = validateLocalExecTarget,
|
|
@@ -2460,10 +2528,181 @@ export async function cmdUp(opts, {
|
|
|
2460
2528
|
const reservedProjectId = projectIdUnder(stackDirFor(execution));
|
|
2461
2529
|
const reservedConnection = connectionWithDockerTarget(
|
|
2462
2530
|
reservedProjectId ? { project_id: reservedProjectId } : {}, localDockerTarget);
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
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
|
+
});
|
|
2467
2706
|
if (!reserved.ok || !reserved.data?.success) {
|
|
2468
2707
|
// A Helper can win the reservation race by taking the queued provision
|
|
2469
2708
|
// job between `request_stack_lease` and `reserve_stack_lease`. That is NOT
|
|
@@ -2505,10 +2744,46 @@ export async function cmdUp(opts, {
|
|
|
2505
2744
|
}), opts, EXIT.LEASE_FAILED);
|
|
2506
2745
|
}
|
|
2507
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
|
+
}
|
|
2508
2782
|
let conn;
|
|
2509
2783
|
try {
|
|
2510
2784
|
conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
|
|
2511
2785
|
} catch (e) {
|
|
2786
|
+
recoveryLock?.release();
|
|
2512
2787
|
if (e?.code === "PROVISION_TIMEOUT") {
|
|
2513
2788
|
// BOT-2154: a bounded start that ran out of time. Release the lease ourselves —
|
|
2514
2789
|
// `stack done --local-exec` stops whatever the start left behind and reaps the lease
|
|
@@ -2533,6 +2808,7 @@ export async function cmdUp(opts, {
|
|
|
2533
2808
|
// review); do NOT cancel.
|
|
2534
2809
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
2535
2810
|
}
|
|
2811
|
+
recoveryLock?.release();
|
|
2536
2812
|
const act = await call("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
2537
2813
|
if (!act.ok || !act.data?.success) {
|
|
2538
2814
|
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
@@ -2691,6 +2967,8 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2691
2967
|
if (!auth) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
2692
2968
|
const call = rpc.call;
|
|
2693
2969
|
let dockerTarget = null;
|
|
2970
|
+
let expectedDockerTarget = null;
|
|
2971
|
+
let provisionedProjectId = null;
|
|
2694
2972
|
let legacyTargetProof = null;
|
|
2695
2973
|
let teardownDir = process.cwd();
|
|
2696
2974
|
let leaseBefore = null;
|
|
@@ -2751,6 +3029,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2751
3029
|
// old lease would still finalize. Refuse on project drift (the provisioned project_id is
|
|
2752
3030
|
// persisted on the lease connection at `up`).
|
|
2753
3031
|
const provisionedProject = current.data.connection?.project_id;
|
|
3032
|
+
provisionedProjectId = provisionedProject ?? null;
|
|
2754
3033
|
if (provisionedProject) {
|
|
2755
3034
|
const currentProject = projectIdUnder(teardownDir);
|
|
2756
3035
|
if (currentProject && currentProject !== provisionedProject) {
|
|
@@ -2763,6 +3042,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2763
3042
|
}
|
|
2764
3043
|
}
|
|
2765
3044
|
const expected = current.data.connection?.botbuddy_docker_target;
|
|
3045
|
+
expectedDockerTarget = expected ?? null;
|
|
2766
3046
|
// BOT-1711 (Codex R13 P1): a modern-target lease that lacks a persisted provisioned
|
|
2767
3047
|
// project_id was NOT provisioned by BOT-1711+ local-exec (e.g. a pre-1.32 lease that
|
|
2768
3048
|
// recorded a non-root stack_path but actually started the worktree ROOT). Its recorded
|
|
@@ -2877,7 +3157,17 @@ export async function cmdDone(leaseId, opts, {
|
|
|
2877
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.",
|
|
2878
3158
|
}), opts, EXIT.LEASE_FAILED);
|
|
2879
3159
|
}
|
|
2880
|
-
|
|
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
|
+
});
|
|
2881
3171
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
2882
3172
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
2883
3173
|
}
|
package/src/wait-core.mjs
CHANGED
|
@@ -676,9 +676,12 @@ export function parseSseFrames(buffer) {
|
|
|
676
676
|
}
|
|
677
677
|
|
|
678
678
|
// ---------------------------------------------------------------------------
|
|
679
|
-
// Cursor —
|
|
679
|
+
// Cursor — replay uses JavaScript Numbers at the relay boundary, so only exact
|
|
680
|
+
// non-negative safe integers can be replayed without changing the requested floor.
|
|
680
681
|
// ---------------------------------------------------------------------------
|
|
681
682
|
|
|
683
|
+
const MAX_SAFE_SIGNAL_SEQUENCE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
684
|
+
|
|
682
685
|
/**
|
|
683
686
|
* @param {string|null|undefined} raw
|
|
684
687
|
* @returns {{kind: "absent"} | {kind: "ok", value: string} | {kind: "invalid", raw: string}}
|
|
@@ -688,7 +691,9 @@ export function normalizeSince(raw) {
|
|
|
688
691
|
const t = String(raw).trim();
|
|
689
692
|
if (t === "") return { kind: "absent" };
|
|
690
693
|
if (!/^\d+$/.test(t)) return { kind: "invalid", raw };
|
|
691
|
-
|
|
694
|
+
const value = BigInt(t);
|
|
695
|
+
if (value > MAX_SAFE_SIGNAL_SEQUENCE) return { kind: "invalid", raw };
|
|
696
|
+
return { kind: "ok", value: String(value) };
|
|
692
697
|
}
|
|
693
698
|
|
|
694
699
|
// ---------------------------------------------------------------------------
|
package/src/wait.mjs
CHANGED
|
@@ -153,7 +153,7 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
153
153
|
OPTIONS
|
|
154
154
|
--any wake on the first matching condition (default; only mode in v1)
|
|
155
155
|
--timeout <seconds> max wait (default 7200; hard cap 28800). Timeout is exit 2, not an error.
|
|
156
|
-
--since <seq> resume from a prior receipt's next_cursor (replay what you missed)
|
|
156
|
+
--since <seq> resume from a prior receipt's next_cursor (0..9007199254740991; replay what you missed)
|
|
157
157
|
--receipt-max-bytes <n> cap the receipt (512..65536; default 10240; payloads truncate to pointers)
|
|
158
158
|
--heartbeat keep this agent session alive while waiting (so it is not reaped)
|
|
159
159
|
--url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
|
|
@@ -216,6 +216,7 @@ function parseArgv(argv) {
|
|
|
216
216
|
mode: "any",
|
|
217
217
|
timeout: 7200,
|
|
218
218
|
since: null,
|
|
219
|
+
sinceProvided: false,
|
|
219
220
|
receiptMaxBytes: 10240,
|
|
220
221
|
heartbeat: false,
|
|
221
222
|
url: process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1",
|
|
@@ -248,7 +249,7 @@ function parseArgv(argv) {
|
|
|
248
249
|
else if (a === "--heartbeat") opts.heartbeat = true;
|
|
249
250
|
else if (a === "--timeout") opts.timeout = Number(optionValue());
|
|
250
251
|
else if (a === "--deadline-at") opts.deadlineAt = optionValue(); // internal recovery argv; never shown as public API
|
|
251
|
-
else if (a === "--since") opts.since = optionValue();
|
|
252
|
+
else if (a === "--since") { opts.sinceProvided = true; opts.since = optionValue(); }
|
|
252
253
|
else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
|
|
253
254
|
else if (a === "--url") opts.url = optionValue();
|
|
254
255
|
// BOT-1574: --token and --profile are RETIRED. A wait authenticates from
|
|
@@ -1572,6 +1573,19 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1572
1573
|
process.exit(EXIT.INVALID);
|
|
1573
1574
|
}
|
|
1574
1575
|
|
|
1576
|
+
// BOT-2453: an explicit replay cursor is a request to replay, never permission
|
|
1577
|
+
// to silently fall back to live-only. Validate it before creating a recoverable
|
|
1578
|
+
// checkpoint, resolving credentials, registering a wait, or opening SSE.
|
|
1579
|
+
if (opts.sinceProvided) {
|
|
1580
|
+
const since = normalizeSince(opts.since);
|
|
1581
|
+
if (since.kind !== "ok") {
|
|
1582
|
+
process.stderr.write("bb-wait: --since must be a non-negative signal sequence <= 9007199254740991\n");
|
|
1583
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_since", cursor: opts.since });
|
|
1584
|
+
process.exit(EXIT.INVALID);
|
|
1585
|
+
}
|
|
1586
|
+
opts.since = since.value;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1575
1589
|
const { conditions, errors } = parseConditions(opts.conditions);
|
|
1576
1590
|
if (errors.length > 0) {
|
|
1577
1591
|
for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
|