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/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
- for (const d of devices) {
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
- (0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}${claim ? ` [${claim.by}]` : ''}`);
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
- try {
685
- const serial = target;
686
- const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
687
- const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
688
- const vals = keys.map(get);
689
- const off = vals.every((v) => v === '0' || v === '0.0');
690
- (0, output_1.out)(`animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun doctor --fix`)'}`);
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 ...] | device caps | device release [serial]\n' +
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), the same graceful
1697
- * degradation as before: a cached plan can still replay for free without any provider.
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
- await remote.getElements();
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
- if ((0, args_1.flagBool)(flags, 'json'))
2280
- (0, output_1.json)({ installed: appPath, ...(remote ? { server: remote.url } : {}) });
2281
- else
2282
- (0, output_1.out)(`installed ${appPath}`);
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
@@ -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 fileFor(serial) {
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)}.json`;
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));