Commit Graph

913 Commits

Author SHA1 Message Date
Michał Pierzchała fcb7e32f1c fix: clarify clean-xcuitest output (#1018)
* fix: clarify clean-xcuitest output

* fix: simplify clean-xcuitest formatter

* fix: clarify clean-xcuitest failure output
2026-07-02 13:53:42 +02:00
Michał Pierzchała 9e14f0e8eb fix: retain hot iOS simulator runner on safe close (#1021) 2026-07-02 13:51:11 +02:00
Michał Pierzchała ce39f106ee perf: overlap iOS runner prewarm, seed booted-memo, hand runners off across daemon restarts (#1011)
* perf: start iOS simulator runner prewarm before close/open dispatch

Since simulator close/open ride simctl and never touch the runner, the
xcodebuild ramp can overlap the app (re)launch instead of following it.
Real devices keep the post-open prewarm because relaunchCloseApp tears
their runner down first; the Maestro prewarm-before-open path is
unchanged.

Daemon-cold plain open: first snapshot 4.6-5.1s -> ~2.5s (the ramp gets a
~2s head start). Daemon-cold open --relaunch: ~8.9s -> ~8.2s mean; most
of the head start is eaten by ramp slowdown under concurrent simctl work,
matching earlier contention observations.

* perf: seed the simulator booted-memo from the device inventory parse

A simctl listing that reports a simulator Booted is the same observation
ensureBootedSimulator would make, so resolving a device now seeds the
memo and the ensureDeviceReady boot check that follows in the same
request skips its own ~0.7s listing. Daemon-cold plain open: ~2.6s ->
~2.0s.

* perf: hand iOS simulator runners off across daemon restarts

Graceful daemon shutdown detaches healthy simulator runner sessions
instead of killing them: the lease token is rewritten to a detached form
(so the daemon's own teardown paths no longer classify it as owned) and
the xcodebuild/runner pair keeps running. The next daemon start adopts a
stale lease when the runner process is alive, the artifact fingerprint
matches the current toolchain, and an uptime probe answers - otherwise
the existing cleanup-and-restart hygiene applies unchanged. Crash-killed
daemons leave the same stale lease, so crash recovery and deliberate
handoff share one path.

The adopted session wraps the orphaned xcodebuild in a pid-backed
surrogate child; every downstream consumer (liveness, kill-tree,
early-exit detection, disposal wait) operates on pid/exitCode, which a
low-frequency exit poll maintains.

Bounds and escape hatches: at most one detached runner per device, the
runner's XCTWaiter self-expires after 24h, clean:daemon still kills by
lease runnerPid, and AGENT_DEVICE_IOS_RUNNER_DETACH=0 disables both
detach and adoption.

Daemon-cold open --relaunch with a handed-off runner: ~8s -> ~3.2s
(startup.durationMs ~780), for both SIGTERM and SIGKILL'd predecessors.

* fix: keep scoped simulator-set runners out of the shutdown handoff

Review finding on #1011: detach released the XCTestDevices device-set
redirect while the runner kept running, and adoption rebuilt the session
without it - a custom-set runner could outlive the symlink/lock that its
xcodebuild depends on, breaking scoped simulator-set isolation.

The redirect's lifetime is bound to the owning session by design, so
scoped-set runners simply do not participate in the handoff: shutdown
detach skips any session holding a redirect (it goes through the normal
dispose-and-restore path), and adoption refuses devices that resolve to a
custom simulator set. Default-set sessions - the only ones that never
hold a redirect - keep the fast handoff.

* fix: gate relaunch runner teardown on Apple platforms explicitly

'device.platform !== \'android\'' predates the platform set growing to
include linux and web - for those it would take the runner-session lock
and walk the lease cleanup path on relaunch for a device that can never
have an XCUITest runner. isApplePlatform() states the actual intent:
Apple non-simulator targets tear down, simulators stay hot, everything
else never touches the runner.

* refactor: quality pass over the iOS startup perf stack

Applied from a 4-angle review (reuse / simplification / efficiency /
altitude) of the #1011 diff:

- RunnerSession.child is now a RunnerProcessHandle (pid/exitCode) instead
  of ChildProcess: every consumer only reads pid/exitCode and kills by
  pid, the adoption surrogate no longer needs double casts, and the
  compiler enforces what a comment used to promise. Real children satisfy
  the handle structurally.
- session-open prewarm scheduling collapsed into schedulePrewarm/
  awaitPrewarm closures; the fallback guard is a dedicated local instead
  of reading the OpenTiming telemetry object (kept separate from the
  promise itself, which is legitimately undefined when prewarm is
  unavailable).
- adoption: expected derived path computed once and threaded through
  (resolveExpectedRunnerCacheMetadata does a recursive source-stat walk
  per call, and it runs under the lease lock); guard chain flattened into
  skip(reason) early returns; probe timeout 2s -> 500ms (localhost
  refusal is instant, the timeout only bounds the wedged-runner case);
  dead path.dirname fallback removed.
- shared normalizeRunnerStartupTimeoutMs and buildRunnerSessionId in
  runner-session-types; deleted the verbatim copies.
- detach kill switch parses through utils/source-value parseBooleanLiteral
  (now exported) instead of a bespoke dialect that treated 'no' as
  enabled.
- dropped buildDetachedRunnerLease's untriggerable idempotence guard,
  direct Map iteration in the detach loop, and a freshness warning on
  markSimulatorBooted for future callers.
2026-07-02 12:38:41 +02:00
Michał Pierzchała 657442260e fix: reduce iOS runner keepalive log noise (#1017) 2026-07-02 12:29:13 +02:00
Michał Pierzchała 996d93e979 perf: cut iOS open --relaunch from 11s to 2.85s (#1010)
* perf: keep iOS simulator runner hot across open --relaunch

open --relaunch tore down the XCUITest runner session before closing the
app, then paid a full xcodebuild test-without-building restart (~6s) after
reopening it. Simulator close/open go through simctl and never touch the
runner, so a healthy runner survives the relaunch; stale runners are
recovered by the readiness preflight + invalidate/restart path that landed
after #705 reverted #700's version of this.

Steady-state open --relaunch on iPhone 17 Pro sim: 11.0s -> 4.9s
(startup.durationMs 5929 -> 1574). Real devices keep the conservative
teardown.

* perf: drop redundant simctl inventory listings from the iOS open path

A single open --relaunch spawned `simctl list devices -j` (~0.7s each)
three times: session-device re-resolve, closeIosApp's booted check, and
launchIosSimulatorApp's booted check.

- ensureBootedSimulator now keeps a 5s recently-observed-Booted memo
  (mirroring DEVICE_READY_CACHE_TTL_MS at the daemon layer) so repeated
  boot checks inside one request cost nothing; shutdownSimulator
  invalidates it, boot transitions seed it.
- refreshSessionDeviceIfNeeded skips the re-resolve while the device's
  XCUITest runner session is alive - a live runner attached to the UDID
  already proves the simulator exists and is booted.

With the runner kept hot across relaunch, steady-state open --relaunch on
iPhone 17 Pro sim drops 4.9s -> 2.85s (startup.durationMs ~1574 -> ~760).
Combined with the previous commit: 11.0s -> 2.85s.

* test: expect single simctl listing in tvOS provider flow

The recently-observed-Booted memo removes the repeat state listings the
launch and terminate boot checks used to make; the transcript now records
one listing per flow.
2026-07-02 11:33:43 +02:00
Michał Pierzchała ed1f30c34f feat: surface appleOs discriminant on public device output (#1009)
* feat: surface appleOs discriminant on public device output

Additive, non-breaking: the public device/result shapes now carry the
stored Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS) alongside
the existing leaf platform (ios/macos). Consumers can distinguish Apple
OSes on the wire instead of only the collapsed ios/macos leaves.

- session-inventory devices/session_list stop stripping appleOs and emit
  it only for Apple devices (non-Apple omit it); platform stays the leaf.
- boot/shutdown success results (contracts/device.ts) gain optional appleOs.
- AgentDeviceDevice/AgentDeviceSessionDevice + client normalizers carry it.
- appleOs values never equal the internal 'apple' token, so the
  apple-platform-output-guard is unaffected.

Tests: devices projection per Apple fixture (ios/ipados/tvos/visionos/
macos) + non-Apple omission; client normalizer coverage.

* fix: gate public appleOs surfacing to Apple platforms

appleOs is Apple-only, but the daemon projection (devices, session_list, boot,
shutdown) and the client normalizers preserved it whenever the field was present —
so a malformed/legacy non-Apple record carrying a valid Apple OS value would leak
it on the public wire. Gate every emission site on isApplePlatform(platform), not
just field presence.

Regression tests: a non-Apple device carrying a stray appleOs='macos' drops it in
both the daemon devices projection and normalizeDevice.
2026-07-02 11:10:05 +02:00
Michał Pierzchała 127e2be4a6 docs: require provider-integration + coverage verification for platform/response changes (#1008)
Process guardrail (addresses the recurring miss behind the Platform-collapse leaks):
- Testing Matrix: platform/device-response changes must also run test:integration:provider
  and test:coverage — check:unit alone misses the provider-integration project (which runs the
  apple-platform-output leak guard).
- PR Readiness: don't call a PR CI-green off a local --project unit run; the Integration Tests
  and Coverage jobs run provider-integration, so verify those on the actual PR head.
2026-07-02 10:45:49 +02:00
Michał Pierzchała 7488346513 feat: route recording + provider platform gates through PlatformPlugin facets (#1007)
* feat: route recording + provider platform gates through PlatformPlugin facets

Phase 3 step b.3 (issue #974): land the two deferred daemon-owned
PlatformPlugin facets as DATA-ONLY seams, mirroring appLog/perf.

recording facet: PlatformPlugin.recording.resolveBackendTag(device)
returns a neutral RecordingBackendTag string ('web'|'android'|'macos'|
'ios-device'|'ios-simulator'|'unsupported', daemon-owned, type-only in the
plugin). resolveRecordingBackendForDevice now maps the tag back to the
daemon-owned backend instance via RECORDING_BACKENDS_BY_TAG, with a
'?? unsupported' fallthrough for the factless linux family. Backend
instances + RecordingBackend type stay in the daemon.

providers facet: PlatformPlugin.providers.platformGatedResolvers declares,
per family, which platform-gated request provider resolvers apply (the data
that replaces each descriptor's hand 'device.platform === …' gate). The
daemon still owns resolver invocation, wrapper composition, and request-scope
concurrency isolation; only the gate moved to data. Ungated resolvers
(appLog/recording providers) stay ungated in the daemon.

Both facet types are type-only in the plugin (R3-clean, like LogBackend), so
no platforms/->daemon value edge and no CLI cold-start regression. Pinned by
two table-equivalence parity tests (independent verbatim oracle, full
platform x kind x target matrix, facet-presence + fallthrough + end-to-end
routing).

* docs: mark b.3 recording/providers facets as landed in CONTEXT.md

This PR routes the recording + providers PlatformPlugin facets, so the durable
architecture context can no longer list them as deferred/on-the-daemon-branch.
Update the Deferred section: all four b.3 facets now route through the plugin;
only the perf sampling body (buildPerfResponseData) still branches in the daemon.
2026-07-02 09:42:50 +02:00
Michał Pierzchała b01fc190f5 fix: pin agent-cdp 1.6.1 (#1006) 2026-07-02 07:41:46 +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 36917157ab test: surface-wide guard that no command response emits internal apple platform (#1005)
Adds a provider-integration guard (apple-platform-output-guard.test.ts) that
stands up a fake-provider daemon for BOTH a macOS Apple session and an
iOS-simulator Apple session, drives EVERY public command off PUBLIC_COMMANDS,
and deep-scans each serialized response for the internal 'apple' platform token
(any string VALUE or object KEY that exactly equals 'apple').

The guard is catalog-driven: a partition test fails if a new public command is
neither in DRIVEN_COMMANDS nor SKIPPED_COMMANDS, so a new command can't silently
escape the check. All 50 public commands are driven; the skip-set is empty.

Caught + fixed a leak PR #1004 misses: doctor's data.platform (session-doctor.ts)
echoed the raw internal device.platform ('apple') when doctor ran against a bound
session with no --platform flag. Now projected via publicPlatformString(device).

doctor's byPlatform.apple KEY leak stays tracked to PR #1004 via a narrow,
documented allowlist entry (not duplicated here).
2026-07-02 07:18:56 +02:00
Michał Pierzchała fa76a91eca fix: project doctor output platform to the public leaf after the collapse (#1004)
The Platform collapse projected most `doctor` output but missed the device
inventory breakdown: `deviceInventoryEvidence` keyed `byPlatform` by the raw
internal `device.platform`, so an Apple session emitted `byPlatform: { apple: … }`
in the doctor response — a machine-facing `apple` leak (approach b keeps output
leaf `ios`/`macos`). Key it by `publicPlatformString(device)` instead (now
`Map<PublicPlatform, …>`, so an `apple` key is compile-impossible), which also
splits Apple devices into ios vs macos in the breakdown.

Also project two doctor command SUGGESTION strings (`apps --platform …`,
`close --platform …`) to the leaf — `--platform apple` was valid but broader than
the session's actual device.

The doctor descriptor routing (command-descriptor `daemon` block) and its lack of
a platform-specific `supports` gate are already correct; toolchain checks already
accept `apple`. Verified: tsc, oxlint, oxfmt, layering, fallow, provider-integration
82/82; full unit green except the known android fillAndroid contention flake
(passes 92/92 in isolation).
2026-07-01 20:47:15 +02:00
Michał Pierzchała cd1551bf42 refactor: collapse public Platform ios/macos into apple (#979) (#1002)
* refactor: collapse public Platform ios/macos into apple (#979)

Phase 3 d.3: collapse the internal `Platform` union from `ios`/`macos` to a
single `apple` platform, with `appleOs` as the sole OS discriminant. Approach
(b) NON-BREAKING: the daemon still ACCEPTS the legacy `ios`/`macos` selectors on
every read path and still EMITS the leaf `ios`/`macos` strings on every output,
so machine consumers see no change.

Kernel (src/kernel/device.ts):
- PLATFORMS = ['apple','android','linux','web']; add PUBLIC_PLATFORMS (leaf) and
  PublicPlatform; PLATFORM_SELECTORS keeps legacy `ios`/`macos` as input aliases.
- New predicates: isMacOs (appleOs- or legacy-leaf-based), isIosFamily (the
  post-collapse equivalent of `platform === 'ios'`), publicPlatformString (output
  projection), deviceFieldsFromPublicPlatform (inverse), isPublicPlatform.
- isMobilePlatform and matchesPlatformSelector are now device-aware (appleOs).

Discovery now stamps `platform: 'apple'` (+ appleOs); ~125 internal
`device.platform === 'ios'|'macos'` branch sites migrated to the predicates,
behavior-preserving. Apple plugin owns `['apple']`; platformDescriptors collapse
to one `apple` row.

Output projection (approach b) emits the leaf via publicPlatformString at:
devices / session_list (session-inventory), boot / shutdown / appstate /
prepare-ios-runner (session-state, session), the selector/backend platform
(selector-runtime/screenshot-runtime/snapshot-runtime/interaction-runtime),
proxy device key, request-lock backfill, runtime-set binding, click-button
validation, and both `.ad` context-line writers.

Contracts/client keep leaf types (PublicPlatform); read paths (parsePlatform,
REPLAY_METADATA_PLATFORMS, matchesPlatformSelector) accept `apple` + legacy
leaves. Adds a parity test gate (platform-collapse-parity.test.ts).

Refs #979 (part of #972).

* fix: project platform to the public leaf at open/perf response sites (#979)

The Platform collapse left two daemon response builders emitting the raw
internal `device.platform` ('apple'), which the client normalizer rejects
(isPublicPlatform excludes 'apple') — dropping the resolved device from the
response:
- session-open-surface.ts: `open` result `platform`/device projection.
- session-perf.ts: the perf/frames/memory base response builders.
Both now go through `publicPlatformString(device)`, so output stays the leaf
`ios`/`macos` per approach (b). (The android/non-apple perf branches were
already leaf-safe.)

Also update macos-desktop provider test: the lifecycle mock observes the
INTERNAL DeviceInfo, which is now `platform:'apple'` (+ appleOs:'macos'), so the
recorded tag is `prepare:apple:desktop`.

Fixes the provider-integration assertions that blocked both the Integration
Tests and Coverage CI jobs (both run the provider-integration project).
Verified: provider-integration 82/82, coverage passes, tsc/oxlint/oxfmt/layering/
fallow green.

* fix: project platform to the public leaf at nested output sites (#979)

The Platform collapse (approach b) projects device.platform through
publicPlatformString at emit sites so machine consumers keep seeing the
leaf ios/macos and never the internal `apple`. Several nested output
fields were missed. Project them and narrow their emitted types to
PublicPlatform:

- Apple perf memory snapshot support (buildAppleMemorySnapshotSupport) —
  response.support.platform / artifact.support.platform, plus the
  sibling sampleAppleFramePerf error data.
- Apple xctrace perf capture/result platform surfaced in the perf
  cpu-profile started/stopped response data.
- snapshotDiagnostics.stats.platform (recordSnapshotTiming) surfaced in
  snapshot/test response data and the slow-snapshot warning string.
- doctor target-app evidence.platform + human summary, and doctor
  target-app-device evidence.booted[].platform.
- provider/cloud UNSUPPORTED_OPERATION error.data.platform for cloud
  Apple devices (reachable via deviceFieldsFromPublicPlatform).

Internal 'apple' emissions (selector-matching input, diagnostic
emitDiagnostic telemetry, session appLog state, replay .ad flags) are
left as-is. Adds focused tests pinning Apple perf memory support to the
leaf and a guard asserting no emitted platform field equals 'apple'.
2026-07-01 19:29:46 +02:00
Michał Pierzchała d1b56c0897 0.18.1 v0.18.1 2026-07-01 16:26:05 +02:00
Michał Pierzchała 4cd40aa621 feat: polish replay test progress reporter (#998)
* feat: polish replay test progress reporter

* test: stabilize replay reporter cursor test in CI

* refactor: dedupe replay reporter live progress checks

* fix: make Expo build cache path configurable
2026-07-01 16:21:56 +02:00
Michał Pierzchała d82675ca80 refactor: relocate Apple plugin, interactor, interactions under platforms/apple (#975) (#1001)
Phase 3 d.1. Makes getInteractor's core -> platforms routing the final shape by
moving the Apple-specific plugin pieces under src/platforms/apple/ while keeping
the generic registry where non-interactor core code can still import it.

Moved to src/platforms/apple/:
- plugin.ts       - the applePlugin instance (APPLE_SUPPORTS_BY_DEFAULT closures,
                    appLog/perf facets, createInteractor/discoverDevices), extracted
                    from the former core/platform-plugin/register-builtins.ts
- interactor.ts   - was core/interactors/apple.ts (createAppleInteractor)
- interactions.ts - was platforms/ios/interactions.ts (the Apple interaction
                    dispatcher: iOS synthesized gesture / tvOS remote-press /
                    macOS desktop-scroll); platforms/ios/ is now removed
- __tests__/watchos-sentinel.test.ts - co-located with its subject

Stayed in core/ (layering):
- The generic registry + PlatformPlugin type stay in core/platform-plugin/plugin.ts.
  core/capabilities.ts (non-interactor core) imports getPlugin/tryGetPlugin, and R3
  forbids core outside core/interactors/ from statically importing platforms/, so the
  registry cannot move.
- register-builtins.ts moved to core/interactors/register-builtins.ts (still core/):
  the android/linux/web wiring plus the registry-population entry point. As an
  interactor-seam module it is the one place R3 permits a static value import of
  platforms/, so it pulls in applePlugin and keeps the exhaustiveness assertion.
- apple-os-capabilities.ts stays in core/ (smallest move; the moved Apple closures
  reach it via a legal platforms -> core import).

Layering guard passes (R1/R2/R3, 678 files). The applePlugin only reaches leaf code
via lazy dynamic import(), so the new static platforms/apple/plugin.ts import at the
seam does not regress CLI cold-start.
2026-07-01 16:21:24 +02:00
Michał Pierzchała f0b926c21f fix: trim root type exports (#999) 2026-07-01 14:47:03 +02:00
Michał Pierzchała 73c4439574 refactor: read Apple capability closures from a per-AppleOS table (#978) (#997)
Introduce a per-`AppleOS` capability data table
(`src/core/platform-plugin/apple-os-capabilities.ts`) — the capability-axis
sibling of `RUNNER_PLATFORM_PROFILES` and the Swift `#if os()` guards — and have
the Apple capability closures read `device.appleOs` through it, collapsing the
scattered `target !== 'tv'` / `platform !== 'macos'` / `isTvOsDevice` predicates
into one lookup (`resolveDeviceAppleOs` + `appleOsCapabilities`).

Discipline (perfect-shape §7 / ADR-0009 step d.5): relocate the OS-axis
predicates into data, never change behavior. Only the AppleOS-shaped facts moved
to the table; the DEVICE-shaped nuance (simulator vs physical device — e.g.
two-finger synthesis is iOS-simulator-only, and the physical-iOS hint) stays in
the reading closure, and non-Apple branches keep their verbatim verdicts
(`appleOsCapabilities` returns `undefined` off the Apple family).

Parity gate: a new table-equivalence test
(`apple-os-capability-table-parity.test.ts`) pins the table-driven closures
byte-for-byte against an INDEPENDENT verbatim copy of the original predicates
across the full {command × sample-device} matrix, and the existing
capability-plugin-routing-parity test is extended with appleOs-bearing
iPadOS/visionOS fixtures so the stored-`appleOs` read path is covered
(iOS/iPadOS/tvOS/macOS/visionOS).

Deferred (behavior-change risk / out of scope): the `isTvOsDevice` interaction
leaves (dispatch-interactions, interactors/apple, platforms/ios) are irreducible
per-device gesture/focus synthesis, not capability admission; per-gesture table
granularity (pinch/rotate/transform) is left as one `multiTouchSynthesis` field
since all three are uniform today.
2026-07-01 14:25:07 +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 d21b8ce32e feat: add doctor command (#883)
* feat: add doctor command

* fix: reduce doctor command complexity

* fix: classify doctor integration flags

* fix: simplify doctor setup

* refactor: split doctor checks

* fix: simplify doctor check set

* fix: include stopped android avds in devices

* fix: report doctor device inventory

* refactor: reuse device inventory selectors

* fix: summarize doctor inventory by platform

* fix: show metro cwd in doctor

* refactor: simplify metro doctor lookup

* fix: update doctor imports after apple consolidation

* feat: make doctor Metro probe controllable and surface hidden toolchain failures

Two gaps found while verifying the doctor command on a real environment:

- Metro host/port were uncontrollable from the CLI: --metro-host/--metro-port
  were rejected by allowedFlags, and readDoctorOptions only read them from
  req.runtime (populated by remote/connection profiles, never a plain CLI
  flag). The Metro check's own hint told users to 'pass the correct
  --metro-host/--metro-port', which did not exist. Declare the flags and read
  them from req.flags (runtime kept as fallback) so the probe can target any
  endpoint, e.g. from outside an RN/Expo project directory.

- A broken per-platform toolchain was silently hidden: readDoctorDeviceInventory
  dropped inventory failures whenever any other platform returned devices, so a
  broken Xcode or Android SDK still reported a green 'pass'. Keep the failures
  and surface each as a warn (device-<platform>) when other platforms have
  devices; scoped --platform runs stay quiet.

* fix: align doctor CI expectations

* feat: extend doctor preflight checks

* fix: keep doctor checks within ci gates

* fix: simplify doctor metro surface

* refactor: trim doctor bundle impact

* fix: restore useful doctor diagnostics

* refactor: reuse doctor output helpers

* refactor: share device inventory grouping

* refactor: keep doctor focused on preflight checks

* refactor: simplify doctor toolchain probes

* fix: keep scoped simulator hint generic

* fix: clarify doctor Xcode selection context

* fix: recognize provider scope in remote doctor

* fix: address doctor review gaps

* fix: keep doctor metro checks inferred
2026-07-01 14:24:32 +02:00
Michał Pierzchała 6e8e45fdaf refactor(daemon): route perf support gate through PlatformPlugin perf facet (#995)
Add the `perf` facet to `PlatformPlugin`, typed platform-neutral as
`{ supportsMetrics(device: DeviceInfo): boolean }` (never the iOS provider
seam). Populate it by wrapping the Apple + Android arms of the existing
`supportsPlatformPerfMetrics` predicate verbatim (both return `true`), leave
linux/web factless, and route `supportsPlatformPerfMetrics` in
`daemon/handlers/session-perf.ts` through `tryGetPlugin(...).perf?.supportsMetrics`
with a `?? false` fallthrough that preserves the former hand disjunction.

Pinned by a table-equivalence parity test
(`daemon/__tests__/perf-plugin-routing-parity.test.ts`) that mirrors the merged
appLog gate: an independent verbatim copy of the former predicate is the BEFORE
oracle across the exhaustive platform x kind x target device matrix, facet
presence/fallthrough are asserted, and `buildPerfResponseData` is exercised with
no-app sessions to prove the daemon actually routes through the facet.

Only the support gate is routed; the perf sampling body (`buildPerfResponseData`)
and the Android-only native-collector gate stay on their daemon branch until each
clears the same gate. Follows the merged appLog facet template (type-only
core->daemon edge, lazy `registerBuiltinPlatformPlugins()`).

Refs #974
2026-07-01 13:37:32 +02:00
Michał Pierzchała 56b41a53ab feat: add cross-platform audio probe (#880)
* feat: add web audio probe

* fix: stabilize web audio probe

* test: cover audio probe review gaps

* fix: address audio probe review feedback

* feat: support macOS audio probe

* docs: document audio probe help

* feat: support simulator audio probe

* test: account for host audio platform support

* refactor: deepen audio probe lifecycle

* perf: trim audio probe package size

* refactor: address audio probe review comments

* refactor: remove audio probe leftovers

* fix: encode audio probe eval options as data

* fix: document audio probe eval sanitization

* fix: sanitize audio probe eval options

* fix: use codeql-recognized eval option sanitizer

* fix: allowlist audio probe eval options

* fix: avoid json-stringified audio eval action

* refactor: trim audio probe input surface

* fix: align audio probe with apple helper paths

* test: update audio capability parity oracle

* refactor: isolate host audio probe backend

* fixup! refactor: isolate host audio probe backend

* fixup! refactor: isolate host audio probe backend

* fixup! test: update audio capability parity oracle
2026-07-01 13:27:45 +02:00
Michał Pierzchała a707348b9b feat: add hosted WebDriver provider support (#948)
* feat: add cloud webdriver artifacts

* fix: clean up local session after provider release failure

* fix: tag cloud webdriver provider requests

* feat: connect hosted webdriver providers

* docs: document hosted provider credentials

* refactor: tighten cloud webdriver provider internals

* refactor: consolidate cloud webdriver provider definitions

* refactor: collapse hosted webdriver runtime wrapper

* refactor: reduce cloud webdriver smell surface

* docs: clarify hosted provider interfaces

* fix: avoid regex slash trimming in webdriver urls

* docs: rename hosted providers to device clouds

* fix: align provider profile imports with remote modules

* fix: skip local android recovery for provider devices

* test: classify cloud provider integration flags

* fix: close active cloud connection session

* test: cover provider disconnect cli flow

* fix: make cloud webdriver sessions launchable

* fix: align cloud webdriver input gestures

* fix: avoid keyboard input during cloud scroll

* fix: constrain cloud webdriver scroll gestures

* refactor: isolate cloud webdriver scroll frame

* refactor: deduplicate cloud webdriver helpers

* refactor: tighten cloud webdriver action types

* fix: harden cloud webdriver release

* fix: polish provider disconnect diagnostics

* refactor: group connection profile helpers

* fix: repair rebased internal paths

* fix: satisfy cloud webdriver CI guards
2026-07-01 13:01:47 +02:00
Michał Pierzchała 3454ff1aa5 refactor: relocate Apple supports()/unsupportedHint() closures onto the plugin (#973) (#993)
Phase 3 step b.2. Move the per-command supports() / unsupportedHint() device
closures VERBATIM off the command-descriptor facet onto the owning
PlatformPlugin's capability.supportsByDefault / unsupportedHintByDefault
(perfect-shape §7 / ADR-0009: relocate, never flatten). Bodies are byte-for-byte
identical; only their ownership moves to the Apple plugin, the family that owns
every discriminating device (macOS-coordinate-pinch, tvOS-no-touch, physical-iOS,
two-finger-synthesis).

The relocation is faithful because every closure is a no-op (returns true /
undefined) on non-Apple devices, so consulting it only for the Apple family
leaves admission unchanged across the full device matrix. isCommandSupportedOnDevice
and unsupportedHintForDevice now read the closure off getPlugin(device.platform);
the command facet carries platform/kind buckets only, and supports/unsupportedHint
are removed from the CommandCapability type.

Parity gate (byte-for-byte, before deleting the hand sites): independent VERBATIM
oracle in capability-plugin-routing-parity.test.ts pins (a) production admission +
hint output unchanged across the {platform x command x kind x target} matrix, and
(b) the relocated Apple-plugin closures are behaviorally identical to the originals
across the device-fixtures sample matrix, with a guard that no non-Apple family
grew a gate.
2026-07-01 12:38:26 +02:00
Michał Pierzchała c0b9c54c44 refactor(apple): promote tvOS to an explicit Apple-OS leaf (#976) (#992)
Name the tvOS Apple-OS leaf instead of branching on a `target === 'tv'`
string smeared across the Apple interaction paths (Phase 3 d.2, part of #972).

- Add `isTvOsDevice(device)` to kernel/device.ts — the sink, so core, the
  command-descriptor registry, and the platform code can all gate on one
  explicit, Apple-only predicate (the layering DAG forbids core importing
  platforms). Android TV shares `target: 'tv'` but is a distinct leaf, so the
  `platform === 'ios'` gate is load-bearing.
- Extract the XCUIRemote focus-navigation command builder into a real
  `src/platforms/apple/os/tvos/` leaf (mirroring os/macos/), delivering the
  dedicated tvOS leaf ADR-0009 deferred.
- Route the unambiguous tvOS gates (interactor back/home/scroll, iOS-touch
  synthesis, registry capability predicates) through `isTvOsDevice`.

Do-not-flatten preserved: the tvOS focus-only interaction contract stays
per-OS. back/home/scroll drive XCUIRemote focus; coordinate tap goes to the
runner un-synthesized (rejected off the focused element); pinch/rotate/
transform stay UNSUPPORTED. This is a rename/extraction — behavior is
byte-identical for tvOS devices.

Intentionally left (noted inline): devices.ts `resolveAppleOs` (the canonical
target→appleOs classification source), and the dispatch pinch/rotate/transform
gates — those `target === 'tv'` checks also reject Android TV, so narrowing
them to the Apple-only leaf would change Android-TV behavior.

Tests: `isTvOsDevice` Apple-only gate (excludes Android TV); tvOS back/home
focus navigation via remotePress menu/home + iOS keeps in-app back; tvOS
rotate/transform reject UNSUPPORTED at dispatch (alongside existing pinch).
2026-07-01 12:24:18 +02:00
Michał Pierzchała 643ed2cbd3 feat: route daemon app-log backend through PlatformPlugin appLog facet (#974) (#991)
Phase 3 b.3: add the first daemon-owned facet to `PlatformPlugin`, typed
against a PLATFORM-NEUTRAL wrapper and pinned by a table-equivalence parity
test before the daemon lookup routes through it.

- `PlatformPlugin.appLog.resolveBackend(device): LogBackend` — a neutral
  string-union tag (never the iOS-simulator provider seam). Optional facet,
  present only on families with an app-log backend (Apple + Android).
- Populate by wrapping the existing `resolveLogBackend` branch verbatim on the
  Apple/Android plugins; linux/web omit the facet and the daemon lookup
  preserves the historical `'android'` fallthrough.
- Route `src/daemon/app-log.ts`'s `resolveLogBackend` through
  `tryGetPlugin(...).appLog?.resolveBackend(...)`; populate the registry at
  module load (idempotent, lazy — mirrors `core/capabilities.ts`).
- Pin with `applog-plugin-routing-parity.test.ts`: the routed function is
  byte-identical to an independent verbatim copy of the former hand branch
  across the device fixtures + exhaustive platform x kind x target matrix.

Deferred (not populated / not parity-added to the contract): `providers`
(load-bearing scope-orchestration seam), `recording` (needs the
de-iOS-naming start/stop-context redesign), and `perf` (heavy internal
response-builder). Each stays the daemon branch's source of truth.
2026-07-01 12:23:40 +02:00
Michał Pierzchała f627203cca feat: gate watchOS as an explicit unsupported sentinel (closes #977) (#990)
XCUITest cannot drive watchOS UI (no XCUIApplication), so a watchOS device has no
runner backend. Today `appleOs: 'watchos'` silently falls through to the iOS
runner profile (resolveRunnerPlatformNameForAppleOs). Reject it explicitly at the
admission seam — createAppleInteractor — with a clear UNSUPPORTED_PLATFORM error,
before any runner work.

Discovery never produces watchOS today (resolveAppleOs only yields
ios/ipados/tvos/visionos), so this changes no real-device behavior; it makes the
"declared but unsupported" watchOS case honest instead of a silent wrong fallback.
Per-AppleOS capability-table integration is deferred to d.5 (#978).

Adds a focused unit test asserting the watchOS rejection and that a non-watchOS
appleOs does not trigger the sentinel.
2026-07-01 12:23:16 +02:00
Michał Pierzchała 1d1cf0eb02 test: relocate the 2 deferred Apple runner/recording tests (closes #980) (#988)
Completes the #983 relocation. The 2 files deferred there compute runtime fs
paths (fileURLToPath/__dirname), so they needed path-depth fixes on top of import
re-relativization:

- runner-client.test.ts -> src/platforms/apple/core/__tests__/ (joins the rest of
  the Apple engine tests); repoRoot recomputed one level deeper.
- recording-scripts.test.ts -> src/recording/__tests__/ (beside the overlay.ts it
  tests); ios-runner RecordingScripts + test/integration/support paths recomputed
  one level shallower.

src/platforms/ios/__tests__/ is now empty (all Apple-engine tests live beside
their source under apple/core and recording). Pure test relocation — tsc, oxlint,
oxfmt, layering guard, fallow green; full unit suite passes (2883).
2026-07-01 11:35:27 +02:00
Michał Pierzchała 2e0879a260 fix: exempt sdk/ public barrels from R3 platforms-seam (unblock main) (#987)
#984 added the R3 platforms-seam layering rule and #986 moved the public SDK
entry barrels into src/sdk/; the two merged mutually inconsistent, so the
Layering Guard is failing on main. sdk/ are public re-export barrels that
legitimately expose platform symbols and are off the CLI cold path (not imported
by bin.ts), so they are a correct R3 exemption alongside core/interactors and the
daemon server — not a cold-start regression.
2026-07-01 10:21:03 +02:00
Michał Pierzchała 87c43562d3 refactor: consolidate public SDK entry barrels into src/sdk/ — Phase 5 (#986)
Move the package's public entry points into a dedicated src/sdk/ folder so the
public surface lives in one clearly-owned place, per plans/perfect-shape.md §5.5
("sdk/ = re-export barrels only") and the §3 target DAG. Follows the #951/#960
pattern (behaviorless path codemod; rslib entry KEYS unchanged so dist output
paths — and therefore package.json `exports` — stay byte-identical).

Barrel structure (all 11 public subpaths now resolve through src/sdk/):
- 9 already-thin barrels moved verbatim (git rename, internal import paths
  re-depthed by one ../): index, artifacts, metro, batch, remote-config,
  install-source, android-adb, contracts, selectors.
- io and finders keep their implementation at src root (real logic + internal
  importers); src/sdk/io.ts and src/sdk/finders.ts re-export them (export *).

package.json exports/main/types: UNCHANGED. Each rslib entry key is preserved
(index, io, ...), so dist output stays dist/src/<name>.{js,d.ts} and the
published subpaths + files do not move. No exports rewrite was needed.

Repoints:
- rslib.config.ts: 11 entry sources -> src/sdk/*.ts (keys unchanged).
- 7 public *-test imports -> src/sdk/*.
- 3 internal src importers of the selectors barrel (find.ts, selector-read.ts,
  resolution.ts) split to import impl directly (utils/selectors-parse.ts +
  daemon/selectors.ts) — removes internal dependence on the public barrel.
- .fallowrc.json entrypoints + vitest coverage exclude (src/sdk/** glob).

Published-surface verification (npm pack --dry-run before/after):
- exports map identical; 146 packed files identical EXCEPT one internal
  code-split chunk renumbered (dist/src/9836.js -> 893.js, byte-identical
  content).
- All 11 public .d.ts byte-identical; 10/11 public .js byte-identical;
  selectors.js differs only by that internal chunk reference. Same symbols.

Gates: tsc 0 · rslib build 0 · oxfmt/oxlint clean · fallow audit (31 files) clean
· vitest unit 2883 pass.
2026-07-01 10:03:57 +02:00
Michał Pierzchała e229957602 refactor: split daemon server runtime into daemon/server/ — Phase 5 (#985)
Phase-5 §5.5 folder move (server side; the daemon/client/ split shipped in
#962). Extracts the process-bootstrap / server-runtime cluster into
src/daemon/server/ as a pure, behaviorless path codemod — no logic changes.

Moved (server bootstrap/runtime — the layer that spins up the daemon and
owns the platform graph; each imported only by the bootstrap layer + each
other):
  src/daemon-runtime.ts          -> src/daemon/server/daemon-runtime.ts
  src/daemon/http-server.ts      -> src/daemon/server/http-server.ts
  src/daemon/transport.ts        -> src/daemon/server/transport.ts
  src/daemon/server-lifecycle.ts -> src/daemon/server/server-lifecycle.ts
  src/daemon/server-shutdown.ts  -> src/daemon/server/server-shutdown.ts

Left in src/daemon/ root (request core / shared wire helpers, out of scope):
  request-router.ts, handlers/, session-store.ts, lease-registry.ts, context.ts
  (the daemon's request layer) and http-contract.ts / http-health.ts /
  http-errors.ts / config.ts (HTTP wire contract + daemon config shared across
  client, remote, and cli — not server-only).

Left: src/daemon.ts (the thin process entry) stays at src/ with the other
package entrypoints; it is coupled to its physical path by four non-import
string references (rslib entry, config dev-mode sentinel, process-identity
detection regex, daemon-client launch srcPath), so moving it is beyond a pure
import codemod.

Rewrote every from/import/import()/type-only specifier per importer
(resolve-based path.relative recompute) across src and test, and renamed the
fallow health-baseline key for http-server.ts. daemon-runtime's static
platforms/ import is now inside the daemon-server seam the layering lint
(#984 R3) allows.

Verification: tsc --noEmit 0; layering check (branch script) unchanged (3
pre-existing R3 violations, 0 new); oxfmt clean; oxlint --deny-warnings 0;
fallow audit --base origin/main clean (14 files); rslib build 0
(internal/daemon entry still emits); vitest 17 passed (daemon-entrypoint,
http-server-rpc-validation, server-shutdown + 3 provider-integration).
2026-07-01 10:02:59 +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 6cd0ec3d20 test: relocate Apple engine unit tests from platforms/ios to platforms/apple/core (#980) (#983)
After #968 moved the OS-agnostic Apple engine to src/platforms/apple/core/, its
tests still lived in src/platforms/ios/__tests__/. Move the 19 that test Apple
engine code into src/platforms/apple/core/__tests__/ so tests sit beside their
source, re-relativizing every import / dynamic import / vi.mock / vi.importActual
specifier to the new depth.

Deferred (still in src/platforms/ios/__tests__/): recording-scripts.test.ts and
runner-client.test.ts — both compute runtime fs paths (__dirname / fileURLToPath)
to ios-runner artifacts, so they need path-string fixes, not just specifier
re-relativization. Tracked under #980.

Pure test relocation — no source or behavior change. tsc + oxlint + oxfmt green;
the moved suites pass (384 tests across the apple + deferred dirs).
2026-07-01 09:16:25 +02:00
Michał Pierzchała db0e084c30 docs: retire plans/phase3-platform-plugin-progress.md; track remaining work in issues (#982)
The remaining Phase 3 Apple PlatformPlugin work (steps b + d) is now filed as
GitHub issues under umbrella #972, so the standalone progress plan is redundant
and a staleness hazard (it already drifted once re: cost.runnerRoundTrips).

- Remove plans/phase3-platform-plugin-progress.md.
- Repoint its references at the durable sources: perfect-shape.md (x3) and
  ADR-0009 now link the Phase 3 tracking issue #972; the plugin.ts step-b facet
  note points at ADR-0009 (+ issue #974). Design rationale stays in
  perfect-shape.md and ADR-0009; live status lives in the issues.
2026-07-01 09:16:01 +02:00
Michał Pierzchała 62b3eb5c4c docs: clarify request-count gate vs. restored cost.runnerRoundTrips in Phase 3 plan (#971)
The Step (c) request-count bullet claimed both the dev-only CI gate AND the
runtime `cost.runnerRoundTrips` surface were removed in #968. #970 restored the
public agent-cost field, so the bullet is stale/misleading for the next
Apple/agent-cost worker.

Split the bullet: the dev-only request-count CI gate (the #966 --debug ndjson
counter + smoke-ios assertion) stays removed (zero runner events on main runs);
the runtime `cost.runnerRoundTrips` agent-cost field (ResponseCost /
buildResponseCost over RUNNER_ROUND_TRIP_PHASES) is a separate pre-existing
surface, restored in #970, and remains part of the agent-cost contract.
2026-07-01 07:37:53 +02:00
Michał Pierzchała f9445d0041 fix: restore runnerRoundTrips agent-cost field dropped in #968 (#970)
PR #968 (apple-platform-consolidation) accidentally dropped the
runnerRoundTrips field from the agent-cost block during an over-broad
conflict resolution. This restores the shipped Phase-4 feature:

- src/kernel/contracts.ts: re-add ResponseCost.runnerRoundTrips: number
- src/utils/diagnostics.ts: restore the countDiagnosticEventsByPhase()
  accessor over the flush-surviving phaseCounts tally (the tally itself
  survived #968; only the accessor was removed)
- src/daemon/request-router.ts: repopulate runnerRoundTrips in the cost
  graft by counting the two real round-trip diagnostic phases
  (ios_runner_command_send + ios_runner_readiness_preflight). The
  RUNNER_ROUND_TRIP_PHASES constant is now defined locally (its former
  home, the dev-only runner-request-count.ts, was removed in #968 and is
  out of scope to restore)
- request-router-cost.test.ts: restore the round-trip counting test and
  the runnerRoundTrips:0 assertion

Byte-identical-default invariant preserved: with --cost OFF or on an
error response the serialized payload is unchanged.
2026-07-01 07:24:44 +02:00
Michał Pierzchała 3df1e145b4 docs: fix stale platforms/ios paths after the Apple consolidation (#968) (#969)
Post-#968 follow-up: the OS-agnostic Apple runner engine moved from
src/platforms/ios/ to src/platforms/apple/core/, but several docs/config still
pointed at the old locations, misleading agents that grep those paths.

- AGENTS.md: repoint the runner-seam map, the Apple-family sync rule, the
  record/trace seam, the search-roots hint, and the platform-backends list at
  src/platforms/apple/core/...; rename "iOS Runner Seams" -> "Apple Runner Seams".
- .fallowrc.json: drop the two stale ignoreExports entries for the removed
  src/platforms/ios/apps.ts and src/platforms/ios/index.ts (the live
  src/platforms/apple/core/apps.ts entry already covers those test-only exports).
- ios-runner/{README,RUNNER_PROTOCOL}.md: point the TypeScript-client links at
  src/platforms/apple/core/runner/runner-client.ts.

Docs/config only; no behavior change. fallow audit + build/lint stay green, and a
live iOS simulator replay suite (6/6) confirms the consolidated runner works.
2026-07-01 07:24:22 +02:00
Michał Pierzchała 26ac865c63 refactor: consolidate Apple platform internals (#968) 2026-06-30 21:30:46 +02:00
Michał Pierzchała f4882bc706 feat: support live replay test reporters (#959)
* feat: support live replay test reporters

* refactor: simplify replay progress readers

* fix: preserve verbose replay reporter progress

* feat: expose semantic replay reporter hooks

* refactor: trim replay reporter context

* refactor: trim reporter progress internals

* refactor: move replay test reporting under replay

* refactor: make live replay reporter hooks synchronous and simplify dispatch

Live reporter hooks (onSuiteStart/onTestStart/onTestStep/onTestResult)
were typed as `void | Promise<void>` but fired from the synchronous daemon
progress stream reader without being awaited, so a stateful async reporter
could receive onSuiteEnd before its live work settled. Type them as `void`
to make the contract honest; onSuiteEnd stays awaited for async flushing.

A returned promise from a misbehaving custom JS reporter is still caught so
it cannot crash the CLI with an unhandled rejection, but it is documented as
unsupported and not awaited.

Collapse the four near-identical per-event hook dispatch branches into a
single table-driven path, and document the synchronous-hook and
exit-code-escalation contracts. Add a regression test covering a throwing
live hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XXHAYxWpvSzqc6CtneYL8J

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 21:10:22 +02:00
Michał Pierzchała 60d1bd1765 fix: respect prepare timeout for runner health checks (#967)
* fix: respect prepare timeout for runner health checks

* fix: share prepare ios-runner timeout budget
2026-06-30 18:27:54 +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 903a35624e fix: extend iOS physical install timeout (#964) 2026-06-30 17:05:16 +02:00
Michał Pierzchała f430888baa refactor: route capability bucket through PlatformPlugin + pin supports() closures (Phase 3 step b) (#965)
b.1: isCommandSupportedOnDevice now reads each platform's capability bucket
from getPlugin(device.platform).capability.bucket (the PlatformPlugin registry,
ADR-0009) instead of the platformDescriptors fold. capabilities.ts registers the
builtin plugins at module load (idempotent, lazy closures only) so the admission
path populates the registry without depending on core/interactors.ts load order.

b.2: the per-command supports()/unsupportedHint() closures stay VERBATIM on the
command-descriptor facet; they cannot move to the plugin's per-FAMILY
capability.supportsByDefault without flattening their per-command shape
(perfect-shape §7). A new table-equivalence parity test pins both the bucket-route
swap and the closures byte-for-byte across the full platform x command x
device-kind x target matrix.
2026-06-30 17:04:33 +02:00
Michał Pierzchała bcf910a2bb refactor: split daemon client driver into daemon/client/ — Phase 5 (#962)
Move the daemon CLIENT driver (the in-process side that sends requests to a
running daemon) out of the src/ root into src/daemon/client/, per
plans/perfect-shape.md §5.5 ('daemon/client/ <- daemon-client*.ts'; the
daemon- prefix co-located client driver + server bootstrap at src root).

Files moved (7): daemon-client{,-lifecycle,-metadata,-progress,-rpc,-timeout,
-transport}.

- git renames; 19 importers repointed via the resolve-based codemod
  (intra-set stays ./, kernel -> ../../, daemon/remote deps recomputed)
- Layering Guard verified: none import src/commands/* (safe under src/daemon/)
- not a public export; no rslib impact
- update fallow-baselines/health.json keys

Behaviorless path codemod; typecheck/lint/format/build/tests green.
2026-06-30 15:51:53 +02:00
Michał Pierzchała 1cbe446df5 chore: prune public package exports (#961)
* chore: prune public package exports

* chore: drop extra facade exports

* chore: remove dead export leftovers

* docs: align public API docs
2026-06-30 15:51:37 +02:00
Michał Pierzchała 189b062519 refactor: extract client/ folder — Phase 5 (#960)
Move the SDK client + companion-tunnel cluster out of the src/ root into a
dedicated src/client/ folder, per plans/perfect-shape.md §5.5 (~8k LOC
client/remote unfoldered at src root; remote/ already extracted in #951).

Files moved (9): client, client-types, client-shared, client-normalizers,
client-companion-tunnel{,-contract,-worker}, client-react-devtools-companion,
companion-tunnel.

- git renames; 68 importers repointed via the resolve-based codemod
  (intra-client stays ./, staying deps recomputed)
- companion-tunnel.ts keeps rslib key 'internal/companion-tunnel' so dist
  output stays dist/src/internal/companion-tunnel.js (public subpath
  byte-identical; verified by build)
- update non-src importers (3 integration tests, vitest coverage include),
  .fallowrc.json entrypoint, fallow-baselines/health.json keys

backend.ts and daemon-client*.ts are intentionally left for follow-up
(daemon/client split). Behaviorless path codemod; all gates green.
2026-06-30 14:36:59 +02:00
Michał Pierzchała 531aad76d8 refactor: PlatformPlugin registry foundation (step a) — Phase 3 (#956)
* refactor: PlatformPlugin registry foundation + parity tests (Phase 3)

* refactor: trim PlatformPlugin step-a contract to implemented facets

Remove the speculative daemon-owned facets (providers/recording/appLog/perf)
from the PlatformPlugin type. The earlier 'recording' facet baked the
iOS-simulator provider seam (IosSimulatorRecordingRequest) into the contract and
could not represent the Android/web/macOS-runner/iOS-device-runner/stop-path
recording contracts, which need the daemon recording context. The step-a
contract now carries only what this slice implements and parity-tests:
id, platforms, familySelector?, createInteractor, discoverDevices, capability.
The facets are introduced in step (b) as platform-neutral, daemon-owned
wrappers, pinned by table-equivalence parity tests (plan updated).
2026-06-30 14:36:33 +02:00
Michał Pierzchała 305594f6b7 fix: avoid unsafe iOS keyboard dismissal (#957) 2026-06-30 14:08:41 +02:00
Michał Pierzchała 65227c6719 refactor: absorb CLI parser into cli/parser/ — Phase 5 (#958)
Move the CLI argument/flag/help parser out of utils/ into a dedicated
src/cli/parser/ folder, per plans/perfect-shape.md §5.5 (utils/ hosts a 3k
CLI parser among its buried subsystems).

Files moved (3): args, cli-flags, cli-help (args->cli-help intra-set import
stays relative).

- git renames; importers repointed via the resolve-based codemod
  (64 importers; staying-utils/kernel deps recomputed to ../../)
- no public-export/rslib impact
- update scripts/integration-progress-model.ts import + fallow-baselines/
  health.json keys (args incl. :high impact variant)

Behaviorless path codemod. typecheck/lint/format/build/tests green;
integration-progress model still runs.
2026-06-30 13:56:04 +02:00
Michał Pierzchała afcf79abfc feat: find/get digest response-views + batch-step elision — Phase 4 (#955)
* feat: find/get digest response-views + batch-step elision — Phase 4

Add opt-in leveled response views for the find and get selector reads and
elide intermediate batch steps to digest, completing the two remaining
Phase 4 agent-cost grafts. All additions activate only when a non-default
responseLevel (digest/full) is requested; the default wire shape is
byte-identical to today (Maestro .ad recompare safe).

- response-views: register a shared selectorReadView under find and get.
  A text read keeps ref/selector + text and drops the redundant verbose
  node; an attrs read keeps a compacted node (semantic attributes only,
  geometry/index/process plumbing dropped); exists/wait/click keep their
  cheap actionable signals. default/full return today's shape unchanged.
- batch: when a non-default level is requested, intermediate steps are
  forced to digest while the final step keeps the requested level. With no
  responseLevel the per-step meta is passed through unchanged.
- tests mirror the existing response-views / response-level suites.

* fix: make find/get digest conservative — never drop interaction warnings

Review feedback on #955: `find` is registered command-wide, but
`find fill/focus/type` return the underlying INTERACTION response, which can
carry cheap, agent-critical signals (notably `warning` from Android
blocking-dialog recovery, plus `message`). The previous allowlist-based digest
silently dropped those under --level digest.

The only token sink in a find/get result is the verbose matched snapshot
`node`, which appears solely on a selector READ (text/attrs). The view is now
conservative: it acts ONLY on a result carrying such a node and otherwise
returns the data UNCHANGED, so node-less shapes (exists/wait/click and the
fill/focus/type interaction responses) are never narrowed. For a text read the
redundant node is dropped; for an attrs read the node is compacted; in both
cases every other cheap field (e.g. `warning`) is preserved verbatim.

Adds a regression test asserting a `find fill` response carrying a `warning`
is returned unchanged under digest.
2026-06-30 13:55:28 +02:00