@botbuddy/cli 1.58.1 → 1.59.0
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 +67 -8
- 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
|
|
|
@@ -3061,9 +3062,17 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
3061
3062
|
const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
|
|
3062
3063
|
const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
|
|
3063
3064
|
const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
|
|
3064
|
-
|
|
3065
|
+
const final = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
|
|
3066
|
+
// BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
|
|
3067
|
+
if (final && typeof final === "object" && incidentBox.outcomeChannel) {
|
|
3068
|
+
Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
|
|
3069
|
+
}
|
|
3070
|
+
return final;
|
|
3065
3071
|
}
|
|
3066
3072
|
|
|
3073
|
+
/** BOT-2467: where a lifecycle result carries its outcome-record channel. */
|
|
3074
|
+
export const OUTCOME_CHANNEL = Symbol.for("botbuddy.stackRunOutcomeChannel");
|
|
3075
|
+
|
|
3067
3076
|
async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
3068
3077
|
// BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
|
|
3069
3078
|
// long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
|
|
@@ -3083,6 +3092,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3083
3092
|
refresh: refreshAuth,
|
|
3084
3093
|
auth: initialAuth,
|
|
3085
3094
|
});
|
|
3095
|
+
// BOT-2467: cmdRun records the run's outcome with the credential this run ended on.
|
|
3096
|
+
incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
|
|
3086
3097
|
const api = adapters.api ?? {
|
|
3087
3098
|
request: (args) => rpc.call("request_stack_lease", args),
|
|
3088
3099
|
get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
|
|
@@ -3713,7 +3724,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3713
3724
|
}
|
|
3714
3725
|
if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
|
|
3715
3726
|
const cleanup = await requestCleanup();
|
|
3716
|
-
|
|
3727
|
+
// BOT-2467: `timeoutPhase` is for the outcome record only (never on the receipt):
|
|
3728
|
+
// an admitted lease whose stack never started is not a capacity shortage.
|
|
3729
|
+
return { exitCode: EXIT.TIMEOUT, outcome: "timeout", timeoutPhase: "provision", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
|
|
3717
3730
|
}
|
|
3718
3731
|
}
|
|
3719
3732
|
if (active?.failed || active?.error || active?.auth) {
|
|
@@ -3969,13 +3982,54 @@ export function runReceiptFields(result) {
|
|
|
3969
3982
|
};
|
|
3970
3983
|
}
|
|
3971
3984
|
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3985
|
+
/**
|
|
3986
|
+
* `stack run`: the lifecycle, its one receipt, then (BOT-2467) one best-effort
|
|
3987
|
+
* `record_stack_run_outcome` call carrying the platform-vs-script classification.
|
|
3988
|
+
* The record runs strictly AFTER the receipt is on stdout and can never change it
|
|
3989
|
+
* or the returned exit code.
|
|
3990
|
+
*/
|
|
3991
|
+
export async function cmdRun(opts, childArgv, {
|
|
3992
|
+
lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
|
|
3993
|
+
} = {}) {
|
|
3994
|
+
const startedAt = now();
|
|
3995
|
+
const result = await lifecycle(opts, childArgv);
|
|
3996
|
+
const fields = runReceiptFields(result);
|
|
3997
|
+
const code = emit(buildReceipt(fields), opts, result.exitCode);
|
|
3998
|
+
try {
|
|
3999
|
+
await report({ fields: stackRunOutcomeFields(result), exitCode: code, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
|
|
4000
|
+
} catch { /* best-effort: the receipt and exit code are already final */ }
|
|
4001
|
+
return code;
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
/**
|
|
4005
|
+
* BOT-2467: the credential an argument-refused `stack run` can record its outcome
|
|
4006
|
+
* with — env/flag/cached session only. Never self-heals (no mint, no Keychain read)
|
|
4007
|
+
* on a fail-fast path.
|
|
4008
|
+
*/
|
|
4009
|
+
/**
|
|
4010
|
+
* BOT-2467: the fields the outcome classifier reads — the receipt's, plus facts the
|
|
4011
|
+
* receipt deliberately does not carry (which wait a timeout ended). Never emitted.
|
|
4012
|
+
*/
|
|
4013
|
+
export function stackRunOutcomeFields(result) {
|
|
4014
|
+
return { ...runReceiptFields(result), ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}) };
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
async function parseErrorOutcomeChannel(argv, { resolveCredential = resolveAgentSessionCredential, callTool = callToolJson } = {}) {
|
|
4018
|
+
try {
|
|
4019
|
+
// Only this command's own options: a credential-looking flag in the child argv
|
|
4020
|
+
// (after `--`) is the child's, never ours.
|
|
4021
|
+
const end = argv.indexOf("--");
|
|
4022
|
+
const ownArgv = end === -1 ? argv : argv.slice(0, end);
|
|
4023
|
+
const credential = await resolveCredential({ argv: ownArgv, env: process.env, cwd: process.cwd(), allowSelfHeal: false });
|
|
4024
|
+
if (typeof credential?.token !== "string" || !AGENT_KEY_RE.test(credential.token)) return null;
|
|
4025
|
+
return { auth: { "x-agent-api-key": credential.token }, callTool };
|
|
4026
|
+
} catch {
|
|
4027
|
+
return null;
|
|
4028
|
+
}
|
|
3975
4029
|
}
|
|
3976
4030
|
|
|
3977
4031
|
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|
|
3978
|
-
export async function cmdStack(argv) {
|
|
4032
|
+
export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeChannel = parseErrorOutcomeChannel } = {}) {
|
|
3979
4033
|
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
3980
4034
|
process.stdout.write(STACK_HELP + "\n");
|
|
3981
4035
|
return EXIT.OK;
|
|
@@ -3983,7 +4037,12 @@ export async function cmdStack(argv) {
|
|
|
3983
4037
|
const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
|
|
3984
4038
|
if (errors.length) {
|
|
3985
4039
|
for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
|
|
3986
|
-
const
|
|
4040
|
+
const fields = { command: command || "?", outcome: "error", error: errors[0] };
|
|
4041
|
+
const code = emit(buildReceipt(fields), opts, EXIT.INVALID);
|
|
4042
|
+
// BOT-2467: a refused `stack run` is recorded as an `invocation` outcome (no lease).
|
|
4043
|
+
if (command === "run") {
|
|
4044
|
+
try { await report({ fields, exitCode: code, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
|
|
4045
|
+
}
|
|
3987
4046
|
process.exitCode = code;
|
|
3988
4047
|
return code;
|
|
3989
4048
|
}
|
|
@@ -4001,7 +4060,7 @@ export async function cmdStack(argv) {
|
|
|
4001
4060
|
case "status": code = await cmdStatus(leaseId, opts); break;
|
|
4002
4061
|
case "touch": code = await cmdTouch(leaseId, opts); break;
|
|
4003
4062
|
case "done": code = await cmdDone(leaseId, opts); break;
|
|
4004
|
-
case "run": code = await cmdRun(opts, childArgv); break;
|
|
4063
|
+
case "run": code = await cmdRun(opts, childArgv, { report }); break;
|
|
4005
4064
|
default:
|
|
4006
4065
|
process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
|
|
4007
4066
|
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
|
|