@botbuddy/cli 1.5.4 → 1.6.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/bin/bb-pw.mjs +7 -0
- package/bin/botbuddy.mjs +3 -1
- package/package.json +8 -3
- package/src/commands.mjs +6 -0
- package/src/docker-hygiene.mjs +187 -21
- package/src/pw/args.mjs +28 -0
- package/src/pw/coordinator.mjs +12 -0
- package/src/pw/daemon.mjs +13 -0
- package/src/pw/readiness.mjs +4 -0
- package/src/pw/reap.mjs +5 -0
- package/src/pw/run.mjs +24 -0
- package/src/pw/targets.mjs +37 -0
- package/src/pw/telemetry.mjs +14 -0
- package/src/botbuddy-release-repair.json +0 -1
package/bin/bb-pw.mjs
ADDED
package/bin/botbuddy.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { run } from "../src/commands.mjs";
|
|
3
3
|
|
|
4
|
-
run(process.argv.slice(2)).
|
|
4
|
+
run(process.argv.slice(2)).then((code) => {
|
|
5
|
+
if (typeof code === "number") process.exitCode = code;
|
|
6
|
+
}).catch((err) => {
|
|
5
7
|
console.error(`\x1b[31m✗\x1b[0m ${err?.message || err}`);
|
|
6
8
|
process.exit(1);
|
|
7
9
|
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botbuddy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"botbuddy": "./bin/botbuddy.mjs"
|
|
7
|
+
"botbuddy": "./bin/botbuddy.mjs",
|
|
8
|
+
"bb-pw": "./bin/bb-pw.mjs"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"bin/",
|
|
@@ -20,9 +21,13 @@
|
|
|
20
21
|
],
|
|
21
22
|
"license": "MIT",
|
|
22
23
|
"engines": {
|
|
23
|
-
"node": ">=
|
|
24
|
+
"node": ">=20.0.0"
|
|
24
25
|
},
|
|
25
26
|
"publishConfig": {
|
|
26
27
|
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@playwright/cli": "0.1.17",
|
|
31
|
+
"playwright": "1.62.0-alpha-1783623505000"
|
|
27
32
|
}
|
|
28
33
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { runWait } from "./wait.mjs";
|
|
|
11
11
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
12
12
|
import { VERSION } from "./version.mjs";
|
|
13
13
|
import { bootstrapProfile, ProfileBootstrapError, profileShellRefresh } from "./profile-bootstrap.mjs";
|
|
14
|
+
import { runPw } from "./pw/run.mjs";
|
|
14
15
|
|
|
15
16
|
export async function run(argv) {
|
|
16
17
|
loadConfig();
|
|
@@ -32,6 +33,7 @@ export async function run(argv) {
|
|
|
32
33
|
case "docker": return cmdDocker(args);
|
|
33
34
|
case "run": return cmdRun(args);
|
|
34
35
|
case "wait": return runWait(args);
|
|
36
|
+
case "pw": return runPw(args);
|
|
35
37
|
case "profile": return cmdProfile(args);
|
|
36
38
|
case "resources": return callTool("list_resources");
|
|
37
39
|
case "agents": return callTool("list_agents");
|
|
@@ -101,6 +103,10 @@ ${bold("AGENT WAITS")}
|
|
|
101
103
|
wait [--any] <condition>... [options]
|
|
102
104
|
Wait once for a pushed BotBuddy signal
|
|
103
105
|
|
|
106
|
+
${bold("BROWSER LANES")}
|
|
107
|
+
pw <lane> <verb> [args…] Drive a lock-gated Playwright lane
|
|
108
|
+
pw --help Show bb-pw-compatible lane usage
|
|
109
|
+
|
|
104
110
|
${bold("OTHER")}
|
|
105
111
|
locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
|
|
106
112
|
help Show this help
|
package/src/docker-hygiene.mjs
CHANGED
|
@@ -78,6 +78,11 @@ SAFETY
|
|
|
78
78
|
• Re-inspects every candidate immediately before deletion.
|
|
79
79
|
• Never force-removes a resource, never runs a prune command, and never removes volumes.
|
|
80
80
|
|
|
81
|
+
PERSISTENT ORBSTACK GHOSTS
|
|
82
|
+
If an exact ID remains listed but OrbStack cannot inspect it, inventory is
|
|
83
|
+
reported but cleanup refuses. Restart or repair OrbStack, then rerun the
|
|
84
|
+
hygiene dry run with the same explicit selector. Never use broad cleanup.
|
|
85
|
+
|
|
81
86
|
EXAMPLES
|
|
82
87
|
botbuddy docker hygiene --context orbstack --ticket BOT-1405 --json
|
|
83
88
|
botbuddy docker hygiene --context orbstack --apply --ticket BOT-1405 \\
|
|
@@ -327,18 +332,143 @@ function nonEmptyLines(text) {
|
|
|
327
332
|
return text.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
|
328
333
|
}
|
|
329
334
|
|
|
330
|
-
function
|
|
335
|
+
function boundedDockerError(value) {
|
|
336
|
+
return String(value || "Docker command failed").trim().replace(/\s+/g, " ").slice(0, 500);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function listInventoryRefs(runDocker, selector, kind, exactId = null) {
|
|
340
|
+
const args = [kind, "ls"];
|
|
341
|
+
if (kind === "container") args.push("--all");
|
|
342
|
+
args.push("--no-trunc");
|
|
343
|
+
if (kind === "container" && !exactId) args.push("--filter", "name=supabase_");
|
|
344
|
+
if (exactId) args.push("--filter", `id=${exactId}`);
|
|
345
|
+
args.push("--format", kind === "container" ? "{{.ID}}\t{{.Names}}" : "{{.ID}}\t{{.Name}}");
|
|
346
|
+
|
|
347
|
+
const refs = nonEmptyLines(selected(runDocker, selector, args)).map((line) => {
|
|
348
|
+
const [rawId, ...rawName] = line.split("\t");
|
|
349
|
+
const id = rawId.trim();
|
|
350
|
+
if (!id) throw new Error(`Docker ${kind} inventory returned an empty ID`);
|
|
351
|
+
return { id, name: rawName.join("\t").trim() };
|
|
352
|
+
});
|
|
353
|
+
const exact = exactId ? refs.filter((ref) => ref.id === exactId) : refs;
|
|
354
|
+
return [...new Map(exact.map((ref) => [ref.id, ref])).values()];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function inspectAttempt(runDocker, selector, kind, refs, extraArgs) {
|
|
358
|
+
const ids = refs.map((ref) => ref.id);
|
|
359
|
+
const args = [...selector, kind, "inspect", ...extraArgs, ...ids];
|
|
360
|
+
const result = runDocker(args);
|
|
361
|
+
if (result?.error || result?.status !== 0) {
|
|
362
|
+
return { ok: false, resources: [], error: commandFailure(args, result).message };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let inspected;
|
|
366
|
+
try {
|
|
367
|
+
inspected = JSON.parse(String(result.stdout || ""));
|
|
368
|
+
} catch {
|
|
369
|
+
return { ok: false, resources: [], error: `docker ${kind} inspect returned malformed JSON` };
|
|
370
|
+
}
|
|
371
|
+
if (!Array.isArray(inspected)) {
|
|
372
|
+
return { ok: false, resources: [], error: `Docker ${kind} inventory returned an unexpected shape` };
|
|
373
|
+
}
|
|
374
|
+
return { ok: true, resources: inspected, error: null };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function exactInspectedResource(attempt, id) {
|
|
378
|
+
if (!attempt.ok || attempt.resources.length !== 1) return null;
|
|
379
|
+
return String(attempt.resources[0]?.Id || "") === id ? attempt.resources[0] : null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function exactInspectError(attempt, kind, id) {
|
|
383
|
+
if (!attempt.ok) return attempt.error;
|
|
384
|
+
return `docker ${kind} inspect did not return the exact requested ID ${id}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function inspectInBatches(runDocker, selector, kind, refs, extraArgs = []) {
|
|
331
388
|
const resources = [];
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
389
|
+
const skipped = [];
|
|
390
|
+
const errors = [];
|
|
391
|
+
for (let offset = 0; offset < refs.length; offset += INSPECT_BATCH_SIZE) {
|
|
392
|
+
const batch = refs.slice(offset, offset + INSPECT_BATCH_SIZE);
|
|
393
|
+
const attempt = inspectAttempt(runDocker, selector, kind, batch, extraArgs);
|
|
394
|
+
let isolate = batch;
|
|
395
|
+
|
|
396
|
+
if (attempt.ok) {
|
|
397
|
+
const byId = new Map();
|
|
398
|
+
let invalidIdentity = false;
|
|
399
|
+
for (const resource of attempt.resources) {
|
|
400
|
+
const id = String(resource?.Id || "");
|
|
401
|
+
if (!id || byId.has(id) || !batch.some((ref) => ref.id === id)) {
|
|
402
|
+
invalidIdentity = true;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
byId.set(id, resource);
|
|
406
|
+
}
|
|
407
|
+
if (!invalidIdentity) {
|
|
408
|
+
for (const ref of batch) {
|
|
409
|
+
const resource = byId.get(ref.id);
|
|
410
|
+
if (resource) resources.push(resource);
|
|
411
|
+
}
|
|
412
|
+
isolate = batch.filter((ref) => !byId.has(ref.id));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const ref of isolate) {
|
|
417
|
+
const isolated = inspectAttempt(runDocker, selector, kind, [ref], extraArgs);
|
|
418
|
+
const isolatedResource = exactInspectedResource(isolated, ref.id);
|
|
419
|
+
if (isolatedResource) {
|
|
420
|
+
resources.push(isolatedResource);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Re-list through the same validated selector and require exact full-ID
|
|
425
|
+
// equality. A prefix match or same-name replacement is never authority.
|
|
426
|
+
const relisted = listInventoryRefs(runDocker, selector, kind, ref.id);
|
|
427
|
+
const current = relisted.find((item) => item.id === ref.id);
|
|
428
|
+
if (!current) {
|
|
429
|
+
skipped.push({
|
|
430
|
+
type: kind,
|
|
431
|
+
id: ref.id,
|
|
432
|
+
name: ref.name,
|
|
433
|
+
project: null,
|
|
434
|
+
reason: "disappeared_during_inventory",
|
|
435
|
+
});
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const retried = inspectAttempt(runDocker, selector, kind, [current], extraArgs);
|
|
440
|
+
const retriedResource = exactInspectedResource(retried, ref.id);
|
|
441
|
+
if (retriedResource) {
|
|
442
|
+
resources.push(retriedResource);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// The row can disappear after the first exact re-list but before the
|
|
446
|
+
// retry returns. Confirm it still exists before calling the engine state
|
|
447
|
+
// persistently uninspectable; daemon/list failures still throw closed.
|
|
448
|
+
const finalRelisted = listInventoryRefs(runDocker, selector, kind, ref.id);
|
|
449
|
+
const finalCurrent = finalRelisted.find((item) => item.id === ref.id);
|
|
450
|
+
if (!finalCurrent) {
|
|
451
|
+
skipped.push({
|
|
452
|
+
type: kind,
|
|
453
|
+
id: ref.id,
|
|
454
|
+
name: current.name || ref.name,
|
|
455
|
+
project: null,
|
|
456
|
+
reason: "disappeared_during_inventory",
|
|
457
|
+
});
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
errors.push({
|
|
461
|
+
type: kind,
|
|
462
|
+
id: ref.id,
|
|
463
|
+
name: finalCurrent.name || current.name || ref.name,
|
|
464
|
+
project: null,
|
|
465
|
+
reason: "uninspectable_during_inventory",
|
|
466
|
+
error: boundedDockerError(exactInspectError(retried, kind, ref.id)
|
|
467
|
+
|| exactInspectError(isolated, kind, ref.id)),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
340
470
|
}
|
|
341
|
-
return resources;
|
|
471
|
+
return { resources, skipped, errors };
|
|
342
472
|
}
|
|
343
473
|
|
|
344
474
|
function projectIdentity(resource) {
|
|
@@ -403,7 +533,7 @@ function skippedResource(type, resource, reason, extra = {}) {
|
|
|
403
533
|
};
|
|
404
534
|
}
|
|
405
535
|
|
|
406
|
-
export function classifyInventory({ containers = [], networks = [] } = {}) {
|
|
536
|
+
export function classifyInventory({ containers = [], networks = [], skipped: discoverySkipped = [], errors = [], listed = {} } = {}) {
|
|
407
537
|
const activeProjects = new Set();
|
|
408
538
|
for (const resource of containers) {
|
|
409
539
|
const name = containerName(resource);
|
|
@@ -412,7 +542,7 @@ export function classifyInventory({ containers = [], networks = [] } = {}) {
|
|
|
412
542
|
}
|
|
413
543
|
|
|
414
544
|
const candidates = [];
|
|
415
|
-
const skipped = [];
|
|
545
|
+
const skipped = [...discoverySkipped];
|
|
416
546
|
let supabaseContainers = 0;
|
|
417
547
|
|
|
418
548
|
for (const resource of containers) {
|
|
@@ -490,9 +620,12 @@ export function classifyInventory({ containers = [], networks = [] } = {}) {
|
|
|
490
620
|
active_projects: [...activeProjects].sort(),
|
|
491
621
|
candidates: candidates.sort(resourceSort),
|
|
492
622
|
skipped: skipped.sort(resourceSort),
|
|
623
|
+
inventory_errors: [...errors].sort(resourceSort),
|
|
493
624
|
counts: {
|
|
494
625
|
containers: containers.length,
|
|
495
626
|
networks: networks.length,
|
|
627
|
+
listed_containers: listed.containers ?? containers.length,
|
|
628
|
+
listed_networks: listed.networks ?? networks.length,
|
|
496
629
|
supabase_containers: supabaseContainers,
|
|
497
630
|
supabase_networks: supabaseNetworks,
|
|
498
631
|
user_bridge_networks: userBridgeNetworks,
|
|
@@ -502,15 +635,21 @@ export function classifyInventory({ containers = [], networks = [] } = {}) {
|
|
|
502
635
|
}
|
|
503
636
|
|
|
504
637
|
function readInventory(runDocker, selector) {
|
|
505
|
-
const
|
|
506
|
-
const
|
|
507
|
-
? inspectInBatches(runDocker, selector, "container",
|
|
508
|
-
: [];
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
? inspectInBatches(runDocker, selector, "network",
|
|
512
|
-
: [];
|
|
513
|
-
return {
|
|
638
|
+
const containerRefs = listInventoryRefs(runDocker, selector, "container");
|
|
639
|
+
const containerResult = containerRefs.length
|
|
640
|
+
? inspectInBatches(runDocker, selector, "container", containerRefs, ["--size"])
|
|
641
|
+
: { resources: [], skipped: [], errors: [] };
|
|
642
|
+
const networkRefs = listInventoryRefs(runDocker, selector, "network");
|
|
643
|
+
const networkResult = networkRefs.length
|
|
644
|
+
? inspectInBatches(runDocker, selector, "network", networkRefs)
|
|
645
|
+
: { resources: [], skipped: [], errors: [] };
|
|
646
|
+
return {
|
|
647
|
+
containers: containerResult.resources,
|
|
648
|
+
networks: networkResult.resources,
|
|
649
|
+
skipped: [...containerResult.skipped, ...networkResult.skipped],
|
|
650
|
+
errors: [...containerResult.errors, ...networkResult.errors],
|
|
651
|
+
listed: { containers: containerRefs.length, networks: networkRefs.length },
|
|
652
|
+
};
|
|
514
653
|
}
|
|
515
654
|
|
|
516
655
|
function validateOrbStack(runDocker, opts) {
|
|
@@ -596,6 +735,7 @@ function baseReceipt(command, opts, now) {
|
|
|
596
735
|
deleted: [],
|
|
597
736
|
reclaimed_space_bytes: 0,
|
|
598
737
|
skipped: [],
|
|
738
|
+
inventory_errors: [],
|
|
599
739
|
pressure: null,
|
|
600
740
|
warnings: [],
|
|
601
741
|
errors: [],
|
|
@@ -698,6 +838,7 @@ export function runDockerWorkflow(argv, {
|
|
|
698
838
|
receipt.inventory = inventory.counts;
|
|
699
839
|
receipt.active_projects = inventory.active_projects;
|
|
700
840
|
receipt.skipped = [...inventory.skipped];
|
|
841
|
+
receipt.inventory_errors = [...inventory.inventory_errors];
|
|
701
842
|
|
|
702
843
|
const ticketCandidates = parsed.opts.ticket
|
|
703
844
|
? inventory.candidates.filter((item) => projectMatchesTicket(item.project, parsed.opts.ticket))
|
|
@@ -712,6 +853,15 @@ export function runDockerWorkflow(argv, {
|
|
|
712
853
|
receipt.candidates = ticketCandidates;
|
|
713
854
|
receipt.candidate_reclaimable_bytes = ticketCandidates.reduce((total, item) => total + item.reclaimable_bytes, 0);
|
|
714
855
|
|
|
856
|
+
if (receipt.inventory_errors.length > 0) {
|
|
857
|
+
receipt.outcome = "refused";
|
|
858
|
+
for (const item of receipt.inventory_errors) {
|
|
859
|
+
receipt.errors.push(`${item.type} ${item.id}${item.name ? ` (${item.name})` : ""} remains listed but cannot be authoritatively inspected: ${item.error}`);
|
|
860
|
+
}
|
|
861
|
+
receipt.recommendation = "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup.";
|
|
862
|
+
return { exitCode: EXIT.DOCKER, receipt, json: parsed.opts.json };
|
|
863
|
+
}
|
|
864
|
+
|
|
715
865
|
if (parsed.command === "preflight") {
|
|
716
866
|
receipt.pressure = evaluatePressure({
|
|
717
867
|
bridgeNetworks: inventory.counts.user_bridge_networks,
|
|
@@ -769,6 +919,17 @@ export function runDockerWorkflow(argv, {
|
|
|
769
919
|
addSkipOnce(receipt, { ...candidate, reason: "revalidation_failed" });
|
|
770
920
|
continue;
|
|
771
921
|
}
|
|
922
|
+
if (fresh.inventory_errors.length > 0) {
|
|
923
|
+
for (const item of fresh.inventory_errors) {
|
|
924
|
+
if (!receipt.inventory_errors.some((existing) => existing.type === item.type && existing.id === item.id)) {
|
|
925
|
+
receipt.inventory_errors.push(item);
|
|
926
|
+
receipt.errors.push(`revalidation blocked by ${item.type} ${item.id}${item.name ? ` (${item.name})` : ""}: ${item.error}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
receipt.recommendation = "Persistent OrbStack inventory blocker: restart or repair OrbStack, then rerun the hygiene dry run with the same explicit selector; never use broad cleanup.";
|
|
930
|
+
addSkipOnce(receipt, { ...candidate, reason: "revalidation_failed" });
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
772
933
|
const revalidated = fresh.candidates.find((item) => item.type === candidate.type
|
|
773
934
|
&& item.id === candidate.id
|
|
774
935
|
&& projectMatchesTicket(item.project, parsed.opts.ticket));
|
|
@@ -855,6 +1016,11 @@ export function formatHumanReceipt(receipt) {
|
|
|
855
1016
|
lines.push(`Skipped (${receipt.skipped.length}):`);
|
|
856
1017
|
for (const item of receipt.skipped) lines.push(` ${item.type} ${item.id} ${item.name} — ${item.reason}`);
|
|
857
1018
|
if (receipt.skipped.length === 0) lines.push(" none");
|
|
1019
|
+
lines.push(`Inventory errors (${receipt.inventory_errors?.length || 0}):`);
|
|
1020
|
+
for (const item of receipt.inventory_errors || []) {
|
|
1021
|
+
lines.push(` ${item.type} ${item.id} ${item.name || "(name unknown)"} — ${item.reason}: ${item.error}`);
|
|
1022
|
+
}
|
|
1023
|
+
if (!receipt.inventory_errors?.length) lines.push(" none");
|
|
858
1024
|
if (receipt.pressure) {
|
|
859
1025
|
lines.push(`Pressure: ${receipt.pressure.projected_pressure_units} projected (${receipt.pressure.status}; warn ${receipt.pressure.warn_pressure}, fail ${receipt.pressure.fail_pressure})`);
|
|
860
1026
|
}
|
package/src/pw/args.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { TARGET_VERBS, parseTargetFlags, hasTargetFlags, buildLocatorTarget, classifyTarget, isSnapshotRef } from "./targets.mjs";
|
|
2
|
+
export const GLOBAL_VERBS = new Set(["list", "close-all", "kill-all", "reap"]);
|
|
3
|
+
const secret = /^@ENV:(.+)$/;
|
|
4
|
+
export function resolveRef(value, env = process.env) {
|
|
5
|
+
const match = secret.exec(String(value));
|
|
6
|
+
if (!match) return { value, secret: false };
|
|
7
|
+
if (!env[match[1]]) throw new Error(`bb-pw: env var ${match[1]} referenced by ${value} is not set`);
|
|
8
|
+
return { value: env[match[1]], ref: value, secret: true };
|
|
9
|
+
}
|
|
10
|
+
export const normaliseLane = (token) => String(token).replace(/^agent-0*/, "").replace(/^lane-/, "");
|
|
11
|
+
function validLane(lane) { return /^[1-9]\d*$/.test(lane); }
|
|
12
|
+
export function planInvocation(argv, env = process.env) {
|
|
13
|
+
if (!argv.length) throw new Error("bb-pw: usage: bb-pw <lane> <verb> [args…] (or: bb-pw <reap|list|close-all|kill-all>)");
|
|
14
|
+
if (GLOBAL_VERBS.has(argv[0])) return argv[0] === "reap" ? { scope: "global", verb: "reap", mode: "reap" } : { scope: "global", verb: argv[0], mode: "exec", execArgv: argv };
|
|
15
|
+
const lane = normaliseLane(argv[0]), verb = argv[1];
|
|
16
|
+
if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
|
|
17
|
+
if (verb === "status") return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
|
|
18
|
+
const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
|
|
19
|
+
if (TARGET_VERBS.has(verb)) {
|
|
20
|
+
const parsed = parseTargetFlags(rest); fresh = parsed.fresh;
|
|
21
|
+
forwarded = hasTargetFlags(parsed.flags) ? [buildLocatorTarget(parsed.flags), ...parsed.rest] : parsed.rest;
|
|
22
|
+
target = forwarded.find((value) => !String(value).startsWith("--")) ?? null;
|
|
23
|
+
if (fresh && target && isSnapshotRef(target)) throw new Error("bb-pw: --fresh cannot target a snapshot ref; use a stable locator");
|
|
24
|
+
}
|
|
25
|
+
const resolved = forwarded.map((value) => resolveRef(value, env));
|
|
26
|
+
const sensitive = resolved.some((value) => value.secret);
|
|
27
|
+
return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: sensitive ? "socket" : "exec", execArgv: sensitive ? null : [`-s=lane-${lane}`, verb, ...forwarded], socketArgs: sensitive ? [verb, ...resolved.map((value) => value.value)] : null, telemetryUrl: ["goto", "open", "go-back", "go-forward", "reload"].includes(verb) ? (resolved[0]?.secret ? resolved[0].ref : forwarded[0] ?? null) : null, secretValues: resolved.filter((value) => value.secret).map((value) => String(value.value)), rollup: verb === "close", target, targetKind: target === null ? null : classifyTarget(target), fresh };
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { SERVER_URL } from "../config.mjs";
|
|
2
|
+
export function createProfileCoordinator({ profile, identity, fetchImpl = fetch } = {}) {
|
|
3
|
+
if (!profile?.token || !identity?.agentId || identity.tenant !== profile.tenant) return { kind: "unverified" };
|
|
4
|
+
let id = 0;
|
|
5
|
+
async function call(name, args) {
|
|
6
|
+
const response = await fetchImpl(SERVER_URL, { method: "POST", headers: { "content-type": "application/json", "x-agent-api-key": profile.token }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }) });
|
|
7
|
+
if (!response.ok) throw new Error(`BotBuddy lock verification returned HTTP ${response.status}`);
|
|
8
|
+
const json = await response.json(); if (json.error) throw new Error(json.error.message || "BotBuddy lock verification failed");
|
|
9
|
+
const text = json.result?.content?.find((item) => item.type === "text")?.text; try { return text ? JSON.parse(text) : {}; } catch { throw new Error("BotBuddy lock verification returned invalid JSON"); }
|
|
10
|
+
}
|
|
11
|
+
return { kind: "profile", async status({ host, slot }) { const result = await call("list_resources", { host, subtype: "playwright_lane" }); const list = Array.isArray(result) ? result : result.resources ?? []; const resource = list.find((item) => item.name === `playwright_lane:${host}:${slot}` || String(item.slot) === String(slot)); return resource ? { held: resource.status !== "free", heldBy: resource.owner_agent_id ?? null } : { held: false, heldBy: null }; }, async emit(event) { await call("record_lane_event", event); }, agentId: identity.agentId };
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
export const daemonDir = (env = process.env) => env.BB_PW_DAEMON_DIR || join(homedir(), ".botbuddy", "pw-daemon");
|
|
8
|
+
export function resolveCliBin(env = process.env) { if (env.BB_PW_CLI_BIN) return { cmd: "node", args: [env.BB_PW_CLI_BIN] }; const require = createRequire(import.meta.url); const pkg = require.resolve("@playwright/cli/package.json"), meta = JSON.parse(readFileSync(pkg, "utf8")), bin = typeof meta.bin === "string" ? meta.bin : meta.bin["playwright-cli"] || Object.values(meta.bin)[0]; return { cmd: "node", args: [join(dirname(pkg), bin)] }; }
|
|
9
|
+
export function spawnExec(plan, env = process.env) { const { cmd, args } = resolveCliBin(env); return new Promise((resolve) => { const child = spawn(cmd, [...args, ...plan.execArgv], { stdio: "inherit", env: { ...env, PWTEST_DAEMON_SESSION_DIR: daemonDir(env) } }); child.on("exit", (code) => resolve(code ?? 1)); child.on("error", () => resolve(127)); }); }
|
|
10
|
+
export function readSession(session, env = process.env) { const root = daemonDir(env); if (!existsSync(root)) return null; for (const item of readdirSync(root)) { try { return JSON.parse(readFileSync(join(root, item, `${session}.session`), "utf8")); } catch {} } return null; }
|
|
11
|
+
export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000 } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => finish({ ok: false, error: error.message })); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
|
|
12
|
+
export function socketAlive(socketPath, { connect = net.createConnection, timeoutMs = 1000 } = {}) { return new Promise((resolve) => { if (!socketPath) return resolve(false); let done = false, socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => finish(true)); socket.on("error", () => finish(false)); setTimeout(() => finish(false), timeoutMs).unref(); }); }
|
|
13
|
+
export async function socketRun(plan, env = process.env) { const session = readSession(plan.session, env); return session?.socketPath ? sendToDaemon(session.socketPath, plan.socketArgs) : { ok: false, error: `bb-pw: no open daemon for ${plan.session}; open it first` }; }
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const NAV = "navigate";
|
|
2
|
+
const interaction = new Set(["click", "fill", "type", "select", "press", "drag", "hover", "dblclick", "check", "uncheck"]);
|
|
3
|
+
export function actionTypeFromMethod(method) { const value = String(method); return ["goto", "open", "go-back", "go-forward", "reload"].includes(value) ? NAV : value === "screenshot" ? "screenshot" : value === "snapshot" ? "snapshot" : interaction.has(value) ? "interaction" : "other"; }
|
|
4
|
+
export function deriveSession(events, { meta = {} } = {}) { const ordered = [...events].sort((a,b) => a.ts-b.ts), type = (name) => ordered.filter((event) => actionTypeFromMethod(event.method) === name); const nav = type(NAV); return { ...meta, started_at: ordered[0]?.ts ?? null, ended_at: ordered.at(-1)?.ts ?? null, duration_ms: ordered.length ? ordered.at(-1).ts - ordered[0].ts : 0, active_ms: 0, idle_threshold_ms: 60000, navigations: nav.length, distinct_routes: [...new Set(nav.map((event) => { try { return new URL(event.url).pathname; } catch { return String(event.url).split("?")[0]; } }))], screenshots: type("screenshot").length, snapshots: type("snapshot").length, interactions: type("interaction").length, actions_total: ordered.length }; }
|
package/src/pw/reap.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { readSession, socketAlive } from "./daemon.mjs";
|
|
3
|
+
export const parsePs = (text) => String(text).split("\n").map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)).filter(Boolean).map(([, pid, ppid, command]) => ({ pid: Number(pid), ppid: Number(ppid), command }));
|
|
4
|
+
export const selectCliDaemons = (list) => list.flatMap((row) => { const found = /cliDaemon\.js\s+(\S+)/.exec(row.command); return found ? [{ ...row, lane: found[1] }] : []; });
|
|
5
|
+
export async function reap({ env = process.env, stdout = process.stdout, exec = execSync, alive = socketAlive, kill = (pid) => { try { process.kill(pid, "SIGKILL"); } catch {} } } = {}) { const processes = selectCliDaemons(parsePs(exec("ps -axw -o pid=,ppid=,command=").toString())), stale = []; for (const daemon of processes) { if (!await alive(readSession(daemon.lane, env)?.socketPath)) { stale.push(daemon.pid); kill(daemon.pid); } } stdout.write(`bb-pw reap: ${stale.length} stale daemon(s) reaped${stale.length ? ` (${stale.join(",")})` : ""}\n`); return stale; }
|
package/src/pw/run.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { planInvocation } from "./args.mjs";
|
|
3
|
+
import { actionTypeFromMethod, NAV } from "./readiness.mjs";
|
|
4
|
+
import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
|
|
5
|
+
import { createProfileCoordinator } from "./coordinator.mjs";
|
|
6
|
+
import { resolveAgentProfile } from "../wait-profile.mjs";
|
|
7
|
+
import { readProfileIdentity } from "../agent-credential-store.mjs";
|
|
8
|
+
const hostFor = (env) => env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname();
|
|
9
|
+
function help(out) { out.write("Usage: botbuddy pw [--profile <name>] <lane> <verb> [args…]\n\nAlias: bb-pw <lane> <verb> [args…]\n"); }
|
|
10
|
+
function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
|
|
11
|
+
async function gate({ env, host, lane, deps }) { if (env.BB_PW_NO_LOCK === "1") return { allowed: true }; let profile, identity; try { profile = await (deps.resolveProfile ?? resolveAgentProfile)({ cwd: deps.cwd ?? process.cwd(), env, explicitProfile: deps.profile ?? null }); identity = await (deps.readIdentity ?? readProfileIdentity)(profile.name); } catch (error) { return { allowed: false, message: `bb-pw: profile verification failed (${error.message}). Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work.` }; } const coordinator = deps.coordinator ?? createProfileCoordinator({ profile, identity, fetchImpl: deps.fetch }); if (!profile.token || !identity || identity.tenant !== profile.tenant || coordinator.kind !== "profile") return { allowed: false, message: "bb-pw: profile identity is missing or tenant-mismatched. Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work." }; try { const status = await coordinator.status({ host, slot: lane }); return status.held && status.heldBy === identity.agentId ? { allowed: true, coordinator } : { allowed: false, message: `bb-pw: lane lock playwright_lane:${host}:${lane} must be held by this profile. Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` }; } catch (error) { return { allowed: false, message: `bb-pw: could not verify lane lock (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` }; } }
|
|
12
|
+
export async function runPw(argv, deps = {}) {
|
|
13
|
+
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; } if (args[0] === "--profile") { if (!args[1]) { stderr.write("bb-pw: --profile needs a name\n"); return 2; } deps = { ...deps, profile: args[1] }; args = args.slice(2); }
|
|
14
|
+
let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
|
|
15
|
+
const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
|
|
16
|
+
if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
|
|
17
|
+
const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
|
|
18
|
+
const auth = await gate({ env, host, lane: plan.lane, deps }); if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
|
|
19
|
+
if (plan.mode === "status") { const lock = auth.coordinator?.status ? await auth.coordinator.status({ host, slot: plan.lane }) : null; stdout.write(JSON.stringify({ lane: plan.lane, session: plan.session, host, lock, spooled_events: telemetry.count(plan.lane) }, null, 2) + "\n"); return 0; }
|
|
20
|
+
if (actionTypeFromMethod(plan.verb) !== "other") telemetry.append(plan.lane, { ts: Date.now(), method: plan.verb, url: actionTypeFromMethod(plan.verb) === NAV ? plan.telemetryUrl : null });
|
|
21
|
+
const inspect = plan.mode === "socket" || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
|
|
22
|
+
if (inspect) { if (plan.fresh) await socketRun({ ...plan, socketArgs: ["snapshot"] }, env).catch(() => {}); const result = await socketRun({ ...plan, socketArgs: plan.socketArgs ?? plan.execArgv.slice(1) }, env); if (result.text) stdout.write(`${redact(result.text, plan.secretValues)}\n`); if (!result.ok) stderr.write(`${redact(plan.targetKind === "ref" && isStaleRefError(result.error) ? staleRefRemediation(plan.target) : result.error, plan.secretValues)}\n`); code = result.ok ? 0 : 1; } else code = await spawnExec(plan, env);
|
|
23
|
+
if (plan.rollup && code === 0) await telemetry.rollup({ lane: plan.lane, coordinator: auth.coordinator, host }); return code;
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const REF = /^(f\d+)?e\d+$/;
|
|
2
|
+
export const TARGET_VERBS = new Set(["click", "dblclick", "fill", "hover", "check", "uncheck", "select"]);
|
|
3
|
+
export const isSnapshotRef = (value) => REF.test(String(value ?? ""));
|
|
4
|
+
export const classifyTarget = (value) => /^getBy[A-Z]/.test(String(value ?? "")) ? "locator" : isSnapshotRef(value) ? "ref" : "selector";
|
|
5
|
+
const BASE = ["role", "placeholder", "text", "testid", "label", "title", "alt"];
|
|
6
|
+
const quote = (value) => `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
7
|
+
export function parseTargetFlags(args) {
|
|
8
|
+
const flags = {}, rest = []; let fresh = false;
|
|
9
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
10
|
+
const match = /^--([a-z]+)(?:=(.*))?$/s.exec(String(args[index]));
|
|
11
|
+
if (!match) { rest.push(args[index]); continue; }
|
|
12
|
+
const [, name, inline] = match;
|
|
13
|
+
if (name === "fresh") { fresh = true; continue; }
|
|
14
|
+
if (name === "exact") { flags.exact = true; continue; }
|
|
15
|
+
if (![...BASE, "name"].includes(name)) { rest.push(args[index]); continue; }
|
|
16
|
+
const value = inline ?? args[++index];
|
|
17
|
+
if (value === undefined) throw new Error(`bb-pw: --${name} needs a value`);
|
|
18
|
+
flags[name] = value;
|
|
19
|
+
}
|
|
20
|
+
return { flags, rest, fresh };
|
|
21
|
+
}
|
|
22
|
+
export const hasTargetFlags = (flags) => Object.keys(flags).some((key) => key !== "exact");
|
|
23
|
+
function exact(method, value, enabled) { return enabled ? `${method}(${quote(value)}, { exact: true })` : `${method}(${quote(value)})`; }
|
|
24
|
+
export function buildLocatorTarget(flags) {
|
|
25
|
+
const bases = BASE.filter((name) => flags[name] !== undefined);
|
|
26
|
+
if (flags.name !== undefined && flags.role === undefined) throw new Error("bb-pw: --name qualifies --role; pass --role too");
|
|
27
|
+
if (bases.length !== 1) throw new Error(`bb-pw: give exactly one base locator, got: ${bases.map((name) => `--${name}`).join(", ") || "none"}`);
|
|
28
|
+
const base = bases[0], isExact = flags.exact === true;
|
|
29
|
+
if (base === "role") {
|
|
30
|
+
const opts = flags.name === undefined ? "" : `, { name: ${quote(flags.name)}${isExact ? ", exact: true" : ""} }`;
|
|
31
|
+
return `getByRole(${quote(flags.role)}${opts})`;
|
|
32
|
+
}
|
|
33
|
+
if (base === "testid") return `getByTestId(${quote(flags.testid)})`;
|
|
34
|
+
return exact({ placeholder: "getByPlaceholder", text: "getByText", label: "getByLabel", title: "getByTitle", alt: "getByAltText" }[base], flags[base], isExact);
|
|
35
|
+
}
|
|
36
|
+
export const isStaleRefError = (text) => /not found in the current page snapshot/i.test(String(text ?? ""));
|
|
37
|
+
export const staleRefRemediation = (ref) => `bb-pw: snapshot ref ${ref} is stale — capture a new snapshot or use a stable locator (--role, --text, --testid)${""}.`;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { deriveSession } from "./readiness.mjs";
|
|
5
|
+
const spoolDir = (env) => env.BB_PW_SPOOL_DIR || join(homedir(), ".botbuddy", "pw-spool");
|
|
6
|
+
export function makeTelemetry({ env = process.env } = {}) {
|
|
7
|
+
const path = (lane) => join(spoolDir(env), `lane-${lane}.jsonl`);
|
|
8
|
+
const readEvents = (lane) => existsSync(path(lane)) ? readFileSync(path(lane), "utf8").split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } }) : [];
|
|
9
|
+
return {
|
|
10
|
+
append(lane, event) { mkdirSync(spoolDir(env), { recursive: true }); appendFileSync(path(lane), JSON.stringify({ ts: event.ts, method: event.method, url: event.url ?? null }) + "\n"); },
|
|
11
|
+
count(lane) { return readEvents(lane).length; },
|
|
12
|
+
async rollup({ lane, coordinator, host }) { const events = readEvents(lane); if (!events.length) return { emitted: false, count: 0 }; const session = deriveSession(events, { meta: { host, slot: String(lane), ticket_id: env.BB_PW_TICKET_ID, ticket_url: env.BB_PW_TICKET_URL, pr_id: env.BB_PW_PR_ID, pr_url: env.BB_PW_PR_URL, branch: env.BB_PW_BRANCH, commit_sha: env.BB_PW_COMMIT, environment: env.BB_PW_ENV } }); const event = { ...session, started_at: new Date(session.started_at).toISOString(), ended_at: new Date(session.ended_at).toISOString(), ended_reason: "close" }; try { if (!coordinator?.emit) return { emitted: false, count: events.length, session: event }; await coordinator.emit(event); rmSync(path(lane), { force: true }); return { emitted: true, count: events.length, session: event }; } catch { return { emitted: false, count: events.length, session: event }; } },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"schema_version":1,"source_version":"1.5.1","source_identity":"56890dbd45a92364dc2a57ed8f09710d3e3d53c5af8999680ef1d19552e19c94"}
|