mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
main
87 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
508b750fbd |
fix(apple): carry the runner's sparse verdict through a viewport refusal (#2572)
* fix(apple): carry the runner's sparse verdict through a viewport refusal A payload the runner declared sparse carries the backend, reason code, and reason that explain it, yet the daemon dropped all of it while reconstructing a viewport from the synthetic root that sparse payloads always carry. Callers saw only an internal engine invariant. The verdict now travels as error.details.snapshotQuality and the hint composes the shared sparse-capture advice with the presented surface host. * fix(replay): keep the capture quality verdict in divergence details The replay failure wrapper rebuilds cause details from a four-key allowlist, so the verdict a device-facing capture produced could not reach the agent that replayed the step. |
||
|
|
15644b6f0d | feat(devices): answer when a device's current boot began (#2575) | ||
|
|
5cf6414fd2 |
fix(contracts): state the scroll keyboard clip once for every platform (#2537)
* fix(contracts): state the scroll keyboard clip once for every platform * fix(apple): surface the scroll keyboard clip as evidence and a typed reason * refactor(contracts): state the scroll keyboard refusal details once and keep the runner's message The Apple scroll owner rebuilt the refusal per command, discarding the runner's measured message and carrying an unmeasured variant of the error builder for it. The shared reason and hint are now one frozen object in scroll-gesture; the Apple owner adds it to the runner's own error (matched on the typed runner code, transport details kept), and the error builder takes a plain measured occlusion, which only Android produces in-process. The help text names the behaviour in one clause; the hint carries the recovery at the moment it matters. |
||
|
|
8e8eeb2ced |
refactor(snapshot): drop the bridge truncation dimension; stop promising --scope for depth caps (#2511)
The dimension and limit the bridge adapter inferred for a cut capture had no renderer and one consumer, the comparison-identity string, where the kind alone gives the same comparability. The runner never produced them. The depth-cap warning suggested --scope to read deeper content; on iOS scope narrows presentation and acquisition stays scope-blind. |
||
|
|
1527146507 |
feat(snapshot): disclose a cut capture on every platform; raise the iOS bridge node cap to 5000 (#2510)
Every backend sets truncated: true when it cuts a capture at one of its limits, but only JSON carried it. One shared warning now renders from that flag in the cross-platform warnings assembly and tells the agent what fell off (what comes last in document order) and what to do. The iOS Simulator AX bridge cap moves from 1500 to 5000 nodes, the Android helper's bound. Measured on a synthetic 600-row screen, acquisition time did not change with the cap while the 1500 cut dropped the on-screen footer. |
||
|
|
0feb4e26a0 |
feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) (#2448)
* 0.21.1 * feat(ios): drive ASWebAuthenticationSession sign-in sheets in place (#2438) iOS apps that sign in via ASWebAuthenticationSession present the identity provider in com.apple.SafariViewService, out of the app's process. Two facts, both verified live on the iOS 26.2 Simulator, made these flows unautomatable: activating or launching the host cancels the auth session, and the host AX bridge cannot see the sheet because the app stays the AX primaryApp. Serve and drive the sheet in place. A closed registry names the host (shared by the TypeScript and Swift sides under a parity test); the runner reads and drives it without activation and never adopts it as the session target; and the Simulator route detects a running host with a cheap device-scoped ps probe and takes the runner path, since the bridge would serve the occluded app tree as if healthy. open refuses to launch a registered host, and captures carry a system-surface disclosure. Presence is foreground state, not tree content: a torn-down host serves a richer tree than a live one, so content heuristics cannot tell them apart. The never-activate guard is what keeps the foreground predicate sound, which also makes the stale-tree failure mode unrepresentable for this flow. Closes #2438 * chore(gates): register contracts/ios-system-surface in the export snapshot * fix(ios): close the system-surface correctness gaps from review Presence probe: absence and probe failure are no longer reported as "no surface". The probe returns present/absent/unknown and the route takes the runner for anything but a proven absent, so a sheet opened between two captures, or a probe that cannot answer, can no longer fall through to a bridge capture that would answer confidently from the occluded app tree. Only a positive observation is memoized. The probe now matches with pgrep and reads only a matched pid's environment, which is ~3x cheaper than the previous full process-environment dump and stops copying every process's environment. Open guard: the refusal moved to every resolved-host launch and terminate, so the URL, deep-link and launch-args branches that returned before the old check can no longer launch the host. Terminating a host is refused too, since that cancels the presented session just as launching it does. Comparison: the surface identity now reaches SnapshotState, and tap-failure corroboration refuses outright when a baseline and a post-action capture disagree about it, instead of letting app and sheet captures meet in legacy same-presentation matching. Selector routes disclose an iOS system surface through the shared disclosure seam rather than reading only the Android field. The contracts import in the launch path is deferred so the app-lifecycle facade's eager closure stays flat, and the runner's comment prose is trimmed because apple/runner ships to npm as uncompiled source. * fix(ios): route the system-surface probe through the Apple tool provider The probe shelled out with runCmd, so every eligible capture spawned a real process even in provider-backed tests that stub the Apple tool seam — 17 real spawns in one scenario file, which is both wasted work and added latency on timing-sensitive settle paths. It now goes through runAppleToolCommand like the sibling ps probe, so a stubbed provider answers instead of spawning. * fix(ios): disclose a skipped bridge when the surface probe cannot answer Routing an unprovable probe to the runner is right, but the early return also skipped runFallback, so the response lost its warning and kept an identity that could still be compared against a bridge publication. An unknown probe now falls back through the same disclosed path as a bridge failure, with its own reason. * fix(ios): keep surface identity through comparison, find, and probe scope A ps read that carries no SIMULATOR_UDID at all was reported as absence, so an unreadable or truncated environment could route a live sheet to the occluded app tree. Only a scope naming a different device is a real negative now; a missing one stays unknown. The shared post-gesture comparison token used comparisonKey or the backend alone, so an app capture and a sheet capture — both XCTest — compared equal and a sheet appearing or dismissing read as a stable surface. The token now carries the surface, which covers stabilization, verify and settle through the one path they share. Mutating find rebuilt its capture without iosSystemSurfaceBundleId, so the shared disclosure helper could not report the sheet on either outcome. It is preserved now. Each fix has a regression that fails without it. * fix(ios): keep surface identity in verify and settle comparisons `--verify` compared node digests and `--settle` diffed node-only baselines, so an app baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one presentation: a meaningless changed verdict, and a whole-surface replacement presented as an in-surface diff with refs. The pre-action baseline now travels with the surface its capture described, from the resolution and the session frame through to the settled capture, and one module owns the comparison for both routes. Across a surface change no same-surface claim is made: evidence reports the transition instead of a digest comparison, the settled diff and its refs are withheld, and both payloads disclose the transition. * refactor(test): move the cross-surface settle tests onto their source mirror The #2438 cross-surface cases were appended to `settle.test.ts`, taking it over the test-file size ratchet (2528 lines, 2359 at the merge-base). They assert the comparison `post-action-surface.ts` owns, so they move to that module's mirror test file, and the device double plus the trees both files drive move to a sibling fixtures module under `__tests__/` rather than being duplicated. Pure move: every test and every assertion is unchanged, and `settle.test.ts` is back under its merge-base length. * test(daemon): cover the cross-surface settle refusal on the generic route `scroll --settle` and `back --settle` plumb the baseline's surface identity through `baselineSurfaceBundleId`, but nothing asserted it: the generic route had zero coverage of the #2438 refusal, so a regression there would have been silent while the element-targeted route stayed green. Assert the same contract the targeted route guarantees, in both directions and for both commands: no diff is attached across an app/sheet boundary — therefore no tail and no `refsGeneration` — the transition is disclosed, and the settle observation still reports its own verdict alongside that disclosure. Each direction falsifies a different half of the plumbing, so both are needed: dropping the baseline's surface identity fails only the sheet-to-app tests (an app baseline has no surface id to lose), and dropping the settled capture's fails only the app-to-sheet tests. No production change: the plumbing was correct, only untested. * refactor(ios): inline the single-caller surface disclosure wrapper iosSystemSurfaceDisclosure() only mapped provenance-or-nothing onto the shared constant for one caller, so the caller now reads the constant directly and the wrapper is gone. Its test becomes a test of the transition disclosure, which is the function that still earns its place (the "sheet is gone" sentence). readAppleSnapshotResult also called readSystemSurfaceProvenance twice inside one spread; it is bound to a local and read once. * docs(adr): state that a presented surface outranks a requested bundle id prepareActiveCommandContext checks for a presented system surface before it resolves or activates command.appBundleId, so a command naming a different app is still served the sheet. That is intended, but the code does not read that way; the amendment now says it plainly. * refactor(ios): carry the system surface in the capture's comparison lineage A capture of an in-place system surface (a web sign-in sheet) describes a different presentation than a capture of the app, so it must never compare equal to one. The `present` branch of the iOS snapshot route returned a bare fallback, so that capture carried no comparison identity at all, and two comparison sites hand-rolled the distinction from `iosSystemSurfaceBundleId` instead. The probe now reports which host it matched, and the `present` branch goes through `runFallback` like the `unknown` branch beside it, lineaged to `<device>:<host bundle>`. The comparison key then differs from an app capture's by construction, so the surface branch in `hasMatchingPresentation` and the surface concatenation in `snapshotComparisonKey` are gone: both sites are plain key equality again, and neither knows that system surfaces exist. Two captures of the same surface still share a lineage, so they stay comparable with each other. A presented surface is not a bridge failure, so it gets its own warning wording: the bridge is inapplicable here, not unavailable. * refactor(interaction): carry the pre-action baseline as one surface-scoped value The same pre-action tree travelled as a flattened nodes/surface pair at every boundary, and each boundary rebuilt it with a conditional spread. Carry SurfaceScopedNodes itself instead: - ResolvedInteractionTarget gets preAction?: SurfaceScopedNodes, replacing the preActionNodes/preActionSurfaceBundleId pair and the PreActionBaselineFields intersection on all three arms of the union. - SettleObservationCommandOptions gets baseline: SurfaceScopedNodes, replacing baselineNodes/baselineSurfaceBundleId. - RefResolution carries tree: SurfaceScopedNodes instead of nodes plus a loose surfaceBundleId. That retires preActionBaselineFields(), preActionBaseline(), evidenceBaseline(), the local SettleBaseline type, the split-then-reassemble in settleObservationCommand, and the 'preActionNodes' in resolved narrowing tests. SurfaceScopedNodes moves to contracts, where ResolvedInteractionTarget can name it; only two sites now mint one from a SnapshotState. Behaviour is unchanged: the cross-surface guarantees keep their existing tests. * fix(ios): identify a surface capture by what the runner served The `present` path stamped the capture's comparison lineage from the host-side presence probe. That probe answers about a host PROCESS and deliberately stays positive while a dismissed host lingers, so during that window the runner truthfully returned APP content while the route lineaged it to the HOST: the sheet capture before the dismissal and the app capture after it compared equal, and a post-gesture poll could read the transition as a stable surface. Derive the identity from the returned capture's `systemSurface` instead - the runner stamps the surface it actually served - and say which of the two the capture holds in the warning. The probe's host is now evidence only: it names the matched host in a route diagnostic so a lingering window is legible in the daemon log. Other reasons keep their lineage and wording byte for byte. Captures that bypass the route's planning (a pinned backend, a custom-actions read) also reach the runner, and the runner serves the sheet there too. They carried no comparison key at all, so a sheet and app content fell through to legacy presentation matching as one presentation and could corroborate a tap across the two. The capture owner now gives those a surface-scoped identity as well, with no fallback-source residue: nothing fell back. An app capture off the route is untouched. * fix(ios): derive a served surface identity at the one stamping point A runner fallback's comparison identity was decided per call site. The `present` path and the off-route path read the runner's `systemSurface` stamp, but the plain `runFallback` path did not: it stamped the app lineage the route had planned, whatever the runner returned. The probe and the capture are separate observations, so a sheet can appear in the gap between them. With the bridge circuit already disabled for the generation, an app capture and a later sheet capture both received the same app-generation key, so tap corroboration could treat two different surfaces as comparable. `stampFallback` now owns the decision for every runner fallback: the surface the runner served outranks the app lineage the route planned. The reason the bridge was skipped survives either way, and app-generation evidence leaves with the app lineage it describes, so two captures of the same sheet still compare equal. `runSurfaceFallback` keeps only the reason, which is the one thing that path decides. |
||
|
|
1fb276448f |
fix(wait): poll through a retriable runner refusal instead of surrendering the budget (#2493)
* fix(wait): poll through a retriable runner refusal instead of surrendering the budget The iOS Smoke lane started failing on main at the merge of #2486: the new smoke:webview-remote-content scenario ended with `wait text "Jump to form" 20000` failing after 288 ms with RUNNER_BUSY. Three defects stacked up. The scenario reused acceptDeepLinkConfirmationIfPresent, whose readiness landmark was hard-coded to the Automation lab's text. Off that route it can never match, so the helper always fell through to its `alert get` probe — and an XCTest alert query against a live WKWebView screen exceeds the runner's 30 s main-thread execution watchdog (measured 10.1 s to fail locally, 10.6 s in CI), abandoning main-thread work and leaving the runner refusing every following command as RUNNER_BUSY. The landmark is now a parameter and each caller passes its own route's, so the probe runs only when the destination genuinely did not arrive. The depth-frontier scenario carried the same mismatch and is fixed with it. A `wait` is a budgeted retry loop, but it abandoned its whole budget on the first retriable refusal. A poll whose failure the producer itself marked retriable is now ridden out like an unreadable capture: the wait keeps polling to its deadline, records the poll as `retriable` in its timeout evidence, and surfaces the refusal only when no readable capture ever completed. RUNNER_WEDGED is not retriable and still ends the wait at once. That classification was also missing on the path the failure actually took. A runner error recovered from the lifecycle journal after a lost transport response was built with a bare toAppErrorCode, so RUNNER_BUSY reached callers as a RUNNER_BUSY wire code with no `retriable` flag, while the live-response path published it as COMMAND_FAILED plus details.runnerErrorCode and retriable: true. Both paths now read the runner's code through one classifier in runner-contract. Live-validated on a booted iPhone 17 Pro simulator against the fixture app: the destination landmark resolves in 389 ms with no alert probe, the page wait succeeds in 81 ms, and the snapshot still carries Link "Jump to form", the "Email address" field label, and the remote-content-boundary XCTest fallback warning. Driving the old sequence first reproduces the wedge, after which the fixed wait polls its full 20 s in `retriable` polls instead of failing instantly. * test(apple-runner): move journaled runner-code classification to the recovery test The two new cases landed in runner-command-retry.test.ts, which was already over the 1,000-line test-file tripwire, so the size ratchet refused its growth. They assert runnerStatusFailureError's reading of the lifecycle journal, so their home is runner-command-recovery.test.ts, which mirrors that module and drives recovery through the real stack against a scripted fake runner. * test(e2e): let a deep-link route mount before probing for its confirmation alert The 2500 ms destination budget was tuned to the Automation lab on a warm simulator. On CI the WebView lab rendered correctly but was not in the bridge tree that fast, so the helper fell through to its `alert get` probe — and that XCTest query against a live WKWebView exceeds the runner's execution watchdog, leaving every later command refused as RUNNER_BUSY. Measured on a freshly created simulator: with the confirmation alert up the probe is correct and costs 1.6 s, because the alert blocks the route and there is no web view to query; with no alert the landmark resolves in 0.1-1.7 s. The budget only has to outlast an honest mount, and overshooting it costs nothing when a confirmation really is up, since that route never renders until it is accepted. Cold-simulator run of the whole scenario: landmark 400 ms, page wait 613 ms, snapshot keeps the page link, the field label, and the XCTest fallback warning. * fix(wait): keep the poll timeline on a wait exhausted by retriable refusals Review finding on #2493: a wait that spent its whole budget being refused threw the last refusal raw, so the common all-RUNNER_BUSY case carried no captures, waitedMs or polls and could not show where its budget went — contradicting the evidence this PR documents. The mirror gap existed on the other exhaustion shape: when the deadline cancelled the final poll, the wait reported a generic stall and dropped the runner code and retry details instead. Both shapes now raise one error that keeps the producer's code, message, hint and retry details and carries the wait's own evidence, with reason wait_capture_stalled and the original as its cause. A content verdict is still preserved untouched, since it already describes the capture it came from, and whether it outranks the stall verdict stays the caller's policy (wait absent). Live-verified against a genuinely wedged simulator runner: COMMAND_FAILED, retriable true, runnerErrorCode RUNNER_BUSY, reason wait_capture_stalled, captures 6, readableCaptures 0, waitedMs 8041, polls retriable,retriable,retriable,retriable,retriable,deadline. |
||
|
|
47b1cae548 |
fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484) (#2486)
* 0.21.1 * fix(ios): refuse Simulator bridge trees that end at a web view's remote content (#2484) Since 0.21.0 the host AX bridge is the snapshot source for local iOS Simulators. It reads one process, and a WebKit page lives in another: Safari and WKWebView screens were published as chrome plus empty webview nodes, with no ref reaching the page. The decoder now counts AXRemoteElement leaves that sit under a WebView ancestor and reach the viewport, and the source refuses such a tree as remote-content-boundary. The existing route fallback serves XCTest, which resolves remote elements, for the rest of the app generation and discloses the switch in the snapshot warning. Frameless leaves are refused; zero-area and off-screen ones are published. Adds a fixture-backed smoke scenario that drives the WebView lab through the default route, amends ADR 0004 and the bridge README, and shares the e2e snapshotNodes helper. |
||
|
|
f57b42166a |
fix(network): report iOS requests that reused a keep-alive connection (#2433)
* fix(network): report iOS requests that reused a keep-alive connection CFNetwork logs a request URL only on the `com.apple.network:connection` line that opens a connection. A request that reuses a keep-alive connection emits a task summary carrying status, timing, and byte counts but no URL anywhere in the log, so a URL-keyed reader dropped it and the dump silently omitted a request that did happen. An "assert this endpoint was called on startup" check therefore read as a definite fail. Correlate a reused task summary with the connection it names and report it against that connection's origin, with `pathUnavailable` set, its status, and its timing. The request path is not in the log at all, so the dump also notes how many requests it could not name — a gap in observation now reads as a gap rather than as a negative observation. Also stop a URL parsed out of a log line from carrying the punctuation that follows it, so an entry's `url` compares equal to the endpoint under test instead of failing on a trailing comma. The correlation lives in the reader rather than a sibling module because `packages/capture-kit/src/index.ts` may not grow its eager import closure. Refs callstack/agent-device#2430 * fix(network): count keep-alive requests the reader cannot name at all Review of the parent commit found the same definite-negative it fixes, one level down: a reused task summary whose connection was opened before the scanned window resolves to no origin, so it produced no entry and no signal — an empty dump reporting "No HTTP(s) entries were found" for a window that demonstrably carried traffic. Count those in the dump's `unnamedRequests` and say so in the notes, so an unnameable request is still a reported observation. Also order the Apple note builders so the keep-alive note no longer trips the `notes.length === 0` guard that suppresses lifecycle guidance, and give the android-backend test a fixture an Apple dump would actually resolve, so the backend gate it names is the thing it proves. * fix(network): scope connection correlation to the process that opened it Review findings on the parent commits: three ways the reader still answers with something other than what it observed. A connection number is only meaningful within one process, but the index keyed on the number alone, so an app that relaunched and reopened the same number inherited the origin its predecessor had contacted — a request attributed to a host it never reached, which is worse than dropping it. Key the index by the compact log's `name[pid]` and the connection number together; a line whose process cannot be read correlates to nothing and its traffic stays unnamed. The simulator recovery pass merged its dump only when it carried entries, so a recovery window holding nothing but unnameable reused-task summaries discarded that count and the response still reported an empty window. Merge whenever the pass observed traffic in either form, and reserve the "none looked like HTTP traffic" note for a pass that found neither. The trailing-separator strip was global, so a valid URL ending in punctuation became a different endpoint. Take the URL from the delimited `url:` field where the format establishes the separator, and leave a bare URL exactly as matched. Regressions cover each: the same connection number under a different pid, an unreadable process identity, recovery-only unnamed traffic, and a path that legitimately ends in a period. * fix(network): reconcile unnamed keep-alive requests across scan windows The app log and the simulator recovery pass cover different, sometimes overlapping windows, so taking the larger of their two unnamed counts was wrong in both directions: two unnameable requests in one window and three in the other reported three rather than five, and a request the recovery pass resolved stayed counted as unnamed from the app log. Carry the identities instead of a count. Every CFNetwork line names its request as `Task <UUID>.<seq>`, scoped here to the emitting process, so the same request seen in two windows is recognisable as one. A merge unions the unnamed identities and subtracts anything either window managed to name, and a resolved reused request carries its identity as `packetId` so that subtraction has something to key on. `NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of identities is what makes the reconciliation exact rather than a lower bound. Regressions cover disjoint windows, overlapping windows, and a request one window named while the other could not. * fix(network): keep unnamed-request identities out of the response `unnamedRequestIds` collected every unresolved task in the scan window and was spread straight into the response, so `network dump 1` could answer with thousands of task ids: an output whose size tracked the log rather than the requested entry limit. The identities exist to reconcile two scan windows, which is a step that finishes before a dump is returned. Keep them there. `NetworkDump` carries `unnamedRequests` as a count again, bounded by construction; the identities ride `ScannedNetworkDump`, the internal widening that the reader and the merge speak, and the Apple runtime projects them away with `withoutScanIdentities` on the way out. Reconciliation is unchanged: overlapping windows still collapse to one request and disjoint windows still sum, because the merge still sees the identities and recomputes the count from them. Regression: five unnameable tasks against `maxEntries: 1` reports all five and exposes no identity list. * fix(network): return scan identities beside the dump, not on it The Apple route stopped leaking task identities into its response, but Limrun and WebDriver return the scanner result directly and both serve Apple sessions, so an iOS `network dump 1` through either still answered with every unresolved task id in the scan window. Projecting at one producer was never going to hold: `ScannedNetworkDump` was assignable to `NetworkDump`, so returning the scanner result compiled everywhere and each producer had to remember not to. Take the shape away instead. `readRecentNetworkTrafficFromText` returns a `NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside the public dump rather than on it, and `mergeNetworkScans` reconciles the pair. A route returning `scan.dump` cannot carry them out, and a route that forgets does not compile. All four producers are updated; the response shape is unchanged. Regressions cover the Apple, Limrun and WebDriver routes: five unnameable tasks against `maxEntries: 1` report the count and expose no identity list. All three fail if the identities are put back on the dump. |
||
|
|
f4c8f3ddda | refactor(apple): carry one phase Deadline through the runner interfaces and test cancellation as a matrix (#2473) | ||
|
|
fea7ca8a43 |
chore(layering): runner modules reach host-kit only through the runner host port (#2470)
R77 apple-runner-host-port bans a direct @agent-device/host-kit/* value import from packages/platform-apple/src/runner/**; the port at runner/host.ts, bound in core/runner-host.ts, is the only door. runner/** sits in the eager closure of seven Apple facade entries eager-closure-budgets.ts holds at a fixed size, so a direct import grows all seven at once (#2423 measured one candidate import adding 5 modules to runner/index.ts's closure, 13 -> 18, after two review rounds spent rediscovering this). |
||
|
|
2d109e5cc5 |
refactor(contracts): additive capability facts on the clipboard family (#2464)
* refactor(contracts): make clipboard capability facts additive Additive capability facts on the clipboard family (#2443, family 3). clipboardRuntimeOperationFacts took exactly two cells, read and write, and both are required. The halves stay separate claims — a WebDriver provider whose Appium clipboard extension exposes only a getter is a real owner with one half and not the other, and `clipboard read` must not be refused because the write half is missing — so both are optional and `unsupported` names the denial an unnamed half reports. A call naming no denial is still refused: omission is a classified refusal, never an unclassified half and never an implied success. Six of the eight owners drive both halves from one shell command set or one leaf gate, so they now state that once: web, HarmonyOS, and Vega name a single denial, and the shared unavailable record carries one clipboard cell where it carried two. The owners that serve clipboard keep naming both halves, and the one install-source double that enumerated the two keys by hand now goes through the family builder like every other construction site. Fact values are unchanged: every owner's clipboard cells are byte-identical, and the family's own test pins an unnamed half reporting the stated denial verbatim, an owner naming nothing answering with the exhaustive shape, and the freeze. * fix(contracts): derive clipboard family denials from the served cell Review follow-up on the clipboard family. Apple restated its leaf branches to choose a clipboard denial, so the leaf split lived twice in one file. It now reads the leaf's own refusal, keeping a leaf-scoped placeholder only where the leaf serves clipboard and therefore has no clipboard refusal to state. Android named a build-level shell verdict as its family denial, which is the claim its own probe refuses to make when the probe did not complete; the denial now follows the probed cell, so an unnamed half reports the unknown rather than a verdict. Pin the shared unavailable record's clipboard fan-out the way the keyboard commit pinned its own: one input cell, two operations, each with its reason. |
||
|
|
1131fb3118 |
refactor(contracts): additive capability facts on the gesture family (#2460)
* refactor(contracts): make gesture capability facts additive Additive capability facts on the gesture family (#2443, family 2). gestureRuntimeOperationFacts took one fact per tier, and it had five: plan, directionalFling, multiTouch, targetAuthoredDrag, viewport. The tiers split exactly where an owner's mechanics split — no tier is one every owner serves, and several owners serve none — so every tier is optional now and `unsupported` names the denial an omitted tier reports. The type still refuses a call that names no denial at all: omission is a classified refusal, never an unclassified tier and never an implied success, and an owner cannot leave the family blank. An owner states a tier only to say something the family denial does not. Web refuses every tier with one cell and names only the drag tier whose wording came from a retired closure. Vega names the two tiers whose refusals came from separate closures. Limrun's session-less and iOS branches name one denial each, where each wrote five. The owners that serve gestures keep naming what they serve: the win is the next tier, which an owner can now ignore entirely. Fact values are unchanged: every owner's gesture cells are byte-identical, and the family's own test pins an omitted tier reporting the stated denial verbatim, an owner that names nothing answering with the exhaustive shape, and the freeze. * fix(platform-apple): name the gesture family denial by leaf Review follow-up on the gesture family. Apple refuses gestures for two reasons depending on the leaf: watchOS and visionOS have no gesture surface at all, while every other leaf without a touch kind is the wrong device kind. One constant cannot be truthful for both, and the family denial is what the first unnamed tier will report, so it now follows the leaf. The Limrun gesture call collapsed to one source for the session-less branch, so pin the reason and hint it reports there rather than availability alone, and cover the directional-fling tier the loop had skipped. |
||
|
|
d80b021fa5 |
refactor(contracts): make keyboard capability facts additive (#2459)
The keyboard family's facts builder took one required cell per operation, so an operation only one owner implements still cost a hand-written denial in every other owner. The builder now takes the family's denial once, as `unsupported`, and every operation cell is optional: an owner names what it serves and omission reports that denial verbatim, with the reason and hint the owner would otherwise have repeated per cell. Omission stays a classified refusal, never an unclassified cell and never an implied success: `unsupported` is required, so a call that leaves the family blank does not compile. An owner that names every operation still states the family refusal, and it must refuse the family rather than one operation of it — whatever the owner leaves unnamed reports that cell verbatim. The shared unavailable-facts input collapses `keyboardStatus`/`keyboardDismiss`/ `keyboardEnter` into one required `keyboard` cell, so the owners that answered the family with raw keys (webdriver, vega, linux, limrun's no-session binding) now answer it through the builder, which is the family's one entry point. Facts are unchanged for every owner, so the per-platform admission assertions hold untouched. Two owner tests now assert the keyboard reason as well as the availability, because one family cell reports one reason for all three operations: the WebDriver inactive session and the stale Limrun identity. Refs #2443 |
||
|
|
bbd53d6c79 |
fix(ios): budget cold toolchain probes for the first-exec signature stall (#2423)
* fix(ios): budget cold toolchain probes for the first-exec signature stall xcodebuild/xcrun toolchain probes in cache-identity.ts and runner-cache-metadata.ts were budgeted for a warm toolchain (10s/5s), below the ~18-19s syspolicyd signature-verification stall on the first exec after a fresh macOS host boots. Share one 30s floor constant between both call sites and retry once after a timeout while the deadline allows, since the second exec is instant. Closes #2422 * fix(apple): duplicate the cold-toolchain-probe budget instead of a shared module toolchain-probe-budget.ts sat outside every platform-apple facade's eager closure, but runner-cache-metadata.ts (imported from it) sits inside all seven -- so the new import added one module to each, tripping the eager-closure-budgets no-growth gate (#2422). Delete the shared module. cache-identity.ts keeps the canonical constant inline (it was already outside the gated closures); runner-cache-metadata.ts declares its own copy, guarded by a new unit test that asserts the two stay equal. * fix(apple): bound the cold toolchain probes by the owning request budget Three synchronous probes could each retry once at 30 s, so a wedged toolchain host blocked a request for ~180 s with no deadline and no cancellation check. The runner cache decision now takes the owning request's budget (remaining ms + abort signal) and builds one clock per fingerprint read: every attempt runs at min(per-call ceiling, remaining), the retry is skipped once the budget is spent, an exhausted budget fails the decision without starting another probe, and an aborted signal surfaces the cancellation instead of retrying. `ensureXctestrunArtifact` passes the build budget and signal, session reuse passes the startup budget and the request signal, and lease adoption passes the startup budget; a caller with neither is still capped at 45 s total, so the worst case falls from ~180 s to 45 s. Error codes, texts, and the probe hint are unchanged. Both retry classifiers now read the exec layer's structured timeout detail instead of matching "timed out after Nms" in the message. The predicate is exported once from host-kit's command surface and reaches `runner-cache-metadata.ts` through the Apple runner host port, so the file's eager closure is unchanged. Tests use a fake clock that only advances when a probe actually blocks for the timeout it was given, so the exhausted-budget and cancellation cases have to spend the budget to pass; both consumers also pin that an error saying "timed out after 10ms" without the structured detail is not retried. Refs #2422 * fix(apple): spend one deadline across the toolchain probes and the step they precede The Apple runner cache decision runs up to three blocking toolchain probes before the step that needs the decision. Those probes were handed the phase's timeout and the step was then handed the same number again, so a cold-start probe stall added its 30 to 45 seconds on top of the phase budget instead of coming out of it. RunnerCacheProbeBudget now carries the phase's deadline rather than a timeout number, and each caller creates exactly one: - ensureXctestrunArtifact: the probes and xcodebuild read the same clock, and a phase with nothing left fails before the spawn. - ensureRunnerSession: the reuse probe spends from the startup clock, and the new session gets what it left. - tryAdoptRunnerSessionFromLease: the fingerprint probe spends from the caller's clock, and the adopted session gets the remainder. COLD_TOOLCHAIN_PROBE_TIMEOUT_MS now has one owner, core/config.ts. Snapshot source imports it; runner-cache-metadata reads it through the Apple runner host port, because core/config.ts is missing from one of the seven eager closures that evaluate that file and a direct import would grow it. createSnapshotSourceDeadline takes an injectable clock so a test can prove that a probe which blocked for its whole timeout leaves the retry only the remainder. * fix(apple): keep cancellation typed after a probe timeout and simplify the probe budget * fix(apple): check cancellation before the warm toolchain fingerprint cache * refactor(apple): guard each toolchain probe attempt in one place Fold the toolchain probe's three duplicated cancellation/budget guard sites (runToolchainProbe's pre-check, runToolchainProbeCommand's retry pre-check, and execToolchainProbeCommand's timeout computation) into one: attemptToolchainProbe checks cancellation and the remaining budget before every exec, first attempt and retry alike. The outer runToolchainProbe now rethrows cancellation and a spent budget instead of swallowing them into a probe failure, and only genuine probe errors become one. * fix(apple): keep cancellation typed when the final toolchain probe fails The guard fold left one gap: a request that aborts while the last probe is in flight and then fails with a non-timeout error has no next attempt whose guard could see the abort, so the catch classified it as an unreadable toolchain. The catch checks the signal again before classifying, as it did before the fold. * refactor(apple): own the toolchain probe budget in platform-apple and trim narration COLD_TOOLCHAIN_PROBE_TIMEOUT_MS moves from @agent-device/host-kit/command to runner/apple-runner-platform.ts, beside the SDK names the probes are run against. Both Apple toolchain probers import it directly, so the runner host port no longer carries a coldToolchainProbeTimeoutMs() accessor for a plain number. isCommandTimeoutError stays in host-kit, where the exec layer stamps the detail it reads. The comments that narrated control flow the code already shows are gone; the cold-start stall rationale (on the constant), the spawnSync cancellation limitation (on the probe clock) and one line per phase-deadline creation site remain. |
||
|
|
805ffb4690 |
fix(ios): budget snapshot bridge test compile from the build ceiling (#2454)
The host bridge unit test compiled the snapshot bridge under a fixed 45 s budget that a cold macOS runner trips on during the first `xcrun` (the signature-scan stall plus clang). Budget both `beforeAll` compiles from a deadline sized to production's build ceiling via `createSnapshotSourceDeadline` and `remainingSnapshotSourceMs`, so the unit lane is never stricter than the preparation path it mirrors. `BUILD_TIMEOUT_MS` is now `@internal`-exported as the single source of truth for that ceiling instead of a second magic number. Closes #2439 |
||
|
|
41e2633f10 |
fix(wait): retire native selector bypass and recover text observations (#2440)
* fix(apple): resolve selector waits through canonical capture * fix(wait): retire dead selector observations and recover native text failures |
||
|
|
8dd1f6c51a |
fix(check): honor Vitest worker configuration (#2437)
* fix(check): honor Vitest worker configuration * test(apple): freeze the default readiness budget clock --------- Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
edbd96ea30 |
refactor(contracts): additive capability facts pilot on touch family (#2420)
* refactor(contracts): make touch capability facts additive The touch family's facts builder took one required cell per operation, so an operation only one owner implements still cost a hand-written denial in every other owner. The builder now takes the family's denial once, as `unsupported`, and every operation cell is optional: an owner names what it implements and omission reports that denial verbatim, with the reason and hint the owner would otherwise have repeated per cell. Omission stays a classified refusal, never an unclassified cell and never an implied success: `unsupported` is required, so a call that leaves the family blank does not compile. Facts are unchanged for every owner, so the per-platform admission assertions hold untouched. Refs #2412 * fix(contracts): keep tap/longPress/fill required in touch facts Making every touch cell optional dropped the compile-time guard on the three operations every owner implements, with nothing replacing it: an owner could silently drop `tap` and nothing would fail. Only the truly per-owner cells (tapRef, hover, hoverRef, fillRef, tapElementSelector) need `unsupported` as their default. Restoring the required fields caught a real regression this refactor introduced: platform-web's touch facts had silently lost their explicit `longPress: readinessUnavailable` cell, so longPress reported the family's `unsupported` fallback instead of the owner's own readiness reason. Restored it, and fixed the other now-required-field call sites (the fully-unavailable owner fixtures, and the screenshot-runtime-fixture test double, which had also silently lost its longPress/fill distinction). Refs #2412 * fix(coverage): repoint hover evidence off the retired runtime.ts source line The touch family's additive-facts refactor dropped every owner's explicit hover: unavailable cell in favor of the family-level unsupported fallback, so the coverage manifests' literal-substring checks against packages/platform-apple/src/runtime.ts and packages/platform-linux/src/runtime.ts broke: that source line no longer exists. Point the macOS, iOS, tvOS, and Linux hover rows at new tests that assert the typed unavailable denial directly against each owner's bound facts, instead of re-adding a hand-written denial line the refactor was meant to retire. Refs #2412 * test(coverage): point hover evidence at the typed-denial tests in the declaration table |
||
|
|
52420d77e0 |
perf(ios): learn generation-scoped native depth hints in the host AX source (#2427)
The host source remembers the native levels a finished recovery accepted, keyed by resolved target id, app generation, and producer, and sends them as nativeLevelsHint so the guest's first request skips the known rejection. The hint changes request strategy only: delivered depth, node bounds, and completeness rules are unchanged. It is learned only after the delivered tree validated, expires after eight hinted captures, is never renewed by a hinted success, and never crosses apps, generations, or producers; explicit raw-depth requests neither use nor teach it. The route's generation circuit stays the only lifecycle owner. The guest reports its request accounting (requests, rejected, continuations, accepted levels) as recovery, which the host emits as the ios_snapshot_source_recovery diagnostic; the source version moves to v1.5.5. The shared recovery fixture gains the host column of every hint case and the hinted recovery cases, replayed through the source adapter itself. The test app gains a deep-tree screen that reproduces the native depth rejection for paired benchmarks. |
||
|
|
4d9e7ffe8f |
test(ios): add a shared AX recovery conformance fixture with a host adapter (#2425)
Add contracts/fixtures/ios-ax-recovery-conformance.json, a shared executable recovery contract for the host AX bridge and the XCTest runner's private AX bridge. Every expectation names the outcome, the native request accounting, and the delivered tree as a canonical preorder signature with its retained node count, so a producer that drops, duplicates, reorders, or re-parents nodes cannot pass as complete. The fixture records per-producer expectations and documents the intentional differences (depth vocabulary, ladders, frontier evidence, budgets, ownership and deadlines, hint lifetime). The host adapter is an Objective-C driver over captureSnapshotTree, compiled and run per case by native-runtime.test.ts. The runner adapter and the accepted-depth memory characterization follow in a stacked PR. |
||
|
|
0dfd65f6a2 |
perf(ios): speed up deep snapshots and keep first taps reliable (#2414)
* perf(ios): recover deep snapshots and isolate optional tap probes * chore(gates): enforce snapshot assets and optional probe lifecycle * fix(ios): preserve capture bounds and local probe recovery * chore(gates): validate base package assets with its own policy * chore(gates): verify recovery failures respect launch observation policy * fix(ios): fail closed on unknown snapshot frontier completeness |
||
|
|
fda41b4c71 |
perf(apple): gate the launch-observation probe on the snapshot circuit breaker (#2391)
* perf(apple): gate the launch-observation probe on the snapshot circuit breaker The Simulator AX-bridge launch-observation probe was re-exported straight through `createAppleSnapshotRoute`, so it never consulted the generation circuit the capture route opens on a typed bridge failure. Re-opening an already-running app therefore re-polled a generation the circuit had already given up on: `application-server-unavailable` and `application-element-missing` carry a 5 s transition window at a 150 ms poll, so ~33 `source.acquire` round trips per `open`, each reconnecting through `connectUntilReady`. Route and probe now share one predicate that rebaselines the generation and reports whether the bridge is disabled for it, so a disabled generation is answered without a bridge round trip while a relaunch — which carries a new generation — clears the circuit and observes as usual. Refs #2198, #2199. * perf(apple): report the launch-observation skip so a live run can prove it A skipped probe and a target that never resolved both end the wait at `unobservable` after zero bridge acquisitions, so on a live device the two are indistinguishable — a live receipt could not show that the circuit gate, rather than a resolution failure, is what stopped the poll. Emit `ios_launch_observation_skipped` with the generation the circuit refused. * test(daemon): assert the prepare budget is wired, not that zero time passed `prepareAppleRunner` spends one budget across the boot wait and the runner, so what reaches the runner is `--timeout` minus whatever readiness already used. The assertion demanded exactly 240000, which holds only when both `Date.now()` reads land in the same millisecond; CI caught it at 239999. Assert each budget carries the unspent remainder and never exceeds the request. Both bounds are live: forcing the remainder to 1 fails the lower bound, and re-spending the full budget fails the upper. |
||
|
|
e7d97f7dfe |
fix(apple): scope perf processes to the resolved app executable (#2406)
* fix(apple): bind perf process selection to the resolved executable * docs(perf): clarify that executable scoping includes sampling * fix(apple): load perf process identity only when sampling |
||
|
|
ef5a459294 |
refactor(daemon): isolate Apple session observations (#2405)
* refactor(daemon): consume a semantic Apple session observation port * chore(gates): retire direct daemon observation imports --------- Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com> |
||
|
|
8299d5b4a7 |
fix(ios): honor the startup budget through a cold Simulator boot (#2325)
* fix(ios): honor the startup budget through a cold Simulator boot A never-booted Simulator runs Apple's first-boot migration, which can take minutes, but the boot wait was capped at a fixed 120 seconds that neither `prepare --timeout` nor `open` could reach (#2324). - The boot wait takes an absolute deadline. `prepare --timeout` now covers the boot and the runner preparation as one budget; `open --timeout` is new and bounds the boot. Expiry fails with `boot_timeout` and leaves the Simulator booting, so a retry finds it further along. - The client envelope for open/prepare keeps the 30s margin over the budget so the daemon's structured timeout wins the race against the client's reset. - `close --shutdown` no longer trusts the session device's selection-time `booted: false`; it always asks simctl. A session opened on a cold Simulator otherwise reported a shutdown that never happened. Supersedes the original implementation of #2325 by @PrinceD96 (head 8bdb85b7a3), which found the bug, the shutdown shortcut, and the validation recipe. Closes #2324 Co-authored-by: PrinceD96 <53633741+PrinceD96@users.noreply.github.com> * fix(ios): keep the confirming boot listing inside the startup budget After bootstatus, the listing that confirms the Booted state ran on its own 15-second timeout and the success path never re-checked the deadline, so a bootstatus that used nearly the whole budget could still return success past it. The listing now gets the remaining budget (capped at its own 15s), and a confirmation that lands after the deadline is reported as boot_timeout. --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> |
||
|
|
d26b0786fb |
perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner (#2329)
* perf(ios): derive runner demand for Simulator opens and stop observation from awaiting the runner Local Simulator opens now decide how much the XCTest runner is needed from the runtime operations declared by the steps still ahead in the same batch: an observation-only plan starts no runner, an unknown plan keeps the speculative prewarm without ever awaiting it, and a plan with an interaction prepares readiness for that step. open --relaunch no longer waits for runner readiness on a Simulator and resets the runner target only when a session is already alive. The Apple find ports report not-proven instead of starting a runner on a Simulator without a live session, so wait and read-only find observe through the canonical AX-bridge tree. Physical devices keep their lifecycle unchanged. The plan travels through the server-private internal request channel, never the wire; the Apple owner maps declared operations to a runner demand through a record complete over the runtime operation union. Refs #2198 * test(fixtures): share one inert audio-probe host across the platform runtime fixtures The Apple and Android runtime fixtures carried identical audio-probe doubles; host-kit now owns the one copy and both fixtures import it. Also folds the two Apple native-find ports onto one admission helper and lifts the Simulator runner prewarm policy out of the open sequence, keeping both under the complexity gate. * fix(ios): answer runner liveness through the runner provider seam The find ports and the relaunch target reset asked the local session registry whether a runner was alive, which misreads scripted and request-scoped runner providers as absent. Liveness is now a provider question: the local provider consults its session registry, a provider without startup cost counts as live, and an awaited prewarm proves liveness without asking. * perf(ios): select plan uses from step input and give young Simulator targets a bounded bridge grace A snapshot, diff, or find step now selects the runtime uses its structured input reaches, the way its handler does, so a plain snapshot no longer counts the custom-actions alternative and an observation-only batch resolves runner demand none. The descriptor declares the selector next to its alternatives; the daemon plan derivation honors it and keeps the union for every other command. Without the runner wait, the first snapshot after an open reached the AX bridge while the app was still becoming the primary foreground owner or registering its accessibility server, and the typed fallback then started the runner the plan had just avoided. A target younger than ten seconds is re-read for a bounded grace measured from the first such failure: five seconds for a missing AX server, one second for an ownership miss so a launch-time system dialog still reaches the fallback quickly. Established targets get no grace. * fix(ios): a registered runner session counts as live only once it has answered A session record exists while xcodebuild is still connecting, so an alive child pid is not a runner that can answer. Treating it as live sent the relaunch target reset into a starting runner, queued behind its connection retries, and the failed reset invalidated the very session the prewarm was building. Liveness now also requires the session's readiness flag, which the first successful runner response sets. * refactor(ios): lift the bridge launch grace out of the snapshot route capture * test(descriptors): pin the snapshot, diff, and find step-use selectors * test: stub runner operations in the replay test-runner suite and keep runner-session tests within the size ratchet A Simulator open schedules a best-effort runner prewarm that outlives its request. The replay test-runner suite opened a Simulator with the real Apple tools, so the prewarm's deferred import resolved after the file finished and spawned into whichever file the worker ran next, where the hermetic signal guard failed an unrelated test. * fix(plan): count only required operations and read find and snapshot steps the way their handlers do Runner demand now counts a command's required operations only: a preferred or conditional operation is a measured fast path the command succeeds without, so get, wait, and read-only find stay observation-only. The step selectors for snapshot, diff, and find live next to the registry and read the daemon step exactly as the handlers do: the daemon flag for custom actions, and find's positionals through the same parser, where a missing action is a click and an unparseable step keeps every declared alternative. The handler and the selector share one action-to-intent map. The batch runner hands each step its remaining steps in handler shape, and the derived operations reach the platform as a typed list on the lifecycle execution instead of an untyped plan on every open. * perf(ios): let open wait for the launched app to become observable, and make runner liveness explicit The snapshot route no longer infers a launch from process start text and retries inside its own capture. Open owns launch timing instead: a local Simulator open asks the AX bridge whether the launched app is observable, bounded by per-code windows measured from the first typed launch-transition failure and never extended, so an ownership miss seen after an AX-server miss shrinks the deadline to the ownership window and a launch-time system dialog still reaches the typed fallback quickly. Any other device, or a bridge that cannot answer, keeps the fixed settle. The open response reports what it learned. Every runner provider now states whether it can answer without a startup wait; a bare executor answers directly by construction and scripted providers say so. The runner prewarm policy and the observation settle move out of the open sequence into their own module, and the native find admission is named for what it admits. * docs(context): keep the runner-demand vocabulary within the guidance budget The enumeration and the no-public-flag rule live on the contract type that owns them; CONTEXT.md keeps the term itself, and two neighbouring entries lose words that carried no meaning. * refactor(contracts): name the runtime operation vocabulary below the operations union The lifecycle execution carries the operations a plan requires, but typing that list with the operations union closed a 36-file type cycle: the operations types depend on the lifecycle types. The vocabulary now lives as a const list below both, proven equal to the union by a type test, so the plan is typed end to end, the Apple host table indexes it without casts, and the daemon narrows descriptor names through a guard instead of a cast. * fix(apple): reach runner liveness through the memoized operations loader Every Apple tool port loads the runner operations through the one memoized loader (#2314): a port that opens its own dynamic import can resolve the unmocked module while a test's mock factory is still loading and let a real local runner escape. The liveness port now uses the loader like its siblings; the facade members consumed only through the loader are declared to fallow, and the plan resolver reads one step per helper to stay under the complexity threshold. * fix(ios): keep bridge-only behavior to iOS Simulators The launch observation, the runner-free find admission, and the relaunch policy apply only where the host AX bridge exists: iOS Simulators. A tvOS Simulator keeps its awaited prewarm and asks for no observation, which the tvOS provider scenario now pins. * bench(ios): add a first-interaction cell to the snapshot convergence harness An open that defers runner readiness moves its cost to the first runner-dependent command. The cell starts each sample like cold, opens the fixture untimed, then times the first press that follows (the deep-link confirmation when the launch URL raises it, otherwise the screen anchor). * bench(ios): read the deep-link confirmation from a snapshot and by node type The open response carries no tree and regular snapshots publish the node type, so the confirmation iOS raises for a launch URL was never seen on this runtime and every deep-linked cell failed its anchor check. * refactor(plan): keep the step-use selectors inside the registry The eager-closure ratchet counts every module the registry loads; the selectors need nothing the registry does not already import, so they live beside find's recording-effect reader instead of adding a module to every entry that loads the registry. * feat(apple): release a speculative runner when the plan is proven observation-only #2198 requires a `none` runner demand to retain no runner, not only to start none. A runner a prewarm started that no command has used yet is speculative: the session records that mark at creation, the first command that is not a readiness probe clears it, and a Simulator open whose plan is proven observation-only asks the runner owner to release a speculative session in the background, so the observation path never waits for a runner to stop either. A runner that has served a command is the session's working runner and stays under the existing idle-stop policy, so a mixed workload does not pay a cold runner start at every observation-only open. The release goes through the runner provider seam: the local provider stops its own speculative session; a provider that never starts speculative work omits the operation and releases nothing. * bench(ios): press an unambiguous target on the catalog and Settings screens The first-interaction cell pressed the screen's anchor text, which on the catalog and iOS Settings screens names two actionable elements (the native tab and the screen title); the CLI refuses that as AMBIGUOUS_MATCH by design, so those two cells could never measure anything. Each such screen now names the element the cell presses. * fix(ios): keep observation on the bridge while app discovery is pending and no runner is live #2331 bounds one capture's wait for the Simulator app discovery and takes the XCTest fallback past it; #2198 stops a Simulator open from awaiting the runner. Together, a `wait` right after a relaunch on a loaded host fell back to XCTest while the runner was still starting, spent its poll budget on that start, and timed out (the iOS smoke lane after the main merge). A capture with no live runner now stays on the single-flight discovery, one wait slice at a time, until the discovery's own deadline or the request signal ends it; a runner that is already live still takes the fallback at once, the cheaper route #2331 chose. * fix(apple): queue a speculative-runner release behind a start that is still in flight A `possible` open's prewarm registers its session only when the start completes, so a `none` open that released in that window found nothing and the runner it meant to release survived as a retained speculative session. The release now takes the runner session lock: it queues behind the in-flight start, sees the registered speculative session, and stops it; a start a command asked for is left alone. Two deferred-start regressions pin both outcomes. |
||
|
|
bd08e6e0f2 |
refactor(contracts): move single-owner modules out of @agent-device/contracts (#2357)
* refactor(daemon): move root-only contracts vocabulary into its owning zone Six @agent-device/contracts modules had no consumer outside the root zones, so the shared vocabulary package carried types only the daemon and root composition ever read. Each one moves to the zone that owns it and every consumer switches to the owning module; no re-export stays behind at the old contracts path. - perf-runtime-plan, snapshot-timeout-evidence, platform-resource-cleanup -> src/daemon - daemon-owner-cleanup -> src/ - interaction-error -> src/core wait-runtime-plan stays in contracts: @agent-device/command-registry consumes it, so it is not root-only after the registry package landed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdXHhx9NkfH1PT4XjYe7tE * refactor(platform): move single-consumer contracts modules into their platform package Four modules in @agent-device/contracts had exactly one consuming package, so the shared vocabulary carried Android- and Apple-specific shapes no other zone could use. Each moves into the package that owns it, with every consumer switched to the owning module and no re-export left at the old contracts path. - android-helper-artifacts -> platform-android/src/helper-artifacts.ts - android-touch-plan -> platform-android/src/touch-plan-lowering.ts, which also retires the package-local touch-plan.ts re-export barrel that existed only to give the contracts module a local name - snapshot-presentation -> platform-android/src/snapshot-presentation-node.ts (renamed to keep the package's existing Android-specific snapshot-presentation.ts distinct) - apple-multitouch-support -> platform-apple/src/multitouch-support.ts APPLE_OS_DISPLAY_NAMES folds into gesture-admission.ts, its one remaining contracts caller, so both gesture refusals still share one copy of the wording without a new contracts subpath for a table its own doc calls non-public. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdXHhx9NkfH1PT4XjYe7tE * refactor(core): move the replay divergence implementation into src/core replay-divergence.ts mixed the wire vocabulary every zone reads with the sanitizing, bounding and reporting implementation only root zones call. The ten value consumers are all root (daemon replay, the session replay coordinator, the daemon client lifecycle, the replay-test reporter, the command error projection, and the MCP tool error), so the implementation moves to src/core/replay-divergence.ts and carries its test unchanged. The types stay in contracts and keep the @agent-device/contracts/divergence subpath, which packages/ad-replay and packages/selectors type-import. ReplayVarScrubEntry follows the implementation: it is the sanitizer's own parameter shape, not part of the divergence wire report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdXHhx9NkfH1PT4XjYe7tE * chore(gates): shrink the contracts export surface by the moved subpaths The nine relocated modules no longer live in @agent-device/contracts, so its exports map drops their subpaths (118 -> 109) and scripts/layering/contracts-exports.snapshot.json is regenerated from the manifest, which is what R11 package-boundaries diffs the live surface against. The two resolution assertions naming the retired snapshot-presentation and snapshot-timeout-evidence subpaths go with them; interaction, snapshot and react-native-overlay still cover both the direct-module and facade shapes the assertions were there to prove. The property tests that needed fast-check left with snapshot-presentation and replay-divergence, so the dependency moves too: contracts drops it and platform-android declares it, as fallow's unused-devDependency check reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdXHhx9NkfH1PT4XjYe7tE --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
89c7536850 |
feat(ios): support explicit iOS simulator keychain reset (#2345)
* feat(ios): support explicit iOS simulator keychain reset `settings clear-app-state` never touched keychain-backed credentials (e.g. Firebase auth), so a customer's fresh-install reset via the CLI left an app signed in when their in-app reset button did not (#2282). simctl exposes no per-app keychain reset, only a whole-simulator one (`simctl keychain <device> reset`), so this ships as a separate, explicit `settings reset-keychain clear` command rather than folding it into `clear-app-state` — callers opt in knowing the scope is the whole simulator, not just the app under test. Split the pre-existing `apps.test.ts` and `snapshot-handler.test.ts` suites along the `app-settings.ts`/`snapshot-settings.ts` modules they actually mirror, since both were already over the test-file-size tripwire and could not grow further. * fix(ios): reject extra reset-keychain arguments and add live-tested keychain fixture settings reset-keychain clear <extra-arg> silently dropped the extra argument in both the CLI reader and the direct-daemon parser, so a caller expecting per-app scoping could get a whole-simulator wipe without any signal something was off. Reject it instead in both places, with tests proving no settings mutation happens. Also add a small keychain-backed "auth" fixture to the test-app's automation lab (expo-secure-store) so the settings reset-keychain guarantee has a real regression surface: authenticate, verify the credential survives clear-app-state and a plain relaunch, then verify reset-keychain actually clears it. Validated live against a disposable iOS simulator. * fix(ci): stop a bare gradle.properties append from corrupting the last line expo prebuild's generated android/gradle.properties has no trailing newline, so `echo "org.gradle.jvmargs=-Xmx4g" >> gradle.properties` appended directly onto its last line instead of a new one, producing expo.inlineModules.watchedDirectories=[]org.gradle.jvmargs=-Xmx4g. Gradle's JSON.parse of that property then fails at configure time, before any real compilation runs -- the exact "Process 'command 'node'' finished with non-zero exit value 1" failure this branch hit on Android Release and the Smoke Tests fixture-app fallback build. This was a dormant bug: the Android build-cache job only runs on a fingerprint miss, and no PR had changed the test-app's native dependencies in a while. Adding expo-secure-store (#2282's keychain fixture) was enough to trigger it. Reproduced locally against a clean install with the exact CI script, confirmed the corrupted property, and confirmed the printf-based fix builds cleanly (870/870 tasks). |
||
|
|
835af32577 |
fix(ios): stop charging every capture for a slow Simulator app discovery (#2331)
* fix(ios): stop charging every capture for a slow Simulator app discovery Since #2279 every eligible Simulator capture resolves its AX-bridge target first, and a cache miss spawns `simctl launchctl list` through xcrun with a 3s timeout on the capture's own critical path. On a loaded CI host that spawn takes longer than 3s, the timeout is not remembered, and the next capture pays it again. A `wait` issued right after `open` lost its whole 10s budget that way (runner findText on the fresh app, then the probe timeout, then a fallback capture cancelled at the deadline) and reported `wait_capture_stalled` with the app already on screen: four iOS Smoke failures on main and PRs since 2026-09-05 21:00, none before. Discovery is now single-flight and detached from the capture that starts it: a capture waits a bounded 1.5s for it, then takes the XCTest fallback while the probe keeps running with its own 15s budget; later captures join the in-flight probe or reuse its result. A responsive host still reaches the bridge on the first capture. The resolver's error names its reason so the route diagnostic says why the fallback ran. * fix(ios): one deadline per discovery, exact claims, route-level regression Review follow-up. The 15s budget was per subprocess (two simctl probes, then a 3s ps read); discovery now shares one deadline across all three. The comments and changelog no longer claim "once per app generation": single-flight removes duplicate discoveries, each capture still waits up to its 1.5s slice for the in-flight one, and a failed discovery is forgotten so the next capture starts another. A route-level test runs the production resolver over a simctl that answers only when released: the first capture falls back within the slice, the released discovery then serves the bridge without a second spawn. * fix(ios): keep the discovery slot until both probes settle Review follow-up. Promise.all rejected as soon as either probe failed and the finally then released the single-flight entry while the other probe was still running, so a failed `simctl list devices` next to a slow `launchctl list` let every later capture start its own launch-job probe. Both probes now settle before the discovery does; the regression test runs three captures against exactly that partial failure and sees one spawn, then the settled discovery reports the runtime failure and frees the slot. |
||
|
|
ebdaa7617e |
feat: delegate reviewed managed automation (#2312)
* feat: delegate reviewed automation through managed lease authority * fix: preserve lazy simulator readiness through scoped authority * fix: admit managed operations at their dispatch boundary * test: move managed automation scenarios to integration lane * chore(gates): declare the private managed readiness scope export |
||
|
|
c4dc5621a3 |
fix(ios): avoid replaying alert mutations (#2323)
* fix(ios): avoid replaying alert mutations * docs(alert): state the single-send rule once, without a backend qualifier --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com> |
||
|
|
cf83afb9c9 |
feat(ios): route Simulator snapshots through AX bridge (#2279)
* feat(ios): route simulator snapshots through AX bridge * fix(ios): preserve snapshot fallback lineage * fix(ios): keep regular depth in presentation * perf(ios): reuse process-verified snapshot targets * fix(ios): refuse snapshots beneath another foreground owner * test(ios): bound native setup and isolate runner reset * test(ios): synchronize helper crashes with request dispatch * test(ios): exercise foreground guards through native capture * chore(gates): run native snapshot ownership regression on iOS CI |
||
|
|
0c8227e9b7 |
refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly
The root host carried two adapters, appInventory and appState, that only
forwarded a platform call back into that platform's own package. Each platform
runtime now performs its own listApps and appState call through a lazy import
inside its package, keeping the deferred load, the AbortSignal threading, and
the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so
Android, Apple and Harmony fixtures no longer stub the two platforms they do
not own.
Android is the one platform runtime whose package now reaches adb directly.
The adb host that adb mechanics require is bound by a module side effect that
only the root can perform, so the Android runtime-module registration binds it
before the module loads. loadAndroidMechanics keeps its own binding import for
the root host ports that reach mechanics without binding a runtime; neither
binder subsumes the other.
Android appstate now runs one foreground-focus loop instead of two. The host
shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter
already closes over its own adb executor, so it calls the executor variant
directly, and that variant took the per-attempt abort check the host variant
had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the
deleted host port and go with it.
Tests: the new ordering test in
src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the
binding import from that registration (order came back
["android-runtime", "adb-host"]); the composed-gateway listApps test in the
same file was seen red by reverting the Android runtime's inlined listApps to a
host.appInventory lookup (TypeError reading 'android'); the new abort test in
packages/platform-android/src/app-state.test.ts was seen red by removing both
signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second
dumpsys was issued and the call resolved). All green after.
* chore(gates): drop the retired app-inventory/app-state host allowances
The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change
deletes, and the ./platform-runtime-app-state-host.ts composition allowance has
no importer left.
* refactor(runtime): construct the Android runtime module with its adb host binding
The Android runtime now calls adb from inside its package for listApps and
appState, which needs the process-wide adb host port bound. That dependency
was hidden in a registry wrapper doing a side-effect import, with a paragraph
explaining why it and loadAndroidMechanics did not subsume each other and an
import-order test pinning the ordering. The package now declares the
dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding
before the runtime loads, and the composition root supplies the one binding
implementation (evaluating its adb host module). The wrapper, the paragraph
and the import-order test are gone; the routed listApps test stays and a
routed appState test joins it.
|
||
|
|
96727a0b42 |
fix(apple-runner): never compare an unavailable toolchain probe; name the mismatching cache keys (#2306)
* fix(apple-runner): never compare an unavailable toolchain probe A timed-out or failed `xcodebuild -version` / `xcrun --show-sdk-*` probe used to fall back to the literal `unknown`, which was memoized for the process and then persisted into the rebuilt cache's metadata, so every later daemon on a healthy host mismatched again and paid a full build-for-testing. Unavailability is now a distinct outcome with no comparable value: only successful probes are memoized, an unreadable toolchain fails the cache decision with a retriable typed error naming the probe that could not answer, and the CI metadata writer refuses to persist a probe it could not read. The cache_metadata_mismatch diagnostic now lists the differing keys with expected and actual values instead of only saying the metadata differed. The runner-source fingerprint moves to the module that owns the runner's source roots, keeping the cache-metadata module within its size budget without adding a module to the Apple facades' eager closure. * refactor(apple-runner): home the fingerprint tests and the rebuild-decision glue Tests mirror source topology: the runner-source fingerprint tests move with the function into runner-source.test.ts and call it directly instead of reaching it through resolveExpectedRunnerCacheMetadata. The rebuild diagnostic's mismatch details move next to the cache state that carries them, so runner-artifact.ts — already past the 500-line extract threshold — gains no behavior. * fix(apple-runner): memoize only a parsed toolchain fingerprint runToolchainProbe cached every nonempty zero-exit answer before parseXcodeVersionOutput could classify it, so a transient malformed xcodebuild answer stayed cached and every later cache decision in the process kept failing after the host recovered. The memo now holds the complete parsed fingerprint per SDK, written only after all three probes answered and parsed; a failed round keeps nothing, so the next request re-probes. Tests cover malformed-to-healthy recovery in one process without resetting the memo, and that a partial round is not kept. * style(apple-runner): oxfmt the cache-metadata module and its tests |
||
|
|
5bb3ea3b2a |
feat(ios): productionize Simulator AX snapshot bridge (#2277)
* feat(ios): productionize Simulator AX snapshot bridge * fix: address Simulator AX bridge review comments * docs: refresh Simulator AX evidence * fix: address new Simulator AX bridge review comments * docs: record public snapshot source timings * fix: preserve size report helper on base checkout * fix: allow base packages without snapshot bridge * fix: close simulator snapshot source ownership gaps * docs: explain simulator bridge language choice |
||
|
|
3726f027ce |
test(platform): extract Apple perf and Android runtime fixtures (#2253)
* test(platform): extract shared fixtures from the Apple perf and Android runtime suites Move the duplicated host-command routing, app-bundle writers, and ps handlers behind packages/platform-apple/src/core/__tests__/perf.fixtures.ts, and the Android runtime host, device, and ordinary-bind builders behind packages/platform-android/src/runtime.fixtures.ts. Every test keeps its title and its own assertions; test and assertion counts are unchanged. * chore(gates): exclude .fixtures.ts modules from changed-line coverage runtime.fixtures.ts (packages/platform-android/src) sits outside any __tests__ dir, so vitest's coverage.include picked it up as production source and the changed-line gate scored its 20 unreachable construction lines directly, failing at 39.39% against the 70% threshold. Add '**/*.fixtures.ts' to coverage.exclude (repo-wide convention: 40+ fixture modules, all test support, never production). The file disappears from lcov and scripts/coverage-changed/model.ts's existing excluded-path fallback reports it non-gating instead, with no duplicate classifier needed there. Planted red (targeted run against the real diff/model, not committed): BEFORE (file present in lcov, all added lines uncovered): totalLines=110 coveredLines=0 pct=0 passed=false. AFTER (file absent from lcov post-fix): totalLines=0 excludedTotal=99 excludedReason=excluded-path passed=true. Confirmed against a real `vitest run --coverage` + `check:coverage-changed` pass: runtime.fixtures.ts absent from coverage/lcov.info, gate PASS, 99 lines reported under excluded/excluded-path. |
||
|
|
7ee1a5ded7 |
refactor(ios): carry provider acquisitions through one presentation owner (#2233)
* refactor(ios): centralize provider snapshot presentation * fix(ios): close provider snapshot ownership gaps * fix(ios): enforce provider snapshot ownership boundary * fix(capture-kit): preserve snapshot engine lazy closure |
||
|
|
db08548026 | refactor: enforce src/utils retirement (#2149) (#2229) | ||
|
|
b8ce09db73 |
refactor(ios): route Appium snapshots through engine (#2224)
* refactor(ios): route Appium snapshots through engine * perf(ios): keep Appium snapshot adapter lazy * fix(ios): preserve legacy snapshot presentation boundary * perf(ios): tighten Appium snapshot facts * test(ios): cover legacy snapshot presentation boundary * test(ios): cover WebDriver snapshot seams * fix(ios): centralize WebDriver snapshot evidence * fix(ios): preserve unavailable snapshot facts * fix(ios): preserve snapshot error context * fix(ios): centralize snapshot presentation ownership * fix(ios): disclose Appium snapshot limits accurately * fix(ios): disclose Appium snapshot evidence limits * fix(ios): harden Appium evidence disclosure * fix(ios): tighten Appium snapshot disclosures * fix(ios): close Appium audit gaps |
||
|
|
1826b2e68b |
refactor(ios): integrate runner with snapshot engine (#2214)
* refactor(ios): integrate runner with snapshot engine * fix(ios): preserve macOS runner snapshots * refactor(ios): keep runner presentation device-aware * fix(ios): validate runner scroll presentation * fix(ios): close presenter package boundaries * fix(ios): preserve snapshot source lineage * test(ios): colocate snapshot engine coverage * fix(ios): settle post-merge audit checks * test(ios): fix manifest parity lint * refactor(ios): simplify runner source walk * test(ios): cover shared package source fixture * fix(ios): close post-merge audit gaps * perf(ios): avoid bundling acquired snapshot path |
||
|
|
4388aeafe0 | fix: preserve physical iOS session health check (#2218) | ||
|
|
7646a73b1b |
perf: avoid redundant physical iOS runner health check (#2215)
* perf: avoid redundant physical iOS runner health check * test: isolate iOS runner prewarm coverage |
||
|
|
3afd154c80 | perf: retain iOS runner across physical relaunch (#2200) | ||
|
|
d330a679e2 |
fix(apple): switch to manual code signing when a provisioning profile is set (#2172)
* fix(apple): switch to manual code signing when a provisioning profile is set CODE_SIGN_STYLE was hardcoded to Automatic even when AGENT_DEVICE_IOS_PROVISIONING_PROFILE was configured, so xcodebuild rejected the resulting PROVISIONING_PROFILE_SPECIFIER + CODE_SIGN_STYLE=Automatic combination with "conflicting provisioning settings" on physical-device runs. Fixes #2153 * fix: satisfy formatting and the test-file size ratchet - oxfmt: wrap the long array literal in the new manual-signing test. - runner-client.test.ts was already pinned at the 1000-line tripwire (1577 lines); adding a test grew it past the pin, which the ratchet test rejects by design ("extract instead of adding to a file over the tripwire"). Extract the pure runner-cache-metadata.ts build- settings tests (signing, bundle, performance, sandbox args) into a new runner-cache-metadata.test.ts, shrinking runner-client.test.ts to 1441 lines and lowering its pin to match. |
||
|
|
5fddf1ce14 |
feat(apple): reclaim retained runners under device-claim authority (#2160)
* feat(apple): reclaim retained runners under device-claim authority Implement the #1320 retained-runner rule: a daemon holding the host-global device claim may stop and replace a warm XCTest runner whose owner no longer holds that claim. Previously worktree B's open failed with an unstructured COMMAND_FAILED (IOS_RUNNER_OWNED_BY_OTHER_DAEMON) for up to five minutes after worktree A closed, until A's daemon idled out. - Runner leases now record deviceClaimProtocol: 1; takeover is gated on the lease declaring claim arbitration, so owners from pre-claims builds are never preempted. - The claim-authority probe is daemon-bound through the existing runner-owner seam and answers from the claim store by process identity; unbound embedders answer false and keep today's refusal. - Disposal now skips device-wide runner container-app termination when the on-disk lease is owned by someone else, so the losing daemon's idle stop or shutdown cannot kill the successor's runner on the shared simulator. - Help topics updated: the live-owner runner rejection now names claim arbitration instead of being unconditional. Live-validated with two daemons sharing one claim store against a throwaway simulator: live-owner open still rejects with structured DEVICE_IN_USE; after close, the contender opens in ~8s with the lease re-owned while the loser daemon is still alive; stopping the loser afterwards leaves the winner's runner healthy. * fix(apple): make disposal ownership check and device-wide teardown one lease-locked operation Review P1 on #2160: cleanupRunnerSessionResources read the on-disk lease token and then terminated the device-global runner container bundles without holding the runner-lease lock, so a loser could pass its check while a successor's reclaim was in flight and then kill the successor's runner. The ownership check, the container-app termination, and the lease release now run as one operation under the runner-lease lock — the same lock a successor holds for its entire reclaim-and-publish window — on every disposal path. Callers already inside the lock (startup abort, lease-publish failure, stopIosRunnerSession) declare leaseLockHeld instead of re-acquiring the non-reentrant lock; if the lock cannot be acquired, all device-wide teardown is skipped and an own unreleased lease turns stale on process exit. The two-owner regression starts loser disposal inside a held successor window, completes the takeover, and proves the loser neither terminates the successor's runner nor touches its lease. Observed red against the pre-fix code. Live-revalidated twice with overlapping daemon stop and takeover open on a real simulator. * fix(apple): match runner takeover authority by canonical device identity Review P1 on #2160: the authority probe matched any active claim with the same raw device id, while claims are canonically scoped by platform family + Apple OS + id — an Android claim whose serial equals an Apple runner id could authorize destructive takeover of a live Apple runner (and the prior probe regression used an Android fixture, blessing exactly that). The probe now receives the full DeviceInfo through the runner-owner seam and the daemon answers with an exact canonical-local-device-key lookup: family, Apple OS, and id must all match the held claim, which also replaces the store scan with a direct keyed read. prepareRunnerLeaseForStartup takes the device rather than a bare id so the lease seam can never degrade the match. Adversarial regressions: a same-id Android claim grants no authority for the Apple device (and the true Apple claim does) at the probe, and the lease-seam test pins that the probe receives full family/OS identity, never a bare id. |
||
|
|
ed26b31c94 |
refactor: contract Apple platform surface (#2125)
* refactor: contract Apple platform surface * refactor: use Apple plugin seam in tests * test: ratchet snapshot handler size |
||
|
|
a6232e51cf | refactor: prune platform split residue (#2123) | ||
|
|
9abcd7fe03 |
refactor: move Apple platform family into package (#2118)
* refactor: move Apple platform family into package * fix: preserve Apple facade sync contracts * fix: complete Apple W4 rebase review fixes |
||
|
|
af6f12e391 |
chore: adopt shared oxlint config (#2115)
* chore: adopt shared oxlint config * fix: preserve project lint boundaries * fix: remove redundant oxlint config |