@botbuddy/cli 1.25.0 → 1.27.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/src/run.mjs CHANGED
@@ -6,15 +6,19 @@
6
6
  // allowed to call update_command_run. A bb-wait is therefore a notification
7
7
  // mechanism, never the owner of a child process.
8
8
 
9
- import { createHash, randomUUID } from "crypto";
10
- import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "fs/promises";
11
- import { dirname, join } from "path";
12
- import { homedir } from "os";
13
- import { spawn } from "child_process";
14
- import { fileURLToPath } from "url";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { spawn } from "node:child_process";
14
+ import { fileURLToPath } from "node:url";
15
15
  import { callToolJson } from "./api.mjs";
16
16
  import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
17
17
  import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
18
+ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
19
+ import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
20
+ import { attestTelemetryCredential, loadTelemetryIdentity } from "./telemetry-config.mjs";
21
+ import { VERSION } from "./version.mjs";
18
22
 
19
23
  export const RUN_SCHEMA_VERSION = 1;
20
24
  export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
@@ -24,6 +28,7 @@ const MAX_CAPTURE_BYTES = 8_192;
24
28
  // BOT-1510: how often the worker flushes lane case verdicts to the backend.
25
29
  // Well under AC-3's 5 s so a begin/verdict is visible within the window.
26
30
  const LANE_FLUSH_INTERVAL_MS = 2_500;
31
+ const FOREGROUND_KINDS = new Set(["test", "wait", "browser", "ci", "hook", "stack"]);
27
32
 
28
33
  // BOT-1582: the server's session-token shape (bb_agent_ + 64 hex) plus the
29
34
  // BOT-1572 bb_sess_ legacy alias, both accepted for one release.
@@ -34,7 +39,7 @@ export function parseRunArgs(argv, env = process.env) {
34
39
  // session, so --session-id becomes optional (the relay derives it). The old
35
40
  // $BOTBUDDY_SESSION_TOKEN is still accepted for one release. $BOTBUDDY_SESSION_ID
36
41
  // is the default when a plain id is used (parity with `botbuddy test`).
37
- const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
42
+ const opts = { sessionId: env.BOTBUDDY_SESSION_ID ?? null, sessionToken: readAgentKeyEnv(env), environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, foreground: false, json: false };
38
43
  const errors = [];
39
44
  const separator = argv.indexOf("--");
40
45
  const flags = separator === -1 ? argv : argv.slice(0, separator);
@@ -62,6 +67,7 @@ export function parseRunArgs(argv, env = process.env) {
62
67
  } catch (error) { errors.push(`--rerun-reason must be a JSON object (${error instanceof Error ? error.message : String(error)})`); }
63
68
  break;
64
69
  }
70
+ case "--foreground": opts.foreground = true; break;
65
71
  case "--json": opts.json = true; break;
66
72
  default: errors.push(`unknown option: ${flag}`);
67
73
  }
@@ -70,7 +76,8 @@ export function parseRunArgs(argv, env = process.env) {
70
76
  // session from the token). A malformed token, or a plain id AND a differing
71
77
  // token, is a hard error.
72
78
  if (opts.sessionToken && !SESSION_TOKEN_RE.test(opts.sessionToken)) errors.push("$BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>");
73
- if (!opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
79
+ if (!opts.foreground && !opts.sessionId && !opts.sessionToken) errors.push("--session-id is required (from register_agent), or set $BOTBUDDY_AGENT_KEY");
80
+ if (opts.foreground && !FOREGROUND_KINDS.has(opts.kind)) errors.push("--kind must be test, wait, browser, ci, hook, or stack with --foreground");
74
81
  if (!opts.environment || !["local", "preview", "staging", "production", "none"].includes(opts.environment)) errors.push("--environment must be local, preview, staging, production, or none");
75
82
  if (!command.length) errors.push("a workload is required after --");
76
83
  if (!Number.isInteger(opts.expectedDuration) || opts.expectedDuration < 0) errors.push("--expected-duration must be a non-negative integer");
@@ -78,6 +85,118 @@ export function parseRunArgs(argv, env = process.env) {
78
85
  return { opts, command, errors };
79
86
  }
80
87
 
88
+ function foregroundOutcome({ exitCode, signal, timedOut }) {
89
+ if (timedOut) return "timed_out";
90
+ if (signal) return "canceled";
91
+ return exitCode === 0 ? "passed" : "failed";
92
+ }
93
+
94
+ function signalExitCode(signal) {
95
+ const codes = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129, SIGKILL: 137 };
96
+ return codes[signal] ?? EXIT.INTERNAL;
97
+ }
98
+
99
+ // Foreground deliberately shares no ownership code with BOT-1350's detached
100
+ // runner. It has exactly one durable pre-spawn boundary (the outbox start
101
+ // event), then returns the child verdict even when terminal delivery degrades.
102
+ export async function launchForegroundRun(argv, {
103
+ cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process,
104
+ } = {}) {
105
+ const { opts, command, errors } = parseRunArgs(argv);
106
+ if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
107
+ if (!opts.foreground) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: "foreground_flag_required", exit_code: EXIT.INVALID } };
108
+ if (!identity?.repository || !identity?.tenant || !identity?.appSlug) {
109
+ return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: "telemetry_identity_required", exit_code: EXIT.INVALID } };
110
+ }
111
+ const telemetry = outbox ?? await createTelemetryOutbox({
112
+ tenant: identity.tenant,
113
+ repository: identity.repository,
114
+ producerVersion: identity.producerVersion ?? VERSION,
115
+ });
116
+ // An authenticated invocation is also the bounded recovery opportunity for
117
+ // prior offline records. Failure remains durable and never alters the child
118
+ // verdict below.
119
+ if (!outbox) {
120
+ // A cached proof keeps offline hooks fast; a new or rotated credential must
121
+ // be authenticated once before it may scope a BotBuddy lifecycle event.
122
+ try {
123
+ await attestTelemetryCredential(identity, telemetry);
124
+ } catch (error) {
125
+ return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.message ?? "telemetry_credential_unattested", exit_code: EXIT.INVALID } };
126
+ }
127
+ await telemetry.importLegacyTestRunReceipts(join(cwd, ".botbuddy", "test-runs"), { maxReceipts: 8, deadline: Date.now() + 2_250 }).catch(() => {});
128
+ await telemetry.replay(
129
+ (event) => deliverExecutionEvent(event, { credential: identity.credential }),
130
+ // Recovery is opportunistic: a large offline backlog must never turn the
131
+ // next hook invocation into an unbounded pre-spawn upload job.
132
+ { maxEvents: 8, deadline: Date.now() + 2_250 },
133
+ ).catch(() => {});
134
+ }
135
+ const runId = randomUUID();
136
+ const context = {
137
+ repo: identity.repository,
138
+ environment: opts.environment,
139
+ runner: identity.appSlug,
140
+ phase: opts.kind === "wait" ? "waiting" : "testing",
141
+ health: "uninstrumented",
142
+ };
143
+ const base = {
144
+ schema_version: 1,
145
+ producer_kind: opts.kind,
146
+ source_run_id: runId,
147
+ attempt: 1,
148
+ occurred_at: new Date().toISOString(),
149
+ context,
150
+ };
151
+ try {
152
+ await telemetry.append({ ...base, action: "start", producer_event_id: `${runId}:start`, producer_sequence: 0 });
153
+ } catch (error) {
154
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.code ?? "telemetry_start_not_durable", exit_code: EXIT.INTERNAL } };
155
+ }
156
+ let child;
157
+ try {
158
+ // A foreground command still needs its own process group: hooks commonly
159
+ // launch package managers or test runners, whose descendants otherwise
160
+ // survive a timeout after their immediate parent exits.
161
+ child = spawnImpl(command[0], command.slice(1), { cwd, detached: process.platform !== "win32", stdio: "inherit", windowsHide: true });
162
+ } catch (error) {
163
+ await telemetry.append({ ...base, action: "terminal", producer_event_id: `${runId}:terminal`, producer_sequence: 1, outcome: "crashed", diagnostic: { code: "spawn_failed", category: "spawn" } }).catch(() => {});
164
+ return { exitCode: EXIT.INTERNAL, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "crashed", run_id: runId, telemetry: "degraded", exit_code: EXIT.INTERNAL } };
165
+ }
166
+ let timedOut = false;
167
+ let killTimer;
168
+ const timeout = setTimeout(() => {
169
+ timedOut = true;
170
+ terminateProcessGroup(child, "SIGTERM");
171
+ killTimer = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), TIMEOUT_GRACE_MS);
172
+ }, opts.timeout * 1000);
173
+ const forwarded = forwardTerminationSignals(child, signalEmitter);
174
+ const result = await waitForChild(child);
175
+ forwarded.cleanup();
176
+ clearTimeout(timeout);
177
+ clearTimeout(killTimer);
178
+ const terminalSignal = forwarded.signal ?? result.signal;
179
+ const outcome = result.error ? "crashed" : foregroundOutcome({ ...result, signal: terminalSignal, timedOut });
180
+ let telemetryState = "durable";
181
+ try {
182
+ await telemetry.append({
183
+ ...base,
184
+ action: "terminal",
185
+ producer_event_id: `${runId}:terminal`,
186
+ producer_sequence: 1,
187
+ // BOT-1317 derives duration from the two lifecycle timestamps. `base`
188
+ // intentionally represents pre-spawn time, so terminal needs a fresh
189
+ // timestamp after the child has actually completed.
190
+ occurred_at: new Date().toISOString(),
191
+ context: { ...context, phase: outcome === "passed" ? "complete" : "failed" },
192
+ outcome,
193
+ ...(terminalSignal ? { diagnostic: { code: "child_signaled", category: "signal", signal: terminalSignal } } : {}),
194
+ });
195
+ } catch { telemetryState = "degraded"; }
196
+ const exitCode = result.error ? EXIT.INTERNAL : timedOut ? signalExitCode("SIGTERM") : terminalSignal ? signalExitCode(terminalSignal) : result.exitCode ?? EXIT.INTERNAL;
197
+ return { exitCode, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome, run_id: runId, telemetry: telemetryState, exit_code: exitCode } };
198
+ }
199
+
81
200
  export function terminalStatus({ exitCode, signal, timedOut = false, ownerLost = false }) {
82
201
  if (ownerLost) return "owner_lost";
83
202
  if (timedOut) return "timed_out";
@@ -280,7 +399,30 @@ function boundedCapture() {
280
399
  }
281
400
 
282
401
  function terminateProcessGroup(child, signal) {
283
- try { process.kill(-child.pid, signal); } catch { try { child.kill?.(signal); } catch { /* already exited */ } }
402
+ if (process.platform !== "win32" && Number.isInteger(child?.pid) && child.pid > 0) {
403
+ try { process.kill(-child.pid, signal); return; } catch { /* fall back to the immediate child */ }
404
+ }
405
+ try { child.kill?.(signal); } catch { /* already exited */ }
406
+ }
407
+
408
+ function forwardTerminationSignals(child, signalEmitter) {
409
+ let signal = null;
410
+ let escalation = null;
411
+ const handlers = new Map();
412
+ for (const candidate of ["SIGINT", "SIGTERM", "SIGHUP"]) {
413
+ const handler = () => {
414
+ if (signal) { terminateProcessGroup(child, "SIGKILL"); return; }
415
+ signal = candidate;
416
+ terminateProcessGroup(child, candidate);
417
+ escalation = setTimeout(() => terminateProcessGroup(child, "SIGKILL"), TIMEOUT_GRACE_MS);
418
+ };
419
+ handlers.set(candidate, handler);
420
+ signalEmitter.on(candidate, handler);
421
+ }
422
+ return {
423
+ get signal() { return signal; },
424
+ cleanup() { clearTimeout(escalation); for (const [candidate, handler] of handlers) signalEmitter.removeListener(candidate, handler); },
425
+ };
284
426
  }
285
427
 
286
428
  function waitForChild(child) {
@@ -343,7 +485,20 @@ export async function cmdRun(args) {
343
485
  return;
344
486
  }
345
487
  if (args[0] === "help" || args[0] === "--help") {
346
- console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run cancel <run_id>");
488
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>");
489
+ return;
490
+ }
491
+ if (args.includes("--foreground")) {
492
+ let identity;
493
+ try { identity = await loadTelemetryIdentity(); }
494
+ catch (error) {
495
+ oneLine({ schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", error: error?.message ?? "telemetry_identity_invalid", exit_code: EXIT.INVALID }, false);
496
+ process.exitCode = EXIT.INVALID;
497
+ return;
498
+ }
499
+ const result = await launchForegroundRun(args, { identity: { ...identity, producerVersion: VERSION } });
500
+ oneLine(result.receipt, Boolean(parseRunArgs(args).opts.json));
501
+ process.exitCode = result.exitCode;
347
502
  return;
348
503
  }
349
504
  const result = await launchRun(args);
package/src/stack.mjs CHANGED
@@ -353,8 +353,8 @@ export function parseSupabaseStatus(text) {
353
353
  // ── runtime (network / process) ──────────────────────────────────────────────
354
354
 
355
355
  // BOT-1520: source both auth headers from the Keychain — the owner OAuth token
356
- // (`botbuddy login`) is preferred, the tenant-bound agent key
357
- // (`botbuddy profile setup`) is the fallback. No plaintext config.json secret.
356
+ // (`botbuddy login`) is preferred, the tenant-bound MCP key
357
+ // (`botbuddy mcp setup`, BOT-1608) is the fallback. No plaintext config.json secret.
358
358
  export async function stackAuthHeader() {
359
359
  const agentKey = await resolveAgentKey();
360
360
  const owner = await resolveOwnerToken({ getConfig });
@@ -1132,7 +1132,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1132
1132
  }
1133
1133
 
1134
1134
  try {
1135
- if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
1135
+ if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy login (agents: botbuddy mcp setup)" };
1136
1136
  // BOT-1585: request_stack_lease requires this machine's hardware id so the lease
1137
1137
  // dispatches to the machine the worktree was registered on. `stack run` builds its
1138
1138
  // own payload (separate from `cmdUp`), so it must send it too.
@@ -0,0 +1,109 @@
1
+ // BOT-1316 — checked-in repo identity, separate from local credentials.
2
+ import { execFile } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { resolveAgentBinding } from "./wait-profile.mjs";
8
+ import { callToolJson } from "./api.mjs";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const REQUIRED_KEYS = ["schema_version", "repository", "app_slug", "profile", "expected_tenant"];
12
+ const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
13
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
14
+
15
+ export function parseTelemetryConfig(raw) {
16
+ let value;
17
+ try { value = JSON.parse(raw); } catch { throw new Error(".botbuddy/config.json is not valid JSON"); }
18
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(".botbuddy/config.json must be an object");
19
+ const keys = Object.keys(value).sort();
20
+ if (keys.length !== REQUIRED_KEYS.length || keys.some((key, index) => key !== [...REQUIRED_KEYS].sort()[index])) {
21
+ throw new Error(".botbuddy/config.json must contain exactly schema_version, repository, app_slug, profile, expected_tenant");
22
+ }
23
+ if (value.schema_version !== 1 || !REPOSITORY.test(value.repository) || !SLUG.test(value.app_slug) || !SLUG.test(value.profile) || !SLUG.test(value.expected_tenant)) {
24
+ throw new Error(".botbuddy/config.json has an invalid schema_version or identity value");
25
+ }
26
+ return { schemaVersion: 1, repository: value.repository, appSlug: value.app_slug, profile: value.profile, expectedTenant: value.expected_tenant };
27
+ }
28
+
29
+ export async function repositoryFromRemote(cwd = process.cwd()) {
30
+ const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { cwd });
31
+ const raw = stdout.trim().replace(/\.git$/, "");
32
+ const match = raw.match(/(?:github\.com[/:])([^/]+\/[^/]+)$/i);
33
+ if (!match) throw new Error("origin remote is not a canonical GitHub repository");
34
+ return match[1];
35
+ }
36
+
37
+ export async function verifyTelemetryIdentity(config, {
38
+ cwd = process.cwd(), repositoryFromRemote: remote = repositoryFromRemote, resolveBinding = resolveAgentBinding, call = callToolJson, verifyCredential = false,
39
+ } = {}) {
40
+ const repository = await remote(cwd);
41
+ if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
42
+ const binding = await resolveBinding({ cwd });
43
+ if (!binding?.token) throw new Error("telemetry requires a locally stored tenant-bound credential");
44
+ if (binding.tenant !== config.expectedTenant) throw new Error("telemetry tenant mismatch with checked-in config");
45
+ // A binding file names the intended tenant; it is not proof that a rotated
46
+ // environment credential still belongs there. Ask the server's read-only
47
+ // identity endpoint before allowing a foreground child to run.
48
+ if (verifyCredential) {
49
+ const verified = await call("whoami", {}, {
50
+ auth: { "x-agent-api-key": binding.token },
51
+ tenant: config.expectedTenant,
52
+ signal: AbortSignal.timeout(2_000),
53
+ });
54
+ if (!verified?.ok || verified.isError || verified.data?.tenant_id !== config.expectedTenant) {
55
+ throw new Error("telemetry credential is not attested to the expected tenant");
56
+ }
57
+ }
58
+ return { repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile, credential: binding.token };
59
+ }
60
+
61
+ export async function loadTelemetryLocation(cwd = process.cwd(), {
62
+ repositoryFromRemote: remote = repositoryFromRemote,
63
+ } = {}) {
64
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
65
+ const root = stdout.trim();
66
+ const raw = await readFile(join(root, ".botbuddy", "config.json"), "utf8");
67
+ const config = parseTelemetryConfig(raw);
68
+ const repository = await remote(root);
69
+ if (repository.toLowerCase() !== config.repository.toLowerCase()) throw new Error("telemetry repository mismatch with origin remote");
70
+ return { root, repository: config.repository, tenant: config.expectedTenant, appSlug: config.appSlug, profile: config.profile };
71
+ }
72
+
73
+ export async function loadTelemetryIdentity(cwd = process.cwd(), options = {}) {
74
+ const location = await loadTelemetryLocation(cwd, options);
75
+ const config = {
76
+ schemaVersion: 1,
77
+ repository: location.repository,
78
+ appSlug: location.appSlug,
79
+ profile: location.profile,
80
+ expectedTenant: location.tenant,
81
+ };
82
+ return verifyTelemetryIdentity(config, { cwd: location.root, ...options });
83
+ }
84
+
85
+ function credentialFingerprint(credential) {
86
+ return createHash("sha256").update(credential).digest("hex");
87
+ }
88
+
89
+ // A tenant-bound key must be proven once before foreground execution can trust
90
+ // it. The proof is keyed to a non-reversible fingerprint in the 0600 outbox
91
+ // status file, so a later rotation or cross-tenant environment value cannot
92
+ // reuse a prior attestation. Once cached, the check remains fully offline.
93
+ export async function attestTelemetryCredential(identity, outbox, { call = callToolJson } = {}) {
94
+ const fingerprint = credentialFingerprint(identity?.credential ?? "");
95
+ if (!identity?.credential || !identity?.tenant || !outbox?.hasCredentialAttestation || !outbox?.recordCredentialAttestation) {
96
+ throw new Error("telemetry credential attestation is unavailable");
97
+ }
98
+ if (await outbox.hasCredentialAttestation({ tenant: identity.tenant, fingerprint })) return { cached: true };
99
+ const verified = await call("whoami", {}, {
100
+ auth: { "x-agent-api-key": identity.credential },
101
+ tenant: identity.tenant,
102
+ signal: AbortSignal.timeout(2_000),
103
+ });
104
+ if (!verified?.ok || verified.isError || verified.data?.tenant_id !== identity.tenant) {
105
+ throw new Error("telemetry credential is not attested to the expected tenant");
106
+ }
107
+ await outbox.recordCredentialAttestation({ tenant: identity.tenant, fingerprint });
108
+ return { cached: false };
109
+ }
@@ -0,0 +1,40 @@
1
+ // BOT-1316 — direct HTTP delivery to BOT-1317. This intentionally bypasses the
2
+ // MCP tool surface: agents never need to call telemetry MCP tools.
3
+ export function executionIngestEndpoint(serverUrl = process.env.BOTBUDDY_SERVER_URL || "https://api.bot-buddy.ai/functions/v1/mcp-server") {
4
+ const url = new URL(serverUrl);
5
+ url.pathname = url.pathname.replace(/\/mcp-server\/?$/, "/execution-ingest");
6
+ return url.toString();
7
+ }
8
+
9
+ export async function deliverExecutionEvent(event, {
10
+ credential, endpoint = executionIngestEndpoint(), fetchImpl = fetch, timeoutMs = 2_000,
11
+ } = {}) {
12
+ if (typeof credential !== "string" || !credential) return { accepted: false, error: "credential_missing" };
13
+ const controller = new AbortController();
14
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
15
+ try {
16
+ const response = await fetchImpl(endpoint, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json", "x-agent-api-key": credential },
19
+ body: JSON.stringify(event),
20
+ signal: controller.signal,
21
+ });
22
+ let body = {};
23
+ try { body = await response.json(); } catch { /* typed HTTP status still controls retry */ }
24
+ if (response.status === 200 || response.status === 202) return { accepted: true, receipt: body.receipt ?? "accepted" };
25
+ // A reaper may have terminalized a run after a successful upload whose
26
+ // response was lost. BOT-1317 correctly refuses a contradictory terminal;
27
+ // retaining that record would otherwise block every later local event.
28
+ if (response.status === 409 && body.error === "terminal_conflict") return { accepted: true, receipt: "terminal_conflict_retired" };
29
+ // The queue cannot repair an event rejected by the server's contract.
30
+ // Quarantine these client errors so one malformed historical line does not
31
+ // starve later valid lifecycle events. Authentication is deliberately left
32
+ // retryable: a key can be restored or rotated without discarding evidence.
33
+ if ([400, 404, 409, 413, 422].includes(response.status)) {
34
+ return { accepted: false, permanent: true, error: body.error ?? `http_${response.status}` };
35
+ }
36
+ return { accepted: false, error: body.error ?? `http_${response.status}` };
37
+ } catch (error) {
38
+ return { accepted: false, error: controller.signal.aborted ? "timeout" : error?.code ?? "transport" };
39
+ } finally { clearTimeout(timeout); }
40
+ }