verikun 0.21.1 → 0.22.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/.claude/skills/verikun/SKILL.md +7 -2
- package/CHANGELOG.md +34 -0
- package/README.md +2 -2
- package/dist/cli.js +106 -8
- package/dist/companion/dump-match.js +100 -0
- package/dist/companion/manager.js +7 -2
- package/dist/companion/protocol.js +10 -4
- package/dist/device/claims.js +594 -0
- package/dist/drivers/adb.js +28 -6
- package/dist/drivers/ios.js +30 -7
- package/dist/errors.js +2 -2
- package/dist/run.js +10 -0
- package/dist/server.js +4 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/tools/verikun-companion/prebuilt/verikun-companion.jar +0 -0
|
@@ -482,7 +482,7 @@ draft-first flow.
|
|
|
482
482
|
- `0` success / found / assertion passed
|
|
483
483
|
- `1` not found / assertion failed / wait timeout
|
|
484
484
|
- `2` usage error **or ambiguous selector** (refine it or add `--index N`)
|
|
485
|
-
- `3` environment error (no device, adb/idb missing, hierarchy dump failed) — for
|
|
485
|
+
- `3` environment error (no usable device, adb/idb missing, hierarchy dump failed) — for
|
|
486
486
|
`ai`/`suite`/`install`/`server` the toolchain is verified up front, so this arrives
|
|
487
487
|
immediately with an install hint rather than mid-flow
|
|
488
488
|
|
|
@@ -498,7 +498,12 @@ draft-first flow.
|
|
|
498
498
|
- **`vk device set` from a plain shell stays applied.** Inside `batch`/`ai`/`suite` it is
|
|
499
499
|
restored automatically even if the flow dies, but a one-off `vk device set airplane=on`
|
|
500
500
|
is yours to `vk device reset` — don't strand someone's phone offline.
|
|
501
|
-
- **
|
|
501
|
+
- **The device picks itself, and won't collide with another agent.** With several attached,
|
|
502
|
+
`vk` takes one no other job is driving and notes it on stderr. Exit `2` means every device
|
|
503
|
+
is genuinely busy (the message names each holder) — pass `-d <serial>`, wait, or
|
|
504
|
+
`vk device release <serial>` if that job is gone. `vk devices` shows who holds what.
|
|
505
|
+
Naming a busy device with `-d` is refused, on purpose: two jobs on one phone corrupt both
|
|
506
|
+
runs, and it surfaces as a bogus assertion failure. `VERIKUN_NO_CLAIM=1` opts out entirely.
|
|
502
507
|
- **`vk text` opens the keyboard.** Use `--enter` to submit, or `vk back` to
|
|
503
508
|
dismiss it before re-inspecting (it can cover elements).
|
|
504
509
|
- **Unicode/emoji** may not type via `adb input text` (an Android limitation);
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.22.1] - 2026-08-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **The Flutter fixture raises a real system permission dialog** (`@vk_permission`, Android
|
|
13
|
+
only), so [#79] reproduces on demand.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Hierarchy reads now see a window another app opens on top** — a runtime-permission dialog is
|
|
17
|
+
no longer invisible to the companion. ([#79])
|
|
18
|
+
|
|
19
|
+
[#79]: https://github.com/ddikman/verikun/issues/79
|
|
20
|
+
|
|
21
|
+
## [0.22.0] - 2026-08-15
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Device claims**: `vk` now picks a device no other job is driving; a busy one is refused
|
|
25
|
+
with exit `2`. Writes `~/.verikun/devices/`; `VERIKUN_NO_CLAIM=1` opts out. ([#49])
|
|
26
|
+
- **`vk devices`**: a `USED BY` column names the job holding each device; `--json` gains
|
|
27
|
+
`claim`. ([#49])
|
|
28
|
+
- **`vk device release [serial]`**: hands a device back without waiting for its claim to go
|
|
29
|
+
stale. ([#49])
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- **Several attached devices no longer exits `2`** — a free one is auto-selected. Pass
|
|
33
|
+
`--device` to pin it, as before. ([#49])
|
|
34
|
+
- **Changelog entries are now one line each**, per the new `changelog-entry` skill — root cause,
|
|
35
|
+
benchmarks and rationale move to the PR body and `docs/`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Failure evidence**: a step that could not resolve a device printed its error three times;
|
|
39
|
+
capture is now skipped when there is no screen to photograph.
|
|
40
|
+
|
|
41
|
+
[#49]: https://github.com/ddikman/verikun/issues/49
|
|
42
|
+
|
|
9
43
|
## [0.21.1] - 2026-08-13
|
|
10
44
|
|
|
11
45
|
### Fixed
|
package/README.md
CHANGED
|
@@ -62,10 +62,10 @@ There is also no need for an MCP here: verikun runs locally with all its depende
|
|
|
62
62
|
|---|---|
|
|
63
63
|
| **Getting started** | [Installation](https://ddikman.github.io/verikun/getting-started/installation/) · [Your first test](https://ddikman.github.io/verikun/getting-started/your-first-test/) · [Using it from an AI agent](https://ddikman.github.io/verikun/getting-started/using-from-an-agent/) |
|
|
64
64
|
| **Guides** | [Writing test cases](https://ddikman.github.io/verikun/guides/writing-test-cases/) · [Natural-language tests](https://ddikman.github.io/verikun/guides/natural-language-tests/) · [Suites](https://ddikman.github.io/verikun/guides/suites/) · [Remote devices & CI](https://ddikman.github.io/verikun/guides/remote-devices-and-ci/) · [iOS setup](https://ddikman.github.io/verikun/guides/ios-setup/) · [Platform support](https://ddikman.github.io/verikun/guides/platform-support/) · [Troubleshooting](https://ddikman.github.io/verikun/guides/troubleshooting/) |
|
|
65
|
-
| **Reference** | [Commands](https://ddikman.github.io/verikun/reference/commands/) · [Selectors](https://ddikman.github.io/verikun/reference/selectors/) · [Auto-wait](https://ddikman.github.io/verikun/reference/auto-wait/) · [Global flags](https://ddikman.github.io/verikun/reference/global-flags/) · [Exit codes](https://ddikman.github.io/verikun/reference/exit-codes/) · [Environment variables](https://ddikman.github.io/verikun/reference/environment-variables/) · [Reports & test runs](https://ddikman.github.io/verikun/reference/reports-and-test-runs/) · [Device state](https://ddikman.github.io/verikun/reference/device-state/) · [Screenshots](https://ddikman.github.io/verikun/reference/screenshots/) · [AI plans & models](https://ddikman.github.io/verikun/reference/ai-plans/) |
|
|
65
|
+
| **Reference** | [Commands](https://ddikman.github.io/verikun/reference/commands/) · [Selectors](https://ddikman.github.io/verikun/reference/selectors/) · [Auto-wait](https://ddikman.github.io/verikun/reference/auto-wait/) · [Global flags](https://ddikman.github.io/verikun/reference/global-flags/) · [Exit codes](https://ddikman.github.io/verikun/reference/exit-codes/) · [Environment variables](https://ddikman.github.io/verikun/reference/environment-variables/) · [Reports & test runs](https://ddikman.github.io/verikun/reference/reports-and-test-runs/) · [Device state](https://ddikman.github.io/verikun/reference/device-state/) · [Device claims](https://ddikman.github.io/verikun/reference/device-claims/) · [Screenshots](https://ddikman.github.io/verikun/reference/screenshots/) · [AI plans & models](https://ddikman.github.io/verikun/reference/ai-plans/) |
|
|
66
66
|
| **Internals** | [Architecture](https://ddikman.github.io/verikun/internals/architecture/) · [Core principles](https://ddikman.github.io/verikun/internals/core-principles/) · [Plan IR & the replay engine](https://ddikman.github.io/verikun/internals/plan-ir-and-engine/) · [Contracts](https://ddikman.github.io/verikun/internals/contracts/) · [Contributing](https://ddikman.github.io/verikun/internals/contributing/) |
|
|
67
67
|
|
|
68
|
-
**Exit codes**, since they are the machine contract everything else rests on: `0` success · `1` not found / assertion failed / timeout · `2` usage error
|
|
68
|
+
**Exit codes**, since they are the machine contract everything else rests on: `0` success · `1` not found / assertion failed / timeout · `2` usage error, ambiguous selector, or a device another job is driving · `3` environment error. Data goes to stdout; diagnostics to stderr. [Full contract](https://ddikman.github.io/verikun/reference/exit-codes/). Parallel agents share a host-level [device claim](https://ddikman.github.io/verikun/reference/device-claims/) so two jobs do not silently land on the same phone.
|
|
69
69
|
|
|
70
70
|
## Feedback — help improve verikun
|
|
71
71
|
|
package/dist/cli.js
CHANGED
|
@@ -61,6 +61,7 @@ const exec_1 = require("./exec");
|
|
|
61
61
|
const drivers_1 = require("./drivers");
|
|
62
62
|
const adb_1 = require("./drivers/adb");
|
|
63
63
|
const settings_1 = require("./device/settings");
|
|
64
|
+
const claims_1 = require("./device/claims");
|
|
64
65
|
const selector_1 = require("./ui/selector");
|
|
65
66
|
const state_support_1 = require("./ui/state-support");
|
|
66
67
|
const format_1 = require("./ui/format");
|
|
@@ -404,6 +405,15 @@ function cmdDevices(ctx) {
|
|
|
404
405
|
catch (e) {
|
|
405
406
|
(0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
|
|
406
407
|
}
|
|
408
|
+
// Who is driving what. Read-only: listing the pool must never claim a device, which is
|
|
409
|
+
// what makes `vk devices` (and `vk doctor`) safe to run while surveying a busy host.
|
|
410
|
+
if ((0, claims_1.claimsEnabled)()) {
|
|
411
|
+
for (const d of allDevices) {
|
|
412
|
+
const claim = (0, claims_1.summarize)(d.serial);
|
|
413
|
+
if (claim)
|
|
414
|
+
d.claim = claim;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
407
417
|
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
408
418
|
(0, output_1.json)(allDevices);
|
|
409
419
|
return 0;
|
|
@@ -431,6 +441,10 @@ function formatDeviceTable(devices) {
|
|
|
431
441
|
{ header: 'STATE', get: (d) => d.state },
|
|
432
442
|
{ header: 'MODEL', get: (d) => d.model ?? '', optional: true },
|
|
433
443
|
{ header: 'PRODUCT', get: (d) => d.product ?? '', optional: true },
|
|
444
|
+
// Optional like the rest, which is load-bearing twice over: nothing changes for a
|
|
445
|
+
// single-user host where no device is ever claimed, and `VERIKUN_NO_CLAIM=1` renders
|
|
446
|
+
// exactly the table it always did.
|
|
447
|
+
{ header: 'USED BY', get: (d) => d.claim?.by ?? '', optional: true },
|
|
434
448
|
{ header: 'NOTE', get: (d) => d.note ?? '', optional: true },
|
|
435
449
|
];
|
|
436
450
|
// Drop optional columns that no device populates (e.g. NOTE for an Android-only list).
|
|
@@ -493,17 +507,39 @@ async function cmdDoctor(ctx) {
|
|
|
493
507
|
return 3;
|
|
494
508
|
const devices = ctx.driver.listDevices();
|
|
495
509
|
const usable = devices.filter((d) => d.state === 'device');
|
|
510
|
+
const claims = (0, claims_1.claimsEnabled)();
|
|
511
|
+
// Read-only, like `vk devices`: doctor surveys the host, it never takes a device.
|
|
512
|
+
const heldByOther = (serial) => {
|
|
513
|
+
const c = claims ? (0, claims_1.summarize)(serial) : undefined;
|
|
514
|
+
return c && !c.mine ? c : undefined;
|
|
515
|
+
};
|
|
496
516
|
(0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
|
|
497
|
-
for (const d of devices)
|
|
498
|
-
(0,
|
|
517
|
+
for (const d of devices) {
|
|
518
|
+
const claim = claims ? (0, claims_1.summarize)(d.serial) : undefined;
|
|
519
|
+
(0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}${claim ? ` [${claim.by}]` : ''}`);
|
|
520
|
+
}
|
|
499
521
|
let ok = true;
|
|
500
|
-
|
|
501
|
-
|
|
522
|
+
const free = usable.filter((d) => !heldByOther(d.serial));
|
|
523
|
+
if (!usable.length) {
|
|
524
|
+
(0, output_1.err)(' -> no usable device');
|
|
525
|
+
ok = false;
|
|
526
|
+
}
|
|
527
|
+
else if (!ctx.device && !free.length) {
|
|
528
|
+
// Every device is busy. Advisory-shaped but genuinely blocking, so it still fails:
|
|
529
|
+
// an interaction command run right now would exit 2, and doctor exists to say so first.
|
|
530
|
+
(0, output_1.err)(' -> every attached device is claimed by another job — `verikun devices` shows who');
|
|
502
531
|
ok = false;
|
|
503
532
|
}
|
|
504
|
-
if (usable.length
|
|
533
|
+
else if (!ctx.device && !claims && usable.length > 1) {
|
|
534
|
+
(0, output_1.err)(' -> multiple devices: pass --device for interaction commands');
|
|
535
|
+
ok = false;
|
|
536
|
+
}
|
|
537
|
+
// Check the device an interaction command would actually land on: the one named, else
|
|
538
|
+
// the first FREE one (which is what auto-selection picks), not merely the first attached.
|
|
539
|
+
const target = ctx.device || free[0]?.serial;
|
|
540
|
+
if (target) {
|
|
505
541
|
try {
|
|
506
|
-
const serial =
|
|
542
|
+
const serial = target;
|
|
507
543
|
const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
|
|
508
544
|
const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
|
|
509
545
|
const vals = keys.map(get);
|
|
@@ -979,7 +1015,7 @@ function cmdClear(ctx) {
|
|
|
979
1015
|
// of applying dark mode and then dying — a half-applied device is worse than none.
|
|
980
1016
|
// - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
|
|
981
1017
|
// `device reset` can undo it even from a later process.
|
|
982
|
-
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps\n' +
|
|
1018
|
+
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | device caps | device release [serial]\n' +
|
|
983
1019
|
`Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
|
|
984
1020
|
function cmdDevice(ctx) {
|
|
985
1021
|
const sub = (ctx.positionals[0] ?? '').toLowerCase();
|
|
@@ -994,10 +1030,43 @@ function cmdDevice(ctx) {
|
|
|
994
1030
|
return deviceReset(ctx, rest);
|
|
995
1031
|
case 'caps':
|
|
996
1032
|
return deviceCaps(ctx);
|
|
1033
|
+
case 'release':
|
|
1034
|
+
return deviceRelease(ctx, rest);
|
|
997
1035
|
default:
|
|
998
1036
|
throw new errors_1.CliError((sub ? `Unknown 'device' subcommand '${sub}'.\n` : '') + DEVICE_USAGE, 2);
|
|
999
1037
|
}
|
|
1000
1038
|
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Hand a device back to the pool. The break-glass named in every "in use by" refusal:
|
|
1041
|
+
* a crashed job's claim expires on its own, but waiting out the TTL is the one thing an
|
|
1042
|
+
* operator should never be forced to do just to get on with their run.
|
|
1043
|
+
*
|
|
1044
|
+
* Deliberately releases someone ELSE's claim too, naming them — you had to type the
|
|
1045
|
+
* serial, and refusing here would leave no way to recover a stuck device at all. Resolves
|
|
1046
|
+
* the device WITHOUT going through the driver, so it works even when the claim being
|
|
1047
|
+
* cleared is the very thing that would refuse the resolve.
|
|
1048
|
+
*/
|
|
1049
|
+
function deviceRelease(ctx, args) {
|
|
1050
|
+
const serial = args[0] || ctx.device;
|
|
1051
|
+
if (!serial) {
|
|
1052
|
+
throw new errors_1.CliError('Usage: verikun device release <serial> (or pass --device / set VERIKUN_DEVICE)', 2);
|
|
1053
|
+
}
|
|
1054
|
+
if (!(0, claims_1.claimsEnabled)()) {
|
|
1055
|
+
(0, output_1.err)('device claims are disabled (VERIKUN_NO_CLAIM) — nothing to release');
|
|
1056
|
+
return 0;
|
|
1057
|
+
}
|
|
1058
|
+
const released = (0, claims_1.releaseClaim)(serial);
|
|
1059
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
1060
|
+
(0, output_1.json)({ serial, released: !!released, ...(released ? { was: released } : {}) });
|
|
1061
|
+
}
|
|
1062
|
+
else if (released) {
|
|
1063
|
+
(0, output_1.out)(`released ${serial} (was held by ${(0, claims_1.describeClaim)(released)})`);
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
(0, output_1.out)(`${serial} was not claimed`);
|
|
1067
|
+
}
|
|
1068
|
+
return 0;
|
|
1069
|
+
}
|
|
1001
1070
|
/** Render a setting value for humans/JSON; a platform that cannot answer says so. */
|
|
1002
1071
|
const showValue = (v) => v ?? 'n/a';
|
|
1003
1072
|
function deviceSet(ctx, args) {
|
|
@@ -1197,6 +1266,10 @@ function cmdRun(positionals, flags, platform, device) {
|
|
|
1197
1266
|
case 'stop':
|
|
1198
1267
|
case 'discard': {
|
|
1199
1268
|
const cleared = run_1.Recorder.clear();
|
|
1269
|
+
// The run is over, so the device goes back to the pool now rather than in TTL
|
|
1270
|
+
// minutes. `mineOnly` because a run can only ever hand back its own device.
|
|
1271
|
+
if (cleared?.device)
|
|
1272
|
+
(0, claims_1.releaseClaim)(cleared.device, { mineOnly: true });
|
|
1200
1273
|
if (asJson)
|
|
1201
1274
|
(0, output_1.json)({ cleared: cleared?.id ?? null });
|
|
1202
1275
|
else
|
|
@@ -1226,6 +1299,9 @@ function cmdRun(positionals, flags, platform, device) {
|
|
|
1226
1299
|
}
|
|
1227
1300
|
}
|
|
1228
1301
|
const { dir, xmlPath, htmlPath, state } = run_1.Recorder.archive(positionals[1], { noLogs, fetchLogs });
|
|
1302
|
+
// Archived means finished: release after log capture, which still needs the device.
|
|
1303
|
+
if (state.device)
|
|
1304
|
+
(0, claims_1.releaseClaim)(state.device, { mineOnly: true });
|
|
1229
1305
|
const { passed, failed } = tally(state.steps);
|
|
1230
1306
|
if (asJson) {
|
|
1231
1307
|
(0, output_1.json)({
|
|
@@ -1374,6 +1450,9 @@ async function cmdBatch(positionals, batchFlags) {
|
|
|
1374
1450
|
(0, output_1.err)('[verikun] batch: no commands to run');
|
|
1375
1451
|
return 0;
|
|
1376
1452
|
}
|
|
1453
|
+
// One process drives the whole batch, so its pid is exact evidence that the device is
|
|
1454
|
+
// still in use — a `kill -9` hands it straight back instead of parking it for the TTL.
|
|
1455
|
+
(0, claims_1.setProcessScoped)(true);
|
|
1377
1456
|
// The finally is what makes `device set` safe to use in a batch: a line that fails
|
|
1378
1457
|
// (or a ^C) still puts the device back, instead of leaving it offline or rotated.
|
|
1379
1458
|
try {
|
|
@@ -1413,6 +1492,7 @@ async function cmdBatch(positionals, batchFlags) {
|
|
|
1413
1492
|
/* the device may be exactly why we are unwinding — never mask the real error */
|
|
1414
1493
|
}
|
|
1415
1494
|
}
|
|
1495
|
+
(0, claims_1.releaseOwnClaims)();
|
|
1416
1496
|
}
|
|
1417
1497
|
}
|
|
1418
1498
|
function parseAiOptions(flags) {
|
|
@@ -1855,6 +1935,7 @@ async function cmdAi(positionals, flags) {
|
|
|
1855
1935
|
throw new errors_1.CliError('Usage: verikun ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--server url] [--show-plan] [--recompile]', 2);
|
|
1856
1936
|
}
|
|
1857
1937
|
const opts = parseAiOptions(flags);
|
|
1938
|
+
(0, claims_1.setProcessScoped)(true); // one process for the whole run — see claims.ts's isLive
|
|
1858
1939
|
// --show-plan: compile (or cache-hit) and print the IR — no device, no backend.
|
|
1859
1940
|
if ((0, args_1.flagBool)(flags, 'show-plan')) {
|
|
1860
1941
|
const nl = readAiTest(file);
|
|
@@ -1875,6 +1956,7 @@ async function cmdAi(positionals, flags) {
|
|
|
1875
1956
|
// otherwise an unattended run leaves the phone offline or in dark mode.
|
|
1876
1957
|
await restoreDeviceOverrides(backend);
|
|
1877
1958
|
await backend.close?.(); // frees a remote server's device lock for the next command
|
|
1959
|
+
(0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
|
|
1878
1960
|
}
|
|
1879
1961
|
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
1880
1962
|
(0, output_1.json)({
|
|
@@ -1934,6 +2016,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1934
2016
|
if (!dirArg)
|
|
1935
2017
|
throw new errors_1.CliError('Usage: verikun suite <dir> [--app <id>] [--server url] [--name n] [--retries n] [--json]', 2);
|
|
1936
2018
|
const opts = parseAiOptions(flags);
|
|
2019
|
+
(0, claims_1.setProcessScoped)(true); // one process for the whole suite — see claims.ts's isLive
|
|
1937
2020
|
// Pre-flight the provider BEFORE touching any device/server: every test needs it
|
|
1938
2021
|
// to compile (on a cache miss) or to repair at runtime.
|
|
1939
2022
|
if (!providerAvailable(opts.model)) {
|
|
@@ -1961,6 +2044,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
1961
2044
|
finally {
|
|
1962
2045
|
await restoreDeviceOverrides(backend);
|
|
1963
2046
|
await backend.close?.();
|
|
2047
|
+
(0, claims_1.releaseOwnClaims)();
|
|
1964
2048
|
}
|
|
1965
2049
|
}
|
|
1966
2050
|
// ---------------------------------------------------------------------------
|
|
@@ -2113,6 +2197,12 @@ async function executeOutcome(command, positionals, flags, sharedDriver) {
|
|
|
2113
2197
|
catch {
|
|
2114
2198
|
/* surfaced by the command handler below */
|
|
2115
2199
|
}
|
|
2200
|
+
// Keep this job's device claim alive. Deliberately NOT gated on the recorder:
|
|
2201
|
+
// `Recorder.beginStep` returns null under VERIKUN_NO_RUN=1 (which the e2e suite
|
|
2202
|
+
// sets), and hanging the heartbeat off it would silently stop claims from being
|
|
2203
|
+
// refreshed for exactly the runs that exercise them.
|
|
2204
|
+
if (serial)
|
|
2205
|
+
(0, claims_1.touchClaim)(serial, platform);
|
|
2116
2206
|
recorder = run_1.Recorder.beginStep(command, positionals, flags, platform, device, serial, driver);
|
|
2117
2207
|
}
|
|
2118
2208
|
}
|
|
@@ -2153,6 +2243,10 @@ async function executeForServer(command, positionals, flags, driver, platform) {
|
|
|
2153
2243
|
catch {
|
|
2154
2244
|
/* surfaced by the command handler below */
|
|
2155
2245
|
}
|
|
2246
|
+
// A server holds one device for its whole life, but its claim still has to look alive
|
|
2247
|
+
// to everyone else on the host — refresh it per request, the same as a local step.
|
|
2248
|
+
if (serial)
|
|
2249
|
+
(0, claims_1.touchClaim)(serial, platform);
|
|
2156
2250
|
// Sample the device clock up front so the caller's run can set logStart (the
|
|
2157
2251
|
// ephemeral recorder never persists RunState). Best-effort — empty/unavailable
|
|
2158
2252
|
// just means archive / vk log fall back to last-N.
|
|
@@ -2290,6 +2384,9 @@ DEVICE STATE (change the device the app runs on, then put it back)
|
|
|
2290
2384
|
equivalent where it doesn't
|
|
2291
2385
|
Refuses \`airplane=on\` over wireless adb (it would cut
|
|
2292
2386
|
this very connection); --allow-wireless overrides.
|
|
2387
|
+
device release [serial] [--json] Hand a claimed device back to the pool. Claims
|
|
2388
|
+
expire on their own; this is for when you don't
|
|
2389
|
+
want to wait. See \`devices\` for who holds what.
|
|
2293
2390
|
|
|
2294
2391
|
BATCH (script many commands in one process)
|
|
2295
2392
|
batch [--file path] [--quiet] Run newline-separated commands — from --file,
|
|
@@ -2351,7 +2448,8 @@ SERVER (expose a locally-connected device to remote verikun clients)
|
|
|
2351
2448
|
VERIKUN_SERVER_AUTH_KEY) to ai/suite/install. The server's device+platform apply.
|
|
2352
2449
|
|
|
2353
2450
|
ENVIRONMENT
|
|
2354
|
-
devices [--json] List attached devices/simulators
|
|
2451
|
+
devices [--json] List attached devices/simulators, and which job is
|
|
2452
|
+
already driving each (USED BY)
|
|
2355
2453
|
doctor [--fix] Diagnose adb/device, and warn if this CLI or the
|
|
2356
2454
|
Claude Code plugin is out of date (a warning only —
|
|
2357
2455
|
it never changes the exit code). --fix disables
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Does the companion's dump agree with the platform's own?
|
|
4
|
+
*
|
|
5
|
+
* This is the standard `calibrate()` holds the companion to before trusting a single one of
|
|
6
|
+
* its reads. It used to be plain string equality, and that is still the fast path — but
|
|
7
|
+
* equality turned out to be stricter than the property it was standing in for, and strictly
|
|
8
|
+
* enough to reject a companion that was right.
|
|
9
|
+
*
|
|
10
|
+
* The property that actually matters is TAP SAFETY: verikun taps the centre of a node the
|
|
11
|
+
* dump reported, so every node the companion serves must be one the platform would have
|
|
12
|
+
* served, with the same bounds. Byte equality implies that. It also demands something extra
|
|
13
|
+
* and unrelated — that the companion reproduce every node the platform emitted, including
|
|
14
|
+
* decor nobody can touch — and that is what broke.
|
|
15
|
+
*
|
|
16
|
+
* MEASURED (issue #79). Asking the accessibility layer for window information is what stops
|
|
17
|
+
* a long-lived connection serving a stale window, but it also makes Android clamp node
|
|
18
|
+
* bounds to the app window rather than the display. On a Pixel 6 emulator (Android 14) that
|
|
19
|
+
* drops exactly one node from the dump, `android:id/navigationBarBackground` at
|
|
20
|
+
* [0,2274][1080,2400] — not clickable, not focusable, and below every pixel the app owns.
|
|
21
|
+
* Every other node, root included, stayed byte-identical. Equality still said no, so the
|
|
22
|
+
* companion was benched and reads went from ~165ms to ~33s.
|
|
23
|
+
*
|
|
24
|
+
* So the rule is a subsequence with two guards:
|
|
25
|
+
*
|
|
26
|
+
* 1. every companion node is byte-identical to a platform node, in document order — this
|
|
27
|
+
* is the tap-safety property, and it is not weakened at all;
|
|
28
|
+
* 2. every platform node the companion lacks is a LEAF and is neither clickable nor
|
|
29
|
+
* focusable — so the companion can never quietly drop something tappable, nor a
|
|
30
|
+
* container whose absence would re-nest the nodes under it.
|
|
31
|
+
*
|
|
32
|
+
* Guard 2 is deliberately stricter than the measurement requires: an unmatched container
|
|
33
|
+
* would still leave its children matchable, but the shapes that produces have not been seen
|
|
34
|
+
* on a real device, and the cost of refusing one is a slow read rather than a wrong tap.
|
|
35
|
+
*
|
|
36
|
+
* What this does NOT still guarantee is that a non-interactive leaf's bounds agree — such a
|
|
37
|
+
* node can differ and be treated as missing. That is the deliberate give: it cannot move a
|
|
38
|
+
* tap, because nothing reads it.
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.dumpsAgree = dumpsAgree;
|
|
42
|
+
const NODE_TAG = /<node\b[^>]*?\/?>/g;
|
|
43
|
+
// A leading \s so `long-clickable="true"` cannot be read as `clickable="true"`.
|
|
44
|
+
const CLICKABLE = /\sclickable="true"/;
|
|
45
|
+
const FOCUSABLE = /\sfocusable="true"/;
|
|
46
|
+
function nodes(xml) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const m of xml.matchAll(NODE_TAG)) {
|
|
49
|
+
const raw = m[0];
|
|
50
|
+
out.push({
|
|
51
|
+
raw,
|
|
52
|
+
leaf: raw.endsWith('/>'),
|
|
53
|
+
interactive: CLICKABLE.test(raw) || FOCUSABLE.test(raw),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** The `<hierarchy …>` element itself — its `rotation` is part of the contract. */
|
|
59
|
+
function header(xml) {
|
|
60
|
+
return /<hierarchy\b[^>]*>/.exec(xml)?.[0] ?? '';
|
|
61
|
+
}
|
|
62
|
+
function dumpsAgree(stock, companion) {
|
|
63
|
+
const a = stock.trim();
|
|
64
|
+
const b = companion.trim();
|
|
65
|
+
if (a === b)
|
|
66
|
+
return { agree: true };
|
|
67
|
+
if (header(a) !== header(b)) {
|
|
68
|
+
return { agree: false, reason: 'the hierarchy header differs (rotation changed mid-capture?)' };
|
|
69
|
+
}
|
|
70
|
+
const stockNodes = nodes(a);
|
|
71
|
+
const compNodes = nodes(b);
|
|
72
|
+
let i = 0; // stock
|
|
73
|
+
for (const want of compNodes) {
|
|
74
|
+
// Skip platform nodes the companion did not emit, proving each is droppable as we go.
|
|
75
|
+
while (i < stockNodes.length && stockNodes[i].raw !== want.raw) {
|
|
76
|
+
const skipped = stockNodes[i];
|
|
77
|
+
if (skipped.interactive) {
|
|
78
|
+
return { agree: false, reason: 'the companion is missing a node the platform reports as tappable' };
|
|
79
|
+
}
|
|
80
|
+
if (!skipped.leaf) {
|
|
81
|
+
return { agree: false, reason: 'the companion is missing a container the platform reports' };
|
|
82
|
+
}
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
if (i === stockNodes.length) {
|
|
86
|
+
return { agree: false, reason: 'the companion reports a node the platform does not' };
|
|
87
|
+
}
|
|
88
|
+
i++; // consume the match
|
|
89
|
+
}
|
|
90
|
+
// Anything left over in the platform's dump must clear the same bar.
|
|
91
|
+
for (; i < stockNodes.length; i++) {
|
|
92
|
+
if (stockNodes[i].interactive) {
|
|
93
|
+
return { agree: false, reason: 'the companion is missing a node the platform reports as tappable' };
|
|
94
|
+
}
|
|
95
|
+
if (!stockNodes[i].leaf) {
|
|
96
|
+
return { agree: false, reason: 'the companion is missing a container the platform reports' };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { agree: true };
|
|
100
|
+
}
|
|
@@ -19,6 +19,7 @@ const exec_1 = require("../exec");
|
|
|
19
19
|
const output_1 = require("../output");
|
|
20
20
|
const errors_1 = require("../errors");
|
|
21
21
|
const version_1 = require("../version");
|
|
22
|
+
const dump_match_1 = require("./dump-match");
|
|
22
23
|
const protocol_1 = require("./protocol");
|
|
23
24
|
const DEVICE_JAR = '/data/local/tmp/verikun-companion.jar';
|
|
24
25
|
/** What we learned about this device last time, kept ON the device because that is what the
|
|
@@ -471,7 +472,11 @@ class Companion {
|
|
|
471
472
|
* shifts every element near the bottom of the screen, and the resulting tap lands
|
|
472
473
|
* somewhere else while still reporting success, which is the worst failure a testing
|
|
473
474
|
* tool has. So do not guess: take one real `uiautomator dump` and adopt whichever
|
|
474
|
-
* source reproduces it
|
|
475
|
+
* source reproduces it.
|
|
476
|
+
*
|
|
477
|
+
* "Reproduces it" is `dumpsAgree()`, not string equality — see the reasoning there for
|
|
478
|
+
* why equality rejected a companion that was right, and exactly what the relaxed rule
|
|
479
|
+
* still guarantees about anything verikun can tap.
|
|
475
480
|
*
|
|
476
481
|
* Costs one stock dump (~2.4s) plus a reconnect (~1.05s), once per companion — not once
|
|
477
482
|
* per process, because the verdict is stored in the companion itself.
|
|
@@ -484,7 +489,7 @@ class Companion {
|
|
|
484
489
|
(0, protocol_1.requestSync)(this.port, 'acquire', 20000);
|
|
485
490
|
for (const dims of ['app', 'real']) {
|
|
486
491
|
const reply = (0, protocol_1.requestSync)(this.port, (0, protocol_1.dumpCommand)(0, dims));
|
|
487
|
-
if ((0, protocol_1.isHierarchy)(reply) && reply.toString('utf8').
|
|
492
|
+
if ((0, protocol_1.isHierarchy)(reply) && (0, dump_match_1.dumpsAgree)(stock, reply.toString('utf8')).agree) {
|
|
488
493
|
(0, protocol_1.requestSync)(this.port, `calibrated ${dims}`, 4000);
|
|
489
494
|
this.writeDeviceNote(dims);
|
|
490
495
|
return dims;
|
|
@@ -15,10 +15,16 @@ exports.portForSerial = portForSerial;
|
|
|
15
15
|
exports.requestSync = requestSync;
|
|
16
16
|
const node_child_process_1 = require("node:child_process");
|
|
17
17
|
const node_path_1 = require("node:path");
|
|
18
|
-
/** Bumped in lockstep with PROTOCOL_VERSION in CompanionApp.java
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
18
|
+
/** Bumped in lockstep with PROTOCOL_VERSION in CompanionApp.java — `tests/companion-protocol
|
|
19
|
+
* .test.ts` reads the Java source and fails if they drift. A companion left running by an
|
|
20
|
+
* older verikun answers `ping` with a different number and is restarted rather than talked
|
|
21
|
+
* to, so a changed dump format can never be replayed by a stale process.
|
|
22
|
+
*
|
|
23
|
+
* 2 — the companion asks for window information, so it tracks a window another app opens on
|
|
24
|
+
* top ([#79]) and its dump omits decor outside the app's own window. MEASURED: without this
|
|
25
|
+
* bump an upgraded verikun reused the running daemon, never pushed the new jar, and went on
|
|
26
|
+
* reading 0 nodes of a permission dialog the stock path saw all 5 of — silently, exit 0. */
|
|
27
|
+
exports.COMPANION_PROTOCOL = '2';
|
|
22
28
|
class CompanionUnavailableError extends Error {
|
|
23
29
|
}
|
|
24
30
|
exports.CompanionUnavailableError = CompanionUnavailableError;
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The device claim store — "which attached device is another job already driving?".
|
|
3
|
+
//
|
|
4
|
+
// Parallel agents (separate worktrees of one repo) share one pool of phones, emulators
|
|
5
|
+
// and simulators. Nothing coordinated the assignment, so two jobs would land on the same
|
|
6
|
+
// device and BOTH die: one `install` of the same package with a different build silently
|
|
7
|
+
// replaces the app under the other's feet, and it presents as an ordinary assertion
|
|
8
|
+
// failure, indistinguishable from a real regression until someone goes digging.
|
|
9
|
+
//
|
|
10
|
+
// A claim is a small JSON file per device under `~/.verikun/devices/`, saying who is
|
|
11
|
+
// driving it and when they were last seen. HOST-global, not workspace-local: run state
|
|
12
|
+
// lives in `./.verikun/` because it describes a working directory, but a device is a fact
|
|
13
|
+
// about the machine, and the jobs that collide are in DIFFERENT directories by definition.
|
|
14
|
+
//
|
|
15
|
+
// Load-bearing properties, each of which has a failure mode behind it:
|
|
16
|
+
//
|
|
17
|
+
// * ONE FILE PER SERIAL, never one shared map. The whole premise is concurrent writers;
|
|
18
|
+
// independent files make every write atomic with no read-modify-write race.
|
|
19
|
+
// * ACQUISITION IS `wx`. An exclusive create is the only way two agents starting at the
|
|
20
|
+
// same instant don't both pick the first free device. Losing that race is not an
|
|
21
|
+
// error — the caller just moves to the next candidate.
|
|
22
|
+
// * READS ARE TOLERANT. An unreadable or corrupt claim reads as UNCLAIMED (same posture
|
|
23
|
+
// as run.ts's loadState and the plan cache): a poisoned file must never be able to
|
|
24
|
+
// brick a device permanently.
|
|
25
|
+
// * LIVENESS IS PID-FIRST WHERE IT CAN BE. See `isLive` — a timeout alone is either too
|
|
26
|
+
// short (steals a device mid-run) or too long (parks a crashed job's phone).
|
|
27
|
+
// * `VERIKUN_NO_CLAIM=1` DISABLES READS AND WRITES, restoring the pre-claim behaviour
|
|
28
|
+
// exactly — including the old exit-2-on-multiple-devices — and making this job
|
|
29
|
+
// invisible to others. That equivalence is the thing to preserve when debugging.
|
|
30
|
+
//
|
|
31
|
+
// Platform-agnostic by design, like `device/settings.ts` and `ui/`: it never touches
|
|
32
|
+
// adb/xcrun. The drivers know which devices exist; this knows which are taken.
|
|
33
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.releaseOwnClaims = releaseOwnClaims;
|
|
35
|
+
exports.setProcessScoped = setProcessScoped;
|
|
36
|
+
exports.claimsEnabled = claimsEnabled;
|
|
37
|
+
exports.claimTtlMs = claimTtlMs;
|
|
38
|
+
exports.claimsDir = claimsDir;
|
|
39
|
+
exports.isMine = isMine;
|
|
40
|
+
exports.isLive = isLive;
|
|
41
|
+
exports.readClaim = readClaim;
|
|
42
|
+
exports.listClaims = listClaims;
|
|
43
|
+
exports.claimDevice = claimDevice;
|
|
44
|
+
exports.touchClaim = touchClaim;
|
|
45
|
+
exports.releaseClaim = releaseClaim;
|
|
46
|
+
exports.describeClaim = describeClaim;
|
|
47
|
+
exports.selectAndClaim = selectAndClaim;
|
|
48
|
+
exports.assertClaimable = assertClaimable;
|
|
49
|
+
exports.summarize = summarize;
|
|
50
|
+
const node_crypto_1 = require("node:crypto");
|
|
51
|
+
const node_fs_1 = require("node:fs");
|
|
52
|
+
const node_os_1 = require("node:os");
|
|
53
|
+
const node_path_1 = require("node:path");
|
|
54
|
+
const errors_1 = require("../errors");
|
|
55
|
+
const output_1 = require("../output");
|
|
56
|
+
const version_1 = require("../version");
|
|
57
|
+
/** Default TTL for a claim whose owner is not a single long-lived process, in minutes. */
|
|
58
|
+
const DEFAULT_TTL_MIN = 5;
|
|
59
|
+
/**
|
|
60
|
+
* Ceiling on trusting a pid, however alive it looks. Pids are recycled, and a
|
|
61
|
+
* process-scoped run heartbeats every step, so a genuinely running job is never anywhere
|
|
62
|
+
* near this old — it only stops a recycled pid resurrecting an abandoned claim forever.
|
|
63
|
+
*/
|
|
64
|
+
const PID_TRUST_MAX_MS = 6 * 60 * 60 * 1000;
|
|
65
|
+
// --- process-wide latch -----------------------------------------------------
|
|
66
|
+
let processScoped = false;
|
|
67
|
+
/**
|
|
68
|
+
* Serials this process has successfully claimed, so a teardown can hand them all back
|
|
69
|
+
* without threading a driver handle through every `finally`. `cmdBatch` in particular
|
|
70
|
+
* never holds a Driver — it dispatches through `executeParsed` — so asking the claim
|
|
71
|
+
* store what it took is the only way it can release anything.
|
|
72
|
+
*/
|
|
73
|
+
const acquired = new Set();
|
|
74
|
+
/** Give back every device this process claimed. Best-effort; teardown must never throw. */
|
|
75
|
+
function releaseOwnClaims(o = {}) {
|
|
76
|
+
const released = [];
|
|
77
|
+
for (const serial of [...acquired]) {
|
|
78
|
+
if (releaseClaim(serial, { ...o, mineOnly: true }))
|
|
79
|
+
released.push(serial);
|
|
80
|
+
}
|
|
81
|
+
return released;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Declare that this process owns its device for its whole run — `ai`, `suite`, `batch`,
|
|
85
|
+
* `server`. That turns the owning pid into EXACT liveness evidence (see `isLive`), so a
|
|
86
|
+
* `kill -9` frees the device immediately instead of parking it until the TTL expires.
|
|
87
|
+
*
|
|
88
|
+
* A process-wide latch rather than a parameter because the claim is acquired lazily, deep
|
|
89
|
+
* inside `Driver.resolvedSerial()`, long after the command that knows this was dispatched.
|
|
90
|
+
* `setOutputQuiet` in output.ts is the same shape for the same reason.
|
|
91
|
+
*/
|
|
92
|
+
function setProcessScoped(v) {
|
|
93
|
+
const prev = processScoped;
|
|
94
|
+
processScoped = v;
|
|
95
|
+
return prev;
|
|
96
|
+
}
|
|
97
|
+
// --- environment ------------------------------------------------------------
|
|
98
|
+
/** Claims are on unless `VERIKUN_NO_CLAIM` is set to anything non-empty (mirrors VERIKUN_NO_RUN). */
|
|
99
|
+
function claimsEnabled(env = process.env) {
|
|
100
|
+
return !env.VERIKUN_NO_CLAIM;
|
|
101
|
+
}
|
|
102
|
+
/** How long a one-off command's claim survives without a heartbeat. `0` disables the TTL. */
|
|
103
|
+
function claimTtlMs(env = process.env) {
|
|
104
|
+
const raw = env.VERIKUN_CLAIM_TTL_MIN;
|
|
105
|
+
if (raw === undefined)
|
|
106
|
+
return DEFAULT_TTL_MIN * 60000;
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
return (Number.isFinite(n) && n >= 0 ? n : DEFAULT_TTL_MIN) * 60000;
|
|
109
|
+
}
|
|
110
|
+
// --- paths ------------------------------------------------------------------
|
|
111
|
+
function claimsDir(o = {}) {
|
|
112
|
+
return (0, node_path_1.join)(o.home ?? (0, node_os_1.homedir)(), '.verikun', 'devices');
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Filename for a serial: readable enough to eyeball the directory, with a hash suffix so
|
|
116
|
+
* two serials that sanitize alike (`192.168.1.5:5555` vs `192.168.1.5_5555`) can never
|
|
117
|
+
* collide onto one file and silently share a claim.
|
|
118
|
+
*/
|
|
119
|
+
function fileFor(serial) {
|
|
120
|
+
const safe = serial.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 48);
|
|
121
|
+
return `${safe}-${(0, node_crypto_1.createHash)('sha1').update(serial).digest('hex').slice(0, 8)}.json`;
|
|
122
|
+
}
|
|
123
|
+
function pathFor(serial, o) {
|
|
124
|
+
return (0, node_path_1.join)(claimsDir(o), fileFor(serial));
|
|
125
|
+
}
|
|
126
|
+
// --- identity ---------------------------------------------------------------
|
|
127
|
+
/** Session identity, same source `run.ts` uses for rollover. Absent in a fresh shell. */
|
|
128
|
+
function currentSession(env) {
|
|
129
|
+
return env.VERIKUN_SESSION || env.TERM_SESSION_ID || undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Is this claim the current job's?
|
|
133
|
+
*
|
|
134
|
+
* EITHER the session matches OR the working directory does — deliberately forgiving,
|
|
135
|
+
* because the only unsafe error here is falsely accusing your own job. An agent harness
|
|
136
|
+
* may run every command in a fresh shell with no stable session id (which is why session
|
|
137
|
+
* alone will not do), and two terminals deliberately sharing one checkout are one job
|
|
138
|
+
* (which is why cwd alone will not do either).
|
|
139
|
+
*/
|
|
140
|
+
function isMine(c, o = {}) {
|
|
141
|
+
const env = o.env ?? process.env;
|
|
142
|
+
const session = currentSession(env);
|
|
143
|
+
if (session && c.session && c.session === session)
|
|
144
|
+
return true;
|
|
145
|
+
return c.cwd === (o.cwd ?? process.cwd());
|
|
146
|
+
}
|
|
147
|
+
/** Whether a pid is running. EPERM means it exists but belongs to someone else — alive. */
|
|
148
|
+
function pidAlive(pid) {
|
|
149
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, 0);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
return e.code === 'EPERM';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function ageMs(c, now) {
|
|
160
|
+
const t = Date.parse(c.heartbeat);
|
|
161
|
+
// An unparseable heartbeat reads as ancient, so a malformed claim is takeable rather
|
|
162
|
+
// than immortal — the same direction every other tolerance in this file leans.
|
|
163
|
+
return Number.isNaN(t) ? Number.POSITIVE_INFINITY : now - t;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Is somebody still driving this device?
|
|
167
|
+
*
|
|
168
|
+
* Two signals, because a heartbeat can only fire BETWEEN commands and neither signal
|
|
169
|
+
* alone covers both owners:
|
|
170
|
+
*
|
|
171
|
+
* * A LIVE PID always means live. The heartbeat cannot tick during a single long
|
|
172
|
+
* command — a large `install`, a `wait --timeout 600000`, a `vk ai` model repair
|
|
173
|
+
* round-trip — so without this a run would lose its device midway through work it
|
|
174
|
+
* had no opportunity to report. The pid only ever EXTENDS liveness here.
|
|
175
|
+
* * PROCESS-SCOPED owners (`ai`/`suite`/`batch`/`server`) are one process for the whole
|
|
176
|
+
* job, so a dead pid also means DONE: the device comes back the instant a `kill -9`
|
|
177
|
+
* lands, rather than being parked until a timer expires. This is the only case where
|
|
178
|
+
* the pid shortens liveness, and it is sound precisely because the process outlives
|
|
179
|
+
* every command in the job.
|
|
180
|
+
*
|
|
181
|
+
* Everything else falls back to idle time. A one-off command's process is gone the moment
|
|
182
|
+
* it printed, so the TTL covers the only gap left: an agent thinking between two commands.
|
|
183
|
+
* One minute is too tight for that (read a screenshot, decide, tap again); five is enough
|
|
184
|
+
* while still handing a crashed job's phone back quickly.
|
|
185
|
+
*/
|
|
186
|
+
function isLive(c, o = {}) {
|
|
187
|
+
const age = ageMs(c, o.now ?? Date.now());
|
|
188
|
+
const ours = c.host === (o.host ?? (0, node_os_1.hostname)()) && c.pid > 0;
|
|
189
|
+
// The ceiling is what stops a RECYCLED pid resurrecting an abandoned claim forever; a
|
|
190
|
+
// job that is genuinely working heartbeats nowhere near this often.
|
|
191
|
+
const running = ours && age <= PID_TRUST_MAX_MS && pidAlive(c.pid);
|
|
192
|
+
if (c.processScoped && ours)
|
|
193
|
+
return running;
|
|
194
|
+
const ttl = claimTtlMs(o.env ?? process.env);
|
|
195
|
+
return running || (ttl > 0 && age <= ttl);
|
|
196
|
+
}
|
|
197
|
+
// --- store ------------------------------------------------------------------
|
|
198
|
+
/** Read one device's claim. Missing, unreadable or malformed all mean UNCLAIMED. */
|
|
199
|
+
function readClaim(serial, o = {}) {
|
|
200
|
+
return readClaimAt(pathFor(serial, o));
|
|
201
|
+
}
|
|
202
|
+
function readClaimAt(p) {
|
|
203
|
+
if (!(0, node_fs_1.existsSync)(p))
|
|
204
|
+
return null;
|
|
205
|
+
try {
|
|
206
|
+
const c = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
|
|
207
|
+
// Guard the two fields every decision reads. A file that parses as JSON but is not a
|
|
208
|
+
// claim must not make `isMine`/`isLive` throw deep inside device resolution.
|
|
209
|
+
if (typeof c?.serial !== 'string' || typeof c?.cwd !== 'string')
|
|
210
|
+
return null;
|
|
211
|
+
return c;
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
(0, output_1.err)(`[verikun] ignoring unreadable device claim ${p} (${e.message})`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Every claim on this host. Powers `vk devices`, and is the list a parallel scheduler reads. */
|
|
219
|
+
function listClaims(o = {}) {
|
|
220
|
+
const dir = claimsDir(o);
|
|
221
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
222
|
+
return [];
|
|
223
|
+
const out = [];
|
|
224
|
+
try {
|
|
225
|
+
for (const name of (0, node_fs_1.readdirSync)(dir)) {
|
|
226
|
+
if (!name.endsWith('.json'))
|
|
227
|
+
continue;
|
|
228
|
+
const c = readClaimAt((0, node_path_1.join)(dir, name));
|
|
229
|
+
if (c)
|
|
230
|
+
out.push(c);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* an unreadable directory is "nothing claimed", never a failure */
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
function record(serial, platform, o, since) {
|
|
239
|
+
const env = o.env ?? process.env;
|
|
240
|
+
const iso = new Date(o.now ?? Date.now()).toISOString();
|
|
241
|
+
return {
|
|
242
|
+
serial,
|
|
243
|
+
platform,
|
|
244
|
+
session: currentSession(env),
|
|
245
|
+
cwd: o.cwd ?? process.cwd(),
|
|
246
|
+
pid: process.pid,
|
|
247
|
+
host: o.host ?? (0, node_os_1.hostname)(),
|
|
248
|
+
processScoped,
|
|
249
|
+
since: since ?? iso,
|
|
250
|
+
heartbeat: iso,
|
|
251
|
+
version: version_1.VERSION,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
let tmpSeq = 0;
|
|
255
|
+
/** A scratch path in the store directory. Never ends in `.json`, so `listClaims` and
|
|
256
|
+
* `findSeed`-style scans skip it even if one is ever left behind. */
|
|
257
|
+
function tmpPath(p) {
|
|
258
|
+
return `${p}.tmp-${process.pid}-${(tmpSeq += 1)}`;
|
|
259
|
+
}
|
|
260
|
+
/** Replace a claim we are already entitled to. `rename` is atomic on POSIX, so a
|
|
261
|
+
* concurrent reader sees the old content or the new one, never a half-written file. */
|
|
262
|
+
function writeAtomic(p, c) {
|
|
263
|
+
const tmp = tmpPath(p);
|
|
264
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(c, null, 2));
|
|
265
|
+
(0, node_fs_1.renameSync)(tmp, p);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Create a claim ONLY if the device is unclaimed, and make it appear fully-formed.
|
|
269
|
+
*
|
|
270
|
+
* Write-then-link, not `writeFileSync(…, {flag:'wx'})`. Both are exclusive — `link()` fails
|
|
271
|
+
* EEXIST just as `wx` does — but `wx` creates the file EMPTY and fills it a moment later,
|
|
272
|
+
* while `link()` publishes an already-written file in one step. That difference decides who
|
|
273
|
+
* owns the device, because a racer landing in the gap reads zero bytes, the tolerant reader
|
|
274
|
+
* correctly calls that corrupt, and corrupt-is-takeable hands it the device too.
|
|
275
|
+
*
|
|
276
|
+
* The gap is microseconds on an idle box and does not reproduce there — it was observed
|
|
277
|
+
* with `wx` under real load (two e2e suites plus an emulator), where six processes racing
|
|
278
|
+
* for one device produced THREE winners. Load is exactly when parallel agents contend, so
|
|
279
|
+
* closing it structurally beats relying on the scheduler.
|
|
280
|
+
*/
|
|
281
|
+
function writeExclusive(p, c) {
|
|
282
|
+
const tmp = tmpPath(p);
|
|
283
|
+
(0, node_fs_1.writeFileSync)(tmp, JSON.stringify(c, null, 2));
|
|
284
|
+
try {
|
|
285
|
+
(0, node_fs_1.linkSync)(tmp, p);
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
if (e.code === 'EEXIST')
|
|
290
|
+
return false;
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
try {
|
|
295
|
+
(0, node_fs_1.unlinkSync)(tmp);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* the link succeeded or never happened; either way the scratch file is disposable */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Take the device, or report who has it.
|
|
304
|
+
*
|
|
305
|
+
* The happy path is an exclusive publish (`writeExclusive`): if no claim existed, we now own
|
|
306
|
+
* it, and no concurrent starter can have won the same device. Only when that fails do we
|
|
307
|
+
* look at who is there — and taking over a dead claim goes unlink-then-publish for the same
|
|
308
|
+
* reason, so exactly one of several racing takers wins instead of two overwriting each other
|
|
309
|
+
* and both believing they own it.
|
|
310
|
+
*/
|
|
311
|
+
function claimDevice(serial, platform, o = {}) {
|
|
312
|
+
const r = claimDeviceInner(serial, platform, o);
|
|
313
|
+
if (r.ok)
|
|
314
|
+
acquired.add(serial);
|
|
315
|
+
return r;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* How many times to re-race for a device whose claim keeps being cleared under us. Each
|
|
319
|
+
* extra round costs one exclusive create against a device nobody live holds, so this is a
|
|
320
|
+
* bound on pathological churn, not a retry budget anyone should hit.
|
|
321
|
+
*/
|
|
322
|
+
const CLAIM_ATTEMPTS = 4;
|
|
323
|
+
/**
|
|
324
|
+
* Run `fn` holding the exclusive right to replace this device's claim, or return null if
|
|
325
|
+
* another taker holds it.
|
|
326
|
+
*
|
|
327
|
+
* Why a token at all: POSIX offers no "remove this file only if it is still the one I
|
|
328
|
+
* read". So "the claim is dead, unlink it and publish mine" is unsound on its own — between
|
|
329
|
+
* the read and the unlink another taker can publish, and the unlink then deletes a LIVE
|
|
330
|
+
* claim, handing the device to two jobs. Measured, not theoretical: 16 racers over one dead
|
|
331
|
+
* claim produced two winners.
|
|
332
|
+
*
|
|
333
|
+
* `link()` gives a sound absent-to-present CAS, so it can elect a single taker. Holding that
|
|
334
|
+
* token, the winner is the only writer of `p` and may replace it atomically — no unlink, no
|
|
335
|
+
* window. The token is held across three syscalls and no I/O, so the only way to strand one
|
|
336
|
+
* is to die inside it; liveness is therefore the owning PID alone, which is exact. That is
|
|
337
|
+
* the difference that matters: a dead CLAIM is the routine steady state (every finished job
|
|
338
|
+
* leaves one), whereas a dead TOKEN needs a crash inside a microsecond window.
|
|
339
|
+
*/
|
|
340
|
+
function withTakeover(p, serial, platform, o, fn) {
|
|
341
|
+
const tok = `${p}.takeover`;
|
|
342
|
+
const me = record(serial, platform, o);
|
|
343
|
+
if (!writeExclusive(tok, me)) {
|
|
344
|
+
const other = readClaimAt(tok);
|
|
345
|
+
if (other && !tokenAbandoned(other, o))
|
|
346
|
+
return null; // a live taker is mid-takeover
|
|
347
|
+
try {
|
|
348
|
+
(0, node_fs_1.unlinkSync)(tok);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
/* another racer cleared it first */
|
|
352
|
+
}
|
|
353
|
+
if (!writeExclusive(tok, me))
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
return fn();
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
try {
|
|
361
|
+
(0, node_fs_1.unlinkSync)(tok);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
/* best effort: a stray token is cleared by the next taker */
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** A takeover token outlives its process only if that process died holding it. Judged on the
|
|
369
|
+
* PID alone — no TTL — because it is never held across anything slow. */
|
|
370
|
+
function tokenAbandoned(t, o) {
|
|
371
|
+
if (t.host !== (o.host ?? (0, node_os_1.hostname)()))
|
|
372
|
+
return false; // not ours to judge; leave it alone
|
|
373
|
+
return !pidAlive(t.pid);
|
|
374
|
+
}
|
|
375
|
+
function claimDeviceInner(serial, platform, o) {
|
|
376
|
+
const p = pathFor(serial, o);
|
|
377
|
+
try {
|
|
378
|
+
(0, node_fs_1.mkdirSync)(claimsDir(o), { recursive: true });
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
return uncoordinated(serial, platform, p, e, o);
|
|
382
|
+
}
|
|
383
|
+
// PUBLISH FIRST, then judge. The invariant this shape exists to hold is: `ok: true` is
|
|
384
|
+
// returned only when an EXCLUSIVE create actually succeeded, or the claim on disk is
|
|
385
|
+
// already ours. Deciding first and writing afterwards cannot hold it — between the
|
|
386
|
+
// decision and the write another racer can publish, and both would report success.
|
|
387
|
+
for (let attempt = 0; attempt < CLAIM_ATTEMPTS; attempt++) {
|
|
388
|
+
const fresh = record(serial, platform, o);
|
|
389
|
+
try {
|
|
390
|
+
if (writeExclusive(p, fresh))
|
|
391
|
+
return { ok: true, claim: fresh };
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
return uncoordinated(serial, platform, p, e, o);
|
|
395
|
+
}
|
|
396
|
+
// Something is there. Note that `held === null` covers BOTH "corrupt" and "vanished a
|
|
397
|
+
// microsecond ago", which is why neither may be treated as ours: the only proof of
|
|
398
|
+
// ownership is a create that succeeded.
|
|
399
|
+
const held = readClaimAt(p);
|
|
400
|
+
if (held && isMine(held, o)) {
|
|
401
|
+
// Keep `since` — how long this job has held the device is the interesting number.
|
|
402
|
+
const refreshed = record(serial, platform, o, held.since);
|
|
403
|
+
try {
|
|
404
|
+
writeAtomic(p, refreshed);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* the file is ours either way; a failed refresh only ages the heartbeat */
|
|
408
|
+
}
|
|
409
|
+
return { ok: true, claim: refreshed };
|
|
410
|
+
}
|
|
411
|
+
if (held && isLive(held, o))
|
|
412
|
+
return { ok: false, held };
|
|
413
|
+
// Dead, or unreadable (a claim nobody can parse would otherwise hold a device forever).
|
|
414
|
+
// Replacing it must be serialized — see `withTakeover` for why unlink-then-create is
|
|
415
|
+
// not enough. Losing the token is not an error; the next attempt re-reads and finds
|
|
416
|
+
// whoever won.
|
|
417
|
+
const taken = withTakeover(p, serial, platform, o, () => {
|
|
418
|
+
// Re-read INSIDE the token. Another taker may have legitimately claimed the device
|
|
419
|
+
// between our judgement and our turn, and this is the only point at which that
|
|
420
|
+
// answer cannot change under us.
|
|
421
|
+
const now = readClaimAt(p);
|
|
422
|
+
if (now && isMine(now, o))
|
|
423
|
+
return { ok: true, claim: now };
|
|
424
|
+
if (now && isLive(now, o))
|
|
425
|
+
return { ok: false, held: now };
|
|
426
|
+
// We are the sole writer of `p` while the token is held, so an atomic replace is
|
|
427
|
+
// safe here — no unlink, and therefore no window in which it could delete a claim
|
|
428
|
+
// newer than the one we judged dead.
|
|
429
|
+
const mine = record(serial, platform, o);
|
|
430
|
+
writeAtomic(p, mine);
|
|
431
|
+
return { ok: true, claim: mine };
|
|
432
|
+
});
|
|
433
|
+
if (taken)
|
|
434
|
+
return taken;
|
|
435
|
+
}
|
|
436
|
+
// Beaten every round. Refusing is the only safe answer left: reporting success here,
|
|
437
|
+
// without a published claim, is precisely how two jobs end up on one device.
|
|
438
|
+
const last = readClaimAt(p);
|
|
439
|
+
if (last && isMine(last, o))
|
|
440
|
+
return { ok: true, claim: last };
|
|
441
|
+
return { ok: false, held: last };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The store is unusable — unwritable home, full disk, a permissions problem. Coordination
|
|
445
|
+
* is a convenience, so this degrades to "drive the device anyway" rather than becoming a
|
|
446
|
+
* new way for a run to fail. The caller proceeds UNCOORDINATED, which is the honest state:
|
|
447
|
+
* no claim was published, and this job is invisible to others.
|
|
448
|
+
*/
|
|
449
|
+
function uncoordinated(serial, platform, p, e, o) {
|
|
450
|
+
(0, output_1.err)(`[verikun] could not write device claim ${p} (${e.message}) — continuing unclaimed`);
|
|
451
|
+
return { ok: true, claim: record(serial, platform, o) };
|
|
452
|
+
}
|
|
453
|
+
/** Refresh my heartbeat. Never throws, never steals: a claim that became someone else's is left alone. */
|
|
454
|
+
function touchClaim(serial, platform, o = {}) {
|
|
455
|
+
if (!claimsEnabled(o.env ?? process.env))
|
|
456
|
+
return;
|
|
457
|
+
try {
|
|
458
|
+
const held = readClaim(serial, o);
|
|
459
|
+
if (held && !isMine(held, o))
|
|
460
|
+
return;
|
|
461
|
+
if (!held) {
|
|
462
|
+
claimDevice(serial, platform, o);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
writeAtomic(pathFor(serial, o), record(serial, platform, o, held.since));
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
/* bookkeeping must never fail a command */
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Drop a claim. `mineOnly` is what the automatic paths (run archive, the `ai`/`batch`/
|
|
473
|
+
* `suite` teardown) pass, so a job can only ever hand back its own device; `vk device
|
|
474
|
+
* release` omits it and says so on stderr, because that is the deliberate break-glass.
|
|
475
|
+
*/
|
|
476
|
+
function releaseClaim(serial, o = {}) {
|
|
477
|
+
try {
|
|
478
|
+
const held = readClaim(serial, o);
|
|
479
|
+
if (!held)
|
|
480
|
+
return null;
|
|
481
|
+
if (!isMine(held, o) && o.mineOnly)
|
|
482
|
+
return null;
|
|
483
|
+
(0, node_fs_1.unlinkSync)(pathFor(serial, o));
|
|
484
|
+
acquired.delete(serial);
|
|
485
|
+
return held;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// --- selection --------------------------------------------------------------
|
|
492
|
+
/** Column form for a table: `workspace 'islamabad' · 3m ago`, or `this job` for our own. */
|
|
493
|
+
function describeClaim(c, o = {}) {
|
|
494
|
+
if (isMine(c, o))
|
|
495
|
+
return 'this job';
|
|
496
|
+
return `${holderLabel(c)} · ${fmtAge(ageMs(c, o.now ?? Date.now()))} ago`;
|
|
497
|
+
}
|
|
498
|
+
/** What to say when a device is demonstrably contended but no holder could be read — a
|
|
499
|
+
* claim being cleared and retaken faster than it can be observed. Rare, and honest. */
|
|
500
|
+
const CONTENDED = 'another job (claim contended)';
|
|
501
|
+
/** Sentence form, for a refusal that reads as prose rather than as a cell. */
|
|
502
|
+
function describeHolder(c, o) {
|
|
503
|
+
return `${holderLabel(c)} (last seen ${fmtAge(ageMs(c, o.now ?? Date.now()))} ago)`;
|
|
504
|
+
}
|
|
505
|
+
function holderLabel(c) {
|
|
506
|
+
const dir = (0, node_path_1.basename)(c.cwd || '');
|
|
507
|
+
if (dir && dir !== '/' && dir !== '.')
|
|
508
|
+
return `workspace '${dir}'`;
|
|
509
|
+
return c.session ? `session ${c.session}` : 'another job';
|
|
510
|
+
}
|
|
511
|
+
/** Local copy of run.ts's private age formatter — five lines, and importing run.ts here
|
|
512
|
+
* would drag the whole recorder into the drivers' dependency graph for a string. */
|
|
513
|
+
function fmtAge(ms) {
|
|
514
|
+
if (!Number.isFinite(ms))
|
|
515
|
+
return 'unknown';
|
|
516
|
+
const m = Math.floor(ms / 60000);
|
|
517
|
+
if (m < 1)
|
|
518
|
+
return `${Math.max(0, Math.floor(ms / 1000))}s`;
|
|
519
|
+
if (m < 60)
|
|
520
|
+
return `${m}m`;
|
|
521
|
+
return `${Math.floor(m / 60)}h${m % 60}m`;
|
|
522
|
+
}
|
|
523
|
+
function label(c) {
|
|
524
|
+
return c.model ? `${c.serial} (${c.model})` : c.serial;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Pick a device nobody else is driving, and claim it in the same breath.
|
|
528
|
+
*
|
|
529
|
+
* Candidates are tried in the backend's own listing order, so an unclaimed phone keeps
|
|
530
|
+
* being reused across runs rather than round-robined — a device that worked last time is
|
|
531
|
+
* the one most likely to work now. A lost `wx` race is simply the next candidate's turn.
|
|
532
|
+
*
|
|
533
|
+
* Throws exit 2 when every candidate is held: the shape is "you must choose", the same as
|
|
534
|
+
* its sibling `Multiple devices connected`, and `--device` is the fix. Not exit 3 — the
|
|
535
|
+
* machine is fine, it is just busy.
|
|
536
|
+
*/
|
|
537
|
+
function selectAndClaim(candidates, platform, o = {}) {
|
|
538
|
+
const skipped = [];
|
|
539
|
+
for (const candidate of candidates) {
|
|
540
|
+
const r = claimDevice(candidate.serial, platform, o);
|
|
541
|
+
if (r.ok)
|
|
542
|
+
return { serial: candidate.serial, skipped, total: candidates.length };
|
|
543
|
+
skipped.push({ candidate, held: r.held });
|
|
544
|
+
}
|
|
545
|
+
const width = Math.max(...skipped.map((s) => label(s.candidate).length), 0);
|
|
546
|
+
const rows = skipped
|
|
547
|
+
.map((s) => ` ${label(s.candidate).padEnd(width)} ${s.held ? describeClaim(s.held, o) : CONTENDED}`)
|
|
548
|
+
.join('\n');
|
|
549
|
+
throw new errors_1.CliError(`Every attached device is in use:\n${rows}\n` +
|
|
550
|
+
'Wait for one, free it with `verikun device release <serial>`, ' +
|
|
551
|
+
'or set VERIKUN_NO_CLAIM=1 to ignore claims.', 2);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Claim the device the caller named explicitly, or refuse.
|
|
555
|
+
*
|
|
556
|
+
* `alternatives` is a THUNK because the Android fast path never lists devices when
|
|
557
|
+
* `--device` is given — it spawns nothing at all — and paying for an `adb devices` round
|
|
558
|
+
* trip only to decorate an error we are not going to raise would tax every run to help
|
|
559
|
+
* the rare one. On the failure path the extra call is free in comparison.
|
|
560
|
+
*/
|
|
561
|
+
function assertClaimable(serial, platform, alternatives = () => [], o = {}) {
|
|
562
|
+
const r = claimDevice(serial, platform, o);
|
|
563
|
+
if (r.ok)
|
|
564
|
+
return;
|
|
565
|
+
// Decorating an error must never be able to replace it. Listing alternatives means
|
|
566
|
+
// asking the platform what is attached, which throws its own exit 3 when nothing is
|
|
567
|
+
// (a plausible state here: the device you named is claimed AND now unplugged).
|
|
568
|
+
let free = [];
|
|
569
|
+
try {
|
|
570
|
+
free = alternatives().filter((c) => c.serial !== serial && !isHeldByOther(c.serial, o));
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
/* no suggestions, then — the refusal below is the message that matters */
|
|
574
|
+
}
|
|
575
|
+
const lines = [
|
|
576
|
+
`${serial} is in use by ${r.held ? describeHolder(r.held, o) : CONTENDED}.`,
|
|
577
|
+
...(free.length ? [` free now: ${free.map(label).join(', ')}`] : []),
|
|
578
|
+
` if that job is gone: verikun device release ${serial}`,
|
|
579
|
+
' to ignore claims: VERIKUN_NO_CLAIM=1',
|
|
580
|
+
];
|
|
581
|
+
throw new errors_1.CliError(lines.join('\n'), 2);
|
|
582
|
+
}
|
|
583
|
+
function isHeldByOther(serial, o) {
|
|
584
|
+
const c = readClaim(serial, o);
|
|
585
|
+
return !!c && !isMine(c, o) && isLive(c, o);
|
|
586
|
+
}
|
|
587
|
+
/** Resolve a device list into `vk devices` rows: who holds what, and is it me. */
|
|
588
|
+
function summarize(serial, o = {}) {
|
|
589
|
+
const c = readClaim(serial, o);
|
|
590
|
+
if (!c || !isLive(c, o))
|
|
591
|
+
return undefined;
|
|
592
|
+
const mine = isMine(c, o);
|
|
593
|
+
return { ...c, mine, by: mine ? 'this job' : describeClaim(c, o) };
|
|
594
|
+
}
|
package/dist/drivers/adb.js
CHANGED
|
@@ -11,6 +11,7 @@ const exec_1 = require("../exec");
|
|
|
11
11
|
const android_parse_1 = require("../ui/android-parse");
|
|
12
12
|
const viewport_1 = require("../ui/viewport");
|
|
13
13
|
const settings_1 = require("../device/settings");
|
|
14
|
+
const claims_1 = require("../device/claims");
|
|
14
15
|
const output_1 = require("../output");
|
|
15
16
|
const ADB = process.env.ADB || 'adb';
|
|
16
17
|
const ADB_HINT = 'install the Android platform-tools (`brew install --cask android-platform-tools`), or point ADB at the binary';
|
|
@@ -214,9 +215,35 @@ class AdbDriver {
|
|
|
214
215
|
if (this.cachedSerial)
|
|
215
216
|
return this.cachedSerial;
|
|
216
217
|
if (this.requested) {
|
|
218
|
+
// An explicit --device is trusted verbatim and costs no `adb devices` round trip —
|
|
219
|
+
// so the claim check here is one small file read, and the list of free alternatives
|
|
220
|
+
// for the refusal message is only paid for if we are actually going to refuse.
|
|
221
|
+
if ((0, claims_1.claimsEnabled)())
|
|
222
|
+
(0, claims_1.assertClaimable)(this.requested, 'android', () => this.usableDevices());
|
|
217
223
|
this.cachedSerial = this.requested;
|
|
218
224
|
return this.cachedSerial;
|
|
219
225
|
}
|
|
226
|
+
const usable = this.usableDevices();
|
|
227
|
+
if (!(0, claims_1.claimsEnabled)()) {
|
|
228
|
+
if (usable.length > 1) {
|
|
229
|
+
const list = usable.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
|
|
230
|
+
throw new errors_1.CliError(`Multiple devices connected; pass --device <serial> (or set VERIKUN_DEVICE):\n${list}`, 2);
|
|
231
|
+
}
|
|
232
|
+
this.cachedSerial = usable[0].serial;
|
|
233
|
+
return this.cachedSerial;
|
|
234
|
+
}
|
|
235
|
+
// Claims turn "ambiguous, you decide" into "one is free, take it" — and a single
|
|
236
|
+
// attached device still goes through here, because "someone else is on the only
|
|
237
|
+
// phone" is exactly where being told beats finding out twenty minutes later.
|
|
238
|
+
const picked = (0, claims_1.selectAndClaim)(usable, 'android');
|
|
239
|
+
if (picked.total > 1) {
|
|
240
|
+
(0, output_1.err)(`[verikun] auto-selected ${picked.serial} — ${picked.total} attached, ${picked.skipped.length} held by another job`);
|
|
241
|
+
}
|
|
242
|
+
this.cachedSerial = picked.serial;
|
|
243
|
+
return this.cachedSerial;
|
|
244
|
+
}
|
|
245
|
+
/** Attached devices in a drivable state, or the honest exit-3 explanation of why none is. */
|
|
246
|
+
usableDevices() {
|
|
220
247
|
const all = this.listDevices();
|
|
221
248
|
const usable = all.filter((d) => d.state === 'device');
|
|
222
249
|
if (usable.length === 0) {
|
|
@@ -226,12 +253,7 @@ class AdbDriver {
|
|
|
226
253
|
}
|
|
227
254
|
throw new errors_1.CliError('No Android devices/emulators connected. Start one, then `verikun devices`.', 3);
|
|
228
255
|
}
|
|
229
|
-
|
|
230
|
-
const list = usable.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
|
|
231
|
-
throw new errors_1.CliError(`Multiple devices connected; pass --device <serial> (or set VERIKUN_DEVICE):\n${list}`, 2);
|
|
232
|
-
}
|
|
233
|
-
this.cachedSerial = usable[0].serial;
|
|
234
|
-
return this.cachedSerial;
|
|
256
|
+
return usable;
|
|
235
257
|
}
|
|
236
258
|
withSerial(args) {
|
|
237
259
|
return ['-s', this.resolvedSerial(), ...args];
|
package/dist/drivers/ios.js
CHANGED
|
@@ -12,6 +12,7 @@ const exec_1 = require("../exec");
|
|
|
12
12
|
const ios_parse_1 = require("../ui/ios-parse");
|
|
13
13
|
const viewport_1 = require("../ui/viewport");
|
|
14
14
|
const settings_1 = require("../device/settings");
|
|
15
|
+
const claims_1 = require("../device/claims");
|
|
15
16
|
const output_1 = require("../output");
|
|
16
17
|
// iOS driver. `xcrun simctl` / `devicectl` cover device discovery and — on a
|
|
17
18
|
// simulator — screenshots, app lifecycle, and logs (no extra install needed).
|
|
@@ -217,6 +218,8 @@ class IdbDriver {
|
|
|
217
218
|
const sims = this.simulators();
|
|
218
219
|
const simUdids = new Set(sims.map((d) => d.serial));
|
|
219
220
|
if (this.requested) {
|
|
221
|
+
if ((0, claims_1.claimsEnabled)())
|
|
222
|
+
(0, claims_1.assertClaimable)(this.requested, 'ios', () => this.claimCandidates(sims));
|
|
220
223
|
this.cachedSerial = this.requested;
|
|
221
224
|
this.cachedIsSim = simUdids.has(this.requested);
|
|
222
225
|
return this.cachedSerial;
|
|
@@ -225,19 +228,39 @@ class IdbDriver {
|
|
|
225
228
|
// drivable target, so prefer it. Only weigh physical devices when no simulator
|
|
226
229
|
// is booted — and only genuinely "connected" ones (devicectl also lists paired-
|
|
227
230
|
// but-idle devices as "available (paired)", which must not count as active).
|
|
228
|
-
const
|
|
229
|
-
const candidates = bootedSims.length > 0 ? bootedSims : listPhysicalDevices().filter((d) => /connected/i.test(d.state));
|
|
231
|
+
const candidates = this.claimCandidates(sims);
|
|
230
232
|
if (candidates.length === 0) {
|
|
231
233
|
throw new errors_1.CliError('No booted iOS simulator or connected device. Boot one (Simulator.app / `xcrun simctl boot`), then `verikun devices`.', 3);
|
|
232
234
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
let serial;
|
|
236
|
+
if (!(0, claims_1.claimsEnabled)()) {
|
|
237
|
+
if (candidates.length > 1) {
|
|
238
|
+
const list = candidates.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
|
|
239
|
+
throw new errors_1.CliError(`Multiple iOS targets; pass --device <udid> (or set VERIKUN_DEVICE):\n${list}`, 2);
|
|
240
|
+
}
|
|
241
|
+
serial = candidates[0].serial;
|
|
236
242
|
}
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
else {
|
|
244
|
+
const picked = (0, claims_1.selectAndClaim)(candidates, 'ios');
|
|
245
|
+
if (picked.total > 1) {
|
|
246
|
+
(0, output_1.err)(`[verikun] auto-selected ${picked.serial} — ${picked.total} available, ${picked.skipped.length} held by another job`);
|
|
247
|
+
}
|
|
248
|
+
serial = picked.serial;
|
|
249
|
+
}
|
|
250
|
+
this.cachedSerial = serial;
|
|
251
|
+
this.cachedIsSim = simUdids.has(serial);
|
|
239
252
|
return this.cachedSerial;
|
|
240
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* The targets auto-resolution will consider, in preference order. A booted simulator is
|
|
256
|
+
* the first-class, unambiguously drivable target, so prefer it; only weigh physical
|
|
257
|
+
* devices when no simulator is booted — and only genuinely "connected" ones (devicectl
|
|
258
|
+
* also lists paired-but-idle devices as "available (paired)", which must not count).
|
|
259
|
+
*/
|
|
260
|
+
claimCandidates(sims) {
|
|
261
|
+
const bootedSims = sims.filter((d) => d.state === 'booted');
|
|
262
|
+
return bootedSims.length > 0 ? bootedSims : listPhysicalDevices().filter((d) => /connected/i.test(d.state));
|
|
263
|
+
}
|
|
241
264
|
udid() {
|
|
242
265
|
return this.resolvedSerial();
|
|
243
266
|
}
|
package/dist/errors.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// stable, agent-readable exit statuses:
|
|
4
4
|
// 0 success / found / assertion passed
|
|
5
5
|
// 1 not found / assertion failed / wait timeout
|
|
6
|
-
// 2 usage error
|
|
7
|
-
// 3 environment error (adb/simctl missing, no
|
|
6
|
+
// 2 usage error, ambiguous selector, or a device another job is driving (caller must refine)
|
|
7
|
+
// 3 environment error (adb/simctl missing, no usable device, dump failed)
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.AmbiguousSelectorError = exports.NoWindowError = exports.SelectorNotFoundError = exports.probeFailure = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
|
|
10
10
|
exports.isEnvError = isEnvError;
|
package/dist/run.js
CHANGED
|
@@ -477,6 +477,16 @@ class Recorder {
|
|
|
477
477
|
capture(driver, quiet = false) {
|
|
478
478
|
if (!driver)
|
|
479
479
|
return;
|
|
480
|
+
// No device, no evidence. When resolution itself is what failed — nothing attached,
|
|
481
|
+
// or another job holds every device — there is no screen to photograph, and both
|
|
482
|
+
// attempts below would re-raise that same error and print it again. Staying silent
|
|
483
|
+
// here is what keeps ONE failure reading as one message instead of three.
|
|
484
|
+
try {
|
|
485
|
+
driver.resolvedSerial();
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
480
490
|
try {
|
|
481
491
|
// Full resolution on purpose — a human reads this in the report — but via the
|
|
482
492
|
// raw path, which reaches the same PNG without the device-side encode.
|
package/dist/server.js
CHANGED
|
@@ -36,6 +36,7 @@ const args_1 = require("./args");
|
|
|
36
36
|
const errors_1 = require("./errors");
|
|
37
37
|
const drivers_1 = require("./drivers");
|
|
38
38
|
const manager_1 = require("./companion/manager");
|
|
39
|
+
const claims_1 = require("./device/claims");
|
|
39
40
|
const output_1 = require("./output");
|
|
40
41
|
const ir_1 = require("./agent/ir");
|
|
41
42
|
const rpc_1 = require("./rpc");
|
|
@@ -376,6 +377,9 @@ async function cmdServer(positionals, flags) {
|
|
|
376
377
|
// toolchain can't drive it — before binding a port. preflight() covers resolving the
|
|
377
378
|
// device AND the tools; without it the server happily listens on a box with no idb
|
|
378
379
|
// and then 500s every /v1/exec.
|
|
380
|
+
// The server owns its device for as long as it listens, so its pid is exact liveness
|
|
381
|
+
// evidence — set before resolving, since that is where the claim is taken.
|
|
382
|
+
(0, claims_1.setProcessScoped)(true);
|
|
379
383
|
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
380
384
|
driver.preflight();
|
|
381
385
|
const serial = driver.resolvedSerial();
|
package/dist/version.js
CHANGED
|
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VERSION = void 0;
|
|
4
4
|
// GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
|
|
5
5
|
// (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
|
|
6
|
-
exports.VERSION = '0.
|
|
6
|
+
exports.VERSION = '0.22.1';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
Binary file
|