@botbuddy/cli 1.64.24 → 1.64.25
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/CHANGELOG.md +4 -0
- 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/pw/staging-auth.mjs +8 -2
- package/src/staging-pool/cli.mjs +41 -0
- package/src/staging-pool/errors.mjs +20 -0
- package/src/staging-pool/take-outbox.mjs +151 -0
- package/src/wait-core.mjs +42 -0
- package/src/wait.mjs +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.64.25
|
|
4
|
+
|
|
5
|
+
- `bb pw` staging-pool takes now durably queue a secret-free, server-attributed telemetry receipt before a browser receives the single-use session. The receipt never includes the state file, path, credential, or caller-selected tenant/session/ticket/PR; a successor session reconciles an undelivered receipt under its verified source-session attribution.
|
|
6
|
+
|
|
3
7
|
## 1.64.20
|
|
4
8
|
|
|
5
9
|
- Session checkpoint writes are deprecated. Keep delivery and acceptance evidence on the Linear ticket; the compatibility MCP tool returns a deprecation notice without writing a checkpoint.
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.25",
|
|
3
|
+
"source_commit": "c07e6d20430392a68124bc1ec8f8675202725aab",
|
|
4
|
+
"source_sha256": "41b187e7b8afceb34b0435e24796d9b313cb0255f17ca8c1eae30c7de81ee87b",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "94b11bbe78ce60791202df0cb36a4d4eddca15c353ad8adcc9007bedf3f3513c"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.64.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.64.25",
|
|
3
|
+
"source_commit": "c07e6d20430392a68124bc1ec8f8675202725aab",
|
|
4
|
+
"source_sha256": "41b187e7b8afceb34b0435e24796d9b313cb0255f17ca8c1eae30c7de81ee87b",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "2f219ac1910318da6483fe4c5e590c3cdb101b03cf4c67032a14254735130ff8"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.64.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
3
|
+
"release_version": "1.64.25",
|
|
4
|
+
"source_commit": "c07e6d20430392a68124bc1ec8f8675202725aab",
|
|
5
|
+
"source_sha256": "41b187e7b8afceb34b0435e24796d9b313cb0255f17ca8c1eae30c7de81ee87b",
|
|
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": "94b11bbe78ce60791202df0cb36a4d4eddca15c353ad8adcc9007bedf3f3513c",
|
|
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": "2f219ac1910318da6483fe4c5e590c3cdb101b03cf4c67032a14254735130ff8",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/pw/staging-auth.mjs
CHANGED
|
@@ -297,7 +297,7 @@ async function requestPoolRefill({ ctxPool, persona, deps, env }) {
|
|
|
297
297
|
* lock-gated lane held by this exact agent, and a staging (or untracked) browser
|
|
298
298
|
* session. Returns the process exit code.
|
|
299
299
|
*/
|
|
300
|
-
export async function runStagingAuth({ plan, host, env, deps, socketRun, resolveSession, stdout, stderr, holder = null }) {
|
|
300
|
+
export async function runStagingAuth({ plan, host, env, deps, socketRun, resolveSession, stdout, stderr, holder = null, credential = null }) {
|
|
301
301
|
const io = { stdout, stderr };
|
|
302
302
|
const poolModule = deps.stagingPoolModule ?? await loadPool();
|
|
303
303
|
const poolOptions = deps.stagingPool ?? {};
|
|
@@ -342,7 +342,13 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
|
|
|
342
342
|
dropGone(ctx, generation);
|
|
343
343
|
let claim;
|
|
344
344
|
try {
|
|
345
|
-
|
|
345
|
+
// The outbox writer is a child `bb call`; carry the gate-verified session
|
|
346
|
+
// credential into that child so it cannot select a cached/durable identity.
|
|
347
|
+
const sessionId = credential?.sessionId ?? env.BOTBUDDY_SESSION_ID;
|
|
348
|
+
const deliveryEnv = credential?.token
|
|
349
|
+
? { ...env, BOTBUDDY_AGENT_SESSION_TOKEN: credential.token, BOTBUDDY_SESSION_ID: sessionId }
|
|
350
|
+
: env;
|
|
351
|
+
claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, poolId: ctxPool.poolId, tenantHint, ...poolOptions, env: deliveryEnv, sessionId });
|
|
346
352
|
} catch (error) {
|
|
347
353
|
if (error?.code === "pool_empty") {
|
|
348
354
|
const refill = await requestPoolRefill({ ctxPool, persona: persona.email, deps, env });
|
package/src/staging-pool/cli.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// runs that copy).
|
|
21
21
|
|
|
22
22
|
import { execFile } from "node:child_process";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
23
24
|
import * as nodeFs from "node:fs/promises";
|
|
24
25
|
import { readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
25
26
|
import { homedir, userInfo } from "node:os";
|
|
@@ -41,6 +42,7 @@ import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, installLine, isKnownCode
|
|
|
41
42
|
import { RegistryError, choosePool, fetchRegisteredPools, whoamiTenant } from "./registry.mjs";
|
|
42
43
|
import { STAGING_POOL_HELP } from "./help.mjs";
|
|
43
44
|
import { refreshExistingRuntime } from "./refresh-runtime.mjs";
|
|
45
|
+
import { enqueueStagingPoolTake, flushStagingPoolTakeOutbox, STAGING_POOL_TAKE_OUTBOX_MAX } from "./take-outbox.mjs";
|
|
44
46
|
|
|
45
47
|
export const BUNDLE_FILES = Object.freeze([
|
|
46
48
|
"binding.mjs",
|
|
@@ -58,6 +60,7 @@ export const BUNDLE_FILES = Object.freeze([
|
|
|
58
60
|
"refresh-runtime.mjs",
|
|
59
61
|
"signin-gotrue-totp.mjs",
|
|
60
62
|
"state-file.mjs",
|
|
63
|
+
"take-outbox.mjs",
|
|
61
64
|
"totp-botbuddy.mjs",
|
|
62
65
|
]);
|
|
63
66
|
|
|
@@ -263,6 +266,8 @@ export async function takeForLane({
|
|
|
263
266
|
fetchImpl = globalThis.fetch,
|
|
264
267
|
exec = promisify(execFile),
|
|
265
268
|
uid = userInfo().uid,
|
|
269
|
+
env = process.env,
|
|
270
|
+
sessionId = env.BOTBUDDY_SESSION_ID,
|
|
266
271
|
logger = () => {},
|
|
267
272
|
} = {}) {
|
|
268
273
|
const ctx = resolveInstalledPool({ home, poolId, tenantHint });
|
|
@@ -271,8 +276,44 @@ export async function takeForLane({
|
|
|
271
276
|
if (!entry) assertPersonaAllowed(target, persona);
|
|
272
277
|
const intervalSeconds = ctx.installed && !ctx.installed.error ? ctx.installed.intervalSeconds : undefined;
|
|
273
278
|
const pool = buildPool({ ctx, target, now, logger, intervalSeconds, fetchImpl });
|
|
279
|
+
// Re-deliver earlier durable receipts before admitting another take. Otherwise
|
|
280
|
+
// a temporary MCP outage could fill the bounded outbox permanently even after
|
|
281
|
+
// the control plane recovers.
|
|
282
|
+
const priorDelivery = await flushStagingPoolTakeOutbox({
|
|
283
|
+
poolDir: ctx.paths.pool,
|
|
284
|
+
botbuddyBin: ctx.installed.botbuddyBin,
|
|
285
|
+
exec,
|
|
286
|
+
env,
|
|
287
|
+
sessionId,
|
|
288
|
+
});
|
|
289
|
+
if (priorDelivery.pending >= STAGING_POOL_TAKE_OUTBOX_MAX) {
|
|
290
|
+
throw Object.assign(new Error("The staging-pool take telemetry outbox is full; no session was handed over."), { code: "take_outbox_full" });
|
|
291
|
+
}
|
|
274
292
|
try {
|
|
275
293
|
const taken = await pool.take({ persona: entry.email, owner });
|
|
294
|
+
try {
|
|
295
|
+
// Persist before handing the browser its state. An unavailable control
|
|
296
|
+
// plane leaves a replayable intent; it never loses the take event.
|
|
297
|
+
await enqueueStagingPoolTake({
|
|
298
|
+
poolDir: ctx.paths.pool,
|
|
299
|
+
poolId: ctx.poolId,
|
|
300
|
+
personaAlias: entry.alias,
|
|
301
|
+
// Local slot names contain a persona-derived prefix and timestamp. The
|
|
302
|
+
// server needs replay correlation only, so persist a one-way opaque id.
|
|
303
|
+
slotId: createHash("sha256").update(taken.slotId).digest("hex"),
|
|
304
|
+
sessionId,
|
|
305
|
+
});
|
|
306
|
+
await flushStagingPoolTakeOutbox({
|
|
307
|
+
poolDir: ctx.paths.pool,
|
|
308
|
+
botbuddyBin: ctx.installed.botbuddyBin,
|
|
309
|
+
exec,
|
|
310
|
+
env,
|
|
311
|
+
sessionId,
|
|
312
|
+
});
|
|
313
|
+
} catch (telemetryError) {
|
|
314
|
+
await pool.discardTaken(taken.slotId).catch(() => {});
|
|
315
|
+
throw telemetryError;
|
|
316
|
+
}
|
|
276
317
|
await requestRefill({ ctx, exec, uid });
|
|
277
318
|
return { taken, target, binding: ctx.poolBinding, persona: entry, discard: () => pool.discardTaken(taken.slotId) };
|
|
278
319
|
} catch (error) {
|
|
@@ -129,6 +129,26 @@ export const ERROR_CODES = Object.freeze({
|
|
|
129
129
|
"Taking a session from the pool failed unexpectedly (see detail).",
|
|
130
130
|
"A filesystem or pool error outside the known cases.",
|
|
131
131
|
`${AUTH} # once; if it repeats, report this code and detail`, "retry_once"),
|
|
132
|
+
take_outbox_unreadable: entry("human", "repair",
|
|
133
|
+
"The durable staging-pool take receipt cannot be read, so no browser session was handed over.",
|
|
134
|
+
"The installed pool's receipt journal is unreadable or was edited outside the CLI.",
|
|
135
|
+
"bb staging-pool uninstall && " + INSTALL),
|
|
136
|
+
take_outbox_invalid: entry("human", "repair",
|
|
137
|
+
"The durable staging-pool take receipt is invalid, so no browser session was handed over.",
|
|
138
|
+
"The installed pool's receipt journal has an invalid pool, persona, or opaque slot record.",
|
|
139
|
+
"bb staging-pool uninstall && " + INSTALL),
|
|
140
|
+
take_outbox_full: entry("agent", "take",
|
|
141
|
+
"The durable staging-pool take receipt journal is full, so no browser session was handed over.",
|
|
142
|
+
"The control plane has not acknowledged prior secret-free take receipts.",
|
|
143
|
+
"bb staging-pool status --check # report this code; do not retry in a loop", "report_blocker"),
|
|
144
|
+
take_outbox_locked: entry("agent", "take",
|
|
145
|
+
"Another staging-pool take is recording its durable receipt, so no browser session was handed over.",
|
|
146
|
+
"A concurrent take still owns the short receipt-journal mutation lock.",
|
|
147
|
+
`${AUTH} # retry once; if it repeats, report this code`, "retry_once"),
|
|
148
|
+
take_outbox_session_required: entry("agent", "take",
|
|
149
|
+
"The staging-pool take has no registered work-session identity, so no browser session was handed over.",
|
|
150
|
+
"The agent session was not registered or its work-session environment was lost before the take began.",
|
|
151
|
+
"bb setup # restore this worktree's registered session, then retry once", "retry_once"),
|
|
132
152
|
|
|
133
153
|
// ── the pool's health (human repairs; agents report once and stop)
|
|
134
154
|
// BOT-2836: a healthy pool emptied between ticks is a wait, not a repair. The take
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// BOT-2934: the local take is not handed to a browser until its secret-free
|
|
2
|
+
// telemetry intent is atomically durable. Delivery is replayable by take_id;
|
|
3
|
+
// the MCP server, never this file, derives tenant/agent/work-session context.
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const FILE = "take-outbox.json";
|
|
9
|
+
const LOCK_FILE = "take-outbox.lock";
|
|
10
|
+
export const STAGING_POOL_TAKE_OUTBOX_MAX = 64;
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const SESSION_RE = UUID_RE;
|
|
13
|
+
const LOCK_ATTEMPTS = 50;
|
|
14
|
+
const LOCK_RETRY_MS = 20;
|
|
15
|
+
const LOCK_STALE_MS = 30_000;
|
|
16
|
+
|
|
17
|
+
function valid(event) {
|
|
18
|
+
return event && typeof event === "object" &&
|
|
19
|
+
UUID_RE.test(event.take_id ?? "") &&
|
|
20
|
+
/^[a-z0-9][a-z0-9-]{0,63}$/.test(event.pool_id ?? "") &&
|
|
21
|
+
/^[a-z0-9][a-z0-9-]{0,39}$/.test(event.persona_alias ?? "") &&
|
|
22
|
+
/^[a-f0-9]{12,64}$/.test(event.slot_id ?? "") &&
|
|
23
|
+
SESSION_RE.test(event.origin_session_id ?? "") &&
|
|
24
|
+
/^[a-f0-9]{64}$/.test(event.origin_session_hash ?? "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sessionHash(sessionId) {
|
|
28
|
+
if (typeof sessionId !== "string" || !SESSION_RE.test(sessionId)) {
|
|
29
|
+
throw Object.assign(new Error("A registered BotBuddy work session is required to record a staging-pool take."), { code: "take_outbox_session_required" });
|
|
30
|
+
}
|
|
31
|
+
return createHash("sha256").update(sessionId.toLowerCase()).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pathFor = (poolDir) => join(poolDir, FILE);
|
|
35
|
+
const lockPathFor = (poolDir) => join(poolDir, LOCK_FILE);
|
|
36
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
|
|
38
|
+
async function withOutboxLock(poolDir, action) {
|
|
39
|
+
await fs.mkdir(poolDir, { recursive: true, mode: 0o700 });
|
|
40
|
+
const lockPath = lockPathFor(poolDir);
|
|
41
|
+
const token = randomUUID();
|
|
42
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
43
|
+
let handle;
|
|
44
|
+
try {
|
|
45
|
+
handle = await fs.open(lockPath, "wx", 0o600);
|
|
46
|
+
await handle.writeFile(JSON.stringify({ token, created_at: new Date().toISOString() }) + "\n");
|
|
47
|
+
await handle.sync();
|
|
48
|
+
try {
|
|
49
|
+
return await action();
|
|
50
|
+
} finally {
|
|
51
|
+
await handle.close();
|
|
52
|
+
try {
|
|
53
|
+
const holder = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
54
|
+
if (holder?.token === token) await fs.rm(lockPath, { force: true });
|
|
55
|
+
} catch { /* a later holder owns the path, or it is already released */ }
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await handle?.close().catch(() => {});
|
|
59
|
+
if (error?.code !== "EEXIST") throw error;
|
|
60
|
+
try {
|
|
61
|
+
const stat = await fs.stat(lockPath);
|
|
62
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
63
|
+
const tomb = `${lockPath}.stale-${randomUUID()}`;
|
|
64
|
+
await fs.rename(lockPath, tomb);
|
|
65
|
+
await fs.rm(tomb, { force: true });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
} catch (lockError) {
|
|
69
|
+
if (lockError?.code !== "ENOENT") throw lockError;
|
|
70
|
+
}
|
|
71
|
+
await pause(LOCK_RETRY_MS);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw Object.assign(new Error("Another staging-pool take is recording its durable receipt; no session was handed over."), { code: "take_outbox_locked" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function read(poolDir) {
|
|
78
|
+
let source;
|
|
79
|
+
try {
|
|
80
|
+
source = await fs.readFile(pathFor(poolDir), "utf8");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code === "ENOENT") return [];
|
|
83
|
+
throw Object.assign(new Error("The staging-pool take outbox is unreadable; no session was handed over."), { code: "take_outbox_unreadable" });
|
|
84
|
+
}
|
|
85
|
+
let parsed;
|
|
86
|
+
try { parsed = JSON.parse(source); }
|
|
87
|
+
catch { throw Object.assign(new Error("The staging-pool take outbox is invalid; no session was handed over."), { code: "take_outbox_invalid" }); }
|
|
88
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !Array.isArray(parsed.events) || !parsed.events.every(valid)) {
|
|
89
|
+
throw Object.assign(new Error("The staging-pool take outbox is invalid; no session was handed over."), { code: "take_outbox_invalid" });
|
|
90
|
+
}
|
|
91
|
+
return parsed.events;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function write(poolDir, events) {
|
|
95
|
+
await fs.mkdir(poolDir, { recursive: true, mode: 0o700 });
|
|
96
|
+
const output = JSON.stringify({ version: 1, events: events.slice(-STAGING_POOL_TAKE_OUTBOX_MAX) }) + "\n";
|
|
97
|
+
const temporary = join(poolDir, `.${FILE}.${randomUUID()}.partial`);
|
|
98
|
+
const handle = await fs.open(temporary, "wx", 0o600);
|
|
99
|
+
try {
|
|
100
|
+
await handle.writeFile(output);
|
|
101
|
+
await handle.sync();
|
|
102
|
+
} finally { await handle.close(); }
|
|
103
|
+
try {
|
|
104
|
+
await fs.rename(temporary, pathFor(poolDir));
|
|
105
|
+
const directory = await fs.open(poolDir, "r");
|
|
106
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await fs.rm(temporary, { force: true });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function enqueueStagingPoolTake({ poolDir, poolId, personaAlias, slotId, sessionId, takeId = randomUUID() }) {
|
|
114
|
+
const event = { take_id: takeId, pool_id: poolId, persona_alias: personaAlias, slot_id: slotId, origin_session_id: sessionId.toLowerCase(), origin_session_hash: sessionHash(sessionId) };
|
|
115
|
+
if (!valid(event)) throw Object.assign(new Error("The staging-pool take telemetry intent is invalid."), { code: "take_outbox_invalid" });
|
|
116
|
+
return withOutboxLock(poolDir, async () => {
|
|
117
|
+
const events = await read(poolDir);
|
|
118
|
+
if (events.length >= STAGING_POOL_TAKE_OUTBOX_MAX) throw Object.assign(new Error("The staging-pool take telemetry outbox is full; no session was handed over."), { code: "take_outbox_full" });
|
|
119
|
+
events.push(event);
|
|
120
|
+
await write(poolDir, events);
|
|
121
|
+
return event;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function flushStagingPoolTakeOutbox({ poolDir, botbuddyBin, exec, env = process.env, sessionId }) {
|
|
126
|
+
const pending = await withOutboxLock(poolDir, () => read(poolDir));
|
|
127
|
+
const originSessionHash = sessionHash(sessionId);
|
|
128
|
+
const delivered = new Set();
|
|
129
|
+
for (let index = 0; index < pending.length; index += 1) {
|
|
130
|
+
const sameSession = pending[index].origin_session_hash === originSessionHash;
|
|
131
|
+
let body;
|
|
132
|
+
try {
|
|
133
|
+
const { origin_session_hash: _originSessionHash, origin_session_id, ...payload } = pending[index];
|
|
134
|
+
const tool = sameSession ? "record_staging_pool_take" : "reconcile_staging_pool_take";
|
|
135
|
+
const bodyPayload = sameSession ? payload : { ...payload, origin_session_id };
|
|
136
|
+
const result = await exec(botbuddyBin, ["call", tool, "--json", JSON.stringify(bodyPayload)], { env, timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
137
|
+
body = JSON.parse(String(result?.stdout ?? ""));
|
|
138
|
+
} catch { continue; }
|
|
139
|
+
if (body?.success === true) delivered.add(pending[index].take_id);
|
|
140
|
+
}
|
|
141
|
+
if (!delivered.size) return { delivered: 0, pending: pending.length };
|
|
142
|
+
const remaining = await withOutboxLock(poolDir, async () => {
|
|
143
|
+
const current = await read(poolDir);
|
|
144
|
+
const next = current.filter((event) => !delivered.has(event.take_id));
|
|
145
|
+
if (next.length !== current.length) await write(poolDir, next);
|
|
146
|
+
return next.length;
|
|
147
|
+
});
|
|
148
|
+
return { delivered: delivered.size, pending: remaining };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const stagingPoolTakeOutboxPath = pathFor;
|
package/src/wait-core.mjs
CHANGED
|
@@ -140,6 +140,31 @@ function parseParams(raw) {
|
|
|
140
140
|
return params;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
function parseStagingPoolParams(raw) {
|
|
144
|
+
const params = {};
|
|
145
|
+
for (const entry of String(raw ?? "").split(",").map((part) => part.trim())) {
|
|
146
|
+
if (!entry) continue;
|
|
147
|
+
const named = /^(pool|persona)=([^=,]+)$/.exec(entry);
|
|
148
|
+
const readiness = /^ready>=([0-9]+)$/.exec(entry);
|
|
149
|
+
const key = named?.[1] ?? (readiness ? "ready" : undefined);
|
|
150
|
+
const value = named?.[2] ?? readiness?.[1];
|
|
151
|
+
if (!key || value === undefined) throw new Error(`staging-pool does not accept ${entry}`);
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(params, key)) throw new Error(`staging-pool does not accept duplicate ${key}`);
|
|
153
|
+
params[key] = value;
|
|
154
|
+
}
|
|
155
|
+
const identifier = (value, label) => {
|
|
156
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(value)) {
|
|
157
|
+
throw new Error(`staging-pool ${label} must be a registered identifier`);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
};
|
|
161
|
+
const pool = identifier(params.pool, "pool");
|
|
162
|
+
const persona = identifier(params.persona, "persona");
|
|
163
|
+
const ready = params.ready === undefined ? 1 : Number(params.ready);
|
|
164
|
+
if (!Number.isSafeInteger(ready) || ready < 1) throw new Error("staging-pool ready>= must be a positive integer");
|
|
165
|
+
return { pool, persona, ready };
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
function asBool(v) {
|
|
144
169
|
if (v === true) return true;
|
|
145
170
|
if (typeof v !== "string") return false;
|
|
@@ -225,6 +250,10 @@ const VALIDATORS = {
|
|
|
225
250
|
if (!CANONICAL_UUID_RE.test(jobId)) throw new Error("staging-pool-refill id must be a canonical UUID");
|
|
226
251
|
return { jobId };
|
|
227
252
|
},
|
|
253
|
+
// BOT-2934 — server-side arm authorization resolves the caller's installed
|
|
254
|
+
// instance. The CLI grammar names only its registered pool/persona, never a
|
|
255
|
+
// host, so it cannot turn a different machine's inventory into readiness.
|
|
256
|
+
"staging-pool": (_p, raw) => parseStagingPoolParams(raw),
|
|
228
257
|
// BOT-2888: a reporter waits for a revision after the last consumed update.
|
|
229
258
|
// The server binds the report to the registering agent and tenant.
|
|
230
259
|
feedback: (p, raw) => {
|
|
@@ -1320,6 +1349,19 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
|
|
|
1320
1349
|
signal.subject_key === `staging-pool-refill:${params.jobId}` &&
|
|
1321
1350
|
signal.payload?.job_id === params.jobId &&
|
|
1322
1351
|
["completed", "failed"].includes(signal.payload?.status);
|
|
1352
|
+
case "staging-pool": {
|
|
1353
|
+
const payload = signal.payload;
|
|
1354
|
+
return signal.signal_type === "staging_pool" &&
|
|
1355
|
+
payload?.schema_version === 1 &&
|
|
1356
|
+
typeof payload.instance_id === "string" && payload.instance_id.length > 0 &&
|
|
1357
|
+
signal.subject_key === `staging-pool:${payload.instance_id}:${payload.persona_alias}` &&
|
|
1358
|
+
payload.pool_id === params.pool &&
|
|
1359
|
+
payload.persona_alias === params.persona &&
|
|
1360
|
+
Number.isSafeInteger(payload.epoch) && payload.epoch >= 1 &&
|
|
1361
|
+
Number.isSafeInteger(payload.revision) && payload.revision >= 1 &&
|
|
1362
|
+
Number.isSafeInteger(payload.ready_count) && payload.ready_count >= params.ready &&
|
|
1363
|
+
payload.availability === "ready" && payload.freshness_phase === "fresh";
|
|
1364
|
+
}
|
|
1323
1365
|
case "feedback":
|
|
1324
1366
|
return signal.signal_type === "feedback_update" &&
|
|
1325
1367
|
signal.subject_key === `feedback:${params.feedbackId}` &&
|
package/src/wait.mjs
CHANGED
|
@@ -175,6 +175,10 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
|
|
|
175
175
|
completed or failed. This is the only condition of its
|
|
176
176
|
wait and is available to each tenant agent that requested
|
|
177
177
|
the deduped job; its terminal receipt is safe to display.
|
|
178
|
+
staging-pool:pool=<id>,persona=<alias>[,ready>=N]
|
|
179
|
+
Wait for N (default 1) fresh, takeable sessions on the
|
|
180
|
+
caller's authorized installed pool instance. This does not
|
|
181
|
+
reserve a session: after a match, run the auth command once.
|
|
178
182
|
feedback:id=<feedback UUID>,after=<revision>
|
|
179
183
|
Wait for a later update to your own feedback; use the
|
|
180
184
|
revision you last consumed and read/acknowledge updates.
|
|
@@ -663,6 +667,8 @@ async function registerWait(opts, conditions, deadlineIso) {
|
|
|
663
667
|
"command_run_single_condition_required",
|
|
664
668
|
"staging_pool_refill_unavailable",
|
|
665
669
|
"staging_pool_refill_single_condition_required",
|
|
670
|
+
"staging_pool_unavailable",
|
|
671
|
+
"staging_pool_single_condition_required",
|
|
666
672
|
"feedback_wait_unavailable",
|
|
667
673
|
]);
|
|
668
674
|
if (INVALID_CONDITION_CODES.has(body.error)) {
|