@botbuddy/cli 1.47.0 → 1.47.1
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/api.mjs +11 -3
- package/src/commands.mjs +4 -0
- package/src/managed-allocation.mjs +77 -0
- package/src/wait-core.mjs +45 -1
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -353,7 +353,7 @@ export async function resolveCallAuth({
|
|
|
353
353
|
if (typeof session.token !== "string" || !AGENT_KEY_RE.test(session.token)) {
|
|
354
354
|
return { error: { ok: false, auth: true, error: "session_token_malformed", source: session.source ?? null } };
|
|
355
355
|
}
|
|
356
|
-
return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null };
|
|
356
|
+
return { auth: { "x-agent-api-key": session.token }, source: session.source ?? null, sessionToken: true };
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
359
|
const owner = await resolveOwner();
|
|
@@ -392,12 +392,20 @@ function toolEndpoint(tenant) {
|
|
|
392
392
|
return url.toString();
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
export async function callToolJson(toolName, args = {}, {
|
|
395
|
+
export async function callToolJson(toolName, args = {}, {
|
|
396
|
+
fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin,
|
|
397
|
+
requireSessionToken = false, resolveAuth = resolveCallAuth,
|
|
398
|
+
} = {}) {
|
|
396
399
|
let auth = pinnedAuth;
|
|
400
|
+
let sessionTokenSelected = false;
|
|
397
401
|
if (!auth) {
|
|
398
|
-
const resolved = await
|
|
402
|
+
const resolved = await resolveAuth({ toolName, preferSession: requireSessionToken });
|
|
399
403
|
if (resolved.error) return resolved.error;
|
|
400
404
|
auth = resolved.auth;
|
|
405
|
+
sessionTokenSelected = resolved.sessionToken === true;
|
|
406
|
+
}
|
|
407
|
+
if (requireSessionToken && !sessionTokenSelected) {
|
|
408
|
+
return { ok: false, auth: true, error: "session_token_required" };
|
|
401
409
|
}
|
|
402
410
|
// BOT-1571 (Codex P1, round 2): `botbuddy stack` and every other
|
|
403
411
|
// callToolJson caller that passes no explicit tenant would send a USER token
|
package/src/commands.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { agentProfileKeyLabel } from "./credential-kinds.mjs";
|
|
|
13
13
|
import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
|
|
14
14
|
import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
|
|
15
15
|
import { cmdStack } from "./stack.mjs";
|
|
16
|
+
import { cmdManagedAllocation } from "./managed-allocation.mjs";
|
|
16
17
|
import { cmdDocker } from "./docker-hygiene.mjs";
|
|
17
18
|
import { cmdRun } from "./run.mjs";
|
|
18
19
|
import { cmdTest } from "./test-lane.mjs";
|
|
@@ -59,6 +60,7 @@ export async function run(argv, {
|
|
|
59
60
|
warnStale = maybeWarnStale,
|
|
60
61
|
errorLog = (line) => console.error(line),
|
|
61
62
|
bootstrap = cmdBootstrap,
|
|
63
|
+
managedAllocation = cmdManagedAllocation,
|
|
62
64
|
} = {}) {
|
|
63
65
|
const [command, ...args] = argv;
|
|
64
66
|
// BOT-2069: `bootstrap close` can retire a live server session. Parse its
|
|
@@ -89,6 +91,7 @@ export async function run(argv, {
|
|
|
89
91
|
case "unlock": return cmdUnlock(args);
|
|
90
92
|
// BOT-1220: batch-scoped local stack leases (request/park/status/touch/done).
|
|
91
93
|
case "stack": return cmdStack(args);
|
|
94
|
+
case "allocation": return managedAllocation(args);
|
|
92
95
|
case "docker": return cmdDocker(args);
|
|
93
96
|
case "run": return cmdRun(args);
|
|
94
97
|
case "telemetry": return cmdTelemetry(args);
|
|
@@ -209,6 +212,7 @@ ${bold("STACK LEASES")}
|
|
|
209
212
|
stack touch <lease_id> Bump the lease idle clock
|
|
210
213
|
stack done <lease_id> Release (done) a lease — reap the stack
|
|
211
214
|
stack help Full stack usage + exit codes
|
|
215
|
+
allocation status <id> <generation> <target_revision> Verify managed placement before Compose
|
|
212
216
|
|
|
213
217
|
${bold("DOCKER HYGIENE")}
|
|
214
218
|
docker hygiene [options] Dry-run/apply safe stale Supabase cleanup on OrbStack
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { callToolJson, CALL_EXIT } from "./api.mjs";
|
|
2
|
+
import { readMachineUuid } from "./machine-id.mjs";
|
|
3
|
+
|
|
4
|
+
export function parseManagedAllocationArgs(args) {
|
|
5
|
+
if (args.length === 1 && ["help", "--help", "-h"].includes(args[0])) return { help: true };
|
|
6
|
+
if (args.length !== 4 || args[0] !== "status") {
|
|
7
|
+
return { error: "Usage: bb allocation status <allocation_uuid> <generation> <target_revision>" };
|
|
8
|
+
}
|
|
9
|
+
const [, id, rawGeneration, rawRevision] = args;
|
|
10
|
+
const uuid = String(id).trim().toLowerCase();
|
|
11
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid)) {
|
|
12
|
+
return { error: "allocation id must be a canonical UUID" };
|
|
13
|
+
}
|
|
14
|
+
const generation = Number(rawGeneration);
|
|
15
|
+
const targetRevision = Number(rawRevision);
|
|
16
|
+
if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(targetRevision) || targetRevision < 1) {
|
|
17
|
+
return { error: "generation and target_revision must be positive integers" };
|
|
18
|
+
}
|
|
19
|
+
return { command: "status", allocationId: uuid, generation, targetRevision };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function evaluateLocalStartEligibility(receipt, localMachineUuid) {
|
|
23
|
+
const server = receipt?.server_start_eligibility;
|
|
24
|
+
if (server?.eligible !== true) return { eligible: false, reason: server?.reason ?? "server_placement_unverified" };
|
|
25
|
+
if (typeof localMachineUuid !== "string" || !localMachineUuid.trim()) {
|
|
26
|
+
return { eligible: false, reason: "local_machine_identity_unavailable" };
|
|
27
|
+
}
|
|
28
|
+
const selected = receipt?.placement?.hardware_uuid;
|
|
29
|
+
if (typeof selected !== "string" || !selected.trim()) {
|
|
30
|
+
return { eligible: false, reason: "machine_binding_unavailable" };
|
|
31
|
+
}
|
|
32
|
+
if (selected.trim().toLowerCase() !== localMachineUuid.trim().toLowerCase()) {
|
|
33
|
+
return { eligible: false, reason: "selected_machine_mismatch" };
|
|
34
|
+
}
|
|
35
|
+
return { eligible: true, reason: null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MANAGED_ALLOCATION_HELP = `bb allocation status <allocation_uuid> <generation> <target_revision>
|
|
39
|
+
|
|
40
|
+
Read the exact managed-container placement and cleanup receipt using the current
|
|
41
|
+
bb_agent_ work-session token, then compare its enrolled machine binding with this
|
|
42
|
+
machine's OS-reported identity. An active registered session is required. This
|
|
43
|
+
command is read-only; it never starts Docker or Compose.`;
|
|
44
|
+
|
|
45
|
+
export async function cmdManagedAllocation(args, {
|
|
46
|
+
call = callToolJson,
|
|
47
|
+
machineUuid = () => readMachineUuid({ env: {} }),
|
|
48
|
+
log = (line) => process.stdout.write(`${line}\n`),
|
|
49
|
+
errorLog = (line) => process.stderr.write(`${line}\n`),
|
|
50
|
+
} = {}) {
|
|
51
|
+
const parsed = parseManagedAllocationArgs(args);
|
|
52
|
+
if (parsed.help) { log(MANAGED_ALLOCATION_HELP); return 0; }
|
|
53
|
+
if (parsed.error) { errorLog(parsed.error); return 4; }
|
|
54
|
+
const response = await call("get_managed_container_allocation_status", {
|
|
55
|
+
allocation_id: parsed.allocationId,
|
|
56
|
+
generation: parsed.generation,
|
|
57
|
+
target_revision: parsed.targetRevision,
|
|
58
|
+
}, { requireSessionToken: true });
|
|
59
|
+
if (!response?.ok || response.isError) {
|
|
60
|
+
const sessionRequired = response?.error === "session_token_required";
|
|
61
|
+
const payload = { schema_version: 1, outcome: "error",
|
|
62
|
+
code: response?.code ?? response?.error_code ?? response?.data?.code ?? (sessionRequired ? "session_token_required" : response?.isError ? "allocation_status_denied" : "status_unavailable"),
|
|
63
|
+
error: response?.error ?? response?.data?.error ?? (response?.isError ? "managed allocation status was refused by the server" : "managed allocation status unavailable") };
|
|
64
|
+
log(JSON.stringify(payload));
|
|
65
|
+
return response?.auth ? CALL_EXIT.AUTH : response?.isError ? CALL_EXIT.ERROR
|
|
66
|
+
: response?.status == null || response.status >= 500 ? CALL_EXIT.TRANSPORT : CALL_EXIT.ERROR;
|
|
67
|
+
}
|
|
68
|
+
const receipt = response.data;
|
|
69
|
+
const result = {
|
|
70
|
+
...receipt,
|
|
71
|
+
local_start_eligibility: evaluateLocalStartEligibility(receipt, machineUuid()),
|
|
72
|
+
};
|
|
73
|
+
log(JSON.stringify(result));
|
|
74
|
+
// Keep shell chaining safe: a status receipt that shows the wrong/unknown
|
|
75
|
+
// machine must not look like a successful preflight to `&&` callers.
|
|
76
|
+
return result.local_start_eligibility.eligible ? 0 : CALL_EXIT.ERROR;
|
|
77
|
+
}
|
package/src/wait-core.mjs
CHANGED
|
@@ -28,6 +28,9 @@ export const EXIT = Object.freeze({
|
|
|
28
28
|
// reap_failed) before it was granted — re-request the lease. BOT-1754 reserves the name
|
|
29
29
|
// LEASE_LOST and forbids reusing 2-8; 9 is the first free code.
|
|
30
30
|
LEASE_LOST: 9,
|
|
31
|
+
// BOT-2330: an exact managed-allocation cleanup failed. The waiter must not
|
|
32
|
+
// report a successful terminal match when the signed Helper outcome failed.
|
|
33
|
+
MANAGED_ALLOCATION_FAILED: 10,
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
// BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
|
|
@@ -50,6 +53,16 @@ export function leaseLostDetail(signal, condition) {
|
|
|
50
53
|
};
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
function managedAllocationFailureDetail(signal, condition) {
|
|
57
|
+
if (condition?.type !== "managed-allocation" || signal?.signal_type !== "managed_allocation" || signal.payload?.state !== "cleanup_failed") return null;
|
|
58
|
+
return {
|
|
59
|
+
allocation_id: signal.payload.allocation_id,
|
|
60
|
+
generation: signal.payload.generation,
|
|
61
|
+
target_revision: signal.payload.target_revision,
|
|
62
|
+
cleanup_error: typeof signal.payload.cleanup_error === "string" ? signal.payload.cleanup_error : "cleanup_failed",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
// BOT-1249: the internal CONTROL signal_type the reconciler emits when a wait's
|
|
54
67
|
// resource identity is canonicalized away. runWaitLoop treats a wait_superseded frame
|
|
55
68
|
// targeting its OWN wait_session as terminal (exit 8) before matchFrame sees it. It is
|
|
@@ -190,6 +203,24 @@ const VALIDATORS = {
|
|
|
190
203
|
const id = hex ? `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` : raw;
|
|
191
204
|
return { id };
|
|
192
205
|
},
|
|
206
|
+
// BOT-2330: terminal state for one owner/session-bound managed allocation.
|
|
207
|
+
// Both fences are required so a stale wait cannot observe a later reuse.
|
|
208
|
+
"managed-allocation"(p) {
|
|
209
|
+
const unexpected = Object.keys(p).filter((key) => !["id", "generation", "target_revision"].includes(key));
|
|
210
|
+
if (unexpected.length) throw new Error(`managed-allocation does not accept ${unexpected.join(", ")}`);
|
|
211
|
+
const id = String(p.id ?? "").trim().toLowerCase();
|
|
212
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
|
|
213
|
+
throw new Error("managed-allocation needs id=<canonical allocation UUID>");
|
|
214
|
+
}
|
|
215
|
+
const generation = Number(p.generation);
|
|
216
|
+
const targetRevision = Number(p.target_revision);
|
|
217
|
+
if (!Number.isSafeInteger(generation) || generation < 1 || !Number.isSafeInteger(targetRevision) || targetRevision < 1) {
|
|
218
|
+
throw new Error("managed-allocation needs positive integer generation=<n> and target_revision=<n>");
|
|
219
|
+
}
|
|
220
|
+
// Keep the condition parameters in the event-stream wire shape. The server
|
|
221
|
+
// validates `target_revision` before it normalizes this saved condition.
|
|
222
|
+
return { id, generation, target_revision: targetRevision };
|
|
223
|
+
},
|
|
193
224
|
// BOT-1064 / BOT-2112 — linear: wake on a logical state change of a named
|
|
194
225
|
// issue in any Linear team bound to the authenticated tenant.
|
|
195
226
|
// The spine subject_key is the issue identifier as stored on the tickets
|
|
@@ -933,6 +964,14 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
933
964
|
// →active). The relay only delivers it to the lease's owning agent, so any
|
|
934
965
|
// stack_lease frame for this lease id is ours to wake on.
|
|
935
966
|
return signal.signal_type === "stack_lease" && signal.subject_key === `lease:${params.id}`;
|
|
967
|
+
case "managed-allocation": {
|
|
968
|
+
const p = signal.payload;
|
|
969
|
+
return signal.signal_type === "managed_allocation" && signal.subject_key === `allocation:${params.id}` &&
|
|
970
|
+
p?.allocation_id === params.id && p?.generation === params.generation &&
|
|
971
|
+
p?.target_revision === params.target_revision &&
|
|
972
|
+
((p?.state === "released" && p?.permit_count === 0 && p?.cleanup_receipt_accepted === true) ||
|
|
973
|
+
(p?.state === "cleanup_failed" && p?.cleanup_receipt_accepted === true));
|
|
974
|
+
}
|
|
936
975
|
case "linear": {
|
|
937
976
|
if (signal.signal_type !== "linear_issue") return false;
|
|
938
977
|
// Identity (BOT-1260): prefer the STABLE Linear issue UUID when BOTH the wait
|
|
@@ -1355,10 +1394,15 @@ export async function runWaitLoop({
|
|
|
1355
1394
|
// reaped: that wait ends lease_lost (BOT-2013) so the caller can re-request instead of
|
|
1356
1395
|
// provisioning against a dead lease.
|
|
1357
1396
|
const finalizeMatch = async (entry, signal) => {
|
|
1358
|
-
const
|
|
1397
|
+
const condition = conditions.find((c) => c.id === entry.condition_id);
|
|
1398
|
+
const lost = leaseLostDetail(signal, condition);
|
|
1359
1399
|
if (lost) {
|
|
1360
1400
|
return finalize("lease_lost", { exitCode: EXIT.LEASE_LOST, matched: [entry], state: lost, error: "lease_lost" });
|
|
1361
1401
|
}
|
|
1402
|
+
const allocationFailure = managedAllocationFailureDetail(signal, condition);
|
|
1403
|
+
if (allocationFailure) {
|
|
1404
|
+
return finalize("error", { exitCode: EXIT.MANAGED_ALLOCATION_FAILED, matched: [entry], state: allocationFailure, error: allocationFailure.cleanup_error });
|
|
1405
|
+
}
|
|
1362
1406
|
return finalize("matched", { exitCode: EXIT.MATCHED, matched: [entry] });
|
|
1363
1407
|
};
|
|
1364
1408
|
|