@botbuddy/cli 1.8.4 → 1.8.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.8.4",
3
+ "version": "1.8.6",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,10 @@
8
8
  "pw": "./bin/pw.mjs",
9
9
  "bb-pw": "./bin/bb-pw.mjs"
10
10
  },
11
+ "exports": {
12
+ "./playwright-reporter": "./src/test/playwright-reporter.mjs",
13
+ "./package.json": "./package.json"
14
+ },
11
15
  "files": [
12
16
  "bin/",
13
17
  "src/",
@@ -1 +1 @@
1
- {"schema_version":1,"source_version":"1.8.3","source_identity":"8a450fe651f42fc96f9fd713da5fe707ff48a914c174496337495adf0c425588"}
1
+ {"schema_version":1,"source_version":"1.8.3","source_identity":"8304ea6fbec329d54d6361cb6cbf5a6789331b0851d09caf151f0663ade18ec2"}
package/src/commands.mjs CHANGED
@@ -7,6 +7,7 @@ import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from
7
7
  import { cmdStack } from "./stack.mjs";
8
8
  import { cmdDocker } from "./docker-hygiene.mjs";
9
9
  import { cmdRun } from "./run.mjs";
10
+ import { cmdTest } from "./test-lane.mjs";
10
11
  import { runWait } from "./wait.mjs";
11
12
  import { green, red, cyan, dim, bold, die } from "./utils.mjs";
12
13
  import { VERSION } from "./version.mjs";
@@ -32,6 +33,7 @@ export async function run(argv) {
32
33
  case "stack": return cmdStack(args);
33
34
  case "docker": return cmdDocker(args);
34
35
  case "run": return cmdRun(args);
36
+ case "test": return cmdTest(args);
35
37
  case "wait": return runWait(args);
36
38
  case "pw": return runPw(args);
37
39
  case "profile": return cmdProfile(args);
package/src/run.mjs CHANGED
@@ -13,12 +13,16 @@ import { homedir } from "os";
13
13
  import { spawn } from "child_process";
14
14
  import { fileURLToPath } from "url";
15
15
  import { callToolJson } from "./api.mjs";
16
+ import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
16
17
 
17
18
  export const RUN_SCHEMA_VERSION = 1;
18
19
  export const EXIT = Object.freeze({ OK: 0, INVALID: 4, BACKEND: 5, INTERNAL: 7 });
19
20
  export const DEFAULT_TIMEOUT_SECONDS = 8 * 60 * 60;
20
21
  export const TIMEOUT_GRACE_MS = 5_000;
21
22
  const MAX_CAPTURE_BYTES = 8_192;
23
+ // BOT-1510: how often the worker flushes lane case verdicts to the backend.
24
+ // Well under AC-3's 5 s so a begin/verdict is visible within the window.
25
+ const LANE_FLUSH_INTERVAL_MS = 2_500;
22
26
 
23
27
  export function parseRunArgs(argv) {
24
28
  const opts = { sessionId: null, environment: null, category: "validation", kind: "other", expectedDuration: 0, timeout: DEFAULT_TIMEOUT_SECONDS, rerunReason: null, json: false };
@@ -91,7 +95,7 @@ function workerInvocation(context) {
91
95
  return [process.execPath, [bin, "run", "--worker", context]];
92
96
  }
93
97
 
94
- export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = callToolJson } = {}) {
98
+ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn, call = callToolJson, childEnv = null, testRun = null } = {}) {
95
99
  const { opts, command, errors } = parseRunArgs(argv);
96
100
  if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
97
101
  const runId = randomUUID();
@@ -106,7 +110,7 @@ export async function launchRun(argv, { cwd = process.cwd(), spawnImpl = spawn,
106
110
  }
107
111
  const context = contextPath(runId);
108
112
  const receipt = receiptPath(runId);
109
- await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString() });
113
+ await writeJson(context, { schema_version: RUN_SCHEMA_VERSION, context_path: context, run_id: runId, run_credential: registration.data.run_credential, receipt_upload: registration.data.receipt_upload, command, cwd, timeout_seconds: opts.timeout, receipt_path: receipt, launched_at: new Date().toISOString(), ...(childEnv && Object.keys(childEnv).length ? { child_env: childEnv } : {}), ...(testRun ? { test_run: testRun } : {}) });
110
114
  const [file, args] = workerInvocation(context);
111
115
  try {
112
116
  const worker = spawnImpl(file, args, { detached: true, stdio: "ignore", windowsHide: true });
@@ -124,9 +128,12 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
124
128
  let timedOut = false;
125
129
  let child;
126
130
  try {
127
- child = spawnImpl(context.command[0], context.command.slice(1), { cwd: context.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
131
+ child = spawnImpl(context.command[0], context.command.slice(1), { cwd: context.cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, ...(context.child_env ? { env: { ...process.env, ...context.child_env } } : {}) });
128
132
  } catch (error) {
129
- return finalize(context, { status: "owner_lost", startedAt, endedAt: new Date().toISOString(), exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, call);
133
+ const endedAt = new Date().toISOString();
134
+ const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${error instanceof Error ? error.message : String(error)}` }, call);
135
+ if (context.test_run) await completeTestRun(context, { status: "owner_lost", exitCode: null, signal: null, startedAt, endedAt, call });
136
+ return receipt;
130
137
  }
131
138
  // A failed spawn can report through the child `error` event on the next turn.
132
139
  // Subscribe before any filesystem I/O so a busy parallel test run (or a real
@@ -140,6 +147,9 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
140
147
  child.stdout?.on("data", (chunk) => capture.append(chunk));
141
148
  child.stderr?.on("data", (chunk) => capture.append(chunk));
142
149
  await writeJson(context.context_path, { ...context, worker_pid: process.pid, workload_pid: child.pid });
150
+ // BOT-1510: for a test lane, tail the reporter's NDJSON and flush per-case
151
+ // verdicts while the child runs. The test process never touches the network.
152
+ const lane = context.test_run ? startLaneFlusher(context, call) : null;
143
153
  let killTimer;
144
154
  const timer = setTimeout(() => {
145
155
  timedOut = true;
@@ -149,11 +159,85 @@ export async function runWorker(contextFile, { spawnImpl = spawn, call = callToo
149
159
  const result = await completion;
150
160
  clearTimeout(timer);
151
161
  clearTimeout(killTimer);
162
+ if (lane) await lane.stop();
163
+ const endedAt = new Date().toISOString();
152
164
  if (result.error) {
153
- return finalize(context, { status: "owner_lost", startedAt, endedAt: new Date().toISOString(), exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, call);
165
+ const receipt = await finalize(context, { status: "owner_lost", startedAt, endedAt, exitCode: null, signal: null, reason: `workload_spawn_failed:${result.error.message}`, output: capture.output, outputCharacters: capture.characters }, call);
166
+ if (context.test_run) await completeTestRun(context, { status: "owner_lost", exitCode: null, signal: null, startedAt, endedAt, call });
167
+ return receipt;
154
168
  }
155
169
  const status = terminalStatus({ ...result, timedOut });
156
- return finalize(context, { status, startedAt, endedAt: new Date().toISOString(), exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, call);
170
+ const receipt = await finalize(context, { status, startedAt, endedAt, exitCode: result.exitCode, signal: result.signal, output: capture.output, outputCharacters: capture.characters }, call);
171
+ // AC-4/AC-5: complete the run (final flush → update_test_run status:completed →
172
+ // emit_test_run_signal fires → the armed `test-run:` wait wakes).
173
+ if (context.test_run) await completeTestRun(context, { status, exitCode: result.exitCode, signal: result.signal, startedAt, endedAt, call });
174
+ return receipt;
175
+ }
176
+
177
+ // Periodic lane flusher: reduce the NDJSON events file to the current case state
178
+ // and upsert it via sync_test_run_cases. Idempotent (keyed by case_key), so a
179
+ // re-flush of an unchanged list is a no-op server-side. Never throws.
180
+ function startLaneFlusher(context, call) {
181
+ const tr = context.test_run;
182
+ let stopped = false;
183
+ let chain = Promise.resolve();
184
+ const tick = () => {
185
+ chain = chain.then(async () => {
186
+ if (stopped) return;
187
+ let text = "";
188
+ try { text = await readFile(tr.events_path, "utf8"); } catch { return; }
189
+ const { cases } = parseLaneEvents(text, { warn: () => {} });
190
+ if (cases.size) await flushLaneCases({ call, testRunId: tr.test_run_id, cases });
191
+ }).catch(() => { /* a flush failure retries on the next tick; never blocks the child */ });
192
+ };
193
+ const timer = setInterval(tick, LANE_FLUSH_INTERVAL_MS);
194
+ timer.unref?.();
195
+ return { async stop() { stopped = true; clearInterval(timer); await chain; } };
196
+ }
197
+
198
+ // Terminal: one last flush, write the .botbuddy/test-runs summary, then retry
199
+ // update_test_run{completed} until accepted (like update_command_run).
200
+ async function completeTestRun(context, { status, exitCode, signal, startedAt, endedAt, call }) {
201
+ const tr = context.test_run;
202
+ let text = "";
203
+ try { text = await readFile(tr.events_path, "utf8"); } catch { /* no events (0-test lane, or offline reporter) */ }
204
+ const { cases } = parseLaneEvents(text, { warn: () => {} });
205
+ if (cases.size) { try { await flushLaneCases({ call, testRunId: tr.test_run_id, cases }); } catch { /* update below still records terminal state */ } }
206
+ const counts = laneCaseCounts(cases);
207
+ const cancelled = status !== "success" && status !== "failure";
208
+ const durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(startedAt));
209
+ const summary = buildLaneSummary({
210
+ lane: tr.lane, runner: tr.runner, testRunId: tr.test_run_id,
211
+ startedAt, finishedAt: endedAt, durationMs, exitCode, signal,
212
+ command: context.command, cwd: context.cwd, gitSha: tr.git_sha ?? null, branch: tr.branch ?? null,
213
+ counts, cancelled, lefthookEnv: laneLefthookEnv(),
214
+ });
215
+ try {
216
+ const summaryPath = join(context.cwd, ".botbuddy", "test-runs", laneSummaryFilename(endedAt, tr.lane));
217
+ await mkdir(dirname(summaryPath), { recursive: true });
218
+ await writeFile(summaryPath, JSON.stringify(summary, null, 2) + "\n");
219
+ } catch { /* the summary is best-effort; the test_runs row is authoritative */ }
220
+ await retryUpdateTestRun({
221
+ test_run_id: tr.test_run_id, status: "completed",
222
+ metadata: { lane: tr.lane, runner: tr.runner, exit_code: exitCode ?? null, duration_ms: durationMs, cwd: context.cwd, command: context.command, command_run_id: context.run_id, cancelled, ...counts },
223
+ }, call);
224
+ return summary;
225
+ }
226
+
227
+ export async function retryUpdateTestRun(update, call, { sleep = (ms) => new Promise((r) => setTimeout(r, ms)), maxAttempts = 6 } = {}) {
228
+ let delay = 1_000;
229
+ for (let attempt = 1; ; attempt++) {
230
+ let res;
231
+ try { res = await call("update_test_run", update); } catch (error) { res = { ok: false, error: error instanceof Error ? error.message : String(error) }; }
232
+ if (res?.ok && !res.isError) return res;
233
+ if (attempt >= maxAttempts) return res; // detached worker gives up rather than hanging forever
234
+ await sleep(delay);
235
+ delay = Math.min(delay * 2, 30_000);
236
+ }
237
+ }
238
+
239
+ function laneLefthookEnv(env = process.env) {
240
+ return { LEFTHOOK: env.LEFTHOOK ?? null, LEFTHOOK_EXCLUDE: env.LEFTHOOK_EXCLUDE ?? null, LEFTHOOK_INCLUDE: env.LEFTHOOK_INCLUDE ?? null };
157
241
  }
158
242
 
159
243
  function boundedCapture() {
@@ -0,0 +1,99 @@
1
+ // BOT-1510 — Playwright reporter for `botbuddy test <lane>` (runner=playwright).
2
+ //
3
+ // Wired via `@botbuddy/cli/playwright-reporter` behind an env-conditional line in
4
+ // the host's playwright.config.ts:
5
+ // reporter: process.env.BOTBUDDY_TEST_RUN_ID
6
+ // ? [["list"], ["@botbuddy/cli/playwright-reporter"]]
7
+ // : [["list"], ["html", { open: "never" }]]
8
+ //
9
+ // It does ONE thing: append NDJSON begin/verdict events to the file named by
10
+ // $BOTBUDDY_TEST_RUN_EVENTS. It NEVER opens a socket — the detached `botbuddy
11
+ // run` worker tails this file and is the only process that talks to the backend.
12
+ // No env → a silent no-op, so a normal `pnpm test:e2e` is unaffected.
13
+
14
+ import { appendFileSync, mkdirSync } from "node:fs";
15
+ import { dirname } from "node:path";
16
+
17
+ const MAX_FAIL_REASON_BYTES = 2048;
18
+
19
+ export function verdictFor(status) {
20
+ switch (status) {
21
+ case "passed": return "pass";
22
+ case "skipped": return "skip";
23
+ case "failed":
24
+ case "timedOut":
25
+ case "interrupted": return "fail";
26
+ default: return "fail";
27
+ }
28
+ }
29
+
30
+ function projectName(test) {
31
+ try {
32
+ const p = test?.parent?.project?.();
33
+ if (p?.name) return p.name;
34
+ } catch { /* older Playwright shapes — fall through */ }
35
+ return "";
36
+ }
37
+
38
+ // Stable per-case identity: "<file>::<titlePath joined ' › '>::<project>".
39
+ export function caseKey(test) {
40
+ const file = test?.location?.file ?? "";
41
+ const path = (typeof test?.titlePath === "function" ? test.titlePath() : []).filter(Boolean).join(" › ");
42
+ return `${file}::${path}::${projectName(test)}`;
43
+ }
44
+
45
+ function truncateUtf8(input, maxBytes) {
46
+ const bytes = new TextEncoder().encode(input);
47
+ if (bytes.length <= maxBytes) return input;
48
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, maxBytes)).replace(/�+$/, "");
49
+ }
50
+
51
+ export default class BotBuddyPlaywrightReporter {
52
+ constructor(_options = {}, { env = process.env } = {}) {
53
+ this._path = env.BOTBUDDY_TEST_RUN_EVENTS || null;
54
+ this._order = new Map();
55
+ this._dirEnsured = false;
56
+ }
57
+
58
+ _append(event) {
59
+ if (!this._path) return; // no telemetry target — a normal local run
60
+ try {
61
+ if (!this._dirEnsured) { mkdirSync(dirname(this._path), { recursive: true }); this._dirEnsured = true; }
62
+ appendFileSync(this._path, JSON.stringify(event) + "\n");
63
+ } catch (err) {
64
+ // Telemetry must never fail the test run.
65
+ process.stderr.write(`[botbuddy-reporter] could not append event: ${err?.message ?? err}\n`);
66
+ }
67
+ }
68
+
69
+ onBegin(_config, suite) {
70
+ if (!this._path) return;
71
+ const tests = typeof suite?.allTests === "function" ? suite.allTests() : [];
72
+ tests.forEach((t, i) => {
73
+ const key = caseKey(t);
74
+ this._order.set(key, i);
75
+ const title = (typeof t?.titlePath === "function" ? t.titlePath() : []).filter(Boolean).join(" › ") || key;
76
+ this._append({ type: "begin", key, title, sort_order: i });
77
+ });
78
+ }
79
+
80
+ onTestEnd(test, result) {
81
+ if (!this._path) return;
82
+ const key = caseKey(test);
83
+ const sort_order = this._order.get(key) ?? this._order.size;
84
+ const firstError = Array.isArray(result?.errors) ? result.errors.find((e) => e?.message)?.message : result?.error?.message;
85
+ this._append({
86
+ type: "verdict",
87
+ key,
88
+ sort_order,
89
+ verdict: verdictFor(result?.status),
90
+ fail_reason: firstError ? truncateUtf8(String(firstError), MAX_FAIL_REASON_BYTES) : null,
91
+ duration_ms: Number.isFinite(result?.duration) ? Math.round(result.duration) : null,
92
+ retry: Number.isInteger(result?.retry) ? result.retry : 0,
93
+ });
94
+ }
95
+
96
+ // Playwright requires this hook to exist; nothing to flush — the worker owns
97
+ // the terminal update.
98
+ onEnd() {}
99
+ }
@@ -0,0 +1,104 @@
1
+ // BOT-1510 — the detached worker's view of a lane's NDJSON telemetry.
2
+ //
3
+ // The Playwright reporter (or the generic runner) appends begin/verdict events
4
+ // to an NDJSON file; the worker tails it and flushes the reduced per-case state
5
+ // to `sync_test_run_cases`. These functions are the pure, unit-tested core:
6
+ // parse (corrupt lines skipped, never a crash), reduce to a case map (last
7
+ // verdict wins), chunk to the 500-case cap, count, and build the terminal
8
+ // `.botbuddy/test-runs/<iso>-<lane>.json` summary.
9
+
10
+ export const SYNC_BATCH = 500;
11
+
12
+ // Parse NDJSON lane events into an ordered case map. Corrupt/partial lines are
13
+ // skipped with a stderr warning (AC-11) and counted in `malformed`.
14
+ export function parseLaneEvents(text, { warn = (m) => process.stderr.write(m) } = {}) {
15
+ const cases = new Map();
16
+ let malformed = 0;
17
+ for (const raw of String(text ?? "").split("\n")) {
18
+ const line = raw.trim();
19
+ if (!line) continue;
20
+ let ev;
21
+ try { ev = JSON.parse(line); } catch { malformed += 1; warn("[botbuddy] skipping corrupt lane event line\n"); continue; }
22
+ if (!ev || typeof ev.key !== "string" || (ev.type !== "begin" && ev.type !== "verdict")) { malformed += 1; continue; }
23
+ const c = cases.get(ev.key) ?? { key: ev.key, title: ev.key, sort_order: cases.size, verdict: "pending", fail_reason: null, duration_ms: null };
24
+ if (typeof ev.title === "string" && ev.title) c.title = ev.title;
25
+ if (Number.isInteger(ev.sort_order)) c.sort_order = ev.sort_order;
26
+ if (ev.type === "verdict") {
27
+ if (ev.verdict) c.verdict = ev.verdict; // last write wins (retries)
28
+ c.fail_reason = typeof ev.fail_reason === "string" ? ev.fail_reason : null;
29
+ if (Number.isFinite(ev.duration_ms)) c.duration_ms = ev.duration_ms;
30
+ }
31
+ cases.set(ev.key, c);
32
+ }
33
+ return { cases, malformed };
34
+ }
35
+
36
+ // Case map → the sync_test_run_cases payload, ordered by sort_order.
37
+ export function laneCasesToSync(cases) {
38
+ return [...cases.values()]
39
+ .sort((a, b) => a.sort_order - b.sort_order)
40
+ .map((c) => ({
41
+ key: c.key, title: c.title, sort_order: c.sort_order, verdict: c.verdict,
42
+ ...(c.fail_reason != null ? { fail_reason: c.fail_reason } : {}),
43
+ ...(c.duration_ms != null ? { duration_ms: c.duration_ms } : {}),
44
+ }));
45
+ }
46
+
47
+ export function chunkCases(arr, size = SYNC_BATCH) {
48
+ const out = [];
49
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
50
+ return out;
51
+ }
52
+
53
+ export function laneCaseCounts(cases) {
54
+ let passed = 0, failed = 0, skipped = 0, pending = 0;
55
+ for (const c of cases.values()) {
56
+ if (c.verdict === "pass") passed += 1;
57
+ else if (c.verdict === "fail") failed += 1;
58
+ else if (c.verdict === "skip") skipped += 1;
59
+ else pending += 1;
60
+ }
61
+ return { case_count: cases.size, passed, failed, skipped, pending };
62
+ }
63
+
64
+ // Flush the current case state in ≤500-case batches. Never throws — a failed
65
+ // batch is reported so the caller can retry on the next tick without ever
66
+ // blocking or killing the child.
67
+ export async function flushLaneCases({ call, testRunId, cases }) {
68
+ const chunks = chunkCases(laneCasesToSync(cases));
69
+ let sent = 0;
70
+ let ok = true;
71
+ for (const chunk of chunks) {
72
+ let res;
73
+ try { res = await call("sync_test_run_cases", { test_run_id: testRunId, cases: chunk }); }
74
+ catch (err) { res = { ok: false, error: err instanceof Error ? err.message : String(err) }; }
75
+ if (res?.ok && !res.isError) sent += chunk.length; else ok = false;
76
+ }
77
+ return { chunks: chunks.length, sent, ok };
78
+ }
79
+
80
+ // The `.botbuddy/test-runs/<iso>-<lane>.json` summary: the log-test-run.mjs
81
+ // shape (BOT-461) plus test_run_id, so BOT-1387/BOT-608 consumers stay
82
+ // compatible while gaining the run linkage.
83
+ export function buildLaneSummary({
84
+ lane, runner, testRunId, startedAt, finishedAt, durationMs, exitCode, signal,
85
+ command, cwd, gitSha = null, branch = null, counts = null, cancelled = false, lefthookEnv = null,
86
+ }) {
87
+ return {
88
+ runner, source: "cli-lane",
89
+ command: Array.isArray(command) ? command.join(" ") : String(command ?? ""),
90
+ started_at: startedAt, finished_at: finishedAt, duration_ms: durationMs,
91
+ exit_code: exitCode ?? null, signal: signal ?? null,
92
+ verdict: exitCode === 0 ? "pass" : "fail",
93
+ git_sha: gitSha, branch, spawn_error: null,
94
+ meta: { lane, cwd, cancelled, ...(counts ?? {}) },
95
+ lefthook_env: lefthookEnv,
96
+ test_run_id: testRunId,
97
+ };
98
+ }
99
+
100
+ // `.botbuddy/test-runs/<iso>-<lane>.json` — the same ISO-safe basename shape as
101
+ // scripts/log-test-run.mjs, but keyed by lane (not runner) per AC-4.
102
+ export function laneSummaryFilename(finishedAtIso, lane) {
103
+ return `${finishedAtIso.replace(/[:.]/g, "-")}-${lane}.json`;
104
+ }
@@ -0,0 +1,275 @@
1
+ // BOT-1510 — `botbuddy test <lane>`: run a repo-configured local test lane AS a
2
+ // BotBuddy test run.
3
+ //
4
+ // The launcher creates a `test_runs` row for the lane, promotes it to `active`
5
+ // (attaching the ticket in a SECOND call so a human's ticket+env draft run is
6
+ // never hijacked by create_test_run's upsert), then hands the lane to the
7
+ // existing durable `botbuddy run` worker with the test-run id in the child's
8
+ // env. It prints exactly one JSON line and exits 0 — like `botbuddy run` — while
9
+ // the lane keeps executing detached. A worker (later phase) tails the reporter's
10
+ // NDJSON and flushes per-case verdicts through `sync_test_run_cases`.
11
+ //
12
+ // Backend down? The lane still runs; the JSON line says telemetry:"unavailable",
13
+ // wait:null, and the child exit code is preserved.
14
+
15
+ import { randomUUID } from "node:crypto";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { readFile } from "node:fs/promises";
18
+ import { dirname, join } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
23
+ import { callToolJson } from "./api.mjs";
24
+
25
+ // EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
26
+ export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
27
+ export const WAITS_URL = "https://app.bot-buddy.ai/waits";
28
+ const KNOWN_RUNNERS = new Set(["playwright", "generic"]);
29
+
30
+ export function parseTestArgs(argv, { env = process.env } = {}) {
31
+ const opts = {
32
+ sessionId: env.BOTBUDDY_SESSION_ID ?? null,
33
+ environment: "local",
34
+ ticket: null, pr: null, repo: null,
35
+ wait: false, json: false,
36
+ };
37
+ const errors = [];
38
+ const separator = argv.indexOf("--");
39
+ const flags = separator === -1 ? argv : argv.slice(0, separator);
40
+ const extra = separator === -1 ? [] : argv.slice(separator + 1);
41
+ let subcommand = null;
42
+ let lane = null;
43
+ const value = (name, i) => { if (flags[i + 1] == null) { errors.push(`${name} needs a value`); return null; } return flags[i + 1]; };
44
+ for (let i = 0; i < flags.length; i++) {
45
+ const flag = flags[i];
46
+ switch (flag) {
47
+ case "--session-id": opts.sessionId = value(flag, i); i++; break;
48
+ case "--environment": opts.environment = value(flag, i); i++; break;
49
+ case "--ticket": opts.ticket = value(flag, i); i++; break;
50
+ case "--pr": { const raw = value(flag, i); i++; opts.pr = raw == null ? null : Number(raw); if (raw != null && !Number.isInteger(opts.pr)) errors.push("--pr must be an integer"); break; }
51
+ case "--repo": opts.repo = value(flag, i); i++; break;
52
+ case "--wait": opts.wait = true; break;
53
+ case "--json": opts.json = true; break;
54
+ default:
55
+ if (flag.startsWith("-")) { errors.push(`unknown option: ${flag}`); break; }
56
+ if (lane === null && subcommand === null) {
57
+ if (flag === "list" || flag === "help") subcommand = flag;
58
+ else lane = flag;
59
+ } else errors.push(`unexpected argument: ${flag}`);
60
+ }
61
+ }
62
+ if (!subcommand && !lane) errors.push("a lane is required (e.g. `botbuddy test e2e`); run `botbuddy test list`");
63
+ return { subcommand, lane, opts, extra, errors };
64
+ }
65
+
66
+ export function findGitRoot(cwd) {
67
+ let dir = cwd;
68
+ for (;;) {
69
+ if (existsSync(join(dir, ".git"))) return dir;
70
+ const parent = dirname(dir);
71
+ if (parent === dir) return null;
72
+ dir = parent;
73
+ }
74
+ }
75
+
76
+ // `.botbuddy/lanes.json` at the git root — no defaults (gate 9): a missing file
77
+ // or an unknown lane is a hard, named failure, never a guessed command.
78
+ export function resolveLane(laneName, { cwd = process.cwd(), readFileImpl = readFileSync } = {}) {
79
+ const root = findGitRoot(cwd) ?? cwd;
80
+ const path = join(root, ".botbuddy", "lanes.json");
81
+ if (!existsSync(path)) return { ok: false, error: "missing", path, root };
82
+ let config;
83
+ try { config = JSON.parse(readFileImpl(path, "utf8")); }
84
+ catch (e) { return { ok: false, error: "invalid", path, root, detail: e instanceof Error ? e.message : String(e) }; }
85
+ const lanes = config?.lanes && typeof config.lanes === "object" ? config.lanes : {};
86
+ const available = Object.keys(lanes);
87
+ if (!laneName || !lanes[laneName]) return { ok: false, error: "unknown", path, root, available };
88
+ const lane = lanes[laneName];
89
+ if (!Array.isArray(lane.command) || lane.command.length === 0) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" has no command` };
90
+ if (!KNOWN_RUNNERS.has(lane.runner)) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" runner must be playwright|generic` };
91
+ return { ok: true, appSlug: config.app_slug ?? null, lane, path, root };
92
+ }
93
+
94
+ // Best-effort git/gh context. Every field falls back to null — never fabricated
95
+ // (gate 9). CLI overrides (--ticket/--pr/--repo) win over the derived values.
96
+ export function defaultGitInfo({ cwd = process.cwd(), ticket = null, pr = null, repo = null } = {}) {
97
+ const git = (args) => { try { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim() || null; } catch { return null; } };
98
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
99
+ const sha = git(["rev-parse", "HEAD"]);
100
+ let ticketId = ticket;
101
+ if (!ticketId && branch) {
102
+ const m = branch.match(/(?:^|\/)((?:bot|ent)-\d+)/i);
103
+ if (m) ticketId = m[1].toUpperCase();
104
+ }
105
+ const ticketUrl = ticketId?.startsWith("BOT-") ? `https://linear.app/botbuddy/issue/${ticketId}` : null;
106
+ let prNumber = pr;
107
+ let prUrl = null;
108
+ if (prNumber == null) {
109
+ try {
110
+ const raw = execFileSync("gh", ["pr", "view", "--json", "number,url"], { cwd, encoding: "utf8" });
111
+ const parsed = JSON.parse(raw);
112
+ prNumber = Number.isInteger(parsed.number) ? parsed.number : null;
113
+ prUrl = parsed.url ?? null;
114
+ } catch { /* no PR / gh unavailable — null, never fabricated */ }
115
+ }
116
+ return { branch, sha, prNumber, prUrl, repo, ticket: ticketId, ticketUrl };
117
+ }
118
+
119
+ // The production lane launcher: run the lane through the durable `botbuddy run`
120
+ // worker, carrying the telemetry env into its detached child.
121
+ async function defaultLaunchLane({ command, sessionId, environment, expectedDurationSeconds, childEnv, testRun, cwd, call }) {
122
+ const argv = ["--session-id", sessionId, "--environment", environment, "--category", "validation", "--kind", "full_suite", "--expected-duration", String(expectedDurationSeconds ?? 0), "--", ...command];
123
+ const result = await launchRun(argv, { cwd, call, childEnv, testRun });
124
+ return { exitCode: result.exitCode, runId: result.receipt?.run_id ?? null };
125
+ }
126
+
127
+ function waitCommand(testRunId, sessionId) {
128
+ return `botbuddy wait 'test-run:id=${testRunId}' --session-id ${sessionId} --heartbeat`;
129
+ }
130
+
131
+ export async function launchTestLane(argv, {
132
+ cwd = process.cwd(),
133
+ env = process.env,
134
+ call = callToolJson,
135
+ launchLane = defaultLaunchLane,
136
+ gitInfo = defaultGitInfo,
137
+ eventsDir = join(homedir(), ".botbuddy", "test-lanes"),
138
+ } = {}) {
139
+ const { lane: laneName, opts, extra, errors } = parseTestArgs(argv, { env });
140
+ if (errors.length) {
141
+ for (const e of errors) process.stderr.write(`botbuddy test: ${e}\n`);
142
+ return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors }) };
143
+ }
144
+ if (!opts.sessionId) {
145
+ process.stderr.write("botbuddy test: --session-id is required (or set $BOTBUDDY_SESSION_ID)\n");
146
+ return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", errors: ["--session-id is required"] }) };
147
+ }
148
+
149
+ const resolved = resolveLane(laneName, { cwd });
150
+ if (!resolved.ok) {
151
+ if (resolved.error === "missing") process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`);
152
+ else if (resolved.error === "unknown") process.stderr.write(`botbuddy test: unknown lane "${laneName}". Available: ${resolved.available.join(", ") || "(none)"}\n`);
153
+ else process.stderr.write(`botbuddy test: invalid lane config (${resolved.detail}) at ${resolved.path}\n`);
154
+ return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: resolved.error, path: resolved.path }) };
155
+ }
156
+
157
+ const git = gitInfo({ cwd, ticket: opts.ticket, pr: opts.pr, repo: opts.repo });
158
+ const sha7 = (git.sha ?? "").slice(0, 7) || "nosha";
159
+ const laneRunId = randomUUID();
160
+ const eventsPath = join(eventsDir, `${laneRunId}.ndjson`);
161
+ const title = `${laneName} · ${git.branch ?? "detached"} · ${sha7}`;
162
+
163
+ // Step 1: create WITHOUT ticket_id (create_test_run upserts on ticket_id+env —
164
+ // passing it would hijack a human's draft/active run for the same ticket).
165
+ const createArgs = {
166
+ app_slug: resolved.appSlug, environment: opts.environment, title,
167
+ commit_sha: git.sha ?? undefined, branch_name: git.branch ?? undefined,
168
+ pr_number: git.prNumber ?? undefined, pr_url: git.prUrl ?? undefined,
169
+ source_kind: "cli-lane", source_id: laneRunId,
170
+ };
171
+ const created = await call("create_test_run", createArgs);
172
+ const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
173
+
174
+ if (testRunId) {
175
+ // Step 2: attach the ticket + promote to active in one update.
176
+ await call("update_test_run", {
177
+ test_run_id: testRunId,
178
+ ticket_id: git.ticket ?? undefined,
179
+ ticket_url: git.ticketUrl ?? undefined,
180
+ status: "active",
181
+ });
182
+ }
183
+
184
+ // Step 3: launch the lane under the durable worker, telemetry env in the child.
185
+ const childEnv = { BOTBUDDY_LANE: laneName };
186
+ if (testRunId) { childEnv.BOTBUDDY_TEST_RUN_ID = testRunId; childEnv.BOTBUDDY_TEST_RUN_EVENTS = eventsPath; }
187
+ const testRun = testRunId
188
+ ? { test_run_id: testRunId, events_path: eventsPath, lane: laneName, runner: resolved.lane.runner, git_sha: git.sha ?? null, branch: git.branch ?? null }
189
+ : null;
190
+ const launch = await launchLane({
191
+ command: [...resolved.lane.command, ...extra],
192
+ sessionId: opts.sessionId, environment: opts.environment,
193
+ expectedDurationSeconds: resolved.lane.expected_duration_seconds ?? 0,
194
+ childEnv, testRun, cwd, call, testRunId, lane: laneName, runner: resolved.lane.runner,
195
+ });
196
+
197
+ const commandRunId = launch.runId;
198
+ const line = testRunId
199
+ ? {
200
+ outcome: "launched", test_run_id: testRunId, command_run_id: commandRunId, lane: laneName,
201
+ wait: waitCommand(testRunId, opts.sessionId),
202
+ receipt_path: commandRunId ? receiptPath(commandRunId) : null, waits_url: WAITS_URL,
203
+ }
204
+ : {
205
+ outcome: "launched", test_run_id: null, command_run_id: commandRunId, lane: laneName,
206
+ telemetry: "unavailable", wait: null,
207
+ receipt_path: commandRunId ? receiptPath(commandRunId) : null, waits_url: WAITS_URL,
208
+ };
209
+
210
+ return { exitCode: launch.exitCode, line: JSON.stringify(line), testRunId, commandRunId, opts };
211
+ }
212
+
213
+ // AC-9 (--wait foreground convenience): block until the run reaches a terminal
214
+ // status, then surface the LANE's exit code (from the command-run receipt the
215
+ // worker wrote, falling back to the run's metadata). A timeout returns exit 2
216
+ // and never touches the still-running lane. We poll get_test_run rather than the
217
+ // SSE wait engine because runWait() hard-exits and cannot yield the lane code.
218
+ const TERMINAL_RUN_STATUSES = new Set(["completed", "archived", "superseded"]);
219
+ export async function waitForLane({
220
+ testRunId, commandRunId, timeoutSec = DEFAULT_TIMEOUT_SECONDS,
221
+ call = callToolJson, readReceipt = (p) => readFile(p, "utf8").then(JSON.parse),
222
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, pollMs = 3_000,
223
+ } = {}) {
224
+ const deadline = now() + timeoutSec * 1_000;
225
+ for (;;) {
226
+ let status = null;
227
+ let metaExit;
228
+ try {
229
+ const res = await call("get_test_run", { test_run_id: testRunId });
230
+ if (res?.ok && !res.isError) { status = res.data?.status ?? null; metaExit = res.data?.metadata?.exit_code; }
231
+ } catch { /* transient — retry until the deadline */ }
232
+ if (status && TERMINAL_RUN_STATUSES.has(status)) {
233
+ let exitCode = Number.isInteger(metaExit) ? metaExit : 0;
234
+ if (commandRunId) { try { const r = await readReceipt(receiptPath(commandRunId)); if (Number.isInteger(r?.exit_code)) exitCode = r.exit_code; } catch { /* metadata fallback */ } }
235
+ return { outcome: "completed", exitCode };
236
+ }
237
+ if (now() >= deadline) return { outcome: "timeout", exitCode: EXIT_TEST.TIMEOUT };
238
+ await sleep(pollMs);
239
+ }
240
+ }
241
+
242
+ function laneList(cwd) {
243
+ const resolved = resolveLane(null, { cwd });
244
+ if (resolved.error === "missing") { process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`); return EXIT_TEST.INVALID; }
245
+ const lanes = resolved.available ?? [];
246
+ process.stdout.write(JSON.stringify({ app_slug: resolved.appSlug ?? null, lanes }) + "\n");
247
+ return EXIT_TEST.OK;
248
+ }
249
+
250
+ function testHelp() {
251
+ console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--wait] [--json] [-- <extra args>]
252
+ botbuddy test list List the lanes configured in .botbuddy/lanes.json
253
+ botbuddy test help Show this help
254
+
255
+ Runs a repo-configured local test lane as a BotBuddy test run, prints a
256
+ ready-to-paste \`botbuddy wait 'test-run:id=…'\` line, and shows the run on
257
+ ${WAITS_URL} while it executes detached.`);
258
+ }
259
+
260
+ export async function cmdTest(args, { cwd = process.cwd() } = {}) {
261
+ const { subcommand } = parseTestArgs(args);
262
+ if (subcommand === "help" || args[0] === "--help" || args[0] === "-h") { testHelp(); return; }
263
+ if (subcommand === "list") { process.exitCode = laneList(cwd); return; }
264
+ const result = await launchTestLane(args, { cwd });
265
+ process.stdout.write(result.line + "\n");
266
+ // --wait (AC-9): after launch, block until the run completes and exit with the
267
+ // lane's exit code; a timeout exits 2 without killing the lane. Only meaningful
268
+ // when telemetry is live (there is a run to wait on).
269
+ if (result.testRunId && result.opts?.wait) {
270
+ const wait = await waitForLane({ testRunId: result.testRunId, commandRunId: result.commandRunId });
271
+ process.exitCode = wait.exitCode;
272
+ return;
273
+ }
274
+ process.exitCode = result.exitCode;
275
+ }
package/src/wait-core.mjs CHANGED
@@ -196,20 +196,44 @@ const VALIDATORS = {
196
196
  // BOT-1066 — ci: wake when a PR's CI reaches a terminal conclusion. Exactly one
197
197
  // selector: scope=latest (follows the PR's latest run — needs pr=), run_id=<id>
198
198
  // (pinned to one run), or sha=<sha> (pinned to a commit). repo= is always required.
199
+ // BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run, sha,
200
+ // or PR (they must all already exist at arm time) and wakes on the first terminal
201
+ // CI run for the repo delivered AFTER the wait was armed — the post-merge / cron
202
+ // train case. Optional branch=/workflow= filter that first run; both are valid
203
+ // ONLY with scope=next (a run_id/sha/latest wait already names its run).
199
204
  ci(p) {
200
205
  const repo = p.repo != null ? String(p.repo).trim() : null;
201
206
  if (!repo || !repo.includes("/")) throw new Error("ci needs repo=<owner/repo>");
202
207
  const selectors = ["scope", "run_id", "sha"].filter((k) => p[k] !== undefined);
203
208
  if (selectors.length !== 1) {
204
- throw new Error("ci needs exactly one of scope=latest | run_id=<id> | sha=<sha>");
209
+ throw new Error("ci needs exactly one of scope=latest|next | run_id=<id> | sha=<sha>");
205
210
  }
211
+ const hasFilter = p.branch !== undefined || p.workflow !== undefined;
206
212
  if (p.scope !== undefined) {
207
- if (p.scope !== "latest") throw new Error("ci scope must be 'latest' (or use run_id=/sha=)");
208
- if (p.pr === undefined || !/^\d+$/.test(String(p.pr).trim())) {
209
- throw new Error("ci scope=latest needs pr=<number>");
213
+ if (p.scope === "latest") {
214
+ if (hasFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
215
+ if (p.pr === undefined || !/^\d+$/.test(String(p.pr).trim())) {
216
+ throw new Error("ci scope=latest needs pr=<number>");
217
+ }
218
+ return { repo, scope: "latest", pr: String(p.pr).trim() };
219
+ }
220
+ if (p.scope === "next") {
221
+ // A future run has no PR head to follow — pr= is meaningless (and run_id/sha
222
+ // are already excluded by the single-selector check above).
223
+ if (p.pr !== undefined) {
224
+ throw new Error("ci scope=next takes no pr= (it targets a future run, not a PR head)");
225
+ }
226
+ const filter = (key) => {
227
+ if (p[key] === undefined) return null;
228
+ const v = String(p[key]).trim();
229
+ if (v === "") throw new Error(`ci ${key}= must be non-empty`);
230
+ return v;
231
+ };
232
+ return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), pr: null };
210
233
  }
211
- return { repo, scope: "latest", pr: String(p.pr).trim() };
234
+ throw new Error("ci scope must be 'latest' or 'next' (or use run_id=/sha=)");
212
235
  }
236
+ if (hasFilter) throw new Error("ci branch=/workflow= are only valid with scope=next");
213
237
  if (p.run_id !== undefined) {
214
238
  if (!String(p.run_id).trim()) throw new Error("ci run_id must be non-empty");
215
239
  return { repo, scope: "run_id", runId: String(p.run_id).trim(), pr: p.pr != null ? String(p.pr).trim() : null };
@@ -679,6 +703,23 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
679
703
  if (String(signal.payload?.repo ?? "").toLowerCase() !== params.repo.toLowerCase()) return false;
680
704
  if (params.scope === "run_id") return String(signal.payload?.run_id) === params.runId;
681
705
  if (params.scope === "sha") return signal.payload?.head_sha === params.sha;
706
+ if (params.scope === "next") {
707
+ // BOT-1507 — live-only: the first TERMINAL run for the repo. Never keys on
708
+ // subject_key (it is run:<id> for post-merge runs and owner/repo#n for PR
709
+ // runs — both are legitimate wakes); a null/empty conclusion is an
710
+ // in-progress signal and never terminal. Optional branch/workflow filters:
711
+ // branch is exact, workflow is the GitHub display name (case/space-insensitive).
712
+ const conclusion = signal.payload?.conclusion;
713
+ if (typeof conclusion !== "string" || conclusion.trim() === "") return false;
714
+ if (params.branch != null && signal.payload?.head_branch !== params.branch) return false;
715
+ if (params.workflow != null) {
716
+ const wf = signal.payload?.workflow_name;
717
+ if (typeof wf !== "string" || wf.trim().toLowerCase() !== params.workflow.trim().toLowerCase()) {
718
+ return false;
719
+ }
720
+ }
721
+ return true;
722
+ }
682
723
  // scope=latest: the PR's run (by subject owner/repo#pr or payload pr_number).
683
724
  return signal.subject_key === `${params.repo}#${params.pr}` ||
684
725
  (signal.payload?.pr_number != null && String(signal.payload.pr_number) === params.pr);
package/src/wait.mjs CHANGED
@@ -75,10 +75,16 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
75
75
  Host-shared; a silently-dead host is detected server-side
76
76
  (capacity_source_stale) via a host beacon — the client
77
77
  grace (default 900s) is a backstop, no longer the only guard.
78
- ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>}
78
+ ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
79
+ | scope=next[,branch=<name>][,workflow=<name>]}
79
80
  a PR's CI reaching a terminal conclusion (owner-scoped);
80
81
  GitHub Actions or an external check_suite provider.
81
82
  A repo with no CI feed is rejected (no_signal_source).
83
+ scope=next is LIVE-ONLY: it wakes on the first terminal
84
+ run delivered AFTER arm (post-merge / cron / scheduled),
85
+ never replays a finished run, and needs no run_id/sha/pr.
86
+ Optional branch= (exact) / workflow= (GitHub workflow
87
+ DISPLAY name, case-insensitive) narrow that first run.
82
88
  staging-green:repo=<owner/repo> verified automatic recovery of an enabled staging gate.
83
89
  Wakes only after a complete non-vacuous workflow success;
84
90
  manual resolutions, overrides, skipped, and neutral runs do not match.