@botbuddy/cli 1.19.0 → 1.19.2
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/stack.mjs +60 -28
- package/src/wait-core.mjs +15 -1
- package/src/wait.mjs +60 -15
package/package.json
CHANGED
package/src/stack.mjs
CHANGED
|
@@ -665,6 +665,7 @@ export async function cmdUp(opts, {
|
|
|
665
665
|
callTool = callToolJson,
|
|
666
666
|
authProvider = stackAuthHeader,
|
|
667
667
|
localProvisionFn = localProvision,
|
|
668
|
+
waitFn = waitForLease,
|
|
668
669
|
emitResult = emit,
|
|
669
670
|
} = {}) {
|
|
670
671
|
let slot;
|
|
@@ -720,7 +721,7 @@ export async function cmdUp(opts, {
|
|
|
720
721
|
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
|
|
721
722
|
}
|
|
722
723
|
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
723
|
-
const parked = await
|
|
724
|
+
const parked = await waitFn(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
|
|
724
725
|
if (parked.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
|
|
725
726
|
if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
726
727
|
if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
|
|
@@ -762,36 +763,67 @@ export async function cmdUp(opts, {
|
|
|
762
763
|
connection: connectionWithDockerTarget({}, localDockerTarget),
|
|
763
764
|
});
|
|
764
765
|
if (!reserved.ok || !reserved.data?.success) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
766
|
+
// A Helper can win the reservation race by taking the queued provision
|
|
767
|
+
// job between `request_stack_lease` and `reserve_stack_lease`. That is NOT
|
|
768
|
+
// a failure — nothing local has started, and the Helper owns (or is about
|
|
769
|
+
// to activate) a live stack for this same lease. Cancelling here would
|
|
770
|
+
// abandon that stack until idle reaping. Two shapes signal the takeover,
|
|
771
|
+
// both from `reserve_stack_lease` which checks lease state before job
|
|
772
|
+
// status (BOT-1587):
|
|
773
|
+
// • PROVISION_JOB_HELPER_CLAIMED — Helper claimed the job, lease still
|
|
774
|
+
// `provisioning`: wait for it to reach active.
|
|
775
|
+
// • LEASE_NOT_PROVISIONING with state `active` — the Helper both claimed
|
|
776
|
+
// AND activated before reserve acquired its locks: already live, so
|
|
777
|
+
// skip the wait (the edge-triggered stream would park until timeout on
|
|
778
|
+
// an already-active lease) and go straight to the read.
|
|
779
|
+
// Either way, follow the Helper to the shared authoritative final read
|
|
780
|
+
// below; only a genuine reserve failure is terminal.
|
|
781
|
+
const helperClaimed = reserved.data?.code === "PROVISION_JOB_HELPER_CLAIMED";
|
|
782
|
+
const helperAlreadyActive = reserved.data?.code === "LEASE_NOT_PROVISIONING" && reserved.data?.state === "active";
|
|
783
|
+
if (helperClaimed || helperAlreadyActive) {
|
|
784
|
+
if (!helperAlreadyActive) {
|
|
785
|
+
const active = await waitFn(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
|
|
786
|
+
if (active.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `a Helper claimed the provision job but it did not reach active in ${opts.timeout}s` }), opts, EXIT.TIMEOUT);
|
|
787
|
+
if (active.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
788
|
+
if (active.failed) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
|
|
789
|
+
if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
790
|
+
}
|
|
791
|
+
// Fall through to the authoritative `get_stack_lease` read below — do
|
|
792
|
+
// NOT run the local provisioner; the Helper owns this stack.
|
|
793
|
+
} else {
|
|
794
|
+
const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
|
|
795
|
+
const leaseCancellation = cancelled.ok && cancelled.data?.success
|
|
796
|
+
? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
|
|
797
|
+
: { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
|
|
798
|
+
return emitResult(buildReceipt({
|
|
799
|
+
command: "up", outcome: "error", lease_id: leaseId, state, slot,
|
|
800
|
+
error: reserved.data?.code || reserved.error || "could not reserve the provision job for local execution",
|
|
801
|
+
lease_cancellation: leaseCancellation,
|
|
802
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
803
|
+
}
|
|
804
|
+
} else {
|
|
805
|
+
let conn;
|
|
806
|
+
try {
|
|
807
|
+
conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
|
|
808
|
+
} catch (e) {
|
|
809
|
+
// Once the local provisioner has run, `supabase start` may have created
|
|
810
|
+
// (or fully started) containers even on a nonzero exit or a status-parse
|
|
811
|
+
// failure. Cancelling here would reap the lease and free the slot while
|
|
812
|
+
// that stack is still up — the exact orphaned/contended-slot hazard this
|
|
813
|
+
// change closes. Leave the lease FENCED (provisioning, reserved to us) so
|
|
814
|
+
// `botbuddy stack done` / the reaper tears the stack down first (BOT-1421
|
|
815
|
+
// review); do NOT cancel.
|
|
816
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
|
|
817
|
+
}
|
|
818
|
+
const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
|
|
819
|
+
if (!act.ok || !act.data?.success) {
|
|
820
|
+
return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
|
|
821
|
+
}
|
|
791
822
|
}
|
|
792
823
|
} else {
|
|
793
|
-
const active = await
|
|
824
|
+
const active = await waitFn(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
|
|
794
825
|
if (active.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `provisioning did not reach active in ${opts.timeout}s (Helper may be down — retry with --local-exec)` }), opts, EXIT.TIMEOUT);
|
|
826
|
+
if (active.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
|
|
795
827
|
if (active.failed) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
|
|
796
828
|
if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
|
|
797
829
|
}
|
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
|
}
|
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";
|
|
@@ -643,10 +643,52 @@ function profileErrorReceipt(profile, error) {
|
|
|
643
643
|
schema_version: 1,
|
|
644
644
|
outcome: "error",
|
|
645
645
|
error,
|
|
646
|
-
recovery:
|
|
646
|
+
recovery: profileCredentialRecovery(profile),
|
|
647
647
|
}, profile, { sessionTenant: profile.tenant, agentId: null });
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
// BOT-1590: canonical one-line recoveries for every setup/auth/parameter error.
|
|
651
|
+
// Each names the exact command to run and the source of any missing/invalid
|
|
652
|
+
// value, so an agent (or a human) can self-serve without opening the docs. The
|
|
653
|
+
// SAME string is written to stderr and the receipt `recovery` field that /waits
|
|
654
|
+
// renders. Keep each to one line and never embed a secret value — names, slots,
|
|
655
|
+
// and env-var names only.
|
|
656
|
+
const RECOVERY = Object.freeze({
|
|
657
|
+
// $BOTBUDDY_SESSION_TOKEN (bb_sess_+64hex) is minted by register_agent; the
|
|
658
|
+
// harness exports it at session start.
|
|
659
|
+
sessionToken: "register_agent → export BOTBUDDY_SESSION_TOKEN=<session_token> (the harness exports it at session start)",
|
|
660
|
+
// $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
|
|
661
|
+
sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
|
|
662
|
+
// A --token / session-token contradiction: the session token is the whole
|
|
663
|
+
// credential, so drop --token (never advise also setting a session id here).
|
|
664
|
+
sessionTokenConflict: "unset --token — $BOTBUDDY_SESSION_TOKEN is the whole credential (protocol 3)",
|
|
665
|
+
// Grammar/condition errors point at the help and the canonical doc.
|
|
666
|
+
conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const KNOWN_PROFILES = "botbuddy-dev, supplyguard-dev";
|
|
670
|
+
|
|
671
|
+
// profile_required / unknown_profile: name the file, a minimal example, and the
|
|
672
|
+
// known profile names so the fix is a single edit (AC-3).
|
|
673
|
+
function profileResolutionRecovery(errorCode) {
|
|
674
|
+
return errorCode === "unknown_profile"
|
|
675
|
+
? `set a known profile in ${PROFILE_FILE} or pass --profile <name> — known profiles: ${KNOWN_PROFILES}`
|
|
676
|
+
: `add ${PROFILE_FILE} ({"schema_version":1,"profile":"botbuddy-dev"}) or pass --profile <name> — known profiles: ${KNOWN_PROFILES}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// A profile-credential error (missing/revoked/wrong-tenant): name the exact
|
|
680
|
+
// setup command and the credential's real source, then rule out human PATs,
|
|
681
|
+
// which are never valid for waits (AC-4). `botbuddy profile setup` stores the
|
|
682
|
+
// key in the macOS Keychain under the profile env-var's name (it REQUIRES the
|
|
683
|
+
// Keychain — ensureProfileCredentialBackend); on a non-Keychain host the
|
|
684
|
+
// supported path is exporting that same profile env var directly, which
|
|
685
|
+
// resolveAgentProfile reads. (The 0600 config.json store is the owner login
|
|
686
|
+
// token's, not a profile credential's — a wait cannot consume it.)
|
|
687
|
+
const HUMAN_PAT_NOTE = "human PATs (BOTBUDDY_AGENT_KEY / BOTBUDDY_AGENT_API_KEY) are not valid for waits";
|
|
688
|
+
function profileCredentialRecovery(profile) {
|
|
689
|
+
return `${profileRecovery(profile)} (stores $${profile.tokenEnv} in the macOS Keychain; on a non-Keychain host export $${profile.tokenEnv} directly); ${HUMAN_PAT_NOTE}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
650
692
|
export async function runWait(argv) {
|
|
651
693
|
const opts = parseArgv(argv);
|
|
652
694
|
const emitReceipt = (receipt, options = {}) => emit(receipt, {
|
|
@@ -671,7 +713,8 @@ export async function runWait(argv) {
|
|
|
671
713
|
const { conditions, errors } = parseConditions(opts.conditions);
|
|
672
714
|
if (errors.length > 0) {
|
|
673
715
|
for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
|
|
674
|
-
|
|
716
|
+
process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
|
|
717
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
|
|
675
718
|
process.exit(EXIT.INVALID);
|
|
676
719
|
}
|
|
677
720
|
|
|
@@ -696,13 +739,13 @@ export async function runWait(argv) {
|
|
|
696
739
|
// is a contradiction and is rejected.
|
|
697
740
|
if (needsRelay && opts.sessionToken) {
|
|
698
741
|
if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
|
|
699
|
-
process.stderr.write(
|
|
700
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token" });
|
|
742
|
+
process.stderr.write(`botbuddy wait: $BOTBUDDY_SESSION_TOKEN must match bb_sess_<64 hex>; ${RECOVERY.sessionToken}\n`);
|
|
743
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
|
|
701
744
|
process.exit(EXIT.INVALID);
|
|
702
745
|
}
|
|
703
746
|
if (opts.token && opts.token !== opts.sessionToken) {
|
|
704
|
-
process.stderr.write(
|
|
705
|
-
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict" });
|
|
747
|
+
process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
|
|
748
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
|
|
706
749
|
process.exit(EXIT.INVALID);
|
|
707
750
|
}
|
|
708
751
|
opts.useSessionToken = true;
|
|
@@ -714,19 +757,19 @@ export async function runWait(argv) {
|
|
|
714
757
|
// timer-only wait needs no relay and no session id (handled by !needsRelay).
|
|
715
758
|
if (!opts.sessionId) {
|
|
716
759
|
process.stderr.write(
|
|
717
|
-
|
|
760
|
+
`botbuddy wait: --session-id is required (or set $BOTBUDDY_SESSION_ID) — the work-graph session id returned by register_agent; ${RECOVERY.sessionId}\n`,
|
|
718
761
|
);
|
|
719
762
|
emitReceipt({
|
|
720
763
|
schema_version: 1,
|
|
721
764
|
outcome: "error",
|
|
722
765
|
error: "session_id_required",
|
|
723
|
-
recovery:
|
|
766
|
+
recovery: RECOVERY.sessionId,
|
|
724
767
|
});
|
|
725
768
|
process.exit(EXIT.INVALID);
|
|
726
769
|
}
|
|
727
770
|
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" });
|
|
771
|
+
process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}'); ${RECOVERY.sessionId}\n`);
|
|
772
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid", recovery: RECOVERY.sessionId });
|
|
730
773
|
process.exit(EXIT.INVALID);
|
|
731
774
|
}
|
|
732
775
|
try {
|
|
@@ -735,14 +778,16 @@ export async function runWait(argv) {
|
|
|
735
778
|
explicitToken: opts.token,
|
|
736
779
|
});
|
|
737
780
|
} catch (err) {
|
|
738
|
-
|
|
739
|
-
|
|
781
|
+
const code = err.code || "invalid_profile";
|
|
782
|
+
const recovery = profileResolutionRecovery(code);
|
|
783
|
+
process.stderr.write(`bb-wait: ${err.message}; ${recovery}\n`);
|
|
784
|
+
emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
|
|
740
785
|
process.exit(EXIT.INVALID);
|
|
741
786
|
}
|
|
742
787
|
opts.token = opts.agentProfile.token;
|
|
743
788
|
if (!opts.token) {
|
|
744
789
|
process.stderr.write(
|
|
745
|
-
`botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run
|
|
790
|
+
`botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run ${profileCredentialRecovery(opts.agentProfile)}\n`,
|
|
746
791
|
);
|
|
747
792
|
emitReceipt(profileErrorReceipt(opts.agentProfile, "profile_required"));
|
|
748
793
|
process.exit(EXIT.AUTH);
|
|
@@ -897,7 +942,7 @@ export async function runWait(argv) {
|
|
|
897
942
|
process.exit(EXIT.AUTH);
|
|
898
943
|
}
|
|
899
944
|
const error = typedProfileError(err.errorCode);
|
|
900
|
-
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run
|
|
945
|
+
process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run ${profileCredentialRecovery(opts.agentProfile)}\n`);
|
|
901
946
|
emitReceipt(profileErrorReceipt(opts.agentProfile, error));
|
|
902
947
|
process.exit(EXIT.AUTH);
|
|
903
948
|
}
|