@botbuddy/cli 1.49.0 → 1.50.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/commands.mjs +13 -2
- package/src/test-ingest.mjs +224 -0
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } fro
|
|
|
33
33
|
import { cmdHost, fixHelperWorkers, formatHelperWorkersLines, inspectHelperWorkers } from "./helper-host.mjs";
|
|
34
34
|
import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
|
|
35
35
|
import { runUsage, usageStatus } from "./usage/sync.mjs";
|
|
36
|
+
import { cmdTestIngest, doctorTestIngest, formatTestIngestLine } from "./test-ingest.mjs";
|
|
36
37
|
import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
|
|
37
38
|
|
|
38
39
|
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
@@ -41,7 +42,8 @@ import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
|
|
|
41
42
|
// runs inside every `git push` under a 5 s budget.
|
|
42
43
|
export function shouldCheckForUpdates(argv) {
|
|
43
44
|
const [command] = argv;
|
|
44
|
-
|
|
45
|
+
// BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
|
|
46
|
+
if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
|
|
45
47
|
if (argv.includes("--json")) return false;
|
|
46
48
|
return true;
|
|
47
49
|
}
|
|
@@ -109,6 +111,7 @@ export async function run(argv, {
|
|
|
109
111
|
return code;
|
|
110
112
|
}
|
|
111
113
|
case "test": return cmdTest(args);
|
|
114
|
+
case "test-ingest": return cmdTestIngest(args);
|
|
112
115
|
case "wait": return runWait(args);
|
|
113
116
|
case "pw": return runPw(args);
|
|
114
117
|
case "mcp": return cmdMcp(args);
|
|
@@ -231,6 +234,8 @@ ${bold("DURABLE WORKLOADS")}
|
|
|
231
234
|
Run locally with a durable lifecycle outbox
|
|
232
235
|
telemetry <status|replay|doctor>
|
|
233
236
|
Inspect, replay, or gate durable lifecycle delivery
|
|
237
|
+
test-ingest --file <payload.json|-> [--tenant <slug>]
|
|
238
|
+
Upload a test-run report with this worktree's agent session
|
|
234
239
|
|
|
235
240
|
${bold("USAGE INGEST")}
|
|
236
241
|
usage sync [--since 2d] [--provider claude|codex|all] [--file <transcript>] [--dry-run] [--quiet]
|
|
@@ -264,6 +269,9 @@ export async function cmdDoctor(args, {
|
|
|
264
269
|
publishReport = () => probeEnginePublishIp(),
|
|
265
270
|
helperWorkersReport = async () => inspectHelperWorkers(),
|
|
266
271
|
helperWorkersFix = (report) => fixHelperWorkers(report),
|
|
272
|
+
// BOT-1982: which credential test-run uploads would use — the ONE place a
|
|
273
|
+
// missing upload credential is reported (the harnesses skip silently).
|
|
274
|
+
testIngestReport = () => doctorTestIngest(),
|
|
267
275
|
} = {}) {
|
|
268
276
|
const parsed = parseDoctorArgs(args);
|
|
269
277
|
if (parsed.help) { log(doctorHelp()); return 0; }
|
|
@@ -288,8 +296,10 @@ export async function cmdDoctor(args, {
|
|
|
288
296
|
const recheck = await helperWorkersReport().catch(() => null);
|
|
289
297
|
helperWorkers = recheck ? { ...recheck, fix: helperFix } : { ...helperWorkers, fix: helperFix };
|
|
290
298
|
}
|
|
299
|
+
// BOT-1982: informational, like usage sync — never changes the exit code.
|
|
300
|
+
const testIngest = await Promise.resolve().then(testIngestReport).catch(() => null);
|
|
291
301
|
if (parsed.json) {
|
|
292
|
-
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}) }));
|
|
302
|
+
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}) }));
|
|
293
303
|
} else {
|
|
294
304
|
log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
|
|
295
305
|
log(`client key: ${result.report.client_key.status}`);
|
|
@@ -306,6 +316,7 @@ export async function cmdDoctor(args, {
|
|
|
306
316
|
if (bootstrap) log(formatBootstrapLine(bootstrap));
|
|
307
317
|
if (usage) log(formatUsageSyncLine(usage));
|
|
308
318
|
for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
|
|
319
|
+
if (testIngest) log(formatTestIngestLine(testIngest));
|
|
309
320
|
}
|
|
310
321
|
if (result.exports) log(result.exports);
|
|
311
322
|
return result.exitCode;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// BOT-1982 — upload a test-run payload to the `test-ingest` edge function with
|
|
2
|
+
// THIS worktree's credential. One resolver + one transport, shared by
|
|
3
|
+
// `bb test-ingest` (tenant repos' harnesses call the binary) and this repo's
|
|
4
|
+
// pre-push reporter (scripts/report-test-runs.mjs imports the module).
|
|
5
|
+
//
|
|
6
|
+
// Why an ordered candidate list with 401 fall-through: the old reporters sent the
|
|
7
|
+
// FIRST exported key, so a dead legacy `BOTBUDDY_API_KEY` left in a shell profile
|
|
8
|
+
// shadowed a valid key and every upload 401'd. Now the worktree's agent session
|
|
9
|
+
// (`bb setup`) is preferred, the durable keys follow, the legacy exports come
|
|
10
|
+
// last, and a 401 tries the next candidate — a stale credential can never shadow
|
|
11
|
+
// a valid one. A missing credential is NOT reported per run: callers stay silent
|
|
12
|
+
// and `bb doctor` reports it once (doctorTestIngest).
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { isAgentStateFresh, readAgentState } from "./agent-state.mjs";
|
|
17
|
+
import { readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
18
|
+
import { readKeychainSecret } from "./agent-credential-store.mjs";
|
|
19
|
+
import { resolveAgentBinding } from "./wait-profile.mjs";
|
|
20
|
+
|
|
21
|
+
const execFileP = promisify(execFile);
|
|
22
|
+
export const DEFAULT_TEST_INGEST_URL = "https://api.bot-buddy.ai/functions/v1/test-ingest";
|
|
23
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
24
|
+
export const EXIT_DELIVERED = 0;
|
|
25
|
+
export const EXIT_NO_CREDENTIAL = 3;
|
|
26
|
+
export const EXIT_NOT_DELIVERED = 5;
|
|
27
|
+
export const TEST_INGEST_FIX = "run `bb setup` in this worktree (or `bb doctor --fix`) so test-run uploads use its agent session";
|
|
28
|
+
|
|
29
|
+
/** test-ingest URL: explicit → functions base → derived from the MCP server URL. */
|
|
30
|
+
export function testIngestEndpoint(env = process.env) {
|
|
31
|
+
if (env.BOTBUDDY_TEST_INGEST_URL) return env.BOTBUDDY_TEST_INGEST_URL;
|
|
32
|
+
if (env.BOTBUDDY_FUNCTIONS_URL) return `${env.BOTBUDDY_FUNCTIONS_URL.replace(/\/+$/, "")}/test-ingest`;
|
|
33
|
+
if (env.BOTBUDDY_SERVER_URL) {
|
|
34
|
+
const url = new URL(env.BOTBUDDY_SERVER_URL);
|
|
35
|
+
url.pathname = url.pathname.replace(/\/mcp-server\/?$/, "/test-ingest");
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
return DEFAULT_TEST_INGEST_URL;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A time-bounded Keychain read: a locked/prompting Keychain must never hang a push.
|
|
42
|
+
async function boundedKeychainRead(service, timeoutMs = 3_000) {
|
|
43
|
+
if (process.platform !== "darwin") return null;
|
|
44
|
+
const bounded = (cmd, args) => execFileP(cmd, args, { timeout: timeoutMs, killSignal: "SIGKILL" });
|
|
45
|
+
return readKeychainSecret(service, { execFileImpl: bounded });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function bindingKey({ cwd, env, readCredential }) {
|
|
49
|
+
try {
|
|
50
|
+
const { token, tokenEnv } = await resolveAgentBinding({ cwd, env, readCredential });
|
|
51
|
+
return token ? { token, source: "mcp_key", env: tokenEnv } : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null; // no/malformed binding → no binding key; never throws into a push
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ordered, de-duplicated credential candidates `{ token, source }` (never logged):
|
|
59
|
+
* 1. BOTBUDDY_TEST_INGEST_KEY — an explicit override (CI's dedicated key)
|
|
60
|
+
* 2. the agent session — BOTBUDDY_AGENT_SESSION_TOKEN, else this worktree's
|
|
61
|
+
* FRESH .botbuddy/agent-state.json (an expired cache is never offered or
|
|
62
|
+
* advertised by `bb doctor`; offline, no self-heal registration from a hook)
|
|
63
|
+
* 3. the binding's tier-2 MCP key (env, else a bounded Keychain read)
|
|
64
|
+
* 4. legacy BOTBUDDY_API_KEY / BOTBUDDY_BB_AGENT_KEY — last, so a stale export
|
|
65
|
+
* can never shadow a valid credential
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveTestIngestCredentials({
|
|
68
|
+
env = process.env, cwd = process.cwd(), readState = readAgentState, isFresh = isAgentStateFresh,
|
|
69
|
+
readCredential = async (name, e) => (typeof e[name] === "string" && e[name].trim()) || (await boundedKeychainRead(name)),
|
|
70
|
+
} = {}) {
|
|
71
|
+
const out = [];
|
|
72
|
+
const add = (token, source) => {
|
|
73
|
+
const value = typeof token === "string" ? token.trim() : "";
|
|
74
|
+
if (value && !out.some((c) => c.token === value)) out.push({ token: value, source });
|
|
75
|
+
};
|
|
76
|
+
add(env.BOTBUDDY_TEST_INGEST_KEY, "explicit");
|
|
77
|
+
add(readAgentSessionTokenEnv(env), "session_env");
|
|
78
|
+
try {
|
|
79
|
+
const state = await readState(cwd);
|
|
80
|
+
if (state?.agent_session_token && isFresh(state, new Date())) add(state.agent_session_token, "session");
|
|
81
|
+
} catch { /* unreadable cache = no session candidate */ }
|
|
82
|
+
const key = await bindingKey({ cwd, env, readCredential });
|
|
83
|
+
if (key) add(key.token, key.source);
|
|
84
|
+
add(env.BOTBUDDY_API_KEY, "legacy_api_key");
|
|
85
|
+
add(env.BOTBUDDY_BB_AGENT_KEY, "legacy_agent_key");
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function postOnce({ url, token, payload, fetchImpl, timeoutMs }) {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchImpl(url, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
96
|
+
body: typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
const text = res.text ? await res.text().catch(() => "") : "";
|
|
100
|
+
let body = null;
|
|
101
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
102
|
+
return { status: res.status, ok: res.ok, text, body };
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { status: null, ok: false, error: controller.signal.aborted ? `timed out after ${timeoutMs}ms` : (error?.message ?? String(error)) };
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* POST `payload` with the first candidate test-ingest accepts. Only a 401 moves to
|
|
112
|
+
* the next candidate (any other answer is the server's verdict on the payload).
|
|
113
|
+
* Returns a token-free receipt:
|
|
114
|
+
* { ok:true, status, source, attempts, summary, errors, deep_link }
|
|
115
|
+
* { ok:false, code:"credential_missing", attempts:[] }
|
|
116
|
+
* { ok:false, code:"ingest_errors", status, source, attempts, summary, errors } (200 + row errors)
|
|
117
|
+
* { ok:false, code:"rejected"|"unreachable", status, source, attempts, error }
|
|
118
|
+
*/
|
|
119
|
+
export async function uploadTestRun({
|
|
120
|
+
payload, url = testIngestEndpoint(), credentials, fetchImpl = globalThis.fetch, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const candidates = Array.isArray(credentials) ? credentials : [];
|
|
123
|
+
if (candidates.length === 0) return { ok: false, code: "credential_missing", attempts: [] };
|
|
124
|
+
const attempts = [];
|
|
125
|
+
let last = null;
|
|
126
|
+
for (const candidate of candidates) {
|
|
127
|
+
const res = await postOnce({ url, token: candidate.token, payload, fetchImpl, timeoutMs });
|
|
128
|
+
attempts.push({ source: candidate.source, status: res.status });
|
|
129
|
+
last = { ...res, source: candidate.source };
|
|
130
|
+
if (res.status === 401) continue;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (last.ok) {
|
|
134
|
+
const summary = last.body?.summary ?? null;
|
|
135
|
+
const errors = Array.isArray(summary?.errors) ? summary.errors : [];
|
|
136
|
+
// test-ingest answers 200 with summary.errors for permanent per-row write
|
|
137
|
+
// failures: the request was accepted but rows are missing, so it is NOT a
|
|
138
|
+
// delivery (the harness must not treat it as one and drop its results).
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
return { ok: false, code: "ingest_errors", status: last.status, source: last.source, attempts, summary, errors, deep_link: last.body?.deep_link ?? null };
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, status: last.status, source: last.source, attempts, summary, errors, deep_link: last.body?.deep_link ?? null };
|
|
143
|
+
}
|
|
144
|
+
if (last.status === null) return { ok: false, code: "unreachable", status: null, source: last.source, attempts, error: last.error };
|
|
145
|
+
const message = last.body?.error ?? last.body?.message ?? String(last.text ?? "").slice(0, 200);
|
|
146
|
+
return { ok: false, code: "rejected", status: last.status, source: last.source, attempts, error: message || `http_${last.status}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `bb doctor` line for test-run uploads: which credential would be tried first. */
|
|
150
|
+
export async function doctorTestIngest(opts = {}) {
|
|
151
|
+
const candidates = await resolveTestIngestCredentials(opts);
|
|
152
|
+
if (candidates.length === 0) return { status: "missing", fix: TEST_INGEST_FIX };
|
|
153
|
+
return { status: "available", source: candidates[0].source, candidates: candidates.map((c) => c.source) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function formatTestIngestLine(report) {
|
|
157
|
+
if (!report) return null;
|
|
158
|
+
return report.status === "available"
|
|
159
|
+
? `test-run upload: available (${report.source})`
|
|
160
|
+
: `test-run upload: MISSING — ${report.fix}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function parseTestIngestArgs(argv = []) {
|
|
164
|
+
const args = { file: null, tenant: null, timeoutMs: null, help: false, errors: [] };
|
|
165
|
+
for (let i = 0; i < argv.length; i++) {
|
|
166
|
+
const a = argv[i];
|
|
167
|
+
const value = () => {
|
|
168
|
+
const v = argv[i + 1];
|
|
169
|
+
if (v === undefined || (v.startsWith("--") && v !== "-")) { args.errors.push(`${a} requires a value`); return null; }
|
|
170
|
+
i++;
|
|
171
|
+
return v;
|
|
172
|
+
};
|
|
173
|
+
if (a === "--help" || a === "-h" || a === "help") args.help = true;
|
|
174
|
+
else if (a === "--file") args.file = value();
|
|
175
|
+
else if (a === "--tenant") args.tenant = value();
|
|
176
|
+
else if (a === "--timeout-ms") {
|
|
177
|
+
const v = value();
|
|
178
|
+
if (v !== null) { const n = Number(v); if (Number.isInteger(n) && n > 0) args.timeoutMs = n; else args.errors.push("--timeout-ms must be a positive integer"); }
|
|
179
|
+
} else args.errors.push(`unknown argument ${a}`);
|
|
180
|
+
}
|
|
181
|
+
if (!args.help && !args.file) args.errors.push("--file <path|-> is required");
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function testIngestHelp() {
|
|
186
|
+
return [
|
|
187
|
+
"bb test-ingest --file <payload.json|-> [--tenant <slug>] [--timeout-ms <n>]",
|
|
188
|
+
"",
|
|
189
|
+
"POSTs a test-run payload ({ executions: [...] }, or a raw vitest/playwright --reporter=json",
|
|
190
|
+
"report plus context fields) to BotBuddy's test-ingest with this worktree's credential:",
|
|
191
|
+
"BOTBUDDY_TEST_INGEST_KEY → agent session (bb setup) → the binding's MCP key → legacy exports.",
|
|
192
|
+
"A 401 tries the next credential. Prints one JSON receipt (never a secret).",
|
|
193
|
+
"Exit 0 delivered · 3 no credential (see `bb doctor`) · 5 rejected, unreachable, or accepted with row errors.",
|
|
194
|
+
].join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function cmdTestIngest(argv, {
|
|
198
|
+
env = process.env, cwd = process.cwd(), log = (l) => console.log(l), errorLog = (l) => console.error(l),
|
|
199
|
+
readStdin = () => readFileSync(0, "utf8"), readFile = (p) => readFileSync(p, "utf8"),
|
|
200
|
+
resolveCredentials = resolveTestIngestCredentials, upload = uploadTestRun,
|
|
201
|
+
} = {}) {
|
|
202
|
+
const args = parseTestIngestArgs(argv);
|
|
203
|
+
if (args.help) { log(testIngestHelp()); return 0; }
|
|
204
|
+
if (args.errors.length) { errorLog(`bb test-ingest: ${args.errors.join("; ")}`); return 2; }
|
|
205
|
+
let payload;
|
|
206
|
+
try {
|
|
207
|
+
payload = JSON.parse(args.file === "-" ? readStdin() : readFile(args.file));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
errorLog(`bb test-ingest: cannot read a JSON payload from ${args.file}: ${error?.message ?? error}`);
|
|
210
|
+
return 2;
|
|
211
|
+
}
|
|
212
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
213
|
+
errorLog("bb test-ingest: the payload must be a JSON object");
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
if (args.tenant && !payload.tenant) payload.tenant = args.tenant;
|
|
217
|
+
const credentials = await resolveCredentials({ env, cwd });
|
|
218
|
+
const receipt = await upload({
|
|
219
|
+
payload, credentials, url: testIngestEndpoint(env), timeoutMs: args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
220
|
+
});
|
|
221
|
+
log(JSON.stringify(receipt));
|
|
222
|
+
if (receipt.ok) return EXIT_DELIVERED;
|
|
223
|
+
return receipt.code === "credential_missing" ? EXIT_NO_CREDENTIAL : EXIT_NOT_DELIVERED;
|
|
224
|
+
}
|