verikun 0.23.0 → 0.25.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/.claude/skills/verikun/SKILL.md +44 -5
- package/CHANGELOG.md +47 -0
- package/README.md +4 -3
- package/dist/agent/remote.js +16 -3
- package/dist/args.js +9 -4
- package/dist/cli.js +342 -28
- package/dist/device/claims.js +12 -2
- package/dist/device/failover.js +230 -0
- package/dist/device/prep.js +156 -0
- package/dist/device/settings.js +82 -0
- package/dist/drivers/adb.js +299 -7
- package/dist/drivers/ios.js +20 -0
- package/dist/run.js +3 -1
- package/dist/server.js +341 -15
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -50,6 +50,7 @@ exports.chooseLogOpts = chooseLogOpts;
|
|
|
50
50
|
exports.evalAssert = evalAssert;
|
|
51
51
|
exports.tokenizeLine = tokenizeLine;
|
|
52
52
|
exports.withBatchGlobals = withBatchGlobals;
|
|
53
|
+
exports.retryAfterDeviceMove = retryAfterDeviceMove;
|
|
53
54
|
exports.serverFromFlags = serverFromFlags;
|
|
54
55
|
exports.ensureDeviceTarget = ensureDeviceTarget;
|
|
55
56
|
exports.terminalFailure = terminalFailure;
|
|
@@ -65,6 +66,7 @@ const adb_1 = require("./drivers/adb");
|
|
|
65
66
|
const lifecycle_1 = require("./drivers/lifecycle");
|
|
66
67
|
const settings_1 = require("./device/settings");
|
|
67
68
|
const claims_1 = require("./device/claims");
|
|
69
|
+
const prep_1 = require("./device/prep");
|
|
68
70
|
const selector_1 = require("./ui/selector");
|
|
69
71
|
const state_support_1 = require("./ui/state-support");
|
|
70
72
|
const format_1 = require("./ui/format");
|
|
@@ -656,10 +658,38 @@ async function cmdDoctor(ctx) {
|
|
|
656
658
|
const c = claims ? (0, claims_1.summarize)(serial) : undefined;
|
|
657
659
|
return c && !c.mine ? c : undefined;
|
|
658
660
|
};
|
|
661
|
+
// Blank lines separate doctor's three blocks — toolchain, device inventory, target detail.
|
|
662
|
+
// Without them the target's indented lines read as a continuation of the last device row.
|
|
663
|
+
(0, output_1.out)('');
|
|
659
664
|
(0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
|
|
660
|
-
|
|
665
|
+
// Probe each device, not just the one an interaction command would land on: "is there a
|
|
666
|
+
// screen lock" and "is this prepared" are facts about a PHONE, and a host driving three of
|
|
667
|
+
// them cannot act on an answer that does not say which. The lock read is one `dumpsys` per
|
|
668
|
+
// device and the prep read is a local file, which is affordable in an explicit setup check.
|
|
669
|
+
const locks = new Map();
|
|
670
|
+
const rows = devices.map((d) => {
|
|
671
|
+
const facts = [];
|
|
672
|
+
if (d.state === 'device') {
|
|
673
|
+
facts.push((0, prep_1.readPrep)(d.serial) ? 'prepared' : 'not prepared');
|
|
674
|
+
const lock = (0, adb_1.lockKindOf)(d.serial);
|
|
675
|
+
locks.set(d.serial, lock);
|
|
676
|
+
if (lock !== 'none' && lock !== 'unknown')
|
|
677
|
+
facts.push(`screen lock: ${lock}`);
|
|
678
|
+
}
|
|
661
679
|
const claim = claims ? (0, claims_1.summarize)(d.serial) : undefined;
|
|
662
|
-
|
|
680
|
+
return {
|
|
681
|
+
serial: d.serial,
|
|
682
|
+
what: `${d.state}${d.model ? ` (${d.model})` : ''}`,
|
|
683
|
+
facts: facts.join(' · '),
|
|
684
|
+
claim: claim ? `[${claim.by}]` : '',
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
// Pad each column to its widest cell so the per-device facts line up and can be scanned
|
|
688
|
+
// down; trimEnd so a device with no facts and no claim leaves no trailing whitespace.
|
|
689
|
+
const w = (pick) => Math.max(...rows.map((r) => pick(r).length), 0);
|
|
690
|
+
const [wSerial, wWhat, wFacts] = [w((r) => r.serial), w((r) => r.what), w((r) => r.facts)];
|
|
691
|
+
for (const r of rows) {
|
|
692
|
+
(0, output_1.out)(` ${r.serial.padEnd(wSerial)} ${r.what.padEnd(wWhat)} ${r.facts.padEnd(wFacts)} ${r.claim}`.trimEnd());
|
|
663
693
|
}
|
|
664
694
|
let ok = true;
|
|
665
695
|
const free = usable.filter((d) => !heldByOther(d.serial));
|
|
@@ -681,25 +711,57 @@ async function cmdDoctor(ctx) {
|
|
|
681
711
|
// the first FREE one (which is what auto-selection picks), not merely the first attached.
|
|
682
712
|
const target = ctx.device || free[0]?.serial;
|
|
683
713
|
if (target) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
if ((0, args_1.flagBool)(ctx.flags, 'fix') && !off) {
|
|
692
|
-
for (const k of keys)
|
|
693
|
-
(0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'put', 'global', k, '0']);
|
|
694
|
-
(0, output_1.out)('animations: disabled (good)');
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
catch {
|
|
698
|
-
(0, output_1.err)('animations: could not read device settings');
|
|
714
|
+
reportTargetStatus(target, adb, locks.get(target) ?? 'unknown');
|
|
715
|
+
// `--fix` is now an alias for `device prep`, so it inherits prep's gate: on a physical
|
|
716
|
+
// device it refuses unless the serial was named. That refusal is the point — the old
|
|
717
|
+
// `--fix` wrote permanently to whichever phone happened to be unclaimed, with no undo.
|
|
718
|
+
if ((0, args_1.flagBool)(ctx.flags, 'fix')) {
|
|
719
|
+
const prepFlags = (0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle') ? { 'no-sleep-when-idle': true } : {};
|
|
720
|
+
return devicePrep({ ...ctx, flags: prepFlags });
|
|
699
721
|
}
|
|
700
722
|
}
|
|
701
723
|
return ok ? 0 : 3;
|
|
702
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* The extra detail for the ONE device an interaction command would land on. Every line names
|
|
727
|
+
* that serial, because the listing above may have shown several and an advisory you cannot
|
|
728
|
+
* attribute to a phone is not actionable.
|
|
729
|
+
*
|
|
730
|
+
* Reads NOTHING through `ctx.driver`, deliberately — resolving a driver claims the device, and
|
|
731
|
+
* doctor surveys the host without taking anything (see the note at the device listing above).
|
|
732
|
+
* The prep record is a plain file read; the animation probe goes through `adb -s <serial>`
|
|
733
|
+
* directly. `lock` is passed in rather than re-probed, so the listing's read is the only one.
|
|
734
|
+
*/
|
|
735
|
+
function reportTargetStatus(serial, adb, lock) {
|
|
736
|
+
(0, output_1.out)('');
|
|
737
|
+
(0, output_1.out)(`target: ${serial} — the device an interaction command would use`);
|
|
738
|
+
const rec = (0, prep_1.readPrep)(serial);
|
|
739
|
+
const when = rec?.preparedAt ? ` (${rec.preparedAt.slice(0, 10)})` : '';
|
|
740
|
+
(0, output_1.out)(rec
|
|
741
|
+
? ` prepared${when} — undo with \`verikun device prep --revert --device ${serial}\``
|
|
742
|
+
: ` not prepared — run \`verikun device prep --device ${serial}\``);
|
|
743
|
+
// Animations are still called out by name rather than folded into "not prepared": they are
|
|
744
|
+
// the single most common cause of a flaky dump, and a device can be prepped and then drift.
|
|
745
|
+
try {
|
|
746
|
+
const vals = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'].map((k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim());
|
|
747
|
+
const off = vals.every((v) => Number(v) === 0);
|
|
748
|
+
(0, output_1.out)(` animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun device prep`)'}`);
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
(0, output_1.err)(` animations: could not read settings on ${serial}`);
|
|
752
|
+
}
|
|
753
|
+
// Advisory, never a failure: a screen lock is a property of someone's phone, not a broken
|
|
754
|
+
// machine. It is worth saying because of what a slept device actually does — the read
|
|
755
|
+
// SUCCEEDS and returns the keyguard, so selectors miss for a reason unrelated to the app.
|
|
756
|
+
if (lock !== 'none' && lock !== 'unknown') {
|
|
757
|
+
// Indented to sit with the target's other lines, but it still NAMES the serial: this goes
|
|
758
|
+
// to stderr (it is a diagnostic, not data), so under `vk doctor > out.txt` it appears on
|
|
759
|
+
// its own with no `target:` header above it to say which phone it means.
|
|
760
|
+
(0, output_1.err)(` screen lock on ${serial} (${lock}) — while it is up, a read returns the lock screen ` +
|
|
761
|
+
'rather than the app.\n' +
|
|
762
|
+
' Remove it on a test device (Settings > Security). verikun never asks for a PIN.');
|
|
763
|
+
}
|
|
764
|
+
}
|
|
703
765
|
function cmdUi(ctx) {
|
|
704
766
|
const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
705
767
|
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
@@ -1158,7 +1220,8 @@ function cmdClear(ctx) {
|
|
|
1158
1220
|
// of applying dark mode and then dying — a half-applied device is worse than none.
|
|
1159
1221
|
// - SNAPSHOT FIRST. The pre-change value is persisted before the change is made, so
|
|
1160
1222
|
// `device reset` can undo it even from a later process.
|
|
1161
|
-
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] |
|
|
1223
|
+
const DEVICE_USAGE = 'Usage: verikun device set <key>=<value> [<key>=<value> ...] | device get [key] | device reset [key ...] | ' +
|
|
1224
|
+
'device prep [--revert] [--dry-run] | device caps | device release [serial]\n' +
|
|
1162
1225
|
`Keys: ${settings_1.SETTING_KEYS.join(', ')}`;
|
|
1163
1226
|
function cmdDevice(ctx) {
|
|
1164
1227
|
const sub = (ctx.positionals[0] ?? '').toLowerCase();
|
|
@@ -1171,6 +1234,8 @@ function cmdDevice(ctx) {
|
|
|
1171
1234
|
return deviceGet(ctx, rest);
|
|
1172
1235
|
case 'reset':
|
|
1173
1236
|
return deviceReset(ctx, rest);
|
|
1237
|
+
case 'prep':
|
|
1238
|
+
return devicePrep(ctx);
|
|
1174
1239
|
case 'caps':
|
|
1175
1240
|
return deviceCaps(ctx);
|
|
1176
1241
|
case 'release':
|
|
@@ -1311,6 +1376,143 @@ function deviceReset(ctx, args) {
|
|
|
1311
1376
|
(0, output_1.out)(summary ? `device reset ${summary}` : 'device reset: nothing to restore');
|
|
1312
1377
|
return 0;
|
|
1313
1378
|
}
|
|
1379
|
+
/**
|
|
1380
|
+
* `vk device prep` — establish the test-device knob set, stickily.
|
|
1381
|
+
*
|
|
1382
|
+
* The whole point is that this OUTLIVES the run, so unlike `deviceSet` the snapshot does not
|
|
1383
|
+
* go into the run file (which `ai`/`suite`/`batch` auto-restore from a `finally`) but into the
|
|
1384
|
+
* host-global prep store. See device/prep.ts for why the two stores exist.
|
|
1385
|
+
*
|
|
1386
|
+
* Unsupported and no-op knobs are SKIPPED with a note rather than failing the command, which
|
|
1387
|
+
* is the difference between prep and `device set`: `set` was told which key to change and must
|
|
1388
|
+
* refuse if it cannot, while prep means "establish what this platform can" — so on iOS, where
|
|
1389
|
+
* every knob is a noop or unsupported, it honestly reports doing nothing instead of erroring.
|
|
1390
|
+
*/
|
|
1391
|
+
function devicePrep(ctx) {
|
|
1392
|
+
const asJson = (0, args_1.flagBool)(ctx.flags, 'json');
|
|
1393
|
+
const dryRun = (0, args_1.flagBool)(ctx.flags, 'dry-run');
|
|
1394
|
+
const serial = ctx.driver.resolvedSerial();
|
|
1395
|
+
if ((0, args_1.flagBool)(ctx.flags, 'revert'))
|
|
1396
|
+
return devicePrepRevert(ctx, serial, dryRun, asJson);
|
|
1397
|
+
// Partition by what this platform can actually do, so the gate below counts real writes.
|
|
1398
|
+
const skipped = [];
|
|
1399
|
+
const applicable = [];
|
|
1400
|
+
for (const knob of prep_1.PREP_KNOBS) {
|
|
1401
|
+
const spec = settings_1.SETTINGS[knob.key];
|
|
1402
|
+
const support = spec.support[ctx.platform];
|
|
1403
|
+
if (support === 'unsupported') {
|
|
1404
|
+
skipped.push({ key: knob.key, reason: spec.manual[ctx.platform] ?? `not supported on ${ctx.platform}` });
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
if (support === 'noop') {
|
|
1408
|
+
skipped.push({ key: knob.key, reason: spec.note[ctx.platform] ?? 'already satisfied on this platform' });
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
// parse() so the target compares equal to a readback (`max` -> the millisecond string).
|
|
1412
|
+
applicable.push({
|
|
1413
|
+
key: knob.key,
|
|
1414
|
+
target: spec.parse(knob.value),
|
|
1415
|
+
current: ctx.driver.getDeviceSetting(knob.key),
|
|
1416
|
+
why: knob.why,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
const changes = applicable.filter((k) => k.current !== k.target);
|
|
1420
|
+
// `--dry-run` is deliberately NOT gated: it writes nothing, and refusing it would mean you
|
|
1421
|
+
// could not find out what prep would do to a phone without first asserting it is a test one.
|
|
1422
|
+
if (dryRun) {
|
|
1423
|
+
if (asJson) {
|
|
1424
|
+
(0, output_1.json)({ serial, prepared: (0, prep_1.isPrepared)(serial), dryRun: true, changes, skipped });
|
|
1425
|
+
}
|
|
1426
|
+
else {
|
|
1427
|
+
(0, output_1.out)(`device prep ${serial} (dry run — nothing written)`);
|
|
1428
|
+
for (const k of applicable) {
|
|
1429
|
+
const state = k.current === k.target ? 'already' : `${showValue(k.current)} -> ${k.target}`;
|
|
1430
|
+
(0, output_1.out)(` ${k.key.padEnd(15)} ${state}`);
|
|
1431
|
+
if (k.current !== k.target)
|
|
1432
|
+
(0, output_1.out)(` ${' '.repeat(15)} ${k.why}`);
|
|
1433
|
+
}
|
|
1434
|
+
for (const s of skipped)
|
|
1435
|
+
(0, output_1.out)(` ${s.key.padEnd(15)} skipped: ${s.reason.replace(/\n/g, ' ')}`);
|
|
1436
|
+
}
|
|
1437
|
+
return 0;
|
|
1438
|
+
}
|
|
1439
|
+
// The gate, immediately before the first write. `!!ctx.device` is true for --device AND
|
|
1440
|
+
// VERIKUN_DEVICE: both are a deliberate act naming one phone, which is the property asked for.
|
|
1441
|
+
const kind = ctx.driver.listDevices().find((d) => d.serial === serial)?.kind ?? 'physical';
|
|
1442
|
+
(0, prep_1.assertPreppable)(kind, serial, !!ctx.device, applicable.length);
|
|
1443
|
+
const original = {};
|
|
1444
|
+
for (const k of applicable) {
|
|
1445
|
+
// Snapshot even a knob already at target: `--revert` has to put back what the device
|
|
1446
|
+
// held before prep, and "it was already off" is exactly as much a fact as a change.
|
|
1447
|
+
if (k.current !== null)
|
|
1448
|
+
original[k.key] = k.current;
|
|
1449
|
+
if (k.current !== k.target)
|
|
1450
|
+
ctx.driver.setDeviceSetting(k.key, k.target);
|
|
1451
|
+
}
|
|
1452
|
+
// Earliest wins across re-preps, or `--revert` would restore the device to prepped.
|
|
1453
|
+
const prior = (0, prep_1.readPrep)(serial);
|
|
1454
|
+
const sleepWhenIdle = !(0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle');
|
|
1455
|
+
(0, prep_1.writePrep)((0, prep_1.newPrepRecord)(serial, ctx.platform, (0, prep_1.mergeOriginals)(prior?.original ?? {}, original), sleepWhenIdle));
|
|
1456
|
+
const applied = Object.fromEntries(applicable.map((k) => [k.key, k.target]));
|
|
1457
|
+
ctx.record?.note({ message: `device prep ${serial} (${changes.length} changed)` });
|
|
1458
|
+
if (asJson) {
|
|
1459
|
+
(0, output_1.json)({ serial, applied, changed: changes.map((c) => c.key), skipped, sleepWhenIdle });
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
(0, output_1.out)(`device prep ${serial}: ${changes.length ? changes.map((c) => `${c.key}=${c.target}`).join(' ') : 'already prepared'}`);
|
|
1463
|
+
for (const s of skipped)
|
|
1464
|
+
(0, output_1.err)(`note: ${s.key} skipped — ${s.reason.replace(/\n/g, ' ')}`);
|
|
1465
|
+
if (sleepWhenIdle)
|
|
1466
|
+
(0, output_1.err)('note: this device will be put to sleep when a run using it finishes');
|
|
1467
|
+
(0, output_1.err)(`undo with: verikun device prep --revert --device ${serial}`);
|
|
1468
|
+
}
|
|
1469
|
+
return 0;
|
|
1470
|
+
}
|
|
1471
|
+
/** Put a prepared device back the way it was found, and forget it. */
|
|
1472
|
+
function devicePrepRevert(ctx, serial, dryRun, asJson) {
|
|
1473
|
+
const rec = (0, prep_1.readPrep)(serial);
|
|
1474
|
+
if (!rec) {
|
|
1475
|
+
if (asJson)
|
|
1476
|
+
(0, output_1.json)({ serial, prepared: false, restored: {} });
|
|
1477
|
+
else
|
|
1478
|
+
(0, output_1.out)(`device prep --revert ${serial}: not prepared, nothing to restore`);
|
|
1479
|
+
return 0;
|
|
1480
|
+
}
|
|
1481
|
+
const entries = Object.entries(rec.original);
|
|
1482
|
+
if (dryRun) {
|
|
1483
|
+
if (asJson)
|
|
1484
|
+
(0, output_1.json)({ serial, prepared: true, dryRun: true, wouldRestore: rec.original });
|
|
1485
|
+
else {
|
|
1486
|
+
(0, output_1.out)(`device prep --revert ${serial} (dry run — nothing written)`);
|
|
1487
|
+
for (const [key, value] of entries)
|
|
1488
|
+
(0, output_1.out)(` ${key.padEnd(15)} ${showValue(ctx.driver.getDeviceSetting(key))} -> ${value}`);
|
|
1489
|
+
}
|
|
1490
|
+
return 0;
|
|
1491
|
+
}
|
|
1492
|
+
const restored = {};
|
|
1493
|
+
const failed = [];
|
|
1494
|
+
for (const [key, value] of entries) {
|
|
1495
|
+
try {
|
|
1496
|
+
ctx.driver.setDeviceSetting(key, value);
|
|
1497
|
+
restored[key] = value;
|
|
1498
|
+
}
|
|
1499
|
+
catch (e) {
|
|
1500
|
+
// Best-effort, like `device reset`: one stubborn knob must not strand the rest.
|
|
1501
|
+
failed.push(`${key} (${e instanceof Error ? e.message.split('\n')[0] : String(e)})`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
// Forget the device even if a knob refused. Keeping the record would leave `vk doctor`
|
|
1505
|
+
// reporting it as prepped forever, and the values that DID go back are already back.
|
|
1506
|
+
(0, prep_1.clearPrep)(serial);
|
|
1507
|
+
ctx.record?.note({ message: `device prep --revert ${serial}` });
|
|
1508
|
+
if (failed.length)
|
|
1509
|
+
(0, output_1.err)(`warning: could not restore ${failed.join(', ')}`);
|
|
1510
|
+
if (asJson)
|
|
1511
|
+
(0, output_1.json)({ serial, restored, ...(failed.length ? { failed } : {}) });
|
|
1512
|
+
else
|
|
1513
|
+
(0, output_1.out)(`device prep --revert ${serial}: ${Object.entries(restored).map(([k, v]) => `${k}=${v}`).join(' ') || 'nothing to restore'}`);
|
|
1514
|
+
return 0;
|
|
1515
|
+
}
|
|
1314
1516
|
function deviceCaps(ctx) {
|
|
1315
1517
|
const rows = settings_1.SETTING_KEYS.map((key) => {
|
|
1316
1518
|
const spec = settings_1.SETTINGS[key];
|
|
@@ -1355,6 +1557,35 @@ function requireSettingKey(v) {
|
|
|
1355
1557
|
* same code. (Remote is a known gap: the overrides live in the *server's* run file, so
|
|
1356
1558
|
* a locally-empty snapshot means this correctly skips — see the issue's Out of scope.)
|
|
1357
1559
|
*/
|
|
1560
|
+
/**
|
|
1561
|
+
* Park prepared devices when the flow that used them ends — #97's "in sleep mode when they're
|
|
1562
|
+
* not [in use]".
|
|
1563
|
+
*
|
|
1564
|
+
* Three properties worth keeping:
|
|
1565
|
+
*
|
|
1566
|
+
* * It can only ever touch a device you explicitly PREPPED, and only if that prep did not
|
|
1567
|
+
* pass `--no-sleep-when-idle`. A borrowed phone that was never prepped is never slept.
|
|
1568
|
+
* * It goes through the driver rather than the command dispatcher, so it does not appear as
|
|
1569
|
+
* a `key sleep` testcase in the report. Parking is host hygiene, not a test step.
|
|
1570
|
+
* * `ownClaimedSerials()` is empty in `--server` mode (the local process never resolves a
|
|
1571
|
+
* device), so the remote path degrades to doing nothing on its own, with no branch here.
|
|
1572
|
+
* Managing a remote device's power is `--allow-device-control`'s job.
|
|
1573
|
+
*
|
|
1574
|
+
* Must run BEFORE `releaseOwnClaims()`, which is what it reads its serials from.
|
|
1575
|
+
*/
|
|
1576
|
+
function parkPreparedDevices(platform) {
|
|
1577
|
+
for (const serial of (0, claims_1.ownClaimedSerials)()) {
|
|
1578
|
+
if (!(0, prep_1.readPrep)(serial)?.sleepWhenIdle)
|
|
1579
|
+
continue;
|
|
1580
|
+
try {
|
|
1581
|
+
(0, output_1.err)(`[verikun] parking prepared device ${serial} (sleep)`);
|
|
1582
|
+
(0, drivers_1.getDriver)(platform, serial).pressKey('sleep');
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
/* teardown must never throw — the device may be exactly why we are unwinding */
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1358
1589
|
async function restoreDeviceOverrides(backend) {
|
|
1359
1590
|
if (!run_1.Recorder.hasDeviceOverrides())
|
|
1360
1591
|
return;
|
|
@@ -1635,6 +1866,7 @@ async function cmdBatch(positionals, batchFlags) {
|
|
|
1635
1866
|
/* the device may be exactly why we are unwinding — never mask the real error */
|
|
1636
1867
|
}
|
|
1637
1868
|
}
|
|
1869
|
+
parkPreparedDevices(platformFromFlags(batchFlags));
|
|
1638
1870
|
(0, claims_1.releaseOwnClaims)();
|
|
1639
1871
|
}
|
|
1640
1872
|
}
|
|
@@ -1693,8 +1925,9 @@ function providerRequirement(model) {
|
|
|
1693
1925
|
return id === 'openai' ? 'OPENAI_API_KEY is not set' : 'ANTHROPIC_API_KEY is not set';
|
|
1694
1926
|
}
|
|
1695
1927
|
/** Route the model to its backend. HTTP providers read their own key; a CLI provider shells
|
|
1696
|
-
* out to its logged-in binary. Unavailable → null (compile/repair off)
|
|
1697
|
-
*
|
|
1928
|
+
* out to its logged-in binary. Unavailable → null (compile/repair off). That is NOT a free
|
|
1929
|
+
* replay path: runAiTest turns a null provider into exit 3 even at a 100% cache hit, because
|
|
1930
|
+
* it must be able to repair a drifted step at runtime — only --show-plan degrades gracefully.
|
|
1698
1931
|
* CLI providers get no `model`, so the CLI picks its own default — the "I just have a
|
|
1699
1932
|
* subscription" path; --effort is likewise inapplicable to them. */
|
|
1700
1933
|
function makeProvider(opts) {
|
|
@@ -1767,6 +2000,38 @@ async function obtainPlan(key, file, opts, cost, provider) {
|
|
|
1767
2000
|
}
|
|
1768
2001
|
return { plan: compiled.plan, cached: false };
|
|
1769
2002
|
}
|
|
2003
|
+
// --- execution backend (local driver vs remote `vk server`) -----------------
|
|
2004
|
+
//
|
|
2005
|
+
// `vk ai`, `vk suite`, and `vk install` run their device work through an
|
|
2006
|
+
// ExecBackend. Local wraps one shared Driver; remote speaks HTTP to a `vk server`
|
|
2007
|
+
// beside the device (--server / VERIKUN_SERVER), where each validated leaf is ONE
|
|
2008
|
+
// round-trip (the auto-wait loop stays server-side). In remote mode the server
|
|
2009
|
+
// owns the device: its /v1/health platform+serial supersede the client's
|
|
2010
|
+
// --platform/--device, and no local driver is ever built.
|
|
2011
|
+
/**
|
|
2012
|
+
* Run a remote read; if it failed AND the server reported it moved device, ask once more.
|
|
2013
|
+
*
|
|
2014
|
+
* This is the ONE place a failed remote read is re-asked, and the narrowness is the point.
|
|
2015
|
+
* It is only ever wired to `preflight` — the connect probe at `vk ai`/`vk suite` startup and
|
|
2016
|
+
* the suite's between-tests health check — where nothing has run yet on either device. A
|
|
2017
|
+
* mid-flow read is never retried: the new device has none of the state the flow built up, so
|
|
2018
|
+
* its screen would answer a different question than the one being asked.
|
|
2019
|
+
*
|
|
2020
|
+
* Gating on the move (rather than retrying every failure) also keeps the connect probe's
|
|
2021
|
+
* fail-fast property: a device that is simply broken still fails on the first try.
|
|
2022
|
+
*
|
|
2023
|
+
* Exported solely so the unit suite can reach it.
|
|
2024
|
+
*/
|
|
2025
|
+
async function retryAfterDeviceMove(read, moved) {
|
|
2026
|
+
try {
|
|
2027
|
+
return await read();
|
|
2028
|
+
}
|
|
2029
|
+
catch (e) {
|
|
2030
|
+
if (!moved())
|
|
2031
|
+
throw e;
|
|
2032
|
+
return read();
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
1770
2035
|
/** The `--server` URL, or VERIKUN_SERVER. Exported-shape helper so `resolveBackend`
|
|
1771
2036
|
* and `vk devices --server` can never disagree about what "remote" means. */
|
|
1772
2037
|
function serverFromFlags(flags) {
|
|
@@ -1939,9 +2204,14 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1939
2204
|
},
|
|
1940
2205
|
platform,
|
|
1941
2206
|
device,
|
|
2207
|
+
moves: [],
|
|
1942
2208
|
};
|
|
1943
2209
|
}
|
|
1944
2210
|
let runCtx = { platform, device };
|
|
2211
|
+
const moves = [];
|
|
2212
|
+
/** Set by the last move; the preflight below reads it to decide whether re-asking is
|
|
2213
|
+
* warranted, then clears it. */
|
|
2214
|
+
let movedDuringCall;
|
|
1945
2215
|
const opts = {
|
|
1946
2216
|
url: server,
|
|
1947
2217
|
authKey: (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined,
|
|
@@ -1949,6 +2219,19 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1949
2219
|
// is identical to a local run's. logStart travels from the server's device clock
|
|
1950
2220
|
// so archive-time / vk log scoping works without a local driver.
|
|
1951
2221
|
onStep: (step, artifacts, logStart) => run_1.Recorder.appendForeignStep(step, artifacts, { ...runCtx, logStart }),
|
|
2222
|
+
onDeviceChange: (c) => {
|
|
2223
|
+
moves.push(c);
|
|
2224
|
+
movedDuringCall = c;
|
|
2225
|
+
// Re-point the run context, so steps after the move are attributed to the device
|
|
2226
|
+
// that actually ran them. This makes `rolloverReason` seal the device-A run and
|
|
2227
|
+
// open a fresh one for B — intended: since a step is never replayed, no single run
|
|
2228
|
+
// can contain steps from two devices, and a report that claimed otherwise would lie.
|
|
2229
|
+
runCtx = { ...runCtx, device: c.to };
|
|
2230
|
+
(0, output_1.err)(`[verikun] server moved device: ${c.from} → ${c.to} (${c.reason})` +
|
|
2231
|
+
(c.retried
|
|
2232
|
+
? ' — retried there'
|
|
2233
|
+
: ' — this step failed on the old device; the next runs on the new one'));
|
|
2234
|
+
},
|
|
1952
2235
|
};
|
|
1953
2236
|
let health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
|
|
1954
2237
|
// `--ensure-device` boots BEFORE runCtx is fixed: resolveBackend bakes the serial
|
|
@@ -1956,7 +2239,15 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1956
2239
|
// attribute every spliced step to a device that didn't exist yet.
|
|
1957
2240
|
health = await ensureRemoteDevice(health, opts, server, flags);
|
|
1958
2241
|
runCtx = { platform: health.platform, device: health.serial ?? undefined };
|
|
1959
|
-
(0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}`
|
|
2242
|
+
(0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}` +
|
|
2243
|
+
(health.failoverEnabled ? ' · failover: on' : ''));
|
|
2244
|
+
// A pool the server has already ruled out explains a lot of otherwise-baffling
|
|
2245
|
+
// behaviour ("why is it on THAT phone?"), so say it once, up front.
|
|
2246
|
+
if (health.quarantined?.length) {
|
|
2247
|
+
(0, output_1.err)(`[verikun] server has ruled out ${health.quarantined.length} device(s):`);
|
|
2248
|
+
for (const q of health.quarantined)
|
|
2249
|
+
(0, output_1.err)(`[verikun] ${q.serial} ${q.reason}`);
|
|
2250
|
+
}
|
|
1960
2251
|
// Say the read path once, here. Reads execute server-side, so this is the only end of the
|
|
1961
2252
|
// connection that knows it — and without it a companion that had silently stood down was
|
|
1962
2253
|
// indistinguishable from one that never engaged, for a whole suite (issue #77). An older
|
|
@@ -1974,7 +2265,8 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1974
2265
|
// healthy and keep grinding. One dump is the cheap call that actually proves it.
|
|
1975
2266
|
preflight: async () => {
|
|
1976
2267
|
await (0, remote_1.pingServer)(opts);
|
|
1977
|
-
|
|
2268
|
+
movedDuringCall = undefined;
|
|
2269
|
+
await retryAfterDeviceMove(() => remote.getElements(), () => movedDuringCall !== undefined);
|
|
1978
2270
|
},
|
|
1979
2271
|
// Hierarchy only: the server exposes no screenshot route, so a remote run's
|
|
1980
2272
|
// engine failure archives without a picture. Honest degrade over a protocol
|
|
@@ -1991,6 +2283,7 @@ async function resolveBackend(platform, device, flags) {
|
|
|
1991
2283
|
platform: health.platform,
|
|
1992
2284
|
device: health.serial ?? undefined,
|
|
1993
2285
|
remote: { url: server, version: health.version, reads: health.reads },
|
|
2286
|
+
moves,
|
|
1994
2287
|
};
|
|
1995
2288
|
}
|
|
1996
2289
|
/**
|
|
@@ -2230,6 +2523,7 @@ async function cmdAi(positionals, flags) {
|
|
|
2230
2523
|
// otherwise an unattended run leaves the phone offline or in dark mode.
|
|
2231
2524
|
await restoreDeviceOverrides(backend);
|
|
2232
2525
|
await backend.close?.(); // frees a remote server's device lock for the next command
|
|
2526
|
+
parkPreparedDevices(platform); // a prepped device goes back to sleep between runs
|
|
2233
2527
|
(0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
|
|
2234
2528
|
}
|
|
2235
2529
|
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
@@ -2268,7 +2562,7 @@ async function cmdInstall(positionals, flags) {
|
|
|
2268
2562
|
if (!(0, node_fs_1.existsSync)(path))
|
|
2269
2563
|
throw new errors_1.CliError(`install: '${appPath}' does not exist`, 2);
|
|
2270
2564
|
const platform = platformFromFlags(flags);
|
|
2271
|
-
const { backend, remote } = await resolveBackend(platform, deviceFromFlags(flags, platform), flags);
|
|
2565
|
+
const { backend, remote, moves } = await resolveBackend(platform, deviceFromFlags(flags, platform), flags);
|
|
2272
2566
|
(0, output_1.err)(`[verikun] installing ${appPath}${remote ? ` via ${remote.url}` : ''}…`);
|
|
2273
2567
|
try {
|
|
2274
2568
|
await backend.install(path);
|
|
@@ -2276,10 +2570,16 @@ async function cmdInstall(positionals, flags) {
|
|
|
2276
2570
|
finally {
|
|
2277
2571
|
await backend.close?.();
|
|
2278
2572
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2573
|
+
// Where it LANDED, not just that it landed: after a failover that is a different
|
|
2574
|
+
// device than the one the run started against, and a caller acting on the old serial
|
|
2575
|
+
// (`adb -s … shell am start`) would be driving a phone without the build.
|
|
2576
|
+
const moved = moves.length ? moves[moves.length - 1] : undefined;
|
|
2577
|
+
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
2578
|
+
(0, output_1.json)({ installed: appPath, ...(remote ? { server: remote.url } : {}), ...(moved ? { deviceChanged: moved } : {}) });
|
|
2579
|
+
}
|
|
2580
|
+
else {
|
|
2581
|
+
(0, output_1.out)(`installed ${appPath}${moved ? ` on ${moved.to}` : ''}`);
|
|
2582
|
+
}
|
|
2283
2583
|
return 0;
|
|
2284
2584
|
}
|
|
2285
2585
|
// ---------------------------------------------------------------------------
|
|
@@ -2318,6 +2618,7 @@ async function cmdSuiteEntry(positionals, flags) {
|
|
|
2318
2618
|
finally {
|
|
2319
2619
|
await restoreDeviceOverrides(backend);
|
|
2320
2620
|
await backend.close?.();
|
|
2621
|
+
parkPreparedDevices(platform);
|
|
2321
2622
|
(0, claims_1.releaseOwnClaims)();
|
|
2322
2623
|
}
|
|
2323
2624
|
}
|
|
@@ -2665,6 +2966,13 @@ DEVICE STATE (change the device the app runs on, then put it back)
|
|
|
2665
2966
|
device reset [key ...] Restore what this run changed. batch/ai/suite also
|
|
2666
2967
|
do this automatically when the flow ends OR fails,
|
|
2667
2968
|
so a dead test can't leave the phone offline.
|
|
2969
|
+
device prep [--dry-run] [--json] Prepare a TEST device once, stickily: animations off,
|
|
2970
|
+
display kept awake, Do Not Disturb on, battery idle off.
|
|
2971
|
+
Survives the run (unlike \`device set\`), so it is undone
|
|
2972
|
+
only by \`--revert\`. A PHYSICAL device must be named with
|
|
2973
|
+
--device — prep must never land on a personal phone.
|
|
2974
|
+
--no-sleep-when-idle keeps the screen on after a run.
|
|
2975
|
+
device prep --revert [--dry-run] Put a prepared device back the way it was found
|
|
2668
2976
|
device caps [--json] What this platform supports, and the manual
|
|
2669
2977
|
equivalent where it doesn't
|
|
2670
2978
|
Refuses \`airplane=on\` over wireless adb (it would cut
|
|
@@ -2720,7 +3028,7 @@ SUITE (run a directory of natural-language tests as one gated suite)
|
|
|
2720
3028
|
|
|
2721
3029
|
SERVER (expose a locally-connected device to remote verikun clients)
|
|
2722
3030
|
server [--bind addr] [--port n] [--auth-key k] [--allow-install]
|
|
2723
|
-
[--allow-device-control[=names]]
|
|
3031
|
+
[--allow-device-control[=names]] [--allow-failover[=serials]|--no-failover]
|
|
2724
3032
|
[--allow-unsafe-anonymous] Serve THIS machine's device over HTTP+JSON for
|
|
2725
3033
|
\`vk ai/suite/install --server <url>\`. Only
|
|
2726
3034
|
verikun's validated action grammar is runnable
|
|
@@ -2738,6 +3046,12 @@ SERVER (expose a locally-connected device to remote verikun clients)
|
|
|
2738
3046
|
the flag the server also starts even when no device is attached, so a client can
|
|
2739
3047
|
boot one: \`vk devices start|stop|restart [name] --server <url>\`, or add
|
|
2740
3048
|
--ensure-device[=name] to ai/suite/install to boot once before the first step.
|
|
3049
|
+
Failover is ON by default: if the bound device cannot serve a request, the server
|
|
3050
|
+
moves to another attached, healthy, unclaimed one and rules the bad one out until it
|
|
3051
|
+
is power-cycled. An install is retried there; a mid-run step is NOT — it fails on the
|
|
3052
|
+
device it ran on, and the next request lands on the healthy one. Passing --device
|
|
3053
|
+
pins the binding and turns this off; --allow-failover[=serials] turns it back on (and
|
|
3054
|
+
bounds where it may go), --no-failover / VERIKUN_NO_FAILOVER disables it outright.
|
|
2741
3055
|
|
|
2742
3056
|
ENVIRONMENT
|
|
2743
3057
|
devices [--all] [--json] List attached devices/simulators, and which job is
|
package/dist/device/claims.js
CHANGED
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
// Platform-agnostic by design, like `device/settings.ts` and `ui/`: it never touches
|
|
32
32
|
// adb/xcrun. The drivers know which devices exist; this knows which are taken.
|
|
33
33
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
34
|
+
exports.ownClaimedSerials = ownClaimedSerials;
|
|
34
35
|
exports.releaseOwnClaims = releaseOwnClaims;
|
|
35
36
|
exports.setProcessScoped = setProcessScoped;
|
|
36
37
|
exports.claimsEnabled = claimsEnabled;
|
|
37
38
|
exports.claimTtlMs = claimTtlMs;
|
|
38
39
|
exports.claimsDir = claimsDir;
|
|
40
|
+
exports.deviceFileStem = deviceFileStem;
|
|
39
41
|
exports.isMine = isMine;
|
|
40
42
|
exports.isLive = isLive;
|
|
41
43
|
exports.readClaim = readClaim;
|
|
@@ -71,6 +73,11 @@ let processScoped = false;
|
|
|
71
73
|
* store what it took is the only way it can release anything.
|
|
72
74
|
*/
|
|
73
75
|
const acquired = new Set();
|
|
76
|
+
/** Which devices this process took. Read by the prep teardown, which has to act on them
|
|
77
|
+
* BEFORE `releaseOwnClaims` empties this — see `parkPreparedDevices` in cli.ts. */
|
|
78
|
+
function ownClaimedSerials() {
|
|
79
|
+
return [...acquired];
|
|
80
|
+
}
|
|
74
81
|
/** Give back every device this process claimed. Best-effort; teardown must never throw. */
|
|
75
82
|
function releaseOwnClaims(o = {}) {
|
|
76
83
|
const released = [];
|
|
@@ -116,9 +123,12 @@ function claimsDir(o = {}) {
|
|
|
116
123
|
* two serials that sanitize alike (`192.168.1.5:5555` vs `192.168.1.5_5555`) can never
|
|
117
124
|
* collide onto one file and silently share a claim.
|
|
118
125
|
*/
|
|
119
|
-
function
|
|
126
|
+
function deviceFileStem(serial) {
|
|
120
127
|
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)}
|
|
128
|
+
return `${safe}-${(0, node_crypto_1.createHash)('sha1').update(serial).digest('hex').slice(0, 8)}`;
|
|
129
|
+
}
|
|
130
|
+
function fileFor(serial) {
|
|
131
|
+
return `${deviceFileStem(serial)}.json`;
|
|
122
132
|
}
|
|
123
133
|
function pathFor(serial, o) {
|
|
124
134
|
return (0, node_path_1.join)(claimsDir(o), fileFor(serial));
|