@botbuddy/cli 1.33.2 → 1.33.4
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-isolation.mjs +211 -4
- package/src/stack.mjs +63 -10
package/package.json
CHANGED
package/src/stack-isolation.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { createHash, randomUUID } from "node:crypto";
|
|
23
23
|
import { cpSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { homedir, hostname } from "node:os";
|
|
25
|
-
import { basename, join } from "node:path";
|
|
25
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
26
26
|
import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
27
27
|
|
|
28
28
|
/** Where materialized leased stacks live inside the worktree (gitignored). */
|
|
@@ -871,6 +871,202 @@ function prune(stackSupabaseDir, { readdir, remove }) {
|
|
|
871
871
|
}
|
|
872
872
|
}
|
|
873
873
|
|
|
874
|
+
// ── BOT-1828: functions that import a file outside supabase/ ───────────────
|
|
875
|
+
//
|
|
876
|
+
// Supply Guard's `supabase/functions/admin-api-keys/index.ts` imports
|
|
877
|
+
// `../../../src/lib/platformApiKeysCatalog.ts` — outside `supabase/`, so the whole-tree
|
|
878
|
+
// copy above never brings it along. The Supabase CLI resolves function imports when it
|
|
879
|
+
// STARTS the edge runtime, so `supabase start` inside the isolated stack fails deep
|
|
880
|
+
// inside the Helper with a raw ENOENT. This is a dependency-free regex walk over
|
|
881
|
+
// `import …/export …/from "…"` and dynamic `import("…")` string literals — not a real
|
|
882
|
+
// module resolver (the published CLI is Node-stdlib only) — so it can miss an exotic
|
|
883
|
+
// re-export form, but every specifier it DOES find is resolved for real (existence +
|
|
884
|
+
// worktree-containment) before anything is trusted.
|
|
885
|
+
const ESCAPING_IMPORT_SCAN_EXTENSIONS = [".ts", ".js", ".mjs"];
|
|
886
|
+
const ESCAPING_IMPORT_MANIFEST_NAMES = ["import_map.json", "deno.json"];
|
|
887
|
+
const SUPABASE_DIR_PREFIX = `supabase${sep}`;
|
|
888
|
+
|
|
889
|
+
const IMPORT_SPECIFIER_PATTERNS = [
|
|
890
|
+
// `import … from "…"` / `export … from "…"` (default, named, namespace, type-only).
|
|
891
|
+
/\b(?:import|export)\b[^'";]*?\bfrom\s*["']([^"']+)["']/g,
|
|
892
|
+
// dynamic `import("…")`.
|
|
893
|
+
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
|
|
894
|
+
// bare side-effecting `import "…"` (no `from` clause).
|
|
895
|
+
/^[ \t]*import\s*["']([^"']+)["']\s*;?/gm,
|
|
896
|
+
];
|
|
897
|
+
|
|
898
|
+
/** Strip comments before scanning for import specifiers (Codex #946 R1 P2): a
|
|
899
|
+
* `// import { old } from "../../../removed.ts"` note (or block comment) is not a
|
|
900
|
+
* live dependency, but the plain regex above has no idea — treating it as one turns
|
|
901
|
+
* a stale comment into a hard refusal of the whole stack. Block comments are removed
|
|
902
|
+
* outright; `//` line comments are stripped with a quote-aware per-character scan so
|
|
903
|
+
* a real specifier like `"https://deno.land/std/foo.ts"` (a `//` INSIDE a string) is
|
|
904
|
+
* never mistaken for a comment. Not a real tokenizer — a `/*`/`//` inside a template
|
|
905
|
+
* literal can still confuse it — but it closes the concrete failure mode a bare
|
|
906
|
+
* comment caused, which is what actually shipped in real function source. */
|
|
907
|
+
function stripComments(source) {
|
|
908
|
+
const noBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
909
|
+
return noBlockComments
|
|
910
|
+
.split("\n")
|
|
911
|
+
.map((line) => {
|
|
912
|
+
let inString = null;
|
|
913
|
+
for (let i = 0; i < line.length; i++) {
|
|
914
|
+
const ch = line[i];
|
|
915
|
+
if (inString) {
|
|
916
|
+
if (ch === "\\") { i++; continue; }
|
|
917
|
+
if (ch === inString) inString = null;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; }
|
|
921
|
+
if (ch === "/" && line[i + 1] === "/") return line.slice(0, i);
|
|
922
|
+
}
|
|
923
|
+
return line;
|
|
924
|
+
})
|
|
925
|
+
.join("\n");
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Every string-literal specifier this source imports or re-exports, deduped. */
|
|
929
|
+
function extractImportSpecifiers(source) {
|
|
930
|
+
const specifiers = new Set();
|
|
931
|
+
const clean = stripComments(source);
|
|
932
|
+
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
|
|
933
|
+
pattern.lastIndex = 0;
|
|
934
|
+
let match;
|
|
935
|
+
while ((match = pattern.exec(clean)) !== null) specifiers.add(match[1]);
|
|
936
|
+
}
|
|
937
|
+
return specifiers;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function isRelativeSpecifier(specifier) {
|
|
941
|
+
return specifier.startsWith("./") || specifier.startsWith("../");
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Recursively list files under `dir` (absolute) whose name matches one of `names`
|
|
945
|
+
* (exact basenames, e.g. "deno.json") or ends with one of `exts` (e.g. ".ts") — via
|
|
946
|
+
* the same low-level readdir/stat the rest of the materializer uses, so a
|
|
947
|
+
* fault-injecting test still exercises this path. A missing `dir` has nothing to scan. */
|
|
948
|
+
function listMatchingFiles(dir, { exts = [], names = [] }, { readdir, stat }) {
|
|
949
|
+
const out = [];
|
|
950
|
+
const pending = [dir];
|
|
951
|
+
while (pending.length) {
|
|
952
|
+
const current = pending.pop();
|
|
953
|
+
let entries;
|
|
954
|
+
try { entries = readdir(current); } catch { continue; }
|
|
955
|
+
for (const entry of entries) {
|
|
956
|
+
const full = join(current, entry);
|
|
957
|
+
let info;
|
|
958
|
+
try { info = stat(full); } catch { continue; }
|
|
959
|
+
if (info.isDirectory()) { pending.push(full); continue; }
|
|
960
|
+
if (names.includes(entry) || exts.some((ext) => entry.endsWith(ext))) out.push(full);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return out;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Scan `supabase/functions/**` (source files plus `import_map.json`/`deno.json`
|
|
968
|
+
* "imports" entries) for RELATIVE specifiers that resolve outside `supabase/`, copy
|
|
969
|
+
* each into the stack directory at the identical worktree-relative path, and follow
|
|
970
|
+
* relative imports of the COPIED files transitively. Refuses loudly — before this
|
|
971
|
+
* call copies anything — if a specifier resolves outside the worktree root or to a
|
|
972
|
+
* file that does not exist, naming the importing file and the specifier. Returns the
|
|
973
|
+
* sorted list of worktree-relative paths copied (possibly empty).
|
|
974
|
+
*/
|
|
975
|
+
function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath }) {
|
|
976
|
+
const functionsDir = join(worktreeRoot, "supabase", "functions");
|
|
977
|
+
const visited = new Set(); // worktree-relative paths already queued or copied
|
|
978
|
+
const queue = [];
|
|
979
|
+
|
|
980
|
+
const resolveSpecifier = (specifier, importingAbsDir, importingRelPath) => {
|
|
981
|
+
// Lexical: `join` collapses `..`/`.` as STRINGS but never follows a symlinked
|
|
982
|
+
// directory component. fs reads/copies below follow symlinks transparently, so
|
|
983
|
+
// this is a perfectly usable path for existence/content — and, per Codex #946
|
|
984
|
+
// R1 P2, it is the path an import specifier reads relative to the COPIED file's
|
|
985
|
+
// own on-disk location once materialized, so it is what the destination and any
|
|
986
|
+
// further relative-import resolution must use too.
|
|
987
|
+
const candidate = join(importingAbsDir, specifier);
|
|
988
|
+
let real;
|
|
989
|
+
try {
|
|
990
|
+
real = realpath(candidate);
|
|
991
|
+
} catch (error) {
|
|
992
|
+
throw new Error(
|
|
993
|
+
`refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which does not exist ` +
|
|
994
|
+
`(resolved to ${candidate}${error?.code ? `: ${error.code}` : ""}). Fix the import or add the missing file.`,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
// Security containment uses the REAL, symlink-resolved location: a symlink
|
|
998
|
+
// whose TARGET is outside the worktree must be refused even when its own
|
|
999
|
+
// lexical path looks like it stays inside.
|
|
1000
|
+
const realRel = relative(worktreeRoot, real);
|
|
1001
|
+
if (realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which resolves to ` +
|
|
1004
|
+
`${real} — outside the worktree root ${worktreeRoot}. Move the file into the worktree, or fix the import.`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
// The DESTINATION (and this file's own identity in `visited`/`queue`) mirrors
|
|
1008
|
+
// the LEXICAL candidate, not `real`: copying to `real`'s location would place
|
|
1009
|
+
// the file somewhere the unmodified import specifier never names, and the
|
|
1010
|
+
// materialized stack would fail with the same missing-file error this ticket
|
|
1011
|
+
// exists to fix (Codex #946 R1 P2).
|
|
1012
|
+
const rel = relative(worktreeRoot, candidate);
|
|
1013
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
`refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which resolves to ` +
|
|
1016
|
+
`${candidate} — outside the worktree root ${worktreeRoot}. Move the file into the worktree, or fix the import.`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
if (rel === "supabase" || rel.startsWith(SUPABASE_DIR_PREFIX)) return; // already copied whole
|
|
1020
|
+
if (visited.has(rel)) return;
|
|
1021
|
+
visited.add(rel);
|
|
1022
|
+
queue.push({ absPath: candidate, relPath: rel });
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
const scanForImports = (absPath, relPath) => {
|
|
1026
|
+
let source;
|
|
1027
|
+
try { source = read(absPath, "utf8"); } catch { return; }
|
|
1028
|
+
for (const specifier of extractImportSpecifiers(source)) {
|
|
1029
|
+
if (isRelativeSpecifier(specifier)) resolveSpecifier(specifier, dirname(absPath), relPath);
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
for (const file of listMatchingFiles(functionsDir, { exts: ESCAPING_IMPORT_SCAN_EXTENSIONS }, { readdir, stat })) {
|
|
1034
|
+
scanForImports(file, relative(worktreeRoot, file));
|
|
1035
|
+
}
|
|
1036
|
+
for (const file of listMatchingFiles(functionsDir, { names: ESCAPING_IMPORT_MANIFEST_NAMES }, { readdir, stat })) {
|
|
1037
|
+
let parsed;
|
|
1038
|
+
try { parsed = JSON.parse(read(file, "utf8")); } catch { continue; }
|
|
1039
|
+
const imports = parsed?.imports;
|
|
1040
|
+
if (!imports || typeof imports !== "object") continue;
|
|
1041
|
+
const relPath = relative(worktreeRoot, file);
|
|
1042
|
+
for (const specifier of Object.values(imports)) {
|
|
1043
|
+
if (typeof specifier === "string" && isRelativeSpecifier(specifier)) {
|
|
1044
|
+
resolveSpecifier(specifier, dirname(file), relPath);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const extraFiles = [];
|
|
1050
|
+
while (queue.length) {
|
|
1051
|
+
const item = queue.shift();
|
|
1052
|
+
const dest = join(stackDir, item.relPath);
|
|
1053
|
+
mkdir(dirname(dest), { recursive: true });
|
|
1054
|
+
// An import-map "imports" entry may be a PREFIX mapping to a directory (e.g.
|
|
1055
|
+
// `"@lib/": "../../../src/lib/"`, valid Deno import-map syntax) rather than a
|
|
1056
|
+
// single file — cpSync without `recursive` throws ERR_FS_EISDIR on that (Codex
|
|
1057
|
+
// #946 R1 P1). Copy the whole directory in that case; there is no single file
|
|
1058
|
+
// whose text to scan for further imports.
|
|
1059
|
+
let isDir = false;
|
|
1060
|
+
try { isDir = stat(item.absPath).isDirectory(); } catch { /* copy() will report it */ }
|
|
1061
|
+
copy(item.absPath, dest, isDir ? { recursive: true, force: true } : { force: true });
|
|
1062
|
+
extraFiles.push(item.relPath);
|
|
1063
|
+
if (!isDir && ESCAPING_IMPORT_SCAN_EXTENSIONS.some((ext) => item.relPath.endsWith(ext))) {
|
|
1064
|
+
scanForImports(item.absPath, item.relPath);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return extraFiles.sort();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
874
1070
|
/**
|
|
875
1071
|
* Materialize (or refresh) the isolated stack directory for this run and return
|
|
876
1072
|
* its path RELATIVE to the worktree root — never `"."`. The directory belongs to
|
|
@@ -941,7 +1137,7 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
|
|
|
941
1137
|
// stable per-slot one, so the project id is exactly as specific (and keeps a
|
|
942
1138
|
// refresh idempotent).
|
|
943
1139
|
const invocationToken = nonce ? String(nonce) : identity.projectId;
|
|
944
|
-
const marker = (portBase) => JSON.stringify({
|
|
1140
|
+
const marker = (portBase, extraFiles = []) => JSON.stringify({
|
|
945
1141
|
schema: 1,
|
|
946
1142
|
project_id: identity.projectId,
|
|
947
1143
|
invocation_token: invocationToken,
|
|
@@ -953,6 +1149,10 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
|
|
|
953
1149
|
retain_until: createdAt + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
|
|
954
1150
|
owner_pid: process.pid,
|
|
955
1151
|
host: host(),
|
|
1152
|
+
// BOT-1828: worktree-relative paths copied because an edge function imports
|
|
1153
|
+
// outside supabase/ — recorded so an operator can see why this stack dir has
|
|
1154
|
+
// (e.g.) a src/ directory, instead of guessing at a `supabase start` failure.
|
|
1155
|
+
extra_files: extraFiles,
|
|
956
1156
|
});
|
|
957
1157
|
const registryDir = io.portRegistryDir ?? defaultPortRegistryDir();
|
|
958
1158
|
const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
|
|
@@ -1016,11 +1216,18 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
|
|
|
1016
1216
|
filter: (source) => !SKIP_ENTRIES.has(basename(source)),
|
|
1017
1217
|
});
|
|
1018
1218
|
write(join(stackDir, "supabase", "config.toml"), text);
|
|
1219
|
+
|
|
1220
|
+
// BOT-1828: bring along any file an edge function imports OUTSIDE supabase/
|
|
1221
|
+
// (Supply Guard's admin-api-keys/index.ts pulls in ../../../src/lib/…) —
|
|
1222
|
+
// `supabase start` resolves function imports when it starts the edge runtime, so
|
|
1223
|
+
// without this the Helper fails deep inside `supabase start` with a raw ENOENT.
|
|
1224
|
+
const extraFiles = copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath: resolveRoot });
|
|
1225
|
+
|
|
1019
1226
|
// Re-state the claim after the copy: the marker records who owns this directory,
|
|
1020
1227
|
// which port block it holds, and how long a later run must keep it (Codex #928
|
|
1021
1228
|
// R7/R12). It lives beside `supabase/`, which the refresh replaces wholesale.
|
|
1022
|
-
atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase), { write, rename });
|
|
1023
|
-
atomicWrite(portClaimPath(registryDir, portBase), marker(portBase), { write, rename });
|
|
1229
|
+
atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase, extraFiles), { write, rename });
|
|
1230
|
+
atomicWrite(portClaimPath(registryDir, portBase), marker(portBase, extraFiles), { write, rename });
|
|
1024
1231
|
return stackPath;
|
|
1025
1232
|
} catch (error) {
|
|
1026
1233
|
try { remove(portClaimPath(registryDir, portBase), { force: true }); } catch { /* best effort */ }
|
package/src/stack.mjs
CHANGED
|
@@ -713,6 +713,20 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
|
|
|
713
713
|
return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
|
|
714
714
|
}
|
|
715
715
|
|
|
716
|
+
// BOT-1827: the abort reason is always one of these four explicit strings —
|
|
717
|
+
// branch on the reason itself rather than inferring "anything but interrupted
|
|
718
|
+
// is a timeout". "matched"/"failed" are only ever set AFTER the SSE loop has
|
|
719
|
+
// already been left via `break` (never from inside the `for await`), so the
|
|
720
|
+
// controller's own teardown can never race a `{ woke: true }` / `{ failed: true }`
|
|
721
|
+
// return the way it did when `ac.abort()` was called before returning from
|
|
722
|
+
// inside the loop.
|
|
723
|
+
function resultForAbortReason(reason) {
|
|
724
|
+
if (reason === "interrupted") return { result: { interrupted: true }, status: "error" };
|
|
725
|
+
// "timeout", or any other/unset reason (e.g. an externally-triggered AbortError
|
|
726
|
+
// with no reason attached) — defensively treated the same as a real deadline.
|
|
727
|
+
return { result: { timeout: true }, status: "timeout" };
|
|
728
|
+
}
|
|
729
|
+
|
|
716
730
|
/**
|
|
717
731
|
* Zero-poll wait for `lease:<leaseId>` to reach a state satisfying `isDone(state)`.
|
|
718
732
|
* Registers a wait_session (visible on /waits), then blocks on the event-stream SSE.
|
|
@@ -751,11 +765,17 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
751
765
|
}
|
|
752
766
|
return result;
|
|
753
767
|
};
|
|
754
|
-
if (ac.signal.aborted)
|
|
768
|
+
if (ac.signal.aborted) {
|
|
769
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
770
|
+
return finish(result, status);
|
|
771
|
+
}
|
|
755
772
|
try {
|
|
756
773
|
({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
|
|
757
774
|
} catch (err) {
|
|
758
|
-
if (ac.signal.aborted)
|
|
775
|
+
if (ac.signal.aborted) {
|
|
776
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
777
|
+
return finish(result, status);
|
|
778
|
+
}
|
|
759
779
|
process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
|
|
760
780
|
}
|
|
761
781
|
const url = new URL(eventStreamBase());
|
|
@@ -767,15 +787,27 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
767
787
|
try {
|
|
768
788
|
res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
|
|
769
789
|
} catch (err) {
|
|
770
|
-
if (ac.signal.aborted)
|
|
790
|
+
if (ac.signal.aborted) {
|
|
791
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
792
|
+
return finish(result, status);
|
|
793
|
+
}
|
|
771
794
|
return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
|
|
772
795
|
}
|
|
773
796
|
if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
|
|
774
797
|
if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
|
|
775
798
|
const decoder = new TextDecoder();
|
|
776
799
|
let buf = "";
|
|
800
|
+
// BOT-1827: record a match/failure locally and `break` out of BOTH loops
|
|
801
|
+
// without touching `ac`/`abort()` from inside the `for await`. Aborting the
|
|
802
|
+
// controller while it is still the signal driving the body iterator makes
|
|
803
|
+
// the implicit IteratorClose (triggered by `break`/`return`) reject with an
|
|
804
|
+
// AbortError, which used to land in the `catch` below and override the
|
|
805
|
+
// already-decided `{ woke: true }` / `{ failed: true }` result with a
|
|
806
|
+
// `{ timeout: true }` — finalizing the wait session twice. Deciding first,
|
|
807
|
+
// aborting only AFTER the loop has been left cleanly, removes the race.
|
|
808
|
+
let decided = null;
|
|
777
809
|
try {
|
|
778
|
-
for await (const chunk of res.body) {
|
|
810
|
+
frameLoop: for await (const chunk of res.body) {
|
|
779
811
|
buf += decoder.decode(chunk, { stream: true });
|
|
780
812
|
const { frames, rest } = parseSseFrames(buf);
|
|
781
813
|
buf = rest;
|
|
@@ -784,13 +816,26 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
|
|
|
784
816
|
try { sig = JSON.parse(f.data); } catch { continue; }
|
|
785
817
|
if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
|
|
786
818
|
const st = sig.payload?.state ?? null;
|
|
787
|
-
if (isFailed(st)) {
|
|
788
|
-
if (isDone(st)) {
|
|
819
|
+
if (isFailed(st)) { decided = { reason: "failed", result: { failed: true, state: st }, status: "error" }; break frameLoop; }
|
|
820
|
+
if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
|
|
789
821
|
}
|
|
790
822
|
}
|
|
791
823
|
} catch (err) {
|
|
792
|
-
|
|
793
|
-
|
|
824
|
+
// A decided result always wins over a teardown error surfaced by leaving
|
|
825
|
+
// the loop (e.g. the body iterator's `return()` rejecting during
|
|
826
|
+
// cleanup) — fall through to the `decided` handling below instead of
|
|
827
|
+
// reporting the teardown failure.
|
|
828
|
+
if (!decided) {
|
|
829
|
+
if (ac.signal.aborted) {
|
|
830
|
+
const { result, status } = resultForAbortReason(ac.signal.reason);
|
|
831
|
+
return finish(result, status);
|
|
832
|
+
}
|
|
833
|
+
return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (decided) {
|
|
837
|
+
ac.abort(decided.reason);
|
|
838
|
+
return finish(decided.result, decided.status);
|
|
794
839
|
}
|
|
795
840
|
// A clean relay EOF is not a timeout: the server intentionally closes on
|
|
796
841
|
// scope changes and proxies can recycle idle streams. Re-arm from a fresh
|
|
@@ -1868,8 +1913,16 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
1868
1913
|
if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
|
|
1869
1914
|
// Belt-and-braces: the same race can still slip between the pre-check above
|
|
1870
1915
|
// and the wait registering (or the wait's own live-signal miss), so recheck
|
|
1871
|
-
// once more before falling back to a generic timeout/error message.
|
|
1872
|
-
|
|
1916
|
+
// once more before falling back to a generic timeout/error message. Mirrors
|
|
1917
|
+
// the provision wait's BOT-1822 recheck (which only ever needed to confirm
|
|
1918
|
+
// failure), but the reap wait can miss EITHER terminal outcome the same
|
|
1919
|
+
// way — BOT-1827 (lease 239af20d): the reap genuinely completed (server
|
|
1920
|
+
// `reaped` live emit) while the zero-poll wait still reported `{ timeout }`,
|
|
1921
|
+
// so a healthy stack was reported as a hard failure. One direct read tells
|
|
1922
|
+
// a real timeout apart from a missed wake in either direction.
|
|
1923
|
+
const recheck = await api.get(leaseId);
|
|
1924
|
+
if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
|
|
1925
|
+
const postCheck = durableReapFailure(recheck);
|
|
1873
1926
|
if (postCheck) return cleanupResult = postCheck;
|
|
1874
1927
|
return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
|
|
1875
1928
|
})();
|