@botbuddy/cli 1.19.1 → 1.20.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/wait-core.mjs +98 -9
- package/src/wait.mjs +65 -15
package/package.json
CHANGED
package/src/wait-core.mjs
CHANGED
|
@@ -576,13 +576,27 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
|
|
|
576
576
|
clone.detail_truncated = true;
|
|
577
577
|
}
|
|
578
578
|
if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "error") {
|
|
579
|
-
|
|
579
|
+
const base = {
|
|
580
580
|
schema_version: clone.schema_version,
|
|
581
581
|
outcome: "error",
|
|
582
582
|
error: typeof clone.error === "string" ? clone.error.slice(0, 48) : "receipt_truncated",
|
|
583
583
|
truncated: true,
|
|
584
584
|
...(clone.client ? { client: clone.client } : {}),
|
|
585
585
|
};
|
|
586
|
+
// BOT-1590: the recovery hint is the whole point of an error receipt, so keep
|
|
587
|
+
// it (trimmed to whatever still fits the cap) rather than dropping it — a
|
|
588
|
+
// caller at the documented minimum cap must still get its one actionable line.
|
|
589
|
+
if (typeof clone.recovery === "string" && clone.recovery.length > 0) {
|
|
590
|
+
const room = maxBytes - Buffer.byteLength(JSON.stringify({ ...base, recovery: "" }));
|
|
591
|
+
if (room > 0) {
|
|
592
|
+
let recovery = clone.recovery;
|
|
593
|
+
while (recovery.length > 0 && Buffer.byteLength(recovery, "utf8") > room) {
|
|
594
|
+
recovery = recovery.slice(0, -1);
|
|
595
|
+
}
|
|
596
|
+
if (recovery.length > 0) base.recovery = recovery;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return base;
|
|
586
600
|
}
|
|
587
601
|
return clone;
|
|
588
602
|
}
|
|
@@ -751,6 +765,28 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
751
765
|
// cannot actually be served. The wait loop handles it separately (fold the
|
|
752
766
|
// capacity_source_stale marker / fast exit); it must not read as capacity here.
|
|
753
767
|
if (signal.payload?.source_stale === true) return false;
|
|
768
|
+
// BOT-1577: a RANKED grant restricts the host broadcast to named winners.
|
|
769
|
+
// When the payload carries granted_wait_session_ids, match ONLY if THIS
|
|
770
|
+
// wait's session id is in it — otherwise keep parking (the server already
|
|
771
|
+
// vetted the slot count and this wait's minSlots when it built the list).
|
|
772
|
+
// A payload WITHOUT the list is a legacy broadcast (older server, or the
|
|
773
|
+
// no-contention case) → fall back to the free_slots >= N threshold, so this
|
|
774
|
+
// stays backward compatible.
|
|
775
|
+
const granted = signal.payload?.granted_wait_session_ids;
|
|
776
|
+
if (Array.isArray(granted)) {
|
|
777
|
+
if (waitSessionId == null || !granted.includes(waitSessionId)) return false;
|
|
778
|
+
// BOT-1577: a ranked grant is only valid until its 120s hold expires. This
|
|
779
|
+
// signal is durable — on a reconnect from an old cursor (or a slow wake) after
|
|
780
|
+
// the hold lapsed and the slot was re-granted to another waiter, event-stream
|
|
781
|
+
// replays the original grant. Reject it once expired — and reserve
|
|
782
|
+
// GRANT_CONSUME_MARGIN_MS (finalize feed-lag reprobe + the up-to-5s finalizeWait
|
|
783
|
+
// that runs before `botbuddy wait` exits), since a grant that lapses in that window
|
|
784
|
+
// is re-granted before the caller can start — so the original client does not start
|
|
785
|
+
// too (double-provision). A payload without grant_expires_at (older server) is accepted;
|
|
786
|
+
// a present-but-malformed expiry fails closed (parks) rather than matching forever.
|
|
787
|
+
if (!grantUnexpired(signal.payload?.grant_expires_at, GRANT_CONSUME_MARGIN_MS)) return false;
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
754
790
|
const free = Number(signal.payload?.free_slots);
|
|
755
791
|
return Number.isFinite(free) && free >= params.minSlots;
|
|
756
792
|
}
|
|
@@ -887,6 +923,55 @@ export function matchFrame(frame, conditions, waitSessionId, sessionTenant) {
|
|
|
887
923
|
return null;
|
|
888
924
|
}
|
|
889
925
|
|
|
926
|
+
/**
|
|
927
|
+
* BOT-1577: derive this wait's grant detail from a ranked container_capacity
|
|
928
|
+
* signal, for the receipt (`matched[].grant = {position, tier, score}`). Returns
|
|
929
|
+
* null when the signal is not a ranked capacity grant addressed to this wait
|
|
930
|
+
* (legacy broadcast, another host, or a payload without the grant list).
|
|
931
|
+
* `position` is 1-based in the server's grant order; tier/score come from the
|
|
932
|
+
* index-aligned granted_grants entry when present.
|
|
933
|
+
*/
|
|
934
|
+
// BOT-1577: a ranked capacity grant is valid only until its 120s hold expires
|
|
935
|
+
// (payload.grant_expires_at, an ISO timestamp). `marginMs` reserves remaining-time before
|
|
936
|
+
// the expiry: a grant accepted with less than the margin left could lapse between the
|
|
937
|
+
// match and the terminal receipt — finalize() can spend up to FEED_PROBE_BUDGET_MS in the
|
|
938
|
+
// feed-lag reprobe for a mixed capacity/linear wait — after which the periodic evaluator
|
|
939
|
+
// re-grants the slot, so accepting it would double-provision (BOT-1577 P1). A payload
|
|
940
|
+
// without the field (older server) is treated as valid for backward compatibility; an
|
|
941
|
+
// unparseable value fails open (valid) rather than dropping a live grant.
|
|
942
|
+
// Minimum hold time a grant must have left when accepted, so it survives the whole path
|
|
943
|
+
// from acceptance to the caller actually PROVISIONING the slot (BOT-1577 P1):
|
|
944
|
+
// • finalize()'s feed-lag reprobe .......... FEED_PROBE_BUDGET_MS (2s)
|
|
945
|
+
// • finalizeWait() runWait() awaits ......... 5s (after the receipt, before exit)
|
|
946
|
+
// • the caller's docker preflight ........... 30s (a single Docker command budget)
|
|
947
|
+
// Below this, a delayed/replayed grant can lapse mid-preflight and be re-granted before
|
|
948
|
+
// the caller starts, double-provisioning the slot. The 120s hold's remainder (~120−37s)
|
|
949
|
+
// then covers the actual `supabase start`; a caller that provisions beyond that window is
|
|
950
|
+
// the consumer's responsibility to re-validate at start (the SG start gate, BOT-1576).
|
|
951
|
+
const GRANT_CONSUME_MARGIN_MS = 2000 + 5000 + 30000;
|
|
952
|
+
function grantUnexpired(grantExpiresAt, marginMs = 0) {
|
|
953
|
+
if (grantExpiresAt == null) return true; // absent (older server) ⇒ accept (legacy)
|
|
954
|
+
const t = Date.parse(grantExpiresAt);
|
|
955
|
+
if (!Number.isFinite(t)) return false; // present but MALFORMED ⇒ fail closed (park)
|
|
956
|
+
return Date.now() + marginMs < t;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function capacityGrantForReceipt(signal, waitSessionId) {
|
|
960
|
+
if (!signal || signal.signal_type !== "container_capacity" || waitSessionId == null) return null;
|
|
961
|
+
const granted = signal.payload?.granted_wait_session_ids;
|
|
962
|
+
if (!Array.isArray(granted)) return null;
|
|
963
|
+
const idx = granted.indexOf(waitSessionId);
|
|
964
|
+
if (idx < 0) return null;
|
|
965
|
+
if (!grantUnexpired(signal.payload?.grant_expires_at)) return null; // BOT-1577: stale grant ⇒ no receipt
|
|
966
|
+
const grant = { position: idx + 1 };
|
|
967
|
+
const meta = Array.isArray(signal.payload?.granted_grants) ? signal.payload.granted_grants[idx] : null;
|
|
968
|
+
if (meta && typeof meta === "object") {
|
|
969
|
+
if (meta.tier != null) grant.tier = meta.tier;
|
|
970
|
+
if (meta.score != null) grant.score = meta.score;
|
|
971
|
+
}
|
|
972
|
+
return grant;
|
|
973
|
+
}
|
|
974
|
+
|
|
890
975
|
// ---------------------------------------------------------------------------
|
|
891
976
|
// The wait loop.
|
|
892
977
|
// ---------------------------------------------------------------------------
|
|
@@ -1411,16 +1496,20 @@ export async function runWaitLoop({
|
|
|
1411
1496
|
|
|
1412
1497
|
const matched = matchFrame(frame, conditions, waitSessionId, sessionTenant);
|
|
1413
1498
|
if (matched) {
|
|
1499
|
+
const entry = {
|
|
1500
|
+
condition_id: matched.id,
|
|
1501
|
+
signal_type: signal.signal_type,
|
|
1502
|
+
seq: signal.seq,
|
|
1503
|
+
subject_key: signal.subject_key,
|
|
1504
|
+
payload: signal.payload ?? null,
|
|
1505
|
+
provenance: "spine",
|
|
1506
|
+
};
|
|
1507
|
+
// BOT-1577: a ranked capacity grant records this wait's position/tier/score.
|
|
1508
|
+
const grant = capacityGrantForReceipt(signal, waitSessionId);
|
|
1509
|
+
if (grant) entry.grant = grant;
|
|
1414
1510
|
return finalize("matched", {
|
|
1415
1511
|
exitCode: EXIT.MATCHED,
|
|
1416
|
-
matched: [
|
|
1417
|
-
condition_id: matched.id,
|
|
1418
|
-
signal_type: signal.signal_type,
|
|
1419
|
-
seq: signal.seq,
|
|
1420
|
-
subject_key: signal.subject_key,
|
|
1421
|
-
payload: signal.payload ?? null,
|
|
1422
|
-
provenance: "spine",
|
|
1423
|
-
}],
|
|
1512
|
+
matched: [entry],
|
|
1424
1513
|
});
|
|
1425
1514
|
}
|
|
1426
1515
|
}
|
package/src/wait.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Run botbuddy wait --help for the condition grammar.
|
|
15
15
|
|
|
16
16
|
import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
|
|
17
|
-
import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
17
|
+
import { resolveAgentProfile, withPrincipalReceipt, PROFILE_FILE } from "./wait-profile.mjs";
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
@@ -90,6 +90,11 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
90
90
|
Host-shared; a silently-dead host is detected server-side
|
|
91
91
|
(capacity_source_stale) via a host beacon — the client
|
|
92
92
|
grace (default 900s) is a backstop, no longer the only guard.
|
|
93
|
+
Grants are RANKED (BOT-1577): when several waiters
|
|
94
|
+
contend, a freed slot is handed to the highest-scored
|
|
95
|
+
one and its receipt carries grant:{position,tier,score};
|
|
96
|
+
the others keep parking. Older servers broadcast without
|
|
97
|
+
the ranking (first-come). Needs cli >= 1.20.0.
|
|
93
98
|
ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
|
|
94
99
|
| scope=next[,branch=<name>][,workflow=<name>]}
|
|
95
100
|
a PR's CI reaching a terminal conclusion (owner-scoped);
|
|
@@ -643,10 +648,52 @@ function profileErrorReceipt(profile, error) {
|
|
|
643
648
|
schema_version: 1,
|
|
644
649
|
outcome: "error",
|
|
645
650
|
error,
|
|
646
|
-
recovery:
|
|
651
|
+
recovery: profileCredentialRecovery(profile),
|
|
647
652
|
}, profile, { sessionTenant: profile.tenant, agentId: null });
|
|
648
653
|
}
|
|
649
654
|
|
|
655
|
+
// BOT-1590: canonical one-line recoveries for every setup/auth/parameter error.
|
|
656
|
+
// Each names the exact command to run and the source of any missing/invalid
|
|
657
|
+
// value, so an agent (or a human) can self-serve without opening the docs. The
|
|
658
|
+
// SAME string is written to stderr and the receipt `recovery` field that /waits
|
|
659
|
+
// renders. Keep each to one line and never embed a secret value — names, slots,
|
|
660
|
+
// and env-var names only.
|
|
661
|
+
const RECOVERY = Object.freeze({
|
|
662
|
+
// $BOTBUDDY_SESSION_TOKEN (bb_sess_+64hex) is minted by register_agent; the
|
|
663
|
+
// harness exports it at session start.
|
|
664
|
+
sessionToken: "register_agent → export BOTBUDDY_SESSION_TOKEN=<session_token> (the harness exports it at session start)",
|
|
665
|
+
// $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
|
|
666
|
+
sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
|
|
667
|
+
// A --token / session-token contradiction: the session token is the whole
|
|
668
|
+
// credential, so drop --token (never advise also setting a session id here).
|
|
669
|
+
sessionTokenConflict: "unset --token — $BOTBUDDY_SESSION_TOKEN is the whole credential (protocol 3)",
|
|
670
|
+
// Grammar/condition errors point at the help and the canonical doc.
|
|
671
|
+
conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const KNOWN_PROFILES = "botbuddy-dev, supplyguard-dev";
|
|
675
|
+
|
|
676
|
+
// profile_required / unknown_profile: name the file, a minimal example, and the
|
|
677
|
+
// known profile names so the fix is a single edit (AC-3).
|
|
678
|
+
function profileResolutionRecovery(errorCode) {
|
|
679
|
+
return errorCode === "unknown_profile"
|
|
680
|
+
? `set a known profile in ${PROFILE_FILE} or pass --profile <name> — known profiles: ${KNOWN_PROFILES}`
|
|
681
|
+
: `add ${PROFILE_FILE} ({"schema_version":1,"profile":"botbuddy-dev"}) or pass --profile <name> — known profiles: ${KNOWN_PROFILES}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// A profile-credential error (missing/revoked/wrong-tenant): name the exact
|
|
685
|
+
// setup command and the credential's real source, then rule out human PATs,
|
|
686
|
+
// which are never valid for waits (AC-4). `botbuddy profile setup` stores the
|
|
687
|
+
// key in the macOS Keychain under the profile env-var's name (it REQUIRES the
|
|
688
|
+
// Keychain — ensureProfileCredentialBackend); on a non-Keychain host the
|
|
689
|
+
// supported path is exporting that same profile env var directly, which
|
|
690
|
+
// resolveAgentProfile reads. (The 0600 config.json store is the owner login
|
|
691
|
+
// token's, not a profile credential's — a wait cannot consume it.)
|
|
692
|
+
const HUMAN_PAT_NOTE = "human PATs (BOTBUDDY_AGENT_KEY / BOTBUDDY_AGENT_API_KEY) are not valid for waits";
|
|
693
|
+
function profileCredentialRecovery(profile) {
|
|
694
|
+
return `${profileRecovery(profile)} (stores $${profile.tokenEnv} in the macOS Keychain; on a non-Keychain host export $${profile.tokenEnv} directly); ${HUMAN_PAT_NOTE}`;
|
|
695
|
+
}
|
|
696
|
+
|
|
650
697
|
export async function runWait(argv) {
|
|
651
698
|
const opts = parseArgv(argv);
|
|
652
699
|
const emitReceipt = (receipt, options = {}) => emit(receipt, {
|
|
@@ -671,7 +718,8 @@ export async function runWait(argv) {
|
|
|
671
718
|
const { conditions, errors } = parseConditions(opts.conditions);
|
|
672
719
|
if (errors.length > 0) {
|
|
673
720
|
for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
|
|
674
|
-
|
|
721
|
+
process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
|
|
722
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
|
|
675
723
|
process.exit(EXIT.INVALID);
|
|
676
724
|
}
|
|
677
725
|
|
|
@@ -696,13 +744,13 @@ export async function runWait(argv) {
|
|
|
696
744
|
// is a contradiction and is rejected.
|
|
697
745
|
if (needsRelay && opts.sessionToken) {
|
|
698
746
|
if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
|
|
699
|
-
process.stderr.write(
|
|
700
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token" });
|
|
747
|
+
process.stderr.write(`botbuddy wait: $BOTBUDDY_SESSION_TOKEN must match bb_sess_<64 hex>; ${RECOVERY.sessionToken}\n`);
|
|
748
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
|
|
701
749
|
process.exit(EXIT.INVALID);
|
|
702
750
|
}
|
|
703
751
|
if (opts.token && opts.token !== opts.sessionToken) {
|
|
704
|
-
process.stderr.write(
|
|
705
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict" });
|
|
752
|
+
process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
|
|
753
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
|
|
706
754
|
process.exit(EXIT.INVALID);
|
|
707
755
|
}
|
|
708
756
|
opts.useSessionToken = true;
|
|
@@ -714,19 +762,19 @@ export async function runWait(argv) {
|
|
|
714
762
|
// timer-only wait needs no relay and no session id (handled by !needsRelay).
|
|
715
763
|
if (!opts.sessionId) {
|
|
716
764
|
process.stderr.write(
|
|
717
|
-
|
|
765
|
+
`botbuddy wait: --session-id is required (or set $BOTBUDDY_SESSION_ID) — the work-graph session id returned by register_agent; ${RECOVERY.sessionId}\n`,
|
|
718
766
|
);
|
|
719
767
|
emitReceipt({
|
|
720
768
|
schema_version: 1,
|
|
721
769
|
outcome: "error",
|
|
722
770
|
error: "session_id_required",
|
|
723
|
-
recovery:
|
|
771
|
+
recovery: RECOVERY.sessionId,
|
|
724
772
|
});
|
|
725
773
|
process.exit(EXIT.INVALID);
|
|
726
774
|
}
|
|
727
775
|
if (!SESSION_UUID.test(opts.sessionId)) {
|
|
728
|
-
process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}')\n`);
|
|
729
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid" });
|
|
776
|
+
process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}'); ${RECOVERY.sessionId}\n`);
|
|
777
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid", recovery: RECOVERY.sessionId });
|
|
730
778
|
process.exit(EXIT.INVALID);
|
|
731
779
|
}
|
|
732
780
|
try {
|
|
@@ -735,14 +783,16 @@ export async function runWait(argv) {
|
|
|
735
783
|
explicitToken: opts.token,
|
|
736
784
|
});
|
|
737
785
|
} catch (err) {
|
|
738
|
-
|
|
739
|
-
|
|
786
|
+
const code = err.code || "invalid_profile";
|
|
787
|
+
const recovery = profileResolutionRecovery(code);
|
|
788
|
+
process.stderr.write(`bb-wait: ${err.message}; ${recovery}\n`);
|
|
789
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
|
|
740
790
|
process.exit(EXIT.INVALID);
|
|
741
791
|
}
|
|
742
792
|
opts.token = opts.agentProfile.token;
|
|
743
793
|
if (!opts.token) {
|
|
744
794
|
process.stderr.write(
|
|
745
|
-
`botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run
|
|
795
|
+
`botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run ${profileCredentialRecovery(opts.agentProfile)}\n`,
|
|
746
796
|
);
|
|
747
797
|
emitReceipt(profileErrorReceipt(opts.agentProfile, "profile_required"));
|
|
748
798
|
process.exit(EXIT.AUTH);
|
|
@@ -897,7 +947,7 @@ export async function runWait(argv) {
|
|
|
897
947
|
process.exit(EXIT.AUTH);
|
|
898
948
|
}
|
|
899
949
|
const error = typedProfileError(err.errorCode);
|
|
900
|
-
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run
|
|
950
|
+
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run ${profileCredentialRecovery(opts.agentProfile)}\n`);
|
|
901
951
|
emitReceipt(profileErrorReceipt(opts.agentProfile, error));
|
|
902
952
|
process.exit(EXIT.AUTH);
|
|
903
953
|
}
|