@botbuddy/cli 1.26.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/package.json +2 -1
- package/src/commands.mjs +39 -1
- package/src/run.mjs +165 -10
- package/src/telemetry-config.mjs +109 -0
- package/src/telemetry-delivery.mjs +40 -0
- package/src/telemetry-outbox.mjs +327 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
"./playwright-reporter": "./src/test/playwright-reporter.mjs",
|
|
14
|
+
"./telemetry": "./src/telemetry-outbox.mjs",
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
package/src/commands.mjs
CHANGED
|
@@ -18,13 +18,16 @@ import { setupMcpKey, revokeMcpKey, resolveMcpConfigKey, resolveEnvVarName, DEFA
|
|
|
18
18
|
import { readAgentBinding } from "./wait-profile.mjs";
|
|
19
19
|
import { runPw } from "./pw/run.mjs";
|
|
20
20
|
import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
|
|
21
|
+
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
22
|
+
import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
|
|
23
|
+
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
21
24
|
|
|
22
25
|
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
23
26
|
// invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
|
|
24
27
|
// must not pay for a registry round-trip.
|
|
25
28
|
export function shouldCheckForUpdates(argv) {
|
|
26
29
|
const [command] = argv;
|
|
27
|
-
if (command === "wait") return false;
|
|
30
|
+
if (command === "wait" || command === "telemetry") return false;
|
|
28
31
|
if (argv.includes("--json")) return false;
|
|
29
32
|
return true;
|
|
30
33
|
}
|
|
@@ -53,6 +56,7 @@ export async function run(argv, {
|
|
|
53
56
|
case "stack": return cmdStack(args);
|
|
54
57
|
case "docker": return cmdDocker(args);
|
|
55
58
|
case "run": return cmdRun(args);
|
|
59
|
+
case "telemetry": return cmdTelemetry(args);
|
|
56
60
|
case "test": return cmdTest(args);
|
|
57
61
|
case "wait": return runWait(args);
|
|
58
62
|
case "pw": return runPw(args);
|
|
@@ -80,6 +84,36 @@ export async function run(argv, {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
async function cmdTelemetry(args) {
|
|
88
|
+
const subcommand = args[0] ?? "help";
|
|
89
|
+
if (!new Set(["status", "replay", "doctor", "help", "--help"]).has(subcommand) || args.length > 1) {
|
|
90
|
+
die("Usage: botbuddy telemetry <status|replay|doctor>");
|
|
91
|
+
}
|
|
92
|
+
if (subcommand === "help" || subcommand === "--help") {
|
|
93
|
+
console.log("botbuddy telemetry status\nbotbuddy telemetry replay\nbotbuddy telemetry doctor");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let location;
|
|
97
|
+
try { location = await loadTelemetryLocation(process.cwd()); }
|
|
98
|
+
catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
|
|
99
|
+
const outbox = await createTelemetryOutbox({ tenant: location.tenant, repository: location.repository, producerVersion: VERSION });
|
|
100
|
+
if (subcommand === "replay") {
|
|
101
|
+
let identity;
|
|
102
|
+
try {
|
|
103
|
+
identity = await loadTelemetryIdentity(process.cwd());
|
|
104
|
+
await attestTelemetryCredential(identity, outbox);
|
|
105
|
+
} catch (error) { console.error(`telemetry: ${error?.message ?? error}`); return 4; }
|
|
106
|
+
await outbox.importLegacyTestRunReceipts(".botbuddy/test-runs");
|
|
107
|
+
const receipt = await outbox.replay((event) => deliverExecutionEvent(event, { credential: identity.credential }));
|
|
108
|
+
console.log(JSON.stringify(receipt));
|
|
109
|
+
return receipt.remaining === 0 ? 0 : 5;
|
|
110
|
+
}
|
|
111
|
+
const status = await outbox.status();
|
|
112
|
+
console.log(JSON.stringify(status));
|
|
113
|
+
if (subcommand === "doctor") return status.queued_count === 0 && !status.last_error_class ? 0 : 5;
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
83
117
|
function cmdHelp() {
|
|
84
118
|
console.log(`${bold("bb")} ${dim(`v${VERSION}`)} — Swarm coordination CLI
|
|
85
119
|
|
|
@@ -132,6 +166,10 @@ ${bold("DOCKER HYGIENE")}
|
|
|
132
166
|
${bold("DURABLE WORKLOADS")}
|
|
133
167
|
run --session-id <id> --environment <env> -- <command>
|
|
134
168
|
Launch a receipt-bearing command under a detached owner
|
|
169
|
+
run --foreground --kind <kind> --environment <env> -- <command>
|
|
170
|
+
Run locally with a durable lifecycle outbox
|
|
171
|
+
telemetry <status|replay|doctor>
|
|
172
|
+
Inspect, replay, or gate durable lifecycle delivery
|
|
135
173
|
|
|
136
174
|
${bold("AGENT WAITS")}
|
|
137
175
|
wait [--any] <condition>... [options]
|
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
|
-
|
|
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);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// BOT-1316 — durable, local-first lifecycle delivery for foreground wrappers.
|
|
2
|
+
// This module deliberately knows nothing about MCP or child output. Producers
|
|
3
|
+
// append a small, allowlisted lifecycle event before spawning a child; delivery
|
|
4
|
+
// is a separate, idempotent step against execution-ingest.
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { chmod, link, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export const OUTBOX_SCHEMA_VERSION = 1;
|
|
12
|
+
export const MAX_OUTBOX_LINE_BYTES = 16 * 1024;
|
|
13
|
+
const LOCK_WAIT_MS = 25;
|
|
14
|
+
const LOCK_ATTEMPTS = 80;
|
|
15
|
+
const SENSITIVE = /(?:[a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@[^\s/]+(?:\/[^\s]*)?|bb_[a-z0-9_-]{10,}|gh[pousr]_[a-z0-9]{16,}|github_pat_[a-z0-9_]{20,}|glpat-[a-z0-9_-]{16,}|xox[baprs]-[a-z0-9-]{10,}|sk-[a-z0-9]{20,}|(?:akia|asia)[a-z0-9]{16}|-----begin[a-z0-9 ]*private key-----[\s\S]*?-----end[a-z0-9 ]*private key-----)|\bbearer\s+[^\s"']+|(?:^|[^a-z0-9])(?:[a-z0-9]+_)*(?:token|api[_-]?key|private[_-]?key|password|secret)(?:_[a-z0-9]+)*\s*[=:]\s*(?:"[^"]*"|'[^']*'|[^\s"']+)/gi;
|
|
16
|
+
const DROP_KEYS = new Set(["stdout", "stderr", "output", "raw_output", "bounded_log", "env", "environment_values", "environment_value", "secrets"]);
|
|
17
|
+
const CONTEXT_KEYS = new Set([
|
|
18
|
+
"agent", "repo", "ticket", "pr", "branch", "sha", "environment", "runner", "phase",
|
|
19
|
+
"worker", "shard", "retry", "current_spec", "passed", "failed", "skipped", "health", "correlations",
|
|
20
|
+
]);
|
|
21
|
+
const CORRELATION_KEYS = new Set([
|
|
22
|
+
"tool_event_id", "command_receipt_id", "test_run_id", "test_execution_id", "wait_session_id",
|
|
23
|
+
"lane_session_id", "agent_signal_seq", "stack_lease_id",
|
|
24
|
+
]);
|
|
25
|
+
const PROGRESS_KEYS = new Set(["completed", "total", "phase", "current_item", "worker", "shard"]);
|
|
26
|
+
const DIAGNOSTIC_KEYS = new Set(["code", "message", "category", "signal", "retryable"]);
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
30
|
+
|
|
31
|
+
function redactString(value) {
|
|
32
|
+
SENSITIVE.lastIndex = 0;
|
|
33
|
+
return value.replace(SENSITIVE, "[REDACTED]");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Exported for wrappers and reporter adapters. Removing forbidden fields is more
|
|
37
|
+
// robust than merely redacting values: raw output is never useful lifecycle data.
|
|
38
|
+
export function redactTelemetry(value) {
|
|
39
|
+
if (Array.isArray(value)) return value.map(redactTelemetry);
|
|
40
|
+
if (!value || typeof value !== "object") return typeof value === "string" ? redactString(value) : value;
|
|
41
|
+
return Object.fromEntries(Object.entries(value)
|
|
42
|
+
.filter(([key]) => !DROP_KEYS.has(key.toLowerCase()))
|
|
43
|
+
.map(([key, item]) => [key, redactTelemetry(item)]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function repositoryHash(repository) {
|
|
47
|
+
return hash(repository.toLowerCase()).slice(0, 32);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validTenant(value) { return typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value); }
|
|
51
|
+
function validRepository(value) { return typeof value === "string" && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value); }
|
|
52
|
+
|
|
53
|
+
function eventPayload(event) {
|
|
54
|
+
const clean = redactTelemetry(event);
|
|
55
|
+
if (!clean || typeof clean !== "object" || Array.isArray(clean)) throw new Error("telemetry event must be an object");
|
|
56
|
+
// execution-ingest is intentionally strict. Keep this client-side allowlist so
|
|
57
|
+
// a reporter cannot accidentally turn arbitrary process state into telemetry.
|
|
58
|
+
const allowed = new Set(["schema_version", "action", "producer_kind", "source_run_id", "attempt", "producer_event_id", "producer_sequence", "occurred_at", "context", "progress", "outcome", "diagnostic", "evidence_refs"]);
|
|
59
|
+
for (const key of Object.keys(clean)) if (!allowed.has(key)) delete clean[key];
|
|
60
|
+
// Mirror the server contract at the producer boundary. A malformed reporter
|
|
61
|
+
// must not create a permanently retrying record merely because it included
|
|
62
|
+
// a harmless but unrecognised field.
|
|
63
|
+
if (clean.context && typeof clean.context === "object" && !Array.isArray(clean.context)) {
|
|
64
|
+
clean.context = Object.fromEntries(Object.entries(clean.context).filter(([key]) => CONTEXT_KEYS.has(key)));
|
|
65
|
+
if (clean.context.correlations && typeof clean.context.correlations === "object" && !Array.isArray(clean.context.correlations)) {
|
|
66
|
+
clean.context.correlations = Object.fromEntries(Object.entries(clean.context.correlations).filter(([key]) => CORRELATION_KEYS.has(key)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (clean.progress && typeof clean.progress === "object" && !Array.isArray(clean.progress)) {
|
|
70
|
+
clean.progress = Object.fromEntries(Object.entries(clean.progress).filter(([key]) => PROGRESS_KEYS.has(key)));
|
|
71
|
+
}
|
|
72
|
+
if (clean.diagnostic && typeof clean.diagnostic === "object" && !Array.isArray(clean.diagnostic)) {
|
|
73
|
+
clean.diagnostic = Object.fromEntries(Object.entries(clean.diagnostic).filter(([key]) => DIAGNOSTIC_KEYS.has(key)));
|
|
74
|
+
}
|
|
75
|
+
return clean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function durableWrite(path, value, { append = false } = {}) {
|
|
79
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
80
|
+
const handle = await open(path, append ? "a" : "w", 0o600);
|
|
81
|
+
try {
|
|
82
|
+
await handle.writeFile(text);
|
|
83
|
+
await handle.sync();
|
|
84
|
+
} finally {
|
|
85
|
+
await handle.close();
|
|
86
|
+
}
|
|
87
|
+
await chmod(path, 0o600);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function atomicJson(path, value) {
|
|
91
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
92
|
+
await durableWrite(temp, `${JSON.stringify(value)}\n`);
|
|
93
|
+
await rename(temp, path);
|
|
94
|
+
// Persist the directory entry, not just the file data, before reporting the
|
|
95
|
+
// acknowledgement checkpoint as durable.
|
|
96
|
+
let dir;
|
|
97
|
+
try { dir = await open(dirname(path), "r"); await dir.sync(); } finally { await dir?.close(); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseRecord(line) {
|
|
101
|
+
let parsed;
|
|
102
|
+
try { parsed = JSON.parse(line); } catch {
|
|
103
|
+
const error = new Error("invalid_json");
|
|
104
|
+
error.code = "invalid_json";
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
if (!parsed || parsed.schema_version !== OUTBOX_SCHEMA_VERSION || typeof parsed.checksum !== "string" || !parsed.event) throw new Error("invalid_record_shape");
|
|
108
|
+
const expected = hash(JSON.stringify(parsed.event));
|
|
109
|
+
if (parsed.checksum !== expected) throw new Error("checksum_mismatch");
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function safeErrorClass(error) {
|
|
114
|
+
const raw = String(error?.code ?? error?.name ?? "delivery_failed");
|
|
115
|
+
return raw.replace(/[^a-z0-9_.-]/gi, "_").slice(0, 96) || "delivery_failed";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function createTelemetryOutbox({
|
|
119
|
+
root = join(homedir(), ".botbuddy", "telemetry"), tenant, repository, producerVersion, now = () => new Date(),
|
|
120
|
+
} = {}) {
|
|
121
|
+
if (!validTenant(tenant)) throw new Error("telemetry tenant must be a lowercase slug");
|
|
122
|
+
if (!validRepository(repository)) throw new Error("telemetry repository must be canonical owner/name");
|
|
123
|
+
if (typeof producerVersion !== "string" || !producerVersion) throw new Error("telemetry producer version is required");
|
|
124
|
+
const directory = join(root, tenant, repositoryHash(repository));
|
|
125
|
+
const path = join(directory, "outbox.ndjson");
|
|
126
|
+
const lockPath = join(directory, "outbox.lock");
|
|
127
|
+
const quarantinePath = join(directory, "outbox.quarantine.ndjson");
|
|
128
|
+
const statusPath = join(directory, "status.json");
|
|
129
|
+
const legacyImportPath = join(directory, "legacy-test-runs.imported.json");
|
|
130
|
+
|
|
131
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
132
|
+
await chmod(directory, 0o700);
|
|
133
|
+
|
|
134
|
+
async function acquireLock() {
|
|
135
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
// Write a complete, fsynced owner record first, then atomically publish
|
|
138
|
+
// it with link(2). A crash can leave an unreferenced temp file but can
|
|
139
|
+
// never strand the shared lock name with missing PID metadata.
|
|
140
|
+
const candidate = `${lockPath}.${process.pid}.${Date.now()}.tmp`;
|
|
141
|
+
await durableWrite(candidate, JSON.stringify({ pid: process.pid, created_at: now().toISOString() }));
|
|
142
|
+
try { await link(candidate, lockPath); } finally { await unlink(candidate).catch(() => {}); }
|
|
143
|
+
return async () => { try { await unlink(lockPath); } catch { /* a process may have cleaned its own stale lock */ } };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error?.code !== "EEXIST") throw error;
|
|
146
|
+
let owner = null;
|
|
147
|
+
try { owner = JSON.parse(await readFile(lockPath, "utf8")); } catch { /* incomplete lock is never age-reclaimed */ }
|
|
148
|
+
// A lock may be reclaimed only after proving the same-host PID is gone.
|
|
149
|
+
if (Number.isInteger(owner?.pid) && owner.pid > 0) {
|
|
150
|
+
let alive = true;
|
|
151
|
+
try { process.kill(owner.pid, 0); } catch (probe) { alive = probe?.code !== "ESRCH"; }
|
|
152
|
+
if (!alive) { try { await unlink(lockPath); } catch { /* winner will retry */ } continue; }
|
|
153
|
+
}
|
|
154
|
+
await sleep(LOCK_WAIT_MS);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const err = new Error("telemetry_outbox_lock_busy");
|
|
158
|
+
err.code = "telemetry_outbox_lock_busy";
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readStatus() {
|
|
163
|
+
try { return JSON.parse(await readFile(statusPath, "utf8")); } catch { return {}; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function writeStatus(patch) {
|
|
167
|
+
const current = await readStatus();
|
|
168
|
+
await atomicJson(statusPath, { ...current, ...patch, producer_version: producerVersion, updated_at: now().toISOString() });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function hasCredentialAttestation({ tenant: attestedTenant, fingerprint }) {
|
|
172
|
+
const persisted = await readStatus();
|
|
173
|
+
const attestation = persisted.credential_attestation;
|
|
174
|
+
return attestation?.schema_version === 1
|
|
175
|
+
&& attestation.tenant === attestedTenant
|
|
176
|
+
&& attestation.fingerprint === fingerprint;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function recordCredentialAttestation({ tenant: attestedTenant, fingerprint }) {
|
|
180
|
+
if (!validTenant(attestedTenant) || !/^[a-f0-9]{64}$/.test(fingerprint ?? "")) throw new Error("invalid telemetry credential attestation");
|
|
181
|
+
await writeStatus({ credential_attestation: { schema_version: 1, tenant: attestedTenant, fingerprint, attested_at: now().toISOString() } });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readLines() {
|
|
185
|
+
try { return (await readFile(path, "utf8")).split("\n").filter(Boolean); } catch (error) { if (error?.code === "ENOENT") return []; throw error; }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function append(event) {
|
|
189
|
+
const release = await acquireLock();
|
|
190
|
+
try {
|
|
191
|
+
const payload = eventPayload(event);
|
|
192
|
+
const record = {
|
|
193
|
+
schema_version: OUTBOX_SCHEMA_VERSION,
|
|
194
|
+
created_at: now().toISOString(),
|
|
195
|
+
producer_version: producerVersion,
|
|
196
|
+
repository,
|
|
197
|
+
event: payload,
|
|
198
|
+
delivery_attempts: 0,
|
|
199
|
+
checksum: hash(JSON.stringify(payload)),
|
|
200
|
+
};
|
|
201
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
202
|
+
if (Buffer.byteLength(line) > MAX_OUTBOX_LINE_BYTES) {
|
|
203
|
+
const error = new Error("telemetry_event_oversized"); error.code = "telemetry_event_oversized"; throw error;
|
|
204
|
+
}
|
|
205
|
+
// append + fsync completes before the caller gets a durable receipt, which
|
|
206
|
+
// is the required pre-spawn barrier for foreground commands.
|
|
207
|
+
await durableWrite(path, line, { append: true });
|
|
208
|
+
await writeStatus({ last_error_class: null });
|
|
209
|
+
return { durable: true, path, event: payload };
|
|
210
|
+
} finally { await release(); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function quarantine(line, offset, reason) {
|
|
214
|
+
await durableWrite(quarantinePath, `${JSON.stringify({ offset, reason, quarantined_at: now().toISOString(), line: line.slice(0, MAX_OUTBOX_LINE_BYTES) })}\n`, { append: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function replay(deliver, { maxEvents = Infinity, deadline = Infinity } = {}) {
|
|
218
|
+
if (typeof deliver !== "function") throw new Error("telemetry replay requires a delivery function");
|
|
219
|
+
const release = await acquireLock();
|
|
220
|
+
try {
|
|
221
|
+
const lines = await readLines();
|
|
222
|
+
const remaining = [];
|
|
223
|
+
let delivered = 0;
|
|
224
|
+
let quarantined = 0;
|
|
225
|
+
let halted = false;
|
|
226
|
+
let attempted = 0;
|
|
227
|
+
let offset = 0;
|
|
228
|
+
for (const line of lines) {
|
|
229
|
+
let record;
|
|
230
|
+
try {
|
|
231
|
+
if (Buffer.byteLength(line) > MAX_OUTBOX_LINE_BYTES) throw new Error("line_oversized");
|
|
232
|
+
record = parseRecord(line);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
quarantined += 1;
|
|
235
|
+
await quarantine(line, offset, error.message || "invalid_record");
|
|
236
|
+
offset += Buffer.byteLength(line) + 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (halted || attempted >= maxEvents || Date.now() >= deadline) { halted = true; remaining.push(record); offset += Buffer.byteLength(line) + 1; continue; }
|
|
240
|
+
try {
|
|
241
|
+
attempted += 1;
|
|
242
|
+
const result = await deliver(record.event, record);
|
|
243
|
+
if (!result?.accepted) {
|
|
244
|
+
if (result?.permanent) {
|
|
245
|
+
quarantined += 1;
|
|
246
|
+
await quarantine(line, offset, result.error ?? "delivery_rejected");
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
throw Object.assign(new Error("delivery_rejected"), { code: result?.error ?? "delivery_rejected" });
|
|
250
|
+
}
|
|
251
|
+
delivered += 1;
|
|
252
|
+
await writeStatus({ last_successful_delivery_at: now().toISOString(), last_error_class: null });
|
|
253
|
+
} catch (error) {
|
|
254
|
+
halted = true;
|
|
255
|
+
remaining.push({ ...record, delivery_attempts: Number(record.delivery_attempts ?? 0) + 1, last_error_class: safeErrorClass(error), last_attempt_at: now().toISOString() });
|
|
256
|
+
await writeStatus({ last_error_class: safeErrorClass(error) });
|
|
257
|
+
}
|
|
258
|
+
offset += Buffer.byteLength(line) + 1;
|
|
259
|
+
}
|
|
260
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
261
|
+
await durableWrite(tmp, remaining.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
262
|
+
await rename(tmp, path);
|
|
263
|
+
let dir;
|
|
264
|
+
try { dir = await open(directory, "r"); await dir.sync(); } finally { await dir?.close(); }
|
|
265
|
+
return { delivered, remaining: remaining.length, quarantined };
|
|
266
|
+
} finally { await release(); }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function status() {
|
|
270
|
+
const lines = await readLines();
|
|
271
|
+
let oldest = null;
|
|
272
|
+
let queued = 0;
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
try {
|
|
275
|
+
const record = parseRecord(line);
|
|
276
|
+
queued += 1;
|
|
277
|
+
if (!oldest || record.created_at < oldest) oldest = record.created_at;
|
|
278
|
+
} catch { queued += 1; }
|
|
279
|
+
}
|
|
280
|
+
const persisted = await readStatus();
|
|
281
|
+
return {
|
|
282
|
+
queued_count: queued,
|
|
283
|
+
oldest_queued_at: oldest,
|
|
284
|
+
last_successful_delivery_at: persisted.last_successful_delivery_at ?? null,
|
|
285
|
+
last_error_class: persisted.last_error_class ?? null,
|
|
286
|
+
producer_version: producerVersion,
|
|
287
|
+
path,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The historical hook reporter writes summaries below `.botbuddy/test-runs`.
|
|
292
|
+
// Import them only through deterministic source/event keys; the persisted
|
|
293
|
+
// fingerprint ledger prevents repeated scans from growing the outbox, while
|
|
294
|
+
// server idempotency remains a second safety net if a machine dies mid-write.
|
|
295
|
+
async function importLegacyTestRunReceipts(legacyDirectory, { maxReceipts = Infinity, deadline = Infinity } = {}) {
|
|
296
|
+
let imported = {};
|
|
297
|
+
try { imported = JSON.parse(await readFile(legacyImportPath, "utf8")); } catch { /* first migration */ }
|
|
298
|
+
let names = [];
|
|
299
|
+
try { names = (await readdir(legacyDirectory)).filter((name) => name.endsWith(".json")); } catch (error) { if (error?.code === "ENOENT") return { imported: 0, skipped: 0 }; throw error; }
|
|
300
|
+
let added = 0;
|
|
301
|
+
let skipped = 0;
|
|
302
|
+
for (const name of names.sort()) {
|
|
303
|
+
if (added >= maxReceipts || Date.now() >= deadline) break;
|
|
304
|
+
const file = join(legacyDirectory, name);
|
|
305
|
+
let raw;
|
|
306
|
+
try { raw = await readFile(file, "utf8"); } catch { skipped += 1; continue; }
|
|
307
|
+
const fingerprint = hash(`${name}\0${raw}`);
|
|
308
|
+
if (imported[fingerprint]) { skipped += 1; continue; }
|
|
309
|
+
let summary;
|
|
310
|
+
try { summary = JSON.parse(raw); } catch { skipped += 1; continue; }
|
|
311
|
+
if (!summary || typeof summary !== "object" || typeof summary.runner !== "string") { skipped += 1; continue; }
|
|
312
|
+
const sourceRunId = `legacy-${fingerprint.slice(0, 32)}`;
|
|
313
|
+
const started = typeof summary.started_at === "string" ? summary.started_at : now().toISOString();
|
|
314
|
+
const finished = typeof summary.finished_at === "string" ? summary.finished_at : now().toISOString();
|
|
315
|
+
const passed = summary.verdict === "pass" || summary.exit_code === 0;
|
|
316
|
+
const context = { repo: repository, environment: "local", runner: summary.runner, phase: passed ? "complete" : "failed", health: "uninstrumented" };
|
|
317
|
+
await append({ schema_version: 1, action: "start", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:start`, producer_sequence: 0, occurred_at: started, context });
|
|
318
|
+
await append({ schema_version: 1, action: "terminal", producer_kind: "hook", source_run_id: sourceRunId, attempt: 1, producer_event_id: `${sourceRunId}:terminal`, producer_sequence: 1, occurred_at: finished, context, outcome: passed ? "passed" : "failed" });
|
|
319
|
+
imported[fingerprint] = { name, imported_at: now().toISOString() };
|
|
320
|
+
added += 1;
|
|
321
|
+
}
|
|
322
|
+
await atomicJson(legacyImportPath, imported);
|
|
323
|
+
return { imported: added, skipped };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { directory, path, quarantinePath, statusPath, append, replay, status, hasCredentialAttestation, recordCredentialAttestation, importLegacyTestRunReceipts };
|
|
327
|
+
}
|