verikun 0.30.0 → 0.32.0-rc.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.
@@ -93,7 +93,9 @@ usually don't need a `wait` before an action — `vk tap @next` already polls fo
93
93
  `@next` to appear.
94
94
 
95
95
  - `vk tap <selector|index>` · `vk tap --at x,y`
96
- - `vk text <selector> "the text" [--clear] [--enter]` — focus the field, then type
96
+ - `vk text <selector> "the text" [--clear] [--enter]` — focus the field, then type. On
97
+ Android it reads the field back and exits `1` if the value did not land (after one
98
+ retype); a field that reformats or rejects input needs `vk tap` + `vk type` instead
97
99
  - `vk type "text" [--enter]` — type into the already-focused field
98
100
  - `vk swipe up|down|left|right [--on <selector>] [--distance f] [--duration ms]`
99
101
  - `vk swipe --from x,y --to x,y [--duration ms]`
@@ -489,7 +491,7 @@ vk suite tests/ --app com.example.app --servers http://a:8391,http://b:8391
489
491
  `totals.wallClockMs` (how long the gate took) from `totals.durationMs` (device-seconds).
490
492
  - `--concurrency N` caps how many run at once — more devices on one host can thrash it.
491
493
  `--max-suite-cost-usd N` stops the suite once total model spend crosses it (exit `1`).
492
- - A device that breaks retires; its tests move to the others. Exit `3` only when all are gone.
494
+ - Local devices bench and rejoin after 45-second probes; server health owns remote membership. Typed loss reruns free on another device at most twice.
493
495
  - Over `--server`, a lane with no free device waits instead of failing tests, and a test whose
494
496
  device left the pool re-runs without spending a retry. With no device for any lane the suite
495
497
  stops after `VERIKUN_SUITE_DEVICE_WAIT_MIN` (default 10) with exit `3` and a `notRun` list.
@@ -508,8 +510,9 @@ vk install ./app-debug.apk --server "$VERIKUN_SERVER" # server needs --allow-i
508
510
  vk suite tests/ --app com.example.app --server "$VERIKUN_SERVER"
509
511
  ```
510
512
 
511
- A wrong URL/key fails fast with exit 3; `409` means every device is already
512
- leased by another run; `503` means the server has no device attached — boot one
513
+ A wrong URL/key fails fast with exit 3. New clients wait in the server FIFO; a tagged
514
+ `409` means the run lost its device and must restart. `503` after the wait means no device
515
+ became available — boot one
513
516
  (below). To expose a device from THIS machine: `vk server --allow-install`
514
517
  (add `--bind <addr>` to leave loopback; auth key auto-generates if unset).
515
518
 
@@ -530,22 +533,17 @@ repairs always land on the same phone. `vk install --server` then installs on ev
530
533
  `vk devices start|restart|stop --server` is refused (`403`) — a pool has no single device
531
534
  to act on.
532
535
 
533
- **If you see `[verikun] server moved device: A → B` on stderr**, the server left a
534
- device that failed and is now on another one. What that means depends on the line:
535
-
536
- - `— retried there` (installs only): the build DID land, on **B**. Anything you go on
537
- to do with an explicit serial must name B, not A.
538
- - `— this step failed on the old device; the next runs on the new one`: your step
539
- failed on **A**. Do NOT re-run it expecting a different answer for the same reason —
540
- the failure was real on A, and B has none of the state your flow built up. Start the
541
- flow again from the top if you want it on B.
542
-
543
- The server rules the bad device out; `vk devices --server <url>` shows why in its `NOTE`
544
- column, and a pooled server re-adopts a device that comes back within a minute. A device
545
- that is still attached keeps its place and is simply dealt last, so its own error keeps
546
- reaching you rather than a bare "no device attached". A device that is **gone** leaves the
547
- pool — so `capacity` can drop mid-job, and an install can come back `exit 0` having skipped
548
- it. That is a success: nothing can be dealt a device running the previous build.
536
+ Remote peers must support held leases and device supervision; upgrade them together.
537
+ Compilation holds no lease. A heartbeat thread keeps the execution lease alive during
538
+ synchronous model repair. Closing the socket or 30 seconds without bytes ends it and
539
+ restores device settings before the next run. Every server, including capacity one,
540
+ readmits wanted devices with boot/build checks before dealing. `--no-failover` disables
541
+ spare recruitment, not readmission. `VERIKUN_NO_DEVICE_WATCH=1` disables supervision.
542
+
543
+ `vk ai --json` exposes `outcome`; only lost-device gets free reruns. Env failures use normal
544
+ retries. A remote suite checks installedSha between tests and refuses a changed build.
545
+ A single `--server` accepts `--ensure-device`, once in the parent. Install waits for holders,
546
+ returns successful devices plus skipped targets, and keeps stale builds out of dealing.
549
547
 
550
548
  ## The device is missing or wedged
551
549
 
package/CHANGELOG.md CHANGED
@@ -6,6 +6,45 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.32.0-rc.1] - 2026-10-03
10
+
11
+ ### Added
12
+ - **Docs site** links unfurl with a social card image built from the illustration.
13
+
14
+ ### Changed
15
+ - **Server diagrams**: explain device reservations, test reruns and recovery in plain language.
16
+ - **`vk server`**: fork one executor per device; confirmed failures kill its process group, including blocked adb/idb children.
17
+ - **`vk server`**: supervise device health and readmit every pool; typed loss reruns free. Disable supervision with `VERIKUN_NO_DEVICE_WATCH`.
18
+ - **Remote leases**: acquire after compilation; heartbeat holds, FIFO admission, and lease-end setting restoration release abandoned clients.
19
+ - **Remote installs**: gate dealing by build SHA; return after first-success grace while failed targets catch up.
20
+ - **`vk suite`**: use lanes at every remote capacity, bench local failures, and preserve archives with explicit JSON outcomes.
21
+ - **Docs site and README** show the new verikun logo and an animated illustration, with light and
22
+ dark variants.
23
+ - **Docs site** primary color is now black, off-white in dark mode.
24
+
25
+ ### Fixed
26
+ - **Docs site** favicon no longer 404s; it shows the logo.
27
+ - **Android actions**: surface device-shell failures and retry safe transport loss once; failed liveness confirmation opens a circuit breaker.
28
+ - **Remote deadlines**: use native HTTP timers and keep draining executors out of dealing; any nonempty `VERIKUN_NO_ADB_RECYCLE` disables restart.
29
+
30
+ ### Removed
31
+ - **Remote clients and servers**: upgrade both to versions supporting held leases and server-owned device health.
32
+ - **Remote leases**: remove no-hold acquisition, implicit execution leases, five-minute takeover, and support for peers without holds and supervision.
33
+ - **Remote RPC**: remove `evicted` and `deviceChanged` response fields; typed errors identify device loss.
34
+ - **Suite children**: remove verdict inference for JSON without `outcome`; same-build results must supply it.
35
+
36
+ ## [0.31.0] - 2026-09-27
37
+
38
+ ### Fixed
39
+ - **`vk text`** no longer re-taps a field that already has focus, which let SwiftKey drop the whole
40
+ value while `text` exited `0`. ([#151])
41
+
42
+ ### Changed
43
+ - **`vk text` on Android** reads the field back, retypes once, and exits `1` if the value is still
44
+ missing; use `tap` + `type` for fields that reformat input. ([#151])
45
+
46
+ [#151]: https://github.com/ddikman/verikun/issues/151
47
+
9
48
  ## [0.30.0] - 2026-09-26
10
49
 
11
50
  ### Fixed
package/README.md CHANGED
@@ -1,9 +1,23 @@
1
1
  # verikun
2
2
 
3
- > **Agent-driven, natural-language mobile tests — during agent development or in CI.** Self-healing and self-improving, with cost caps and test reports.
3
+ <table>
4
+ <tr>
5
+ <td width="260" align="center" valign="middle">
6
+ <picture>
7
+ <source media="(prefers-color-scheme: dark)" srcset="docs/readme/illustration-dark.svg">
8
+ <img src="docs/readme/illustration-light.svg" alt="verikun" width="230">
9
+ </picture>
10
+ </td>
11
+ <td valign="middle">
12
+
13
+ **Agent-driven, natural-language mobile tests — during agent development or in CI.** Self-healing and self-improving, with cost caps and test reports.
4
14
 
5
15
  **📚 [Documentation](https://ddikman.github.io/verikun/)** — installation, guides, full command reference, and internals.
6
16
 
17
+ </td>
18
+ </tr>
19
+ </table>
20
+
7
21
  - **Agent CLI** — `vk <command>`: one-shot commands to inspect the screen as a semantic tree (or screenshot) and act on it.
8
22
  - **Puppeteer for native mobile** — a thin wrapper over native Android and iOS automation runners with zero runtime dependencies.
9
23
  - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step. Tests share a preamble with `@include`, written once instead of pasted into each. A compile that does not cover its test is rejected rather than cached as a pass. [What that costs](https://ddikman.github.io/verikun/reference/cost/), and how the `--max-cost-usd` ceiling bounds it.
@@ -22,6 +36,9 @@ $ vk tap @sign_in_btn
22
36
  tapped [3] Button "Sign in" @sign_in_btn (540,1020) tap
23
37
  ```
24
38
 
39
+ **Remote compatibility in 1.0:** upgrade the CLI and server together. Remote execution requires
40
+ a streaming held lease and device supervision; pre-hold peers are no longer supported.
41
+
25
42
  ## Install
26
43
 
27
44
  Requires Node ≥ 18 and the Android platform-tools (`adb`) on your `PATH`.
@@ -57,6 +74,10 @@ verikun ships as a skill and plugin, not an MCP server, and that is deliberate.
57
74
 
58
75
  There is also no need for an MCP here: verikun runs locally with all its dependencies, and the agent calls it through the plain `vk` CLI — no shared session, data, or authentication to broker.
59
76
 
77
+ Remote suites acquire after compilation, wait in a server FIFO, and rerun typed device loss
78
+ without spending a retry. Held leases release on client disappearance; recovering devices
79
+ restore settings and verify the retained build before returning to service.
80
+
60
81
  ## Documentation
61
82
 
62
83
  | | |
@@ -8,12 +8,8 @@
8
8
  * of CPU on the error loop. `adb kill-server && adb start-server` took it to zero and every
9
9
  * device came back in ~5s.
10
10
  *
11
- * WHY VERIKUN CARES, rather than leaving this to the operator: adb rot DEFEATS DEVICE
12
- * FAILOVER. `device/failover.ts` moves the server off a device that fails, but every
13
- * candidate sits behind the same host adb — so when the transport is what broke, failover
14
- * walks the pool retiring healthy phones for a host-side fault. That is the same polarity
15
- * error `ARTIFACT_RULES` exists to prevent on the install path: enumerate the thing you can
16
- * actually attribute, and never blame the open-ended side.
11
+ * A host transport failure affects every device behind adb. The server supervises
12
+ * correlated losses together and recycles under a host lock, excluding foreign claims.
17
13
  *
18
14
  * THE RATE IS A LEAKED-HANDLE COUNTER, which is what makes this measurable rather than
19
15
  * guessed. adb scans USB at ~1Hz and each STALE device handle throws one violation per
@@ -125,7 +121,7 @@ function describeRot(health) {
125
121
  * exactly, for the rare host running other adb work alongside the server.
126
122
  */
127
123
  function adbRecycleEnabled(platform) {
128
- return platform === 'android' && process.env.VERIKUN_NO_ADB_RECYCLE !== '1';
124
+ return platform === 'android' && !process.env.VERIKUN_NO_ADB_RECYCLE;
129
125
  }
130
126
  /** The running adb server's pid. Undefined when there is none, or when `pgrep` is absent. */
131
127
  function adbServerPid() {
@@ -31,17 +31,6 @@ class GuardBlindError extends Error {
31
31
  this.name = 'GuardBlindError';
32
32
  }
33
33
  }
34
- /**
35
- * A run the server EVICTED cannot continue: its phone left the pool, and every later call is
36
- * refused the same way. So the catches below that absorb a failed READ — a guard's retry, the
37
- * empty-tree fallback, a repair that could not be made — must let it through. Absorbed, a lost
38
- * phone read as an absent guard, a "read found no element" FAIL or a failed repair, and a
39
- * parallel suite lost the class it needs to re-run the test as a fresh run (#147).
40
- */
41
- const rethrowIfEvicted = (e) => {
42
- if (e instanceof errors_1.RunEvictedError)
43
- throw e;
44
- };
45
34
  const describe = (leaf) => [leaf.command, ...leaf.positionals, ...leaf.flags.map((f) => (f.value === 'true' ? `--${f.name}` : `--${f.name} ${f.value}`))]
46
35
  .join(' ')
47
36
  .trim();
@@ -202,7 +191,7 @@ async function runPlan(plan, deps) {
202
191
  return await deps.getElements();
203
192
  }
204
193
  catch (e) {
205
- rethrowIfEvicted(e);
194
+ (0, errors_1.rethrowIfLost)(e);
206
195
  return [];
207
196
  }
208
197
  };
@@ -259,6 +248,7 @@ async function runPlan(plan, deps) {
259
248
  // is a bad read of a live screen, not a blind one, and it is already handled below.
260
249
  let everRead = false;
261
250
  let lastErr;
251
+ let lostSince;
262
252
  const minLooks = settleMs > 0 ? 2 : 1;
263
253
  for (;;) {
264
254
  let els;
@@ -266,11 +256,18 @@ async function runPlan(plan, deps) {
266
256
  try {
267
257
  els = await deps.getElements();
268
258
  everRead = true;
259
+ lastErr = undefined;
260
+ lostSince = undefined;
269
261
  }
270
262
  catch (e) {
271
- rethrowIfEvicted(e);
263
+ if (!(e instanceof errors_1.DeviceGoneError))
264
+ (0, errors_1.rethrowIfLost)(e);
272
265
  els = undefined; // transient dump failure — retry once before concluding "absent"
273
266
  lastErr = e;
267
+ if (e instanceof errors_1.DeviceGoneError)
268
+ lostSince ??= Date.now();
269
+ else
270
+ lostSince = undefined;
274
271
  }
275
272
  // An EMPTY tree is not a screen, it is a bad read: a live app always has nodes, and
276
273
  // this device routinely returns a partial/blank dump mid-transition (measured: `ui`
@@ -296,7 +293,7 @@ async function runPlan(plan, deps) {
296
293
  // One successful read — even an empty tree — and the ordinary semantics resume exactly:
297
294
  // settleMs=0 is still a single-shot probe. It must never make a merely ABSENT selector
298
295
  // more patient, or every guard silently costs 10s.
299
- if (!everRead && lastErr instanceof errors_1.TransientReadError && Date.now() < transientDeadline) {
296
+ if (((!everRead && lastErr instanceof errors_1.TransientReadError) || (lastErr instanceof errors_1.DeviceGoneError && Date.now() - (lostSince ?? Date.now()) < 8000)) && Date.now() < transientDeadline) {
300
297
  await (0, wait_1.sleep)(GUARD_POLL_MS);
301
298
  continue;
302
299
  }
@@ -306,6 +303,8 @@ async function runPlan(plan, deps) {
306
303
  //
307
304
  // A no-window that outlives its grace lands here too, and still aborts: at that point
308
305
  // the app really is gone, and reporting "absent" would be the same false green.
306
+ if ((0, errors_1.isDeviceLoss)(lastErr))
307
+ throw lastErr;
309
308
  if (!everRead && (0, errors_1.isEnvError)(lastErr))
310
309
  throw new GuardBlindError(selector, lastErr);
311
310
  return false;
@@ -379,7 +378,7 @@ async function runPlan(plan, deps) {
379
378
  repaired = node;
380
379
  }
381
380
  catch (e) {
382
- rethrowIfEvicted(e);
381
+ (0, errors_1.rethrowIfLost)(e);
383
382
  const msg = e instanceof Error ? e.message : String(e);
384
383
  return { status: 'fail', where, reason: `repair failed: ${msg}` };
385
384
  }
@@ -404,6 +403,8 @@ async function runPlan(plan, deps) {
404
403
  // sets status=passed/exitCode=0 and drops the failure evidence) so the report and
405
404
  // JUnit stay consistent with the green run, then continue. Scoped to screenshot/
406
405
  // shot — every other command's failure stays terminal.
406
+ if ((0, errors_1.isDeviceLoss)(outcome.error) || outcome.error instanceof errors_1.RunEvictedError)
407
+ (0, errors_1.rethrowIfLost)(outcome.error);
407
408
  if (isScreenshotLeaf(current)) {
408
409
  const why = outcome.error ? outcome.error.message.split('\n')[0] : `exited ${outcome.code}`;
409
410
  deps.log(`[ai] ${where}: screenshot capture failed (${why}) — continuing (best-effort review screenshot)`);
@@ -21,7 +21,10 @@ Each step is one of three node types:
21
21
  --no-restart skips the force-stop, just bringing it forward)
22
22
  stop <package> — force-stop the app
23
23
  tap <selector> — tap the element a selector resolves to (scrolls it into view first)
24
- text <selector> <value...> — focus a field and type value (--clear to clear first, --enter to submit)
24
+ text <selector> <value...> — focus a field and type value (--clear to clear first, --enter to submit);
25
+ FAILS if the field does not then hold the value. For a field that
26
+ reformats or rejects input (masks, currency, invalid-input tests),
27
+ use tap <selector> then type <value...> instead
25
28
  type <value...> — type into the already-focused field
26
29
  key <name> | back | home | enter
27
30
  swipe <up|down|left|right> [--on <selector>] — scroll/swipe (up = scroll down the page)
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const node_worker_threads_1 = require("node:worker_threads");
4
+ const node_http_1 = require("node:http");
5
+ const node_https_1 = require("node:https");
6
+ // Own the socket AND the timer: synchronous model repair on the main thread cannot
7
+ // starve client-originated heartbeat bytes. Closing the process closes this hold too.
8
+ const { url, headers, timeoutMs } = node_worker_threads_1.workerData;
9
+ const endpoint = new URL(url);
10
+ const send = endpoint.protocol === 'https:' ? node_https_1.request : node_http_1.request;
11
+ let leased = false;
12
+ let closing = false;
13
+ const req = send(endpoint, { method: 'POST', headers }, res => {
14
+ let data = '';
15
+ res.setEncoding('utf8');
16
+ res.on('data', (chunk) => {
17
+ data += chunk;
18
+ if (leased || !data.includes('\n'))
19
+ return;
20
+ if (res.statusCode === 200) {
21
+ try {
22
+ node_worker_threads_1.parentPort?.postMessage({ kind: 'leased', body: JSON.parse(data.split('\n')[0]) });
23
+ leased = true;
24
+ }
25
+ catch {
26
+ node_worker_threads_1.parentPort?.postMessage({ kind: 'error', message: 'invalid lease response' });
27
+ req.destroy();
28
+ }
29
+ }
30
+ });
31
+ res.on('end', () => {
32
+ if (!leased) {
33
+ let body;
34
+ try {
35
+ body = JSON.parse(data);
36
+ }
37
+ catch {
38
+ body = { error: data };
39
+ }
40
+ node_worker_threads_1.parentPort?.postMessage({ kind: 'status', status: res.statusCode, body });
41
+ }
42
+ else if (!closing)
43
+ node_worker_threads_1.parentPort?.postMessage({ kind: 'ended' });
44
+ stop();
45
+ });
46
+ res.on('error', e => { if (!closing)
47
+ node_worker_threads_1.parentPort?.postMessage({ kind: 'error', message: e.message }); stop(); });
48
+ });
49
+ const heartbeat = setInterval(() => { if (!req.destroyed)
50
+ req.write('.'); }, 10_000);
51
+ const timeout = setTimeout(() => {
52
+ if (!leased)
53
+ req.destroy(new Error('lease acquisition timed out'));
54
+ }, timeoutMs);
55
+ function stop() { clearInterval(heartbeat); clearTimeout(timeout); req.destroy(); node_worker_threads_1.parentPort?.close(); }
56
+ req.on('error', e => { if (!closing)
57
+ node_worker_threads_1.parentPort?.postMessage({ kind: 'error', message: e.message }); stop(); });
58
+ node_worker_threads_1.parentPort?.on('message', m => { if (m === 'close') {
59
+ closing = true;
60
+ stop();
61
+ } });
62
+ req.flushHeaders();
63
+ req.write('.');