@botbuddy/cli 1.60.8 → 1.60.10
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/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/commands.mjs +2 -2
- package/src/pw/run.mjs +9 -1
- package/src/staging-pool/errors.mjs +8 -1
- package/src/usage/sync.mjs +58 -8
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.10",
|
|
3
|
+
"source_commit": "311044889f28c3b22902954ec29c3c117f46d638",
|
|
4
|
+
"source_sha256": "a82e951ccda7aa65571953e874a01cf30f75bc218ed5592a2bffa2e7b0d3219a",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "c078605d49201ad3532212bbac322216b06c108c425024a7f68f6ecdab89fd5a"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.60.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.60.10",
|
|
3
|
+
"source_commit": "311044889f28c3b22902954ec29c3c117f46d638",
|
|
4
|
+
"source_sha256": "a82e951ccda7aa65571953e874a01cf30f75bc218ed5592a2bffa2e7b0d3219a",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "af6bfdf5e66b133356c620a5dd9aaacc1cee11d8f0f594bb8f5e8447cb932704"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.60.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.60.10",
|
|
4
|
+
"source_commit": "311044889f28c3b22902954ec29c3c117f46d638",
|
|
5
|
+
"source_sha256": "a82e951ccda7aa65571953e874a01cf30f75bc218ed5592a2bffa2e7b0d3219a",
|
|
6
6
|
"targets": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"goos": "darwin",
|
|
9
9
|
"goarch": "arm64",
|
|
10
10
|
"binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "c078605d49201ad3532212bbac322216b06c108c425024a7f68f6ecdab89fd5a",
|
|
12
12
|
"mode": "0755"
|
|
13
13
|
},
|
|
14
14
|
"linux-x64": {
|
|
15
15
|
"goos": "linux",
|
|
16
16
|
"goarch": "amd64",
|
|
17
17
|
"binary": "daemon/bin/linux-x64/botbuddy-daemon",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "af6bfdf5e66b133356c620a5dd9aaacc1cee11d8f0f594bb8f5e8447cb932704",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -278,8 +278,8 @@ ${bold("STAGING SIGN-IN")} (BOT-2084)
|
|
|
278
278
|
persona, no password (${cyan("bb pw auth --help")})
|
|
279
279
|
staging-pool status [--check]
|
|
280
280
|
Pool health: one reason, who acts, the next command
|
|
281
|
-
staging-pool install|tick|mint-one|uninstall
|
|
282
|
-
Human only,
|
|
281
|
+
staging-pool install --tenant <slug> | tick | mint-one | uninstall
|
|
282
|
+
Human only, any directory (${cyan("bb staging-pool --help")})
|
|
283
283
|
help symptom staging-pool Every refusal code with its cause and next command
|
|
284
284
|
|
|
285
285
|
${bold("OTHER")}
|
package/src/pw/run.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { VERSION } from "../version.mjs";
|
|
|
11
11
|
import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
|
|
12
12
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
|
|
13
13
|
import * as stagingAuth from "./staging-auth.mjs";
|
|
14
|
+
import { DOC_SECTIONS, DOC_URL } from "../staging-pool/errors.mjs";
|
|
14
15
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
15
16
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
16
17
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
@@ -134,7 +135,12 @@ export async function runPw(argv, deps = {}) {
|
|
|
134
135
|
// while-loop in runPwInner). Everything after the first non-option (the lane) is a
|
|
135
136
|
// Playwright workload argument and is never a BotBuddy credential.
|
|
136
137
|
const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token", "--environment"];
|
|
137
|
-
|
|
138
|
+
// BOT-2648 acceptance: with no tenant binding, `bb doctor --fix` cannot help: the
|
|
139
|
+
// caller is outside a registered agent worktree, which lane locks need.
|
|
140
|
+
const NO_BINDING_NEXT = "Agent: run this from your registered project worktree (the checkout with .botbuddy-agent.json; run `bb setup` there once), then retry. bb help symptom tenant_binding_required";
|
|
141
|
+
const selfHealErrorLine = (error) => error?.code === "tenant_binding_required"
|
|
142
|
+
? `bb-pw: tenant_binding_required — ${error?.message ?? "no BotBuddy tenant binding here"} ${NO_BINDING_NEXT}. Docs: ${DOC_URL}#${DOC_SECTIONS.take}\n`
|
|
143
|
+
: `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
|
|
138
144
|
// The leading credential options, NORMALIZED to split `flag value` pairs so an
|
|
139
145
|
// inline `--agent-key=<token>` is recognized by legacyAgentAliasHint (which
|
|
140
146
|
// matches exact flag tokens) exactly like the split form. Credential flags are
|
|
@@ -247,6 +253,8 @@ async function runPwInner(argv, deps = {}) {
|
|
|
247
253
|
// BOT-2648 (Codex P2, #1323): the credential's tenant picks the installed staging pool.
|
|
248
254
|
deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null, credentialTenant: credential.identity?.tenant ?? null };
|
|
249
255
|
} catch (error) {
|
|
256
|
+
// BOT-2648 acceptance: an auth refusal is the standard JSON receipt (actor, next, doc, symptom).
|
|
257
|
+
if (plan.mode === "auth" && error?.code === "tenant_binding_required") return stagingAuth.refuse({ stdout, stderr }, "tenant_binding_required", 3, {}, authVars(plan, deps));
|
|
250
258
|
stderr.write(selfHealErrorLine(error)); return 3;
|
|
251
259
|
}
|
|
252
260
|
}
|
|
@@ -52,6 +52,13 @@ export const ERROR_CODES = Object.freeze({
|
|
|
52
52
|
"The lane lock is held by another agent session of your owner, not by this session.",
|
|
53
53
|
"The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
|
|
54
54
|
`bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
|
|
55
|
+
// BOT-2648 acceptance: the take runs from a registered agent worktree. Lane locks
|
|
56
|
+
// are per agent worktree, so a directory with no .botbuddy-agent.json has no
|
|
57
|
+
// agent identity to hold the lane (the pool itself never needs a checkout).
|
|
58
|
+
tenant_binding_required: entry("agent", "take",
|
|
59
|
+
"bb pw needs a registered agent worktree, and this directory has no BotBuddy tenant binding.",
|
|
60
|
+
"No .botbuddy-agent.json was found here or above, so there is no per-worktree agent identity to hold the lane lock.",
|
|
61
|
+
`bb setup # run inside your registered project worktree (the checkout with .botbuddy-agent.json); then from there: bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
|
|
55
62
|
persona_not_allowlisted: entry("agent", "take",
|
|
56
63
|
"Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
|
|
57
64
|
"A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
|
|
@@ -310,7 +317,7 @@ export const ERROR_CODES = Object.freeze({
|
|
|
310
317
|
// Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
|
|
311
318
|
export const AUTH_ONLY_CODES = Object.freeze(new Set([
|
|
312
319
|
"auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
|
|
313
|
-
"persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
|
|
320
|
+
"tenant_binding_required", "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
|
|
314
321
|
"session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
|
|
315
322
|
"session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
|
|
316
323
|
"daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
|
package/src/usage/sync.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// success). It carries row COUNTS only — never paths, never prompt text, never
|
|
11
11
|
// token totals. Fail-open: any failure exits 0 unless --strict.
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
13
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
14
15
|
import { readdir, stat } from "node:fs/promises";
|
|
15
16
|
import { homedir, hostname as systemHostname } from "node:os";
|
|
@@ -90,16 +91,20 @@ export function transcriptRoots(env = process.env, home = homedir()) {
|
|
|
90
91
|
* subdirectories) modified since `cutoffMs`. `shouldStop` is checked before
|
|
91
92
|
* every directory read and stat, so a huge history or a slow filesystem cannot
|
|
92
93
|
* outlive the run's time budget (the pre-push lane runs with 5 s).
|
|
93
|
-
*
|
|
94
|
+
* `errors` counts directories or files that exist but could not be read
|
|
95
|
+
* (EACCES, EPERM, …); a missing root or a file that vanished is not an error.
|
|
96
|
+
* @returns {Promise<{ files: string[], stopped: boolean, errors: number }>}
|
|
94
97
|
*/
|
|
95
98
|
export async function listTranscripts(root, cutoffMs, { shouldStop = () => false, keep = () => false } = {}) {
|
|
96
99
|
const out = [];
|
|
97
100
|
let stopped = false;
|
|
101
|
+
let errors = 0;
|
|
102
|
+
const benign = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR";
|
|
98
103
|
const halt = () => (stopped = stopped || shouldStop());
|
|
99
104
|
async function walk(dir) {
|
|
100
105
|
if (halt()) return;
|
|
101
106
|
let entries;
|
|
102
|
-
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
107
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (!benign(error)) errors += 1; return; }
|
|
103
108
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
104
109
|
for (const entry of entries) {
|
|
105
110
|
if (halt()) return;
|
|
@@ -109,12 +114,12 @@ export async function listTranscripts(root, cutoffMs, { shouldStop = () => false
|
|
|
109
114
|
try {
|
|
110
115
|
const info = await stat(path);
|
|
111
116
|
if (info.mtimeMs >= cutoffMs || keep(path, info)) out.push(path);
|
|
112
|
-
} catch { /* vanished between readdir and stat */ }
|
|
117
|
+
} catch (error) { if (!benign(error)) errors += 1; /* ENOENT: vanished between readdir and stat */ }
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
await walk(root);
|
|
117
|
-
return { files: out, stopped };
|
|
122
|
+
return { files: out, stopped, errors };
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/** Which adapter owns a single `--file`: under a Codex root, or a rollout whose first record is session_meta. */
|
|
@@ -373,19 +378,24 @@ export async function syncUsage(opts, deps = {}) {
|
|
|
373
378
|
roots = transcriptRoots(env, home), repoFor = createRepoResolver(), branchFor = createBranchResolver({ env }), cliVersion = "0.0.0",
|
|
374
379
|
tenantFor = createTenantResolver({ env }), resolveTarget = defaultResolveTarget, readLines = readCompleteLines,
|
|
375
380
|
serverUrl = env.BOTBUDDY_SERVER_URL || DEFAULT_SERVER_URL, statePath = cursorPath(home),
|
|
381
|
+
newSyncId = randomUUID,
|
|
376
382
|
} = deps;
|
|
377
383
|
const startedMs = now();
|
|
378
384
|
const deadlineMs = startedMs + opts.maxSeconds * 1000;
|
|
379
385
|
const parseDeadlineMs = startedMs + opts.maxSeconds * 1000 * PARSE_BUDGET_FRACTION;
|
|
380
386
|
const startedAt = new Date(startedMs).toISOString();
|
|
387
|
+
// BOT-2649: one id for this whole invocation, sent with every batch (both
|
|
388
|
+
// providers), so v_usage_sync_hosts judges the attempt as a whole: one
|
|
389
|
+
// refused batch fails the sync even when a later batch succeeded.
|
|
390
|
+
const syncId = newSyncId();
|
|
381
391
|
const receipt = {
|
|
382
392
|
schema_version: 1, command: "usage sync", outcome: "ok", trigger: opts.trigger, dry_run: opts.dryRun,
|
|
383
393
|
providers: [], files_scanned: 0, files_read: 0, envelopes: 0, batches: 0,
|
|
384
394
|
tenant: null, skipped_other_tenant: 0, skipped_unbound: 0,
|
|
385
395
|
seen: 0, inserted: 0, superseded: 0, deduped: 0, refused: 0, refusal_reasons: {}, batches_all_refused: 0,
|
|
386
|
-
refused_local: 0, parse_errors: 0,
|
|
396
|
+
refused_local: 0, parse_errors: 0, read_errors: 0,
|
|
387
397
|
held_open_messages: 0, cost_state_withheld: 0,
|
|
388
|
-
privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, duration_ms: 0,
|
|
398
|
+
privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, sync_id: syncId, duration_ms: 0,
|
|
389
399
|
};
|
|
390
400
|
const finish = (outcome, error) => {
|
|
391
401
|
receipt.outcome = outcome;
|
|
@@ -441,6 +451,8 @@ export async function syncUsage(opts, deps = {}) {
|
|
|
441
451
|
},
|
|
442
452
|
});
|
|
443
453
|
if (listed.stopped) receipt.budget_exhausted = true;
|
|
454
|
+
// BOT-2649: an unreadable transcript directory is a read failure, not an idle host.
|
|
455
|
+
receipt.read_errors += listed.errors ?? 0;
|
|
444
456
|
plan.push({ provider, files: listed.stopped ? [] : listed.files });
|
|
445
457
|
}
|
|
446
458
|
}
|
|
@@ -466,7 +478,7 @@ export async function syncUsage(opts, deps = {}) {
|
|
|
466
478
|
: createClaudeAdapter({ hmac, environment, repoFor, tenantFor });
|
|
467
479
|
const run = {
|
|
468
480
|
host: hostname(), trigger: opts.trigger, provider, cli_version: cliVersion,
|
|
469
|
-
parser_version: adapter.parserVersion, started_at: startedAt,
|
|
481
|
+
parser_version: adapter.parserVersion, started_at: startedAt, sync_id: syncId,
|
|
470
482
|
};
|
|
471
483
|
|
|
472
484
|
// Envelopes are queued with a global sequence; a file's cursor commits only
|
|
@@ -535,7 +547,9 @@ export async function syncUsage(opts, deps = {}) {
|
|
|
535
547
|
for (const path of files) {
|
|
536
548
|
if (now() >= parseDeadlineMs) { receipt.budget_exhausted = true; break; }
|
|
537
549
|
let info;
|
|
538
|
-
|
|
550
|
+
// A file that vanished since listing is benign; any other stat failure
|
|
551
|
+
// (e.g. EACCES) means this run could not read pending usage (BOT-2649).
|
|
552
|
+
try { info = await stat(path); } catch (error) { if (error?.code !== "ENOENT") receipt.read_errors += 1; continue; }
|
|
539
553
|
const sourceHmac = hmac(resolve(path));
|
|
540
554
|
// Cursors are per (tenant, file): a run skips other tenants' records, so a
|
|
541
555
|
// shared per-file cursor would consume them for every later tenant's run.
|
|
@@ -560,6 +574,7 @@ export async function syncUsage(opts, deps = {}) {
|
|
|
560
574
|
parser.parseRecord(record, offset);
|
|
561
575
|
}, { shouldStop: () => now() >= parseDeadlineMs });
|
|
562
576
|
} catch {
|
|
577
|
+
receipt.read_errors += 1; // counted: a run that could not read is not a clean idle run
|
|
563
578
|
continue; // unreadable file: skip, cursor unchanged
|
|
564
579
|
}
|
|
565
580
|
if (result.stopped) receipt.budget_exhausted = true;
|
|
@@ -606,8 +621,43 @@ export async function syncUsage(opts, deps = {}) {
|
|
|
606
621
|
}
|
|
607
622
|
commitDelivered();
|
|
608
623
|
}
|
|
624
|
+
// BOT-2649: a run that uploaded nothing still records one empty ok run, so an
|
|
625
|
+
// idle but healthy host is not aged into "stale" by v_usage_sync_hosts. Only a
|
|
626
|
+
// CLEAN idle run does: one that refused records locally, hit unparseable lines,
|
|
627
|
+
// could not read a transcript or ran out of budget did not prove usage lands, so it records nothing and the
|
|
628
|
+
// host ages as before rather than reading healthy (independent review on #1324).
|
|
629
|
+
if (!opts.dryRun && receipt.batches === 0) {
|
|
630
|
+
const withheld = receipt.budget_exhausted ? "budget_exhausted"
|
|
631
|
+
: receipt.refused_local > 0 ? "refused_local"
|
|
632
|
+
: receipt.parse_errors > 0 ? "parse_errors"
|
|
633
|
+
: receipt.read_errors > 0 ? "read_errors" : null;
|
|
634
|
+
if (withheld) {
|
|
635
|
+
receipt.empty_run_withheld = withheld;
|
|
636
|
+
} else {
|
|
637
|
+
const error = await postEmptyRun();
|
|
638
|
+
if (error === OUT_OF_TIME) return finalize(null);
|
|
639
|
+
if (error) return finalize(error);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
609
642
|
return finalize(null);
|
|
610
643
|
|
|
644
|
+
async function postEmptyRun() {
|
|
645
|
+
// Same deadline rules as a batch: never START a request past the budget.
|
|
646
|
+
if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
|
|
647
|
+
const resolved = await ensureAuth();
|
|
648
|
+
if (resolved.error) return resolved.error;
|
|
649
|
+
if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
|
|
650
|
+
const run = { host: hostname(), trigger: opts.trigger, cli_version: cliVersion, started_at: startedAt, sync_id: syncId };
|
|
651
|
+
const result = await postBatch({
|
|
652
|
+
endpoint: usageIngestEndpoint(serverUrl), tenant: resolved.tenant,
|
|
653
|
+
headers: resolved.headers, run, envelopes: [], fetchImpl, timeoutMs: deadlineMs - now(),
|
|
654
|
+
});
|
|
655
|
+
if (!result.ok) return result.error;
|
|
656
|
+
receipt.empty_run = true;
|
|
657
|
+
if (result.run_id) receipt.run_ids.push(result.run_id);
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
|
|
611
661
|
async function finalize(error) {
|
|
612
662
|
const outcome = error ? "error" : opts.dryRun ? "dry_run" : receipt.budget_exhausted ? "partial" : "ok";
|
|
613
663
|
finish(outcome, error);
|