78 Commits

Author SHA1 Message Date
Michał Pierzchała 32ba4b67f4 chore: add FreeRange range-analysis check (#1354)
* chore: add freerange check

* ci: install bun for freerange check

* fix: preserve numeric range contracts

* fix: guard diff overlay geometry

* refactor: isolate diff overlay bounds
2026-07-21 11:49:33 +02:00
Michał Pierzchała ef118b9d11 ci(test-app): fingerprint-keyed build cache — disk locally, Release artifacts in CI (#1321)
Splits the test app's build caching by context instead of running one remote
cache for both.

Locally, `expo run:*` caches the native build on disk via the
expo-build-disk-cache provider, keyed by the Expo fingerprint. A second run with
no native change reuses the first build; a screen edit never rebuilds, because
Metro serves JS. This is the original ask — "next time we don't build unless
native changes" — and needs no token, no network, and no custom provider.

In CI, test-app-build-cache.yml builds a Release binary per platform when the
fingerprint has no artifact yet, and publishes it as a GitHub Actions artifact
named `fingerprint.<hash>.<platform>`. Release, not dev-client, so the JS bundle
is embedded and a consuming job needs no Metro. setup-fixture-app installs it by
downloading the artifact and refreshing the JS with @expo/repack-app, so keying
on the native-only fingerprint stays correct — a JS-only change reuses the same
native binary in seconds. It falls back to an inline build when no artifact
exists yet, so a caller is never left without an app.

Release removes the sharp edges the dev-client cache needed. Its simulator .app
is universal (x86_64+arm64) rather than the active-arch-only slice a debug build
emits, so no architecture tag. It links against the SDK but loading is gated by
the deployment target, which the fingerprint already covers, so no toolchain
tag. And the CLI only narrows *debug* builds to the device ABI, so a Release APK
spans every ABI without the undocumented --all-arch flag. The artifact name
collapses to fingerprint plus platform.

This deletes build-cache-provider.js entirely — with it goes the custom Expo
provider that had to reach GitHub from inside @expo/cli, and every workaround
that forced: the fetch-nodeshim User-Agent shim, the arch/Xcode identity, the
upload-intent handoff. CI now talks to the artifacts API with plain `gh api`
outside the patched fetch, and locally the disk cache never hits the network.

The fingerprint comes from @expo/fingerprint's own `fingerprint:generate` (no
--platform, matching what @expo/cli hashes). Gitignoring /ios and /android is
what makes it machine-independent: the library asks the VCS whether the platform
markers are ignored and, concluding CNG, skips hashing them — so a developer's
prebuild output and a fresh CI checkout agree.

conformance-differential consumes setup-fixture-app, so it gains
`permissions: actions: read` for the artifact lookup.

The artifact lookup is non-fatal: a query outage leaves the id empty and
falls through to an inline build like a miss does, rather than exiting the
composite under set -e and turning a cache blip into a caller failure.
test/scripts/setup-fixture-app-fallback-smoke.sh drives that step's real shell
against a failing gh and asserts source=build; ci.yml runs it.
2026-07-18 09:36:32 +02:00
Michał Pierzchała 856d5d4900 test: replace the hand-typed Maestro fixture with a generated conformance oracle (#1289)
* test: replace the hand-typed Maestro fixture with a generated conformance oracle

Closes #1274.

The old harness (scripts/maestro-conformance*) compared 5 hand-authored flows
against a hand-typed transcription of Maestro 2.5.1's command model. It proved
parser self-consistency, not conformance: all four bug classes that cost #1217
days of live debugging slipped past it by construction, and it verified no
upstream SHAs despite parsing them.

Every expected value here is generated from the pinned upstream artifacts.
dev.mobile:maestro-orchestra:2.5.1 is published on Maven Central, so the harness
runs the real parser and reads the real bytecode — no full Maestro source build.

Layer 1 (parser): a Gradle/Kotlin harness drives the pinned YamlCommandReader
over a corpus of 42 vendored maestro-test flows (sha256-recorded) plus authored
bug-class, coverage, and invalid flows, capturing each parse. The verifier parses
each flow with the live engine and classifies it identical / both-reject /
we-reject / mismatch / we-are-lenient. Every non-identical outcome must be a
declared divergence, so the 17 we-reject entries in expected-divergence.ts are
the mechanical parity backlog (assertTrue, clipboard, travel, killApp, and
option-level gaps) rather than silent drift.

Layer 2 (semantics): ASM reads static-final constants straight from the pinned
bytecode without initializing driver classes (MAX_RETRIES_ALLOWED=3,
SCREENSHOT_DIFF_THRESHOLD=0.005, ANIMATION_TIMEOUT_MS=15000, erase cap, and the
iOS pre-tap gate we intentionally omit), plus the parser-observed 400ms swipe
default. Each is cross-checked against MAESTRO_COMPATIBILITY_PRESETS.

Layer 3 (differential): scheduled device scenarios. Cross-engine comparison is
outcome parity only and says so; finer behavior is asserted engine-side via
invariants over replay-timing.ndjson. Bug class 4's detector — a tap must not
consume the whole settle budget, since a full-budget tap means the stability loop
never latched while the flow still passes — is pure and unit-tested against
synthetic traces; only the device run is scheduled-only.

regenerate.mjs verifies the pinned jar SHA-256s before trusting output and is
byte-deterministic across runs. Layers 1-2 verify in normal CI via node --test
with no Java (the job installs deps: unlike the layering guard it copies, the
verifier parses with the live engine, which imports the `yaml` package).

Acceptance: the four bug classes each have a fixture; every command in
SUPPORTED_MAESTRO_COMMAND_NAMES (the parser's own dispatch table, now exported as
the single source of truth) is corpus-covered or listed unverified; the five
documented deviations are expected-divergence entries.

* fix: address review findings on the conformance oracle

P1 — layer-3 scenarios could never run. They pointed at layer-1 corpus flows,
which exist only to be PARSED: they name a fictional com.example.app and elements
that exist on no device. A device run would have failed before exercising any
runtime behavior, making bug class 4's detector silently vacuous. Layer 3 now has
its own flows under differential/flows/ driving the real fixture app
(examples/test-app, com.callstack.agentdevicelab); the workflow builds and
installs it and hard-fails if it is missing. A test enforces the separation so a
scenario can never point back at the parse corpus.

Nothing else in this repo builds or installs the Expo fixture app, so those steps
are new and unproven. The workflow is therefore dispatch-only: the cron is removed
until a supervised first run proves the path. A nightly job that fails at 05:00
every day teaches nothing.

P2 — layer 3 installed whatever version the online installer served. It now pins
MAESTRO_VERSION from pinned-upstream.json, so layer 3 cannot drift from the
version layers 1-2 claim, and asserts `maestro --version` matches.

P2 — fixture content was not bound to regeneration. CI compared only the embedded
upstream metadata, so a hand edit to a captured command or constant passed: the
transcription failure mode this oracle exists to remove. Two-layer fix, because
per-PR CI must stay Java-free and cannot re-derive:
  - Each fixture now carries a contentHash seal that the verifier recomputes, so
    editing a capture breaks the build. Tamper-evident, and tested by actually
    tampering rather than assuming a hash comparison works.
  - New scheduled conformance-regenerate job re-runs the harness against the
    pinned jars and fails on any byte difference. Forgery cannot survive a real
    re-derivation. This is what makes "generated from upstream" enforced.

P3 — boot-ios-test-simulator requires runtime-version; now passed alongside
preferred-device-name, as the other iOS workflows do.

* tmp: trigger layer-3 differential on this branch to prove the device path

workflow_dispatch cannot run pre-merge (it registers from the default branch), so
this temporary push trigger exists only to execute the never-run device path on
the PR head and capture evidence. Removed before merge.

* fix(ci): install the fixture app unfrozen for the layer-3 device run

First live run of the device path failed at the very first step:
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. CI implies --frozen-lockfile and the fixture
app's lockfile is out of sync with its package.json overrides. No CI job has ever
built examples/test-app, so that drift was never surfaced.

* fix: drop --ignore-workspace from test-app:install (defeats #649 security overrides)

The first live run of the layer-3 device path failed at
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH, and the cause is a real latent bug rather
than a stale lockfile.

#649 moved the fixture app's `overrides` into examples/test-app/pnpm-workspace.yaml
precisely because pnpm only honors overrides from a workspace root — they pin
transitive deps (ws, brace-expansion, xmldom, postcss, uuid, shell-quote) to
versions that clear Dependabot alerts. But `test-app:install` passes
--ignore-workspace, which ignores that very file, so the overrides are dropped
and no longer match the lockfile that has them baked in. It goes unnoticed
locally because interactive installs are not frozen, and no CI job has ever
installed this app.

Dropping --ignore-workspace makes examples/test-app resolve as its own workspace
root (it has its own pnpm-workspace.yaml and is not a member of the repo-root
workspace), so the overrides apply and a frozen install succeeds. Verified both
directions locally: with the flag + --frozen-lockfile reproduces the CI failure;
without it, a frozen install completes and the lockfile's overrides stay intact.

Note the workaround this replaces would have been actively harmful: installing
with --no-frozen-lockfile resolves the mismatch by regenerating the lockfile
WITHOUT the overrides, silently reverting the app to the vulnerable transitive
versions #649 pinned away.

* fix: make layer-3 scenarios prove what they claim, and parse the Maestro version

Run 3 (29497919702) got the whole device path working: Expo build (30m), app
installed, simctl check, pinned Maestro CLI install. Only the version ASSERTION
failed — `maestro --version` prints an analytics banner before the version, and
`tr -d '[:space:]'` mashed banner+version into one string. The CLI was correctly
2.5.1. Match the semver line instead, and set MAESTRO_CLI_NO_ANALYTICS (CI should
not phone home). Verified the parse against the exact CI output: banner and clean
forms both yield 2.5.1, wrong/empty still fail.

tap-retry-if-no-change was vacuous: it tapped a navigating control, so the first
tap always succeeded and retryIfNoChange never ran — it passed while proving
nothing. It now taps the app's non-interactive title so the screen cannot change
and the retry path is forced, and asserts tapRetries >= 1 from the trace
(MaestroRuntimeMetrics already records it per step). A new metricAtLeast invariant
kind carries the assertion; a test reproduces the old vacuity.

percent-swipe no longer claims bug class 1. Truncation vs rounding is a <=1px
delta that no app-observable device outcome can distinguish, so pass/pass could
never back that claim up. The runtime half is instead pinned exactly by a pure
unit test of resolveMaestroCoordinate (it short-circuits on a known viewport, so
no device is needed) — verified to catch the regression by flipping trunc->round,
which turns 3 of 6 tests red. Truncation had no test coverage at all before this.
A test now forbids any device scenario from re-claiming bug class 1.

* fix(ci): pass --maestro and match the fixture app's real UI in layer-3 flows

Run 4 (29500262301) reached the differential itself — build, install, simctl
check and the pinned Maestro 2.5.1 verification all passed — and surfaced two
real bugs, both mine:

1. The runner invoked `agent-device test <flow>` without --maestro, so every
   scenario failed with "test does not support this file type". The repo's own
   scripts/run-test-app-maestro-suite.mjs passes it; the flag is what routes a
   .yaml through the Maestro compat engine.

2. settle-after-tap and percent-swipe assumed home-open-form is on screen at
   launch. It is not: real Maestro reported "Element not found: home-open-form",
   and the app's own helper flow scrolls it into view first. settle-after-tap now
   scrolls before tapping, mirroring that helper; percent-swipe no longer
   navigates at all and swipes the scrollable home screen, so it tests the
   conversion and nothing else.

The remaining two flows already reported maestro=pass, so only the agent-device
invocation was wrong for those. Note the settle invariant correctly reported
"no-data: no completed tapOn steps" and FAILED rather than passing — a detector
that cannot run is a failure, as intended.

* feat: declare layer-3 divergences and schedule the differential

Layer 3 ran both engines for the first time (29504440599) and immediately found a
real engine bug. Blocking the measurement instrument on repairing what it just
measured inverts the dependency, so layer 3 now gets the contract layer 1 already
had: every divergence is a decision on the record.

Adds `knownDivergence: { reason, tracking }` to the scenario type — the layer-3
twin of FLOW_DIVERGENCES. A declared divergence keeps the run green; only
UNDECLARED ones fail. Two rules stop that from rotting, both enforced
mechanically rather than by prose discipline:

  - `tracking` is required and must be a real issue URL (run.test.ts), because a
    declaration with nothing behind it is how "temporarily expected" becomes
    permanent without anyone deciding to.
  - a stale declaration FAILS: if a declared-divergent scenario starts passing,
    the run goes red until the declaration is removed. The fix PR must delete it,
    and the differential then enforces the gap stays closed — the oracle is the
    acceptance test for its own findings.

Declared:
  - settle-after-tap  -> #1299. Our scrollUntilVisible times out finding
    home-open-form where Maestro 2.5.1 scrolls to it and passes. Real engine
    correctness bug in an advertised command, found by this differential. Blocks
    bug class 4's device detector until fixed.
  - tap-retry-if-no-change -> #1300. The invariant caught the scenario being
    vacuous: both engines pass but tapRetries was 0, so retryIfNoChange never
    ran. Needs an inert fixture control; a scenario defect, not an engine one.

Proven green on both engines and enforced now: percent-swipe,
optional-warned-not-failed — the latter is real device-verified warned-vs-failed
parity.

With declarations in place the differential is green, so the schedule goes in
(cron 05:00) per #1274. A green run still prints what it is not proving.

* fix: park the flaky retry scenario instead of declaring it a divergence

Run 29510020718 fired the stale-declaration guard on its first outing and caught
my own mistake. tap-retry-if-no-change measured tapRetries=0 in run
29504440599 and tapRetries=1 in 29510020718 — same flow, same commit. So it is
not vacuous as #1300 originally claimed: it is NON-DETERMINISTIC. The tap
sometimes holds the hierarchy signature still and sometimes does not, because the
fixture home screen carries live content.

That exposes a real limit of the mechanism added in the previous commit:
knownDivergence assumes the divergence REPRODUCES. A declared-but-flaky scenario
flips between known-divergence (green) and stale-declaration (red) at random — a
coin-flip scheduled job, which is worse than no scenario because it teaches
people to ignore the differential.

So the scenario is parked, not declared. The flow and the tapRetries invariant
stay implemented and unit-tested, so the fix PR only re-adds the scenario once
the fixture has an inert control. retryIfNoChange therefore has NO device
coverage right now — tracked in #1300 and stated plainly rather than disguised by
a green run. A test keeps it out of the active set until then.

#1300 updated with the corrected diagnosis and both runs' evidence.

Active differential: settle-after-tap (declared divergence, #1299), percent-swipe
and optional-warned-not-failed (both enforced, pass/pass on real devices).

* fix: make a knownDivergence waiver cover exactly one failure, not any failure

P1 from re-review, and a real flaw: the code did not do what its own comment
claimed. runScenario() collapsed every unexpected outcome and every invariant
failure into `misbehaved`, then turned ANY of them green if the scenario carried
a declaration. So while the #1299 scrollUntilVisible waiver is open, upstream
Maestro could start failing too — or a different invariant could break — and the
scheduled job would still report known-divergence and pass. A waiver for one bug
was silently amnesty for the next. That is the exact failure this oracle exists
to prevent, committed one commit after building the guard against it.

knownDivergence now requires an `expected` signature: both engines' outcomes plus
each declared invariant's status. The runner matches it exactly —
  - matches            -> known-divergence (green, tracked)
  - misbehaves differently -> failed (red): not the failure the waiver covers
  - stops misbehaving  -> stale-declaration (red): remove the declaration
#1299's signature pins what runs 29504440599/29510020718 actually observed:
maestro=pass, agent-device=fail, settle invariant no-data.

Tests prove unrelated failures stay red under an open waiver: upstream also
failing, our engine unexpectedly passing, a different invariant status, and a new
invariant appearing are each NOT covered. A signature where both engines pass is
rejected outright as describing no divergence.

Also retains replay-timing.ndjson as a run artifact (review evidence note): the
invariants are computed from that trace, so a report saying "tapRetries was 0"
cannot be audited once the runner is gone without it.

* perf(ci): cache the fixture app build for the layer-3 differential

The differential job took ~30 minutes, of which 1331s (22 min, 79%) was building
the Expo fixture app and only 347s was the differential itself — rebuilt from
scratch on every run for an app that changes almost never.

Cache the built .app, keyed on everything that can change the binary: the app's
sources, native config, dependency graph, the build step itself, the iOS runtime,
and the Xcode version. Mirrors the existing setup-apple-replay prebuilt-runner
cache (same action pin, same Xcode-key + source-hash shape).

On a hit the build is skipped entirely and the bundle is installed straight onto
the booted simulator (~seconds), taking the job to roughly 8 minutes. On a miss
it falls back to exactly the previous behaviour and repopulates, so the worst
case is unchanged. The existing simctl verification still gates both paths, so a
bad cache cannot produce a vacuous green: if the app is not installed, the job
fails loudly rather than running scenarios against nothing.

Note the first run after this lands is necessarily a miss.

* refactor(ci): extract setup-fixture-app so any job can use the cached app

The fixture-app build + cache was inline in the differential workflow, so nothing
else could reach it. Extracted to a composite action mirroring
setup-apple-replay, because the capability is what #320 has been missing: it
wants replay coverage moved off Apple system apps onto a controlled fixture with
stable ids, and that fixture (examples/test-app) already exists — CI just had no
way to build and install it.

The cache is genuinely shared. GitHub caches are per-repository and readable
across workflows, and a run restores from its own branch or the default branch,
so once a run on main populates it every workflow gets the hit and only the first
one pays the ~22 minutes. The key is computed inside the action from a fixed
input list and deliberately contains nothing caller-specific — folding a caller's
workflow path into it would silently unshare the cache.

Also removes a duplication risk: the action reads the bundle id from the built
app's Info.plist rather than hardcoding it, so it cannot drift from what was
actually built, and it fails loudly if the app is not installed. The conformance
workflow keeps its own narrower assertion — that the installed id is the one its
scenarios target — since that is its concern, not the action's.

Usage:
  - uses: ./.github/actions/setup-fixture-app
    with:
      runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
  # outputs: app-path, app-id, cache-hit

* chore(ci): remove the temporary branch push trigger

Run 29519848340 on this head executed both engines against the real fixture app
and came back green, so the trigger that existed only to prove the never-run
device path has done its job.

Merged config is now cron (05:00) + workflow_dispatch, as required by #1274.

  known-divergence  settle-after-tap  maestro=pass agent-device=fail (#1299)
  ok                percent-swipe              maestro=pass agent-device=pass
  ok                optional-warned-not-failed maestro=pass agent-device=pass

This commit will not itself trigger a run: GitHub evaluates triggers at the
pushed commit, and the push trigger is gone in it.
2026-07-16 20:22:28 +02:00
Michał Pierzchała 1a1ef7c419 feat(android): one persistent automation helper owning snapshot + viewport + canonical injection (#1281)
* feat(android): consolidate touch injection and gesture viewport into the persistent snapshot helper (#1275)

One Android automation helper now owns snapshot capture, gesture viewport
resolution, and canonical one-/two-pointer plan injection. A live persistent
helper session executes gesture/viewport commands over its socket protocol;
without a session the same APK runs one-shot via am instrument. The separate
one-shot multitouch helper APK is deleted (atomic replacement, no fallback).
Touch scheduling/injection is extracted into focused Java classes
(TouchPlan, TouchPlanInjector, PointerEventSchedule, GestureViewportReader)
instead of growing SnapshotInstrumentation. ADR 0013 amended.

* fix(android): stop a structurally-failed helper session before the one-shot viewport retry

A structured ok=false viewport response leaves the session process alive, and
Android permits only one instrumentation owner of UiAutomation - running the
one-shot fallback against a still-live helper contends with it and masks the
original structured failure. Stop the session first; regression pins that the
one-shot retry only executes once the session is gone.

* refactor(android): extract helper touch dispatch into focused classes; split session tests; document helper API v2 (PR #1281 review)

Addresses findings 2 and 3 from PR #1281 review (finding 1, viewport
session-stop ordering, was already fixed in 5961b9247).

- Extract SnapshotInstrumentation.java's one-shot/session touch dispatch
  into TouchCommandHandler.java (viewport/gesture population, UiAutomation-
  parameterized) and SessionResponseWriter.java (session response encoding),
  with shared PROTOCOL/HELPER_API_VERSION/OUTPUT_FORMAT constants moved to
  a tiny HelperProtocol.java. SnapshotInstrumentation.java shrinks from 908
  to 803 lines; wire format (header keys/values, error shapes) is unchanged.
- Split touch-helper.test.ts (~720 lines) into touch-helper.test.ts
  (normalize/parse/one-shot gesture+viewport+result envelope) and
  touch-helper-session.test.ts (persistent-session transport + fake-session
  harness), moving shared device/plan/install-probe fixtures used by both
  files into touch-helper.fixtures.ts.
- Update android/snapshot-helper/README.md to document helper API v2: the
  one-shot viewport/gesture modes, the android-touch-plan-v1 payload shape,
  and the persistent session's socket command/response contract.

* fix(android): invalidate helper session after APK replacement; recycle viewport windows; align ADR 0002 (PR #1281 re-review)

- prepareAndroidTouchHelper now mirrors the snapshot path: when
  ensureAndroidSnapshotHelper replaces the APK (install.installed), the
  persistent session started against the previous binary is stopped before
  any touch command, so gestures run one-shot against the fresh install
  instead of a dead/stale session socket. Regression: 'an APK replacement
  stops the stale session and the gesture runs one-shot' drives a live fake
  session through an outdated-install probe (new outdatedVersionAdb fixture)
  and asserts the session socket receives no gesture, the one-shot
  instrumentation path executes, and the session is gone.
- GestureViewportReader.read no longer leaks AccessibilityWindowInfo: a
  single pass copies the active/focused and first-application bounds into
  locals, every window is recycled in a finally, and the existing precedence
  (active/focused app bounds, root-in-active-window, fallback app bounds,
  IllegalStateException) is applied afterwards, unchanged.
- ADR 0002's touch-synthesis paragraph is amended (2026-07, issue #1275) to
  the shared-helper model, consistent with ADR 0013: a live persistent
  helper session executes touch commands directly, one-shot otherwise; the
  old stop-before-gesture requirement is kept as historical context.

* fix(android): resolve touch helper artifact from the ADB provider like snapshots do (PR #1281 re-review)

- prepareAndroidTouchHelper now uses the same artifact precedence as
  snapshot capture: the scoped adbProvider's snapshotHelperArtifact when
  present, otherwise the bundled resolver (whose strict unavailable error
  is preserved). The provider artifact drives both the install decision
  and the instrumentationRunner used for one-shot commands, so an
  ADB-backed provider that supplies a helper artifact but no native touch
  override runs snapshots and gestures against the same single helper
  (issue #1275). Regression: 'a provider-supplied snapshotHelperArtifact
  overrides the bundled artifact for touch' pins the provider packageName
  on the install probe, the provider apkPath on the install call, the
  provider instrumentationRunner on the am instrument args, and that the
  bundled resolver is never invoked.
- ADR 0002 now states explicitly that one-shot retry applies only to
  idempotent reads (viewport) after the failed session is stopped;
  non-idempotent gesture failures surface directly.
- Helper README session transport corrected: a persistent process serving
  one short-lived socket connection per request (the server closes each
  accepted connection), not a single long-lived connection.

* fix(android): guard touch session reuse on helper identity, stop mismatched sessions (PR #1281 re-review)

Persistent helper sessions are keyed by device, so touch reuse must also
prove the live session runs the helper binary the command selected. The
session record now stores its helper identity (packageName, runner,
helperVersion, helperVersionCode — the same values that feed the snapshot
session identity), and runAndroidSnapshotHelperSessionTouchCommand takes
the requesting helper identity: on mismatch (packageName/runner always;
version/versionCode when both sides define them) it stops the session and
returns undefined, so the touch command runs one-shot against the selected
artifact — gestures never start sessions; the next snapshot restarts one
with the right artifact. Matching identity reuses the session as before.
Snapshot capture identity and behavior are unchanged.

Regression: 'a provider artifact that mismatches the live session helper
stops it and runs one-shot' — a live fake session from the bundled fixture
artifact, then a gesture through an ADB provider supplying an
already-current artifact with a distinct packageName/runner (no install):
the old session socket receives zero gesture commands, the session is
stopped, the one-shot am instrument args end with the provider runner, and
helperTransport is 'instrumentation'.

* fix(android): include artifact sha in helper session identity; evict stale install memo entries (PR #1281 re-review)

Same-version binary replacement changes only the APK sha, so identity
guards keyed on package/runner/version/versionCode could not detect a
crossover between two artifacts that differ only in bytes:

- The artifact sha256 now joins the helper identity end-to-end:
  AndroidSnapshotHelperCaptureOptions gains helperSha256 (snapshot.ts
  passes artifact.manifest.sha256 alongside version/versionCode), the
  session record stores it, createSessionIdentity includes it (making
  snapshot session reuse sha-aware, consistent with the install path's
  existing sha check), and the touch identity guard compares it via the
  same both-defined rule.
- ensureAndroidSnapshotHelper's install memo now evicts every other
  cached decision for the same device+package when it records an
  install/current decision, so installing B invalidates A's stale
  'current' memo and a later command selecting A re-inspects the device
  instead of skipping the sha check.

Regressions: 'a same-version artifact with a different sha stops the live
session and runs one-shot' (touch-helper-session.test.ts — B owns the live
session, a gesture selecting same-version different-sha A sends zero
commands to B's socket, stops it, and completes one-shot) and 'installing
a same-version different-sha helper evicts the stale install memo'
(snapshot-helper.test.ts — A:current cached, B installed, selecting A
re-inspects and reinstalls instead of serving the stale memo). Both
verified to fail without their fix.
2026-07-16 17:04:42 +02:00
Michał Pierzchała e58cbcdb5f refactor: colocate native platform sources under android/, apple/, linux/ (#1273)
Move the scattered root-level native projects into per-platform folders and drop
the now-redundant platform prefix:

- android-ime-helper/        -> android/ime-helper/
- android-multitouch-helper/ -> android/multitouch-helper/
- android-snapshot-helper/   -> android/snapshot-helper/
- apple-runner/              -> apple/runner/
- macos-helper/              -> apple/macos-helper/
- src/platforms/linux/atspi-dump.py -> linux/atspi-dump.py

Only repo source paths move. Identity surfaces stay frozen so no user's runner
cache is invalidated on upgrade: the derived-cache key hashes source paths
relative to AgentDeviceRunner and excludes packageVersion, and the
~/.agent-device/{apple-runner,macos-helper} namespaces, the
agent-device-android-*-helper artifact/manifest/protocol names, the
AgentDeviceRunner Xcode project, and the `prepare ios-runner` CLI command are
unchanged. Updates build/package scripts, CI, package.json files+scripts,
ignore/attr/fallow configs, runtime path resolvers, and test fixtures.

Also: re-base repo-root-relative refs inside the moved apple/runner for the
added nesting level (gated XCUITest fixture walk + two doc links), and clean the
legacy dist/apple-runner packaged output so the relocated runner can't
double-ship into the wholesale-included dist (with a regression test).
2026-07-15 21:47:38 +02:00
Michał Pierzchała 37895caf99 refactor: replace Maestro compat with typed direct engine (#1217)
* test: add pinned Maestro conformance harness

* feat: add typed Maestro program IR parser

* docs: define direct Maestro engine architecture

* test: compare Maestro oracle with typed IR

* feat: add direct Maestro program engine

* refactor: narrow Maestro execution context

* refactor: tighten Maestro program parsing

* fix: verify iOS Maestro visibility waits

* refactor: isolate retained Maestro runtimes

* refactor: type Maestro target resolution

* refactor: harden typed Maestro execution

* refactor: share in-page swipe planning

* feat: add typed Maestro runtime port

* refactor: parse Maestro suite metadata from typed IR

* refactor: centralize Maestro include loading

* feat: execute Maestro files through typed engine

* refactor: share replay built-in variables

* fix: make Maestro target intent explicit

* fix: refresh Maestro targets before input

* refactor: format Maestro progress from typed IR

* feat: compile typed Maestro replay plans

* feat: bind typed Maestro runtime to public commands

* feat: route Maestro YAML through typed runtime

* refactor: remove legacy Maestro runtime

* refactor: remove obsolete replay control model

* refactor: split typed Maestro plan modules

* fix: harden typed Maestro runtime semantics

* docs: update direct Maestro architecture

* fix: reconcile Maestro runtime with merged contracts

* fix: harden typed Maestro execution boundaries

* fix: harden typed Maestro runtime evidence

* perf: avoid eager Maestro device resolution

* refactor: finalize typed Maestro execution

* fix: reject Android system-only helper snapshots

* fix: preserve Android system dialog snapshots

* fix: make helper-backed CI deterministic

* refactor: invalidate Maestro observations before dispatch

* fix: make Maestro selector policy explicit

* refactor: remove Maestro ranking sentinels

* refactor: make Maestro own observation stabilization

* refactor: source Maestro compatibility presets

* refactor: keep Maestro failure reports typed

* refactor: simplify Maestro runtime policy

* fix: isolate Maestro engine failures

* refactor: consolidate Maestro swipe presets

* fix: align Maestro selector and observation semantics

* fix: preserve atomic iOS Maestro taps

* fix: require semantic uniqueness for Maestro taps

* fix: preserve Maestro parse provenance

* docs: pin Maestro compatibility presets

* docs: reconcile Maestro gesture viewport contract

* perf: resolve Maestro gesture viewport directly

* test: align Maestro replay regressions

* fix: order Android gesture lift after endpoint

* fix: settle Maestro gestures before continuation

* fixup! fix: order Android gesture lift after endpoint

* refactor: normalize Maestro swipes once

* refactor: fail impossible Maestro observations

* refactor: normalize Maestro defaults alias

* test: reconcile Android provider scenarios

* fix(android): synchronize single-pointer move events

* test: align repair digest parsing

* refactor: type Maestro runtime operations

* refactor: keep Maestro controls compact

* refactor: name Maestro diagnostic limit

* fix: align Maestro parser and settling semantics

* fix: complete Maestro compatibility semantics

* docs: define Maestro compatibility boundaries

* fix: refresh iOS runner target after relaunch

* fix: reset prewarmed iOS runner after URL open

* fix: preserve iOS Maestro target and swipe intent

* fix: harden direct Maestro runtime semantics

* fix: preserve ranked Maestro replay suggestions

* fix: align maestro tap runtime semantics

* fix: stabilize maestro ci contracts

* fix: tighten maestro runtime architecture

* fix: reconcile maestro replay with latest main

* perf: tighten Maestro iOS stabilization

* fix: preserve Maestro app lifecycle sessions

* fix: restore Maestro CI coverage

* fix: address Maestro engine review findings

* refactor: consolidate Maestro compatibility internals

* fix: scope Maestro target evidence to childOf
2026-07-15 21:26:42 +02:00
Kenichi Saito 236016ed8a fix(ios): support remote-hosted alerts on physical devices (#1232)
* fix(ios): probe remote-hosted system modals (AccessorySetupKit picker) when the springboard mirror yields no hittable actions

* fix(ios): fail closed on host state, guard dismissal re-query, unit-test probe routing

Addresses review on #1232:
- Gate the remote-host probe to a foreground host
  (RemoteHostedSystemModalPolicy.isEligibleHostState); background/unknown hosts
  fail closed instead of substituting an unrelated action tree.
- Wrap the alert-resolution fallback query in safeElementsQuery so a dismissed
  remote host raising kAXErrorServerNotFound is absorbed.
- Extract routing/gating into RemoteHostedSystemModalPolicy and add
  simulator-free unit tests under AGENT_DEVICE_RUNNER_UNIT_TESTS.

* refactor(ios): centralize blocking system modal resolution

* fix(ios): bound alert dismissal rechecks

* feat(ios): enable alerts on physical devices

* fix(ios): bound alert system modal resolution

* test(ios): add AccessorySetupKit picker fixture

* fix(ios): validate remote-hosted system modal interactions

* chore: keep pnpm checks non-interactive

* fix(ios): share alert command deadline

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-07-14 17:19:17 +02:00
Michał Pierzchała 139153ce64 fix: bound the iOS system-modal snapshot probe to the capture deadline (#1244) (#1248)
* fix: bound the iOS system-modal snapshot probe to the capture deadline (#1244)

The pre-plan SpringBoard system-modal probe (`blockingSystemAlertSnapshot`)
ran before `runSnapshotCapturePlan`, outside the 20s plan budget, the bounded
main-thread watchdog, and the XCTest recovery envelope. A slow
`springboard.alerts`/`sheets`/descendant enumeration (seen on
ASWebAuthenticationSession consent and notification-permission dialogs) could
therefore stall `snapshot -i` for 30-39s.

Run the probe as a bounded capture tier instead: it shares the snapshot plan
deadline and executes on the same `runMainThreadWork` watchdog as the tree and
query backends (`systemModalProbeBudget`, clamped by the remaining deadline).
On timeout the abandoned probe is tracked like an abandoned tree capture, so the
plan recovers through the independent (private-AX on simulator) backend, later
commands fail fast as busy instead of queueing behind it, and the runner is
never wedged. Diagnostics identify the probe and its elapsed time.

The shared abandonment bookkeeping used by both the tree capture and the modal
probe is extracted into retain/release helpers.

* fix: don't re-enter main for bookkeeping behind an abandoned snapshot probe (#1244)

After the bounded system-modal probe times out, runMainThreadWork abandons its
query but that query keeps grinding on the main thread. The capture plan recovers
through the independent private-AX backend and returns — but executeSnapshotDispatched
then ran its didRecordXCTestFailure/retry bookkeeping through another runMainThreadWork
hop, which queues behind the same abandoned query and re-stalls the command for up to
the 30s execution watchdog (or throws), reintroducing the very stall the recovery
avoided. Skip that bookkeeping while abandoned XCTest work is outstanding — the policy
setNeedsPostSnapshotInteractionDelay already uses — so the recovered response returns
immediately and a subsequent command still reports RUNNER_BUSY until the work drains.

Adds an in-bundle regression that fails if the guard is removed. The snapshot recovery
loop is factored into executeDispatchedWithRecovery so the guard is exercisable without
a live capture.

The alert command's SpringBoard detection is intentionally left on its existing 30s
command watchdog here; giving alert its own physical-iOS capability is tracked as a
focused follow-up (#1231).

* test: force the bounded system-modal probe timeout through snapshotFast

Adds a minimal probeWork seam to boundedBlockingSystemAlertSnapshot (threaded
through snapshotFast/snapshotRaw, defaulting to today's exact production
closure) so a test can substitute a blocking probe body while still running
the real runMainThreadWork wrap and the real onAbandoned/onDrained hooks.

The new regression test drives snapshotFast (the real entry point, not
boundedBlockingSystemAlertSnapshot directly) with an injected probeWork that
blocks past the probe's slice, forcing a genuine timeout, and asserts in
order: busy/penalty accounting once the timeout fires, a recovered payload
returned while the probe is still abandoned (before drain), and release
(hasAbandonedTreeCapture() false / idle) once the probe drains. Verified
revert-sensitive: bypassing the runMainThreadWork wrap, or dropping the
onAbandoned/onDrained hooks, each turn this test red.

* ci(ios): run the #1244 system-modal probe regressions so a wrapper/hook revert is caught

* fix: gate the #1244 probe-timeout test seam behind the unit-test flag

Addresses PR #1248 re-review blockers:

1. `probeWork` was a test-only DI parameter on the production signatures of
   `snapshotFast`, `snapshotRaw`, and `boundedBlockingSystemAlertSnapshot`.
   Gate it behind `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` (the same active
   compilation condition the build script already sets via
   AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS, and the same one this file
   already uses to gate its unit-test methods): production callers now
   compile the original, unparameterized signatures byte-identical to
   pre-`probeWork`. Both overloads of `boundedBlockingSystemAlertSnapshot`
   delegate to a new private `boundedBlockingSystemAlertSnapshotBody`, which
   is the only place the real `runMainThreadWork` wrap and the real
   `onAbandoned`/`onDrained` hooks are defined, so a revert there fails
   through both the production and the test-seam overload.

2. The regression test previously asserted `.idle`/`hasAbandonedTreeCapture()
   == false` right after signaling the probe's release semaphore, racing the
   drain instead of synchronizing on it. It now polls
   `hasAbandonedTreeCapture()` (bounded) on the same background queue after
   signaling release, fulfilling a dedicated `drained` expectation that the
   test `wait(for:timeout:)`s on before the release assertions, so a slow or
   missing drain fails deterministically instead of racing.

3. The regression only drove `snapshotFast`, leaving a `snapshotRaw`-only
   wrapper regression uncaught. The test body is now a private helper
   parameterized over the entry point, called once for `snapshotFast`
   (existing test, unchanged name) and once for `snapshotRaw` (new
   `...ForSnapshotRaw` test), keeping ios.yml's `-only-testing` list in sync.

Verified revert-sensitive for both entry points: temporarily bypassing the
bounding wrap in `snapshotFast` or `snapshotRaw` turns each entry point's own
test red (and only that one); dropping the onAbandoned/onDrained hooks in
the shared body turns both tests red. Restoring the code turns all green
again.

* fix: collapse snapshotFast/snapshotRaw to one production entry point

Reviewer blocker on #1248: the previous #if/#else split compiled a unit-test
overload with a probeWork parameter that the regression tests called, while
shipping builds compiled a separate #else implementation that no test ever
exercised. Reverting the shipped snapshotFast/snapshotRaw/
boundedBlockingSystemAlertSnapshot to bypass the bounded probe would have left
the tests green.

Collapse each command to a single, always-compiled production implementation
(no probeWork parameter anywhere), and move the only injectable seam to a
tiny systemModalProbeOverrideForTesting property (stored on RunnerTests since
extensions can't hold stored properties) consulted from inside
boundedBlockingSystemAlertSnapshot's probe closure. Tests now call the real
snapshotFast/snapshotRaw entry points and set the override instead of passing
probeWork.
2026-07-14 09:29:30 +02:00
Michał Pierzchała f474f0784e feat: unify gesture planning and multi-touch execution (#1212)
* feat: unify gesture planning and multi-touch execution

* fix: correct unified gesture helper behavior

* refactor: tighten unified gesture architecture

* fix: preserve gesture routing contracts

* test: account for fresh gesture viewport

* refactor: remove retired gesture series

* fix: preserve example app navigation targets

* test: reconcile unified gestures with helper ownership

* docs: update Android helper gesture protocol

* fix: refresh Maestro percentage swipe frames

* refactor: remove stale Maestro frame cache

* fix: harden unified gesture execution

* fix: model gesture viewport in providers

* refactor: remove legacy gesture paths

* fix: remove unused swipe preset parser

* refactor: tighten unified gesture boundaries

* fix: close gesture review gaps

* fix: preserve gesture compatibility contracts

* fix: preserve multi-touch recording semantics

* fix: refresh Apple runner state after app relaunch

* test: lock Apple fling fallback route

* fix: close Apple runner review gaps

* refactor: tighten unified gesture seams

* refactor: consolidate gesture planning policy

* fix: preserve swipe response compatibility

* fix: keep gesture lab aligned with replay coordinates
2026-07-13 13:16:38 +02:00
devin-ai-integration[bot] d585d74172 chore: close out architecture experiments (#1213)
* chore: close out architecture experiments

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: record unavailable live experiment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: make Android perf script atomic

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: explain atomic perf workflow

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: simplify back-edge diagnostics

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-11 15:07:32 +02:00
devin-ai-integration[bot] 952bc3704a refactor: keep command and daemon-route owner-file claims tooling-only (#1178) (#1192)
* refactor(command-descriptor): keep owner-file claims tooling-only

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(daemon): keep daemon-route owner-file claims tooling-only

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(daemon): guard against re-adding owner-file paths to the production route chain

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(command-descriptor): derive owner-file projection from colocated RAW_COMMAND_DESCRIPTORS

- Keep ownerFiles on each RAW_COMMAND_DESCRIPTORS entry as the source of truth.
- Add tooling-only __OWNER_FILES__ build flag so production bundles omit the
  ownerFiles properties entirely.
- Derive COMMAND_OWNER_FILES from RAW_COMMAND_DESCRIPTORS instead of a
  hand-maintained parallel table.
- Guard command-explain tests against leaking ownerFiles into production
  descriptor objects.
- Enable treeshake.propertyReadSideEffects: false in tsdown to help drop the
  dead ownerFiles branch from production bundles.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: apply oxfmt formatting

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(build): guard tooling metadata exclusion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(command-descriptor): drop global treeshake option and add bundle guard

- Remove treeshake.propertyReadSideEffects from tsdown.config.ts; the
  __OWNER_FILES__ define + conditional spread already keeps owner files out
  of the bundle, so the global DCE lever is unnecessary and scope-creeping.
- Add a comment on the __OWNER_FILES__ global declaration explaining the
  deliberate type-versus-runtime mismatch.
- Add test/output-economy/owner-files-no-leak.test.ts to build dist and
  assert that no command or daemon-route owner-file path appears in the
  emitted JS.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(build): remove owner metadata property reads

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(command-descriptor): enforce owner claim totality

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-11 07:46:00 +02:00
Michał Pierzchała d4146c7f1b feat: add Android test IME helper for deterministic text entry (#1198) (#1201)
* feat: add Android test IME helper for deterministic text entry (#1198)

Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.

- android-ime-helper/: InputMethodService + build/package scripts on the
  existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
  lifecycle (shared with the other two helpers via the new
  helper-package-install.ts), activation on session open, and on-device
  restore-hygiene (previous IME persisted to a device settings key so any
  daemon/state-dir can recover it; restored on close, daemon teardown, and
  daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
  when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
  copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
  devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
  now-provably-live skillgym non-ASCII eval case, updated the ASCII
  fallback's error message to point at the helper instead of dead-ending.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out

Addresses the independent review's blockers and should-fixes.

SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.

CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.

Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(#1201): pin the API-36 exported-receiver constraint in a comment

The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)

Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.

Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.

Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams

Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:

- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
  + its test, Java handler, README): nothing routes through it — `keyboard enter`
  uses the keyevent ENTER path — so the new production-exports gate flagged it as
  dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
  resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
  fallow-baselines/production-unused-exports.json, matching how the sibling helper
  reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
  grandfathered there.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)

Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.

Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.

Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(#1201): suppress fallow class-member false-positive on state-dir accessor

CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(#1201 review): durable persist before switch + device-scoped recovery markers

Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.

P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.

P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-10 19:38:37 +02:00
devin-ai-integration[bot] 47134bf764 feat: add derived fail-open check:affected selector (#1195)
* feat: add derived fail-open check:affected selector

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor: simplify selector for complexity gate; add docs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: fail open on ambiguous non-source fixtures; guard catalog against real package.json/vitest.config

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor: use src/utils/exec.ts process helpers in check:affected runner

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(check:affected): SkillGym ownership, honest catalog, working-tree discovery

- Add SkillGym ownership for skills/ and test/skillgym/; stop short-circuiting
  their Markdown as docs-only (findings 2 & 4).
- Drop the fabricated GitHub 'SkillGym' job: it is a local-only gate, now
  localRunnable with no CI job, guarded by a workflow-existence self-test (3).
- Fold working-tree (staged/unstaged/untracked) state into local discovery and
  disable rename detection so both rename paths classify (1).
- Add run.test.ts entrypoint regressions (real diff/status/rename discovery,
  --run order/skip/stop-on-failure).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(check:affected): union staged + unstaged diffs so they cannot cancel

A single `git diff HEAD` nets index against working tree, so a staged add
and an unstaged delete of the same file cancel and hide it. Collect
`--cached` (staged) and unstaged diffs separately and union them; add a
cancellation regression test.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(check:affected): cover required suite gates

* refactor(check:affected): delegate tests to vitest

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-10 17:53:52 +02:00
Michał Pierzchała fb1117f229 ci: ratchet against production-unused exports (#1202)
* ci: ratchet against test-only exports

Three exported-and-unit-tested-but-unreferenced-in-production incidents
this week (#1166 getNearestCommandNames, #1167 buildSettleTail, #1199
clearMetroSessionHints) — the first two were caught by fallow's dead-code
check because they had zero importers anywhere; #1199 was missed because a
test file imports the export, and fallow's default reachability graph
counts a test import as "used".

Adds a second, stricter pass reusing fallow's own --production mode
(entry.exclude test/story/dev files) via scripts/test-only-exports/check.ts:
an export alive in fallow's default graph but dead in its production graph,
with no other reference anywhere in its own file, has no production call
site — exactly the #1199 shape. Ratchets against a checked-in baseline
(scripts/test-only-exports-baseline.json, 77 entries); new findings fail
`pnpm check:test-only-exports` (wired into CI's Fallow job and
check:tooling). A `// test-seam: <reason>` comment above an export is the
escape hatch for intentional test seams.

Also extends .fallowrc.json's ignoreExports for seven daemon route handlers
(src/daemon/handlers/*.ts) that are genuinely production-reachable through
request-handler-chain.ts's `typeof import()` lazy-load pattern, which
fallow's static import graph can't trace as a named-export consumer —
without this they were false positives in the production-mode pass.

* fix: harden test-only-exports ratchet per review

Addresses the two should-fixes and all five minors from the independent
review of #1202:

- Replace the regex own-file occurrence count with an oxc-parser AST walk
  (typescript@7 ships no JS scanner API, so the review's fallback tool
  suggestion is the primary): identifiers are counted as AST nodes deduped
  by source span, so mentions in JSDoc/block comments, strings, and
  template-literal text no longer masquerade as call sites (review finding
  1, both constructed cases re-verified fixed), and a `//` inside a string
  no longer hides real usages (finding 6). Span dedupe keeps barrel
  re-exports (`export { x } from`) counting once. The sharper count
  surfaced one organic false negative on main: `selector` in
  src/commands/index.ts was previously exempted because the regex matched
  "selector" inside the './...selector-read.ts' import path string; it is
  now baselined alongside its sibling `ref` (same re-export line).
- Make the baseline shrink-only (finding 2): --update-baseline refuses new
  findings with the same wire/delete/annotate message, so the `// test-seam:`
  annotation in the reviewed source diff is the only acceptance path;
  CONTRIBUTING no longer documents baseline regeneration as an acceptance
  option and now describes baseline growth as a deliberate manual edit.
- Stale baseline entries now emit a `::warning` CI annotation (finding 3).
- Commit a re-runnable fixture test (finding 4): check.test.ts mirrors
  scripts/layering/model.test.ts, builds a synthetic package with a
  clearMetroSessionHints-shaped export (JSDoc self-mention included),
  asserts it is flagged, and asserts the annotated twin passes; wired
  before the check in pnpm check:test-only-exports.
- Mark the unreadable/unparseable-file fallbacks CONSERVATIVE: per
  CONTRIBUTING's convention (finding 5).
- Document the dynamic property access (obj[name]) blind spot in the
  script header and CONTRIBUTING (finding 7).

* fix: harden test-only export ratchet

* refactor: use native Fallow export gate

* chore: refresh production export baseline
2026-07-10 17:14:54 +02:00
devin-ai-integration[bot] ae74c51abd chore: add agent-efficiency regression guards (#1174)
* chore: ratchet architecture dependency graph

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: ratchet agent-facing output economy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: derive command navigation explanations

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: keep efficiency checks fallow-clean

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(layering): enforce back-edge ceiling monotonicity and cover root src files

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(layering): pin back-edge-ceiling ratchet to PR merge-base

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(output-economy): baseline-independent actionability floors, policy-derived error, like-for-like screenshot surfaces

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(explain): resolve true CLI aliases, canonical usage, and derived owners

Surface true CLI aliases from parser normalization (long-press, metrics,
tap, launch, relaunch) distinct from catalog keys, preserving implied-flag
semantics (relaunch => open --relaunch). Extract the canonical single-line
usage builder to src/utils/cli-usage.ts so schemas without usageOverride
include positionals and flags. Replace guessed handler paths with a
completeness-checked daemon-route owner map keyed by the closed
DaemonCommandRoute union, fixing silently-dropped non-kebab routes
(reactNative, recordTrace) and generic dispatch. Add table-driven coverage
for aliases, synthesized usage, split-family/route-variant/dispatch owners,
structured output, and explain:command CLI exit/stdout/stderr.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: enforce exact ratchets and compact command explain

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: colocate command ownership metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: bind daemon owners to production routes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: preserve generic dispatch bundling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: enforce monotonic output budgets

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-10 11:54:09 +02:00
Michał Pierzchała e4139b6802 ci: deepen node 22 packaged smoke (#1125) 2026-07-06 10:24:12 +02:00
Michał Pierzchała 915f5ba374 build: support packaged CLI on Node 22.12 (#1116) 2026-07-06 07:59:37 +02:00
Michał Pierzchała d167eaf0f3 ci: drop duplicate unit-test run, cache Size base dist, typecheck with tsgo (#1094)
Three measured dev-loop/CI cuts, no signal loss:

- typecheck now runs tsgo (already trusted for declaration emit by the
  tsdown build): 21.7s -> 5.3s locally, and check:tooling drops to ~18s
  total. tsc stays available as typecheck:tsc; verified tsgo fails on
  type errors and respects noUnusedLocals.
- remove the Unit Tests CI job: Coverage runs the same unit +
  provider-integration suites under coverage thresholds, so the job
  reran ~64s of tests every PR for no extra signal.
- Size workflow: skip docs-only paths (same paths-ignore as CI) and
  cache the base commit's dist keyed on base SHA, since dist is fully
  determined by that commit. Startup medians are still measured fresh
  on the same runner so the base/PR startup comparison stays
  same-machine; the cache is saved immediately after the base
  measurement so the PR build never poisons it.


Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 16:40:34 +02:00
Michał Pierzchała cd0cd16a9e build: migrate the library build from rslib to tsdown (Rolldown) (#1087)
* build: migrate the library build from rslib to tsdown (Rolldown)

Replace the Rspack-based rslib build with tsdown, the Rolldown-based
library bundler from the Vite toolchain family, so bundling, testing
(Vitest/Vite), linting (oxlint), and formatting (oxfmt) all run on the
same OXC/Rolldown stack.

Outcome vs the rslib baseline (size-report):
- build time: ~53s -> ~2s
- JS raw +16.2 kB (+1.1%), JS gzip +2.7 kB (+0.6%) - the residual gap
  is OXC vs SWC minifier tightness, not chunking
- npm tarball -3.0 kB
- CLI --version startup ~3 ms faster; --help within the +/-5 ms
  measurement noise of interleaved A/B runs

Chunk-merging experiments (single shared group, entries-aware groups,
small-module groups) all regressed either total size or --help startup
(a merged shared chunk adds +140 ms), so the default Rolldown split
graph is kept. Custom codeSplitting groups also currently trip a
rolldown-plugin-dts bug that re-emits type-only imports as runtime
imports.

Declarations still bundle per entry via tsgo; dist layout, entry names,
and the internal/ worker/daemon entry resolution contract are unchanged.
@microsoft/api-extractor was only consumed by rslib dts bundling and is
removed together with @rslib/core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS

* ci: only cache the pnpm store when setup installs dependencies

The layering-guard job uses setup-node-pnpm with install-deps: false, so
it never creates a pnpm store. setup-node's post-job cache save then
fails with a path validation error whenever the lockfile hash misses the
cache - which any lockfile-changing PR does. Gate the cache on
install-deps so no-install jobs skip pnpm store caching entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-04 15:58:05 +02:00
Michał Pierzchała b6128c0088 docs: retire plans/perfect-shape.md — roadmap complete (#1003)
* docs: retire plans/perfect-shape.md — roadmap complete

The perfect-shape roadmap (two-registry thesis: CommandDescriptor +
PlatformPlugin, typed-result spine, folder DAG + layering lint, agent-cost,
and the Apple apple+appleOs platform model with a non-breaking leaf wire) is
substantively complete and merged. Per its own §5 retirement note, the durable
decisions now live in ADR-0008 (command descriptor) and ADR-0009 (Apple/AppleOS),
and current-state terms in CONTEXT.md; this removes the last plan file.

- Delete plans/perfect-shape.md (plans/ is now empty and gone).
- CONTEXT.md: add "Architecture (perfect-shape refactor, completed 2026-07)"
  end-state summary plus a "Deferred / next-minor" note (Phase 2c client-types
  narrowing, b.3 recording/providers facets, strict DAG back-edge inversion,
  legacy alias drops) so nothing is lost.
- Repoint every remaining perfect-shape.md/§ reference (ADRs 0003/0008/0009,
  ci.yml, scripts/layering/check.ts, and the platform-plugin/apple comments)
  to ADR-0008/0009 or CONTEXT.md. No dangling references remain.

Docs/comment-only; tsc, oxlint, oxfmt, and the layering DAG check all pass.

* docs: repoint dangling perfect-shape section refs before retiring the roadmap

Removing plans/perfect-shape.md left three comments citing bare section numbers
with no surviving target. The rationales are already inlined, so drop the numbers
(and point the do-not-flatten note at the durable ADR):
- src/platforms/apple/plugin.ts: `(§7)` -> "do-not-flatten; see docs/adr/0009".
- src/core/interactors/register-builtins.ts: "the §5.1 ... sketch" -> "an ... sketch".
- scripts/layering/check.ts: drop `(§5.5 ...)`, keep the inline "re-export barrels only".
2026-07-02 07:19:37 +02:00
Michał Pierzchała a3e967526a refactor: rename ios-runner -> apple-runner (#981) (#996)
Finish the cosmetic ios-runner -> apple-runner rename now that the
top-level XCTest runner is the OS-agnostic Apple engine
(iOS/iPadOS/tvOS/macOS/visionOS from one Xcode project).

Cosmetic only, no behavior change:
- git mv ios-runner/ -> apple-runner/ (AgentDeviceRunner, README, RUNNER_PROTOCOL)
- Update repo project-path consumers: build-xcuitest-apple.sh, package.json
  files globs, .fallowrc.json, write-xcuitest-cache-metadata.mjs,
  runner-xctestrun.ts fingerprint/project paths, recording overlay + test,
  daemon-client-timeout kill pattern, setup-apple-replay hashFiles glob,
  ci.yml swift-compat scan, AGENTS.md.
- Rename runtime home cache/derived/lease dir default
  ~/.agent-device/ios-runner -> ~/.agent-device/apple-runner (build script,
  package/clean scripts, runner-xctestrun RUNNER_DERIVED_ROOT, runner-lease,
  runner-contract hint, cli-help/commands.md docs) and the tests asserting it.
- Rename OS-agnostic runner symbols: runIosRunnerCommand ->
  runAppleRunnerCommand, prewarmIosRunnerCache -> prewarmAppleRunnerCache,
  createIosRunnerCachePrewarmOnColdBoot / createIosRunnerCacheColdBootPrewarmForOpen
  -> createAppleRunner* (+ call sites, type aliases, test mocks).

Intentionally left as ios-runner (out of scope / would change behavior):
- prepare ios-runner CLI subcommand (user-facing command name)
- AGENT_DEVICE_IOS_RUNNER_* env var names and .tmp/ios-runner-derived CI values
- ios-runner-prebuilt cache-key-prefix, ci.yml job id, workflow/ADR filenames
- agent-device-ios-runner-<version> release artifact basenames

Part of #972 (Phase 3 - Apple PlatformPlugin).
2026-07-01 14:24:48 +02:00
Michał Pierzchała 3d70943550 feat: enforce import-direction DAG (Phase-5 layering lint) (#984)
Generalize the inline CI "Layering Guard" grep into a structured
import-direction lint (scripts/layering/check.ts) over the resolved
import graph, per plans/perfect-shape.md §5.5.

The full target DAG (kernel ◄ platforms ◄ core ◄ commands ◄ {cli,
client, daemon/server}; client ◄ daemon/client) is only partly realized
— the client/remote/metro extraction, the daemon/server split, and the
utils dissolution are still pending Phase-5 moves, so the tree still
holds legitimate back-edges (platforms→core, commands→cli, utils→*).
Enforcing the whole DAG today would need a mass import rewrite that
Phase 5 defers. The lint therefore enforces the three invariants the
completed moves (kernel/, daemon/client/) already guarantee and that are
green today:

  R1 kernel-sink      — nothing under src/kernel/ imports another zone,
                        except the one type-only kernel→contracts re-export.
  R2 commands-floor   — nothing below the command surface (kernel,
                        platforms, core, daemon) imports src/commands/.
                        Generalizes the former guard (daemon + platforms).
  R3 platforms-seam   — platforms/ is statically imported only at the
                        core interactor seam (src/core/interactors/) and by
                        the daemon server; elsewhere use a dynamic import()
                        or a type-only import, preserving CLI cold-start.

Dynamic import('../platforms/*') and `import type` stay allowed.

Fixes the three pre-existing R3 violations by converting static
platforms value imports to dynamic imports (all in already-async call
sites, behavior-preserving and cold-start-improving):
  - src/client/client.ts        debug.symbols → lazy symbolicateCrashArtifact
  - src/cli/commands/web.ts      setup/doctor → lazy agent-browser-tool
  - src/core/dispatch-interactions.ts  runner-sequence → lazy (matches the
                                       file's own dynamic-import pattern)

Wire the check into the Layering Guard CI job and add a check:layering
package.json script (also folded into check:tooling). scripts/layering/**
is excluded from fallow (untested CI script, like scripts/perf/**).
2026-07-01 10:02:45 +02:00
Michał Pierzchała 26ac865c63 refactor: consolidate Apple platform internals (#968) 2026-06-30 21:30:46 +02:00
Michał Pierzchała edc8dd059b ci: automate iOS runner request-count gate for the Apple runner unwind (Phase 3 step c prep) (#966)
Replaces the manual "run with --debug, hand-count the runner phases" check with
an automated, committed assertion so the Phase 3 step (c) runner relocation (and
future runner refactors) can prove byte-identical runner request behavior.

- src/daemon/runner-request-count.ts: pure, unit-testable counter. Parses the
  daemon --debug diagnostics ndjson and counts the iOS-runner round-trip phases,
  plus baseline parse/compare logic. Owns RUNNER_ROUND_TRIP_PHASES as the single
  source of truth, now imported by request-router.ts (was a local const) so the
  in-process cost graft and the external counter never drift.
- src/daemon/__tests__/runner-request-count.test.ts: 13 unit tests over synthetic
  ndjson fixtures (tolerant parse, counting, baseline parse/compare). Run in the
  normal unit suite; no hardware.
- scripts/runner-request-count/: assertion harness (run.ts) + committed baseline
  (expected-counts.json). Drives the existing smoke-ios replay scenario with
  --debug in an isolated --state-dir, counts runner round-trips from daemon.log,
  and asserts against the baseline. --update regenerates the baseline. Infra
  hiccups are inconclusive (don't fail); only a real count drift fails.
- .github/workflows/ios.yml: new "Assert iOS runner request count" step in the
  smoke-ios job, reusing the booted simulator.
- package.json: `validate:runner-count` script. .fallowrc.json: harness entry.

The baseline ships unarmed (established=false); the harness records observed
counts (printed + uploaded as a test/artifacts artifact) without failing, so the
maintainer arms it once from a real CI run.
2026-06-30 17:26:01 +02:00
Michał Pierzchała 9dc07cc56e perf: reuse Apple runner cache across version bumps (#900)
* perf: reuse Apple runner cache across version bumps

* perf: remove unused Apple runner symbols

* perf: keep Swift runner unit tests out of runtime builds

* perf: skip Apple runner asset catalog in runtime builds

* perf: use concrete simulator for xcuitest script builds

* perf: show cold Apple runner startup progress

* perf: prewarm Apple runner cache during simulator boot

* refactor: dedupe Apple runner option plumbing
2026-06-29 15:43:15 +02:00
Michał Pierzchała 19f73c8fe3 ci: fix iOS simulator boot timeout (#845) 2026-06-23 16:24:50 +02:00
Michał Pierzchała bf8d952e21 fix: speed up web snapshots (#842)
* fix: speed up web snapshots

* ci: stabilize iOS simulator smoke boot
2026-06-23 14:13:30 +02:00
Michał Pierzchała c6fd3dc972 test: add live web platform smoke (#832)
* test: add live web platform smoke

* test: harden web smoke cleanup
2026-06-21 13:05:51 +02:00
Michał Pierzchała d05cfc727b feat: manage web backend setup (#833) 2026-06-20 11:44:00 +02:00
Michał Pierzchała d29b86d7b8 fix: report maestro ios runner setup failures (#809) 2026-06-16 16:28:28 +01:00
Michał Pierzchała 963ffc259c refactor: move daemon-shared contracts out of commands (#741) 2026-06-10 14:28:24 +02:00
Michał Pierzchała 645577554a ci: enforce lint and formatting, add warn-only layering guard (#732)
Add a Lint & Format job (oxlint --deny-warnings + new format:check script) and
a warn-only guard that flags imports of src/commands/* from src/daemon and
src/platforms; the guard flips to a hard failure once shared contracts move
out of the commands layer.

https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-10 10:36:18 +02:00
Michał Pierzchała 0f7187f543 fix: scope source daemon state by worktree (#719)
* fix: scope source daemon state by worktree

* docs: clarify worktree daemon state tradeoffs

* ci: harden Apple runner cache

* chore: keep daemon state helper internal

* ci: validate Apple runner cache restores

* ci: simplify Apple runner cache setup
2026-06-10 10:32:58 +02:00
Michał Pierzchała c2b29d5600 fix: stabilize Maestro replay on iOS (#713)
* fix: stabilize Maestro replay on iOS

* fix: scope iOS runner cleanup to daemon owner

* fix: lease iOS runner ownership per device

* fix: release prepared iOS runner daemon in CI

* fix: inline runner lease release cleanup
2026-06-09 14:12:57 +02:00
Michał Pierzchała 2014cb6873 fix: harden covered snapshot targets (#708)
* fix: block covered snapshot targets

* fix: harden covered snapshot targets
2026-06-08 14:08:52 +02:00
Michał Pierzchała 5c083eacc6 fix: harden iOS replay runner prewarm (#705)
* fix: harden iOS replay runner prewarm

* fix: avoid stale iOS runner during relaunch

* fix: stop stale iOS runner processes

* fix: clean stale iOS runners before startup
2026-06-07 20:37:23 +02:00
Michał Pierzchała ad7b386444 feat: cache iOS runner artifacts during prepare (#688)
* feat: cache ios runner artifacts during prepare

* refactor: deepen ios runner lifecycle

* refactor: generalize apple runner prepare

* refactor: simplify apple runner lifecycle

* fix: clarify runner prepare recovery diagnostics

* refactor: trim apple runner provider surface

* test: simplify runner recovery diagnostics assertion
2026-06-04 15:37:05 -07:00
Michał Pierzchała 7400701857 perf: speed up iOS swipes and harden runner cache (#676)
* perf: speed up ios swipes and harden runner cache

* fix: harden maestro replay smoke tests
2026-06-03 04:59:13 -07:00
Michał Pierzchała 45cfad5cc5 feat: e2e command perf benchmark harness + nightly CI (#630)
* feat: add e2e command perf benchmark harness + nightly CI

Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.

Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).

* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync

Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).

* perf(harness): warm the runner after open so the first measured command is clean

The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.

* refactor(perf): address review + fix Fallow CI

- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
  error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
  change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
  test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
  --n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
  step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
  elements = node count). check:fallow now green; typecheck/lint/unit pass.

* perf(harness): downgrade sample ok when a batch step reports ok:false

Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.
2026-05-31 14:37:59 +02:00
Michał Pierzchała 8e1f8a9f8f perf: lazy load daemon handlers and report bundle size (#608)
* perf: lazy load daemon handlers

* perf: thin command metadata paths

* refactor: isolate platform inventory loading

* refactor: trim lazy loading cleanup

* test: guard daemon routing metadata drift

* ci: report startup timing with size
2026-05-30 16:49:21 +02:00
Michał Pierzchała 3283e5e3e5 ci: skip core CI for docs-only PRs (#619) 2026-05-30 15:38:50 +02:00
Michał Pierzchała 0932ad56a7 ci: publish MCP registry metadata (#627) 2026-05-30 15:38:06 +02:00
Michał Pierzchała 5083d04560 ci: stop waiting for preview pages build (#607) 2026-05-28 17:24:35 +02:00
Michał Pierzchała b2a39d2368 ci: gate PR preview builds (#603)
* ci: gate PR preview builds

* ci: include setup action in preview trigger
2026-05-28 17:10:44 +02:00
Michał Pierzchała 233df070f1 ci: skip platform smoke for docs-only PRs (#604) 2026-05-28 17:06:51 +02:00
Michał Pierzchała 7a2428e5e2 ci: reduce duplicated runner work (#602) 2026-05-28 15:57:58 +02:00
Michał Pierzchała 47b981c8ad feat: add gesture command coverage (#576)
* feat: add gesture command coverage

* fix: align iOS fling provider fixture

* feat: group gesture commands

* fix: clarify android gesture support

* feat: add android multitouch gestures

* fix: address gesture review feedback

* refactor: simplify gesture plumbing

* fix: keep gesture subcommands internal

* fix: update iOS provider pan transcript
2026-05-22 18:01:58 +02:00
Michał Pierzchała 840bef56ca fix: tighten env var surface (#560) 2026-05-19 17:11:31 +02:00
Michał Pierzchała 094c290703 perf: speed up iOS replay runner (#557)
* perf: speed up iOS replay runner

* fix: harden ios replay fast paths

* fix: address ci validation failures

* refactor: trim unused ios replay surface
2026-05-19 11:36:48 +02:00
Michał Pierzchała 59d28e8446 refactor: add provider-first device lab tests (#542)
* refactor: add provider-first device lab tests

* refactor: tighten device lab provider seams

* test: cover provider lab contracts

* docs: record device lab harness direction

* ci: run device lab integration tests

* test: move device lab under integration

* test: extract device lab helpers

* refactor: centralize apps filter defaults

* test: drop lab-covered unit tests

* test: fold platform happy paths into device lab

* test: reuse device lab helpers

* test: move device lab to in-process harness

* test: replace session handler cases with device lab

* test: harden device lab scenario contracts

* docs: define unit test retention policy

* test: expand provider device lab coverage

* test: harden provider device lab coverage

* test: cover manifest install and runner session contracts

* chore: remove unused provider cleanup code

* test: split android find device lab scenario

* test: track provider lab architecture progress

* test: clarify provider lab roadmap progress

* test: advance provider lab session coverage

* test: move menubar click routing to device lab

* test: move menubar snapshots to device lab

* refactor: centralize screenshot flag plumbing

* refactor: colocate screenshot flag metadata

* test: cover all public commands in device lab

* test: move macos wait success to device lab

* test: drop redundant perf and diff units

* test: move push payload paths to device lab

* test: move network parsing to device lab

* test: move log cleanup to device lab

* test: move log restart and boot to device lab

* test: move ios physical boot to device lab

* test: cover perf startup in device lab

* test: extract android and ios device lab worlds

* test: trim device lab world surface

* test: split snapshot capture unit coverage

* test: deepen device lab coverage and trim handler units

* test: clean up device lab migration scaffolding

* test: report device lab public command coverage

* refactor: make Apple provider seams semantic

* refactor: tighten device inventory and Linux provider seams

* refactor: tighten request provider scoping

* refactor: add semantic macos host provider

* test: broaden device lab find coverage

* test: cover workflow flags in device lab

* refactor: promote linux input provider seam

* test: clarify device lab flag coverage

* test: classify snapshot force-full progress

* test: enforce device lab progress in ci

* test: stabilize device lab ci

* test: move packaged metro smoke to integration

* test: drop stale provider seam coverage

* test: harden provider scope regression coverage

* refactor: remove stale platform barrels

* refactor: keep linux clipboard and screenshots semantic

* refactor: move macos host tools behind provider

* fix: honor remote artifact output paths

* test: deepen runtime coverage for daemon and runner paths

* test: share loopback test helpers

* refactor: make daemon runtime importable

* fix: honor replay target metadata

* chore: tighten final device lab quality gates

* test: share device lab setup helpers

* test: remove generic apple lab fallback

* test: deduplicate device lab helpers

* chore: tighten fallow duplication signal

* refactor: share apple diagnostic helpers

* fix: detect active android ime during fill verification

* test: consolidate provider-backed integration suite

* ci: fix fallow and iOS smoke setup

* chore: consolidate cleanup after ci fixes

* test: split vitest unit and integration projects

* docs: mention MCP discovery metadata

* docs: add agent skills context pointers

* fix: close provider recording coverage gaps

* fix: restore mcp compatibility smoke

* test: cover provider edge regressions

* test: consolidate loopback helpers

* docs: remove stale provider routing reference

* fix: harden final provider review issues

* chore: defer mcp cleanup from provider refactor
2026-05-18 14:50:52 +02:00