refactor: migrate get to the request-bound device runtime
Summary
Migrates the get descriptor onto a request-bound device runtime (ADR 0019 §6), Wave 4 of #1739.
Cutover rule id R36.
Along the way this PR also became the home of the selector capture seam (because get is its
first consuming command) and retired two legacy paths that the seam made retirable: the read
dispatch alias and get's direct-iOS selector shortcut.
Start record: https://github.com/callstack/agent-device/issues/1739#issuecomment-5340128572
What lands here
1. get is runtime-backed. It declares selectorCaptureRuntimePlanUses — required
captureSnapshot / captureSnapshotWithoutActiveApp, preferred readTextAtPoint — resolves
its plan, inspects the exact owner's facts once, refuses before binding, and binds exactly once
through the admitRuntimePlan token.
2. The selector capture seam. Extracted from #1876, which could not ship it standalone: with
find's cutover deferred it had no consuming command (ADR 0019 §10) and was not dead-code clean
(check:production-exports 19 → 20). resolveBoundSelectorCapture returns a record of bound
operations, so readText is a second member rather than a signature change. wait and is are
stacked on this branch and consume it.
3. The read dispatch alias is retired, in full. Read-only find now constructs a bound
backend, so get text and find <q> get text execute the same bound readTextAtPoint. With no
consumer left, the whole chain is deleted: the read registry entry and its dispatch: {}
projection, DISPATCH_HANDLERS.read, handleReadCommand, the legacy adapter, the duplicated
platform reader branches, and the now-empty 'dispatch-alias' catalog group. find's descriptor
stays LEGACY_PLATFORM_EXECUTION — only its read leg moved, and it claims no cutover row.
Deleting the registry entry drops 'read' from DescriptorDispatchCommandName, so a surviving
DISPATCH_HANDLERS.read is a compile error rather than something R36 has to police.
4. The direct-iOS selector shortcut is retired. dispatchDirectIosSelectorGet reached
runAppleRunnerCommand through a path R36 declares no operation for; admitting before a bypass is
not executing through the seam. Every get target shape now resolves through the bound capture.
queryDirectIosSelector itself stays — offscreen-target probe still consumes it and it
remains single-copy.
Cost, stated plainly: get text id=… loses its tree-capture skip on iOS. A simple id selector
that previously answered from a direct runner query now captures the tree and resolves through it.
is measured the comparable shortcut at 0.14 s held / 0.25 s failed. No fallback was added and the
latency is not recovered anywhere. querySelector returns as a declared, fact-admitted, §9-measured
operation in a later unit that also moves the offscreen probe, retiring the root function in one
move — the joint-retirement shape that worked for read.
Retired
- the
getcapability bucket, andrequireCommandSupportedadmission forget; 'get'fromHARMONYOS_SUPPORTED_COMMANDSandWEB_QUERY_COMMANDS;- the entire
readchain (above); dispatchDirectIosSelectorGetand its two now-unreachable helpers;resolveBoundGetRuntime,get-runtime.tsand its test,elementReadRuntimeUse/
elementReadRuntimePlan— all superseded by the seam rather than left alongside it;src/platform-runtime-element-text-host.tsand itselementTexthost wiring.
Declared behavior changes
- watchOS
getmoves from capability-admitted-then-runner-failure to a typed unavailable
admission refusal — the same sentinelsnapshotanddiffalready landed. - An unexpected live-read failure now fails
get texton an editable iOS node instead of
silently returning stale captured-tree text. That is the point of retiring the generic catch. get text id=…loses its fast path (above).
Test fixture change that is not incidental churn
makeSession in interaction-touch-fixtures.ts now returns makeIosAppSession. On an
iOS-family leaf the without-active-app capture row is unavailable, so a selector command run against
a session with no tracked app resolves the without-active-app plan and is refused at admission
before it captures, returning the shared SESSION_NOT_FOUND "run open first". Reviewers should
connect this to the admission-refusal surface every selector command inherits, not read it as churn.
Validation
pnpm check:affected --run && git push: PASS — "all runnable checks passed".- unit-core: 938 files / 7,071 tests.
- Layering: green. The cutover table parses to 21 distinct rows via the layering parser (not
grep), with R36 and R39 both present and no silent merge. fallow audit: clean on changed files.- Live evidence on iOS Simulator
iPhone 17 Pro(F7D6F9A4-…, confirmed in the runner log):get text/get attrsover@refand selector, both not-found paths, and the §9 measurement —
required path alone 511 chars on a[truncated]node, bound preferred read 1,126 chars.
Red-before-green was captured for every regression: R36's 5 pre-cutover violations, the
admission-ordering regression (expected true to be false), the typed-outcome propagation
(promise resolved 'snap' instead of rejecting), and a planted TS2322 proving the exhaustiveness
guard bites — re-proved after the refactor that removed the runtime reason list.
Accounting — read this before the size discussion
Production only (excluding tests, fixtures, docs), from the merge-base: +753 / −238 = net +515.
Two corrections to figures quoted earlier in review:
- An earlier +790 / −224 measurement was against a stale base.
- The
Interactorchange was expected to remove ~89 lines by deleting
platform-runtime-element-text-host.ts. It bought ~4, not ~89 (net +520 → +516 before a lint
fix). The four readers still have to exist — they relocated into the Apple/Android/Linux
interactors, and the contract's binder absorbed the resolve/guard logic. Only the port vanished.
That change was made for consistency of mechanism (findTextandreadTextAtPointnow reach
their mechanics the same way, so Wave 5/6 retires one seam instead of two) and it delivers that
fully — but it should not be read as having delivered a size win it did not.
Per #1842, the CI Size report on this head is the authority; pnpm size --base <ref> does not
exist and local comparisons are not the evidence.
Structural outcomes from the size investigation: ElementTextRuntimeExecution was a byte-identical
duplicate of SnapshotRuntimeExecution (removed, reused); FrozenUnavailablePlatformRuntimeFacts is
now derived (Required<Omit<…>>) rather than restated, so a future cell cannot be added to one
and forgotten in the other; and a stale comment claiming a duplication this branch had already
retired was removed.
Known gaps
- Not the last selector unit:
captureSnapshotkeeps its optionalcaptureData?seam and the
legacycaptureSnapshotWithInteractorfallback. CaptureSnapshotInput.signalis not on this branch — it belongs towait(#1875), along with
the regression proving per-poll abort and quiescence.snapshotPlanUnavailableResponseis deliberately not exported; it lands withwait, its
first external consumer.- Android, HarmonyOS, Linux, Web and every provider row are covered by fact/parity evidence only.
Docs and skills are unchanged: CLI grammar, help, and user-visible get behavior are identical
apart from the declared changes above.