refactor: migrate back/home/orientation/tv-remote/keyboard to the request-bound device runtime - #1955
Conversation
fda3e35 to
e1e34ae
Compare
Size Report
npm unpacked components
Startup median (7 runs, lower is better):
Top changed chunks:
Top changed packed files
|
Review findings[P1] Close the watchOS sentinel before declaring these facts. The Apple interactor explicitly rejects watchOS because it has no XCUITest backend, but the new Apple facts admit back, home, orientation, and keyboard dismiss/enter. Those commands now bind and fail only when resolving the interactor; the retired Apple capability table refused home, keyboard, and orientation on this sentinel. Mark every interactor-backed operation unavailable for watchOS and make the leaf test assert no binding, so facts remain the support authority. [P2] Preserve the tv-remote target-mismatch response. Before this cutover, non-TV targets received the shared TV-target message and cross-target selector hint. Exact-fact admission now returns a generic refusal and owner-specific hints instead. Keep facts as the support authority, but preserve the existing daemon response and pin iOS plus Android-mobile parity. Validation is currently red on this head: Coverage fails the new orientation router test (its focused run passes, which does not resolve the full-suite failure), and Layering Guard fails the unrelated tmpdir child-liveness test. Both need a green CI rerun; the Coverage failure needs diagnosis if it recurs. |
|
Not merge-ready at exact head P1: Apple facts admit the explicitly unsupported watchOS sentinel. P2: CI: Coverage has one real owner-action failure in |
|
Thanks for both passes — pushed P1 (fixed): confirmed — P2 (investigated, no change): traced the retired code path all the way through. So the current fact-based admission already reproduces old production parity byte-for-byte for every reachable case. Rather than reintroduce the (dead) unified message, I added a daemon-level CI: Layering Guard's failure is |
Deep code-quality reviewVerified green at The headline: this PR is the moment five of these leaves stop being a coincidence and become a missing abstraction, and the diff rearranges that duplication rather than deleting it. Three of the findings below are the same shape — boilerplate that got a wrapper instead of a model. 1. Seven copies of "fact-keyed table of interactor binders" — this is the code-judo moveThe same body now exists in seven places:
Every one of those bodies is 100% contracts vocabulary: And the justification attached to those suppressions doesn't hold:
ADR 0019 forbids a shared package between sibling platform packages. It does not forbid contracts, which every one of these packages already imports, and which already owns both halves of what's being duplicated: the binders and the fact keys. This isn't a shared-mechanics problem — there are no mechanics here. I prototyped the fix and it typechecks clean. Contracts exports a return bindAdmittedLocalInteractorOperations({
device: request.device,
signal: request.scope.signal,
resolveInteractor: host.localInteractors.resolve,
facts,
operations: ['back', 'home', 'setOrientation', 'tvRemote',
'keyboardStatus', 'keyboardDismiss', 'keyboardEnter'],
});Apple keeps its bespoke Related, same root cause: 2.
|
|
Root-caused the Coverage failure — it was real, not a flake (reproduced 2/2 in CI). Pushed
On my machine (real
While tracing this I also found (and fixed) that |
|
Prior correctness blockers are fixed and exact-head CI/device evidence is strong, but this is not ready yet. First, the migration adds about 1,921 net production lines and +15.3 kB unpacked; |
|
Both fixed — pushed Size/extraction (fixed): Moved the new back/home/orientation/tv-remote/keyboard facts and bindings out of the two owning-facet files into sibling modules, matching the pattern the repo already uses (
Both owning files are now well clear of the 500-line extraction threshold, and both sibling modules are well under the 300-line target. No further growth to itemize/justify — the remaining diff is the fact-cell logic itself (five new operations across six owner packages), which has nowhere further to shrink without changing what's being admitted. Coverage masking (fixed): Agreed — swapping the fixture to Apple proved the wiring but stopped exercising the Android route that actually failed.
|
|
Still not ready at |
|
The latest commits correctly remove several duplicated binder/admission patterns, but three owning-structure blockers remain:
This is still an escalated change (~1,946 net production-ish lines, +12.8 kB unpacked). Update the stale PR description to current scope and itemize justified growth plus why the remaining smaller designs were rejected after the known deletions above. Exact-head checks currently have no failures, but Coverage and Android/iOS smoke were still running when reviewed. |
1 similar comment
|
The latest commits correctly remove several duplicated binder/admission patterns, but three owning-structure blockers remain:
This is still an escalated change (~1,946 net production-ish lines, +12.8 kB unpacked). Update the stale PR description to current scope and itemize justified growth plus why the remaining smaller designs were rejected after the known deletions above. Exact-head checks currently have no failures, but Coverage and Android/iOS smoke were still running when reviewed. |
|
Fixed 6 of 7 findings, pushed across four commits ( Finding 1 (seven copies of the fact-keyed binder table) — fixed. Extracted Finding 3 (copy-pasted admit-then-wrap) — fixed, both halves.
Finding 2 ( Finding 5 (hand-expanded Finding 6 (restated contract types) — fixed both instances.
Finding 7 (naming/placement) — fixed both bullets. Each commit is Finding 4 ( |
|
All three owning-structure blockers are now fixed, and the PR description is updated. 1. Note 2.
Wire output is unchanged: 3. PR description — updated. Restated "Shared helper" as "Shared abstractions" covering all four abstractions these review rounds produced (not just Verified at |
|
Not ready despite green CI. The refactor also grows already-over-budget |
|
The ADR 0014 ordering, extraction, and caller-array drift are improved correctly, but exact head
Completed checks are green; Coverage/iOS/Android were still running at review time. |
|
All four points fixed, pushed as 1. ADR 0014 ordering bug — real, and fixed. You're right: Split the thunk into two phases in the new
This mirrors the generic route's own 2. 3. 4. Evidence/growth refreshed. PR description's "Growth accounting" table now reflects this cycle: Verified at |
|
CI update at e2ffc13: this is not ready yet. Coverage is an infrastructure failure: all 7,814 tests passed before the Node/V8 worker crashed on |
|
Behavior review is clean at f2d03a8: the catalog ownership and ADR 0014 pre-invocation expiry are sound, iOS live E2E passed 15/15 (the prior email failure did not reproduce), Coverage passed 7,823 tests, and all 28 checks are green. Three readiness items remain: (1) move the lone |
f2d03a8 to
95354c8
Compare
…uest-bound device runtime Continues the ADR 0019 platform-runtime migration (Wave 5 generic leaves): five generic-route commands move off dispatchKnownCommand/Interactor legacy dispatch onto fact-owned admission, one bind per handler. keyboard uses the R35 action-selected single-bind pattern (status/dismiss/enter each admit and bind independently). All 8 owner runtime packages gained fact-cell tests for the new operations; six smoke-coverage integration oracles and nine daemon/capability unit test files were updated for the retired capability- catalog admission these commands no longer carry.
…ntime bindKeyboardStatus/Dismiss/Enter repeated the same signal-check + resolveInteractor call; factor it into resolveKeyboardInteractor so each binder is a two-line call instead of a six-line copy. No behavior change — the three contract-module mutants planted earlier in review still kill on this shape.
… tv-remote non-TV parity P1: watchOS has no constructible Apple interactor (XCUITest cannot drive its UI, ADR-0009), matching the existing captureScreenshot/captureSnapshot/readTextAtPoint/findSelector pattern in this same file. appleBackFact/appleHomeFact/appleMobileInputEligible admitted every Apple OS but tvOS/macOS, wrongly including watchOS. Facts now refuse watchOS explicitly for back, home, orientation, and keyboard dismiss/enter, with a fact-cell test asserting no binding for every one of them. P2: verified the daemon's generic-route capability gate already reproduced the retired per-platform tv-remote hint text (message stays the generic "<command> is not supported on this device", hint carries the owner-specific text) for every device that could reach dispatch in the old system -- the retired handleTvRemoteCommand's own "supported only on TV targets" check was unreachable there and only exercised by a test calling dispatchCommand directly. Added a daemon-level test pinning the exact iOS and Android-mobile hint strings to make that parity explicit instead of implicit. Also fixes a fallow complexity finding the P1 test edit introduced by splitting the fact-cell assertions into five small named helpers instead of one large function.
… real adb Root-caused the CI-only Coverage failure (unreproducible locally in isolation, reproducible 2/2 in the full CI run): every generic-route leaf this migration touches carries `androidBlockingDialogGuard: true`, and `dispatchGenericCommand` calls `ensureNoAndroidBlockingDialogReady` unconditionally for any `platform: 'android'` session reaching the real request router -- regardless of whether admission is fact-based or capability-based. That check calls `getAndroidBlockingDialogFocus`, which shells out to the real `adb` binary. orientation-runtime.test.ts's "request router joins..." test used a synthetic `platform: 'android'` device through `createRequestHandler` (the real router), without stubbing the platform ADB layer -- only the runtime gateway was mocked. On a host with a real `adb` binary (my machine) the subprocess fails fast and `allowFailure` tolerates it, costing ~800ms-1.1s but still succeeding. On a host with no `adb` binary at all (CI's Coverage job, a plain unit-test lane with no Android SDK) the spawn itself throws, which isn't something `allowFailure` catches, producing exactly the observed `ok: false` unsupported-operation response. back/home/tv-remote's equivalent router-join tests already use Apple/Vega devices, so they never reached this path. Switched orientation's fixture to match -- Apple, since the fixture's facts/execution are fully synthetic and platform-agnostic regardless. Also: renamed the widely-shared 'emulator-5554'/'ios-simulator' device-id literals in back/orientation/tv-remote/keyboard-runtime.test.ts to file-scoped ids. Device claims for a `local-family` owner binding hit the real on-disk `require-owner` claim file (keyed only by canonical device id), and 27+ pre-existing test files already share 'emulator-5554'; this migration added three more consumers of it under a `require-owner` policy that reaches real admission, which was worth eliminating as a source of doubt even though it wasn't the actual root cause here.
…test the real Android dialog-guard path packages/platform-apple/src/runtime.ts and packages/provider-limrun/src/app-log-runtime.ts grew past the repo's 500-line extraction threshold. Move the new back/home/orientation/ tv-remote/keyboard facts and bindings into packages/platform-apple/src/navigation/runtime.ts (new sibling module, matching deployment/runtime.ts's existing pattern), and the new keyboard facts/bindings for limrun into the existing packages/provider-limrun/src/interaction-operations.ts (which already held the sibling navigation logic). Also fix orientation-runtime.test.ts's router-join test: it previously swapped its device fixture from Android to Apple to dodge the real adb-backed blocking-dialog guard, which masked the Android route that was actually failing in CI. Keep the Android fixture and stub getAndroidBlockingDialogFocus instead, the same seam request-router-android-modal.test.ts already uses.
…/tv-remote/keyboard Following main's #1969 (facade granularization), give each of this branch's five new contract modules their own package.json entry subpath and move every value-importer (owner runtime packages, the daemon binders, and their tests) off the wide @agent-device/contracts/platform facade onto the specific module that owns the symbol — the same convention #1969 established for the rest of the vocabulary. Keeps this migration's files out of the contracts-entry-closure gate and out of the eager-evaluation cost #1969 measured for the daemon's permanent hubs (registry.ts, dispatch.ts).
…mission; drop restated types
Addresses the review's finding 1 (seven per-owner copies of the same
"fact-keyed table of interactor binders" pattern) by extracting
bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations
into packages/contracts/src/interactor-operation-catalog.ts. Each owner now
requests the subset of back/home/setOrientation/tvRemote/keyboard{Status,
Dismiss,Enter} it admits, instead of hand-writing
`facts.operations.<key>.available ? bind…(resolver) : {}` per operation.
Applied across all seven call sites (apple, android, harmonyos, vega, linux,
webdriver, limrun) and collapsed limrun's two separate bind functions
(navigation, keyboard) into one shared call.
Finding 3 (resolveBoundKeyboardRuntime copy-pastes admit-then-wrap three
times): extracted a local admitKeyboardAction<...> helper mirroring
resolveBoundGenericRuntime's admit-then-defer shape, so the three action
branches (status/dismiss/enter) share one admission path.
Finding 6 (execute* helpers hand-restate a contract that can drift): back/
home/orientation/tv-remote/keyboard's execute functions are now typed off
`BoundDeviceRuntime<typeof xRuntimeUse>` (derived from the actual bind-use
value) instead of a hand-written `Readonly<{ operations: Readonly<{...}> }>`
shape. Also fixed provider-limrun's `RuntimeOperationUnavailability |
{ available: true }` restating RuntimeOperationFact by hand — folded away
entirely once the bind functions it typed were removed.
Finding 7 (naming/placement): platform-apple/runtime.ts's misleadingly-named
`captureOperations` bucket (held deployment/network/recording/find, not just
capture) collapsed into one flat `operations` object now that the navigation
bucket is a single function call instead of six ternaries.
Exported RuntimeAdmissionRequest from runtime-admission.ts (needed by the new
keyboard admission helper). Added packages/contracts/src/
interactor-operation-catalog.test.ts for the new shared binder table.
pnpm typecheck, check:fallow, check:layering, and the full unit-core suite
(1010 files / 7513 tests, one known contention-flake excluded) are green.
…tch pair Addresses the review's finding 5: GENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITS bundled two orthogonal things (daemon/recording traits, and the legacy capability+dispatch pair migration strips), forcing every migrated descriptor to hand-expand the constant minus two fields plus an explanatory comment. Split into GENERIC_MUTATING_COMMAND_TRAITS (the shared daemon/recording traits) and LEGACY_LINUX_DEVICE_EXECUTION (the dispatch/capability pair). back/home/orientation/tv-remote (this migration) and focus (an earlier one, same pattern, previously a stale reference to the retired constant name) now spread the trait constant directly instead of hand-expanding it; the still-legacy `scroll` descriptor spreads both pieces, equivalent to the retired constant. pnpm typecheck, check:fallow, check:layering, and the registry/daemon test suites are green.
…e-way duplication Finding 3's second half: the three bindKeyboardX functions and six bindLocal/ProviderKeyboardXInteractor entry points differed only by method name and label string. Replaced with one generic bindKeyboardAction<Key> dispatching off the operation key (interactor[key], resolved from a small label table) plus two shared local/provider dispatch helpers the six named exports each call with their own key — collapsing three copies of the bind logic into one and six near-duplicate entry-point bodies into one line each, while keeping every exported name and type signature unchanged. pnpm typecheck, check:fallow, check:layering, and pnpm check:affected --run are green.
…trategy Addresses the review's finding 2: handleKeyboardCommand re-implemented runSessionOrSelectorDispatch's orchestration step for step (session/selector guard, device resolve, ref-frame expiry, record) instead of reusing it, because the shared function had no seam for keyboard's bind-and-execute admission — only the legacy requireCommandSupported + dispatchCommand path. That left the shared orchestrator with one caller instead of two, and set a precedent that would fork a new copy for each of the 28 remaining session-route migrations. Gave runSessionOrSelectorDispatch an `execute` parameter: the orchestration (guard, resolve device, admit-then-execute, expire ref frame if mutating, derive and record next session) stays in one place, and callers supply their own admission/execution strategy. Extracted `legacySessionDispatchExecute` for the still-legacy capability-gate-then-dispatchCommand shape `handleTriggerAppEventCommand` (the remaining legacy caller) now passes explicitly, and `keyboardSessionExecute` for keyboard's bind-and-execute shape. Deleted the now-fully-redundant `executeBoundKeyboardCommand` — its result recording duplicated what the shared orchestrator's tail already does. pnpm typecheck, check:fallow, check:layering, the full daemon test suite (321 files / 2271 tests), and pnpm check:affected --run are green.
…ssResult by owner app-log-runtime.ts was still 589 lines after the shared-abstraction fixes; moves fact assembly (limrunAppLogFacts/limrunAppLogRecoveryFacts/limrunLifecycleFacts/deploymentOptions) to a new facts-runtime.ts and the shared device-identity predicate to device.ts, the leaf both files already depend on. app-log-runtime.ts is now 336 lines. KeyboardDismissResult was an 11-field optional bag with executeKeyboardDismiss separately re-deriving platform from the device and projecting subsets by hand. Each owner (android, apple, harmonyos) now tags its own result with a `kind` discriminant, so an owner can only ever produce its own shape, and the daemon derives the wire `platform` label from `kind` instead of guessing from the device a second time. Wire output is unchanged.
…ssion/selector dispatch; derive catalog operations from facts runSessionOrSelectorDispatch awaited execute(device, session) — which bundled admission and the mutating invocation together — before expiring the ref frame, so a rejecting or timed-out invocation left a stale frame active (ADR 0014 requires expiry immediately before the mutating call, with no success-only rollback). Split the execute thunk into `prepare` (admission only) + a deferred `execute` invocation, so the orchestrator can expire between them regardless of how the invocation resolves. Added a regression test proving the frame still expires when the invocation rejects. Extracted runSessionOrSelectorDispatch and its keyboard/trigger-app-event callers into a new session-selector-dispatch.ts, matching this file's own convention of one file per command-group (session.ts shrinks from 571 to well under its 500-line budget). bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations accepted both a facts object and a separately hand-maintained `operations` array naming the same keys — a second source of truth that could drift from what the facts actually admit. Removed the array; the binder now walks the fixed set of navigation operations and lets each owner's own facts decide what binds, exactly as before but with one source of truth.
…e keyboard orchestration tests NAVIGATION_INTERACTOR_OPERATIONS was declared as a plain readonly array independently of the NavigationInteractorOperation union it walked, so a future union member could compile without ever being added to the walk list, silently preventing an admitted fact from binding. Made the tuple the single canonical value: the union type is now derived from it via `(typeof TUPLE)[number]`, so LOCAL_BINDERS/PROVIDER_BINDERS' Record<NavigationInteractorOperation, ...> completeness is checked against the same tuple, not a separately hand-kept list. Added a regression test binding all seven operations at once to pin the runtime walk, independent of the type-level guarantee. Moved the four keyboard-orchestration tests (the two ADR 0014 ref-frame seam tests plus the two session/selector-guard tests) out of the mixed appstate/perf test file into a new session-selector-dispatch.test.ts, colocated with the file they exercise. Strengthened the rejection regression test to assert the frame is already expired from inside the rejecting keyboardDismiss callback itself, pinning the exact pre-invocation seam rather than only checking the end state after the dispatch settles.
… lost in rebase Rebasing onto origin/main dropped these five package.json export entries during conflict resolution (the granular-subpath commit's package.json changes silently lost during merge). Restored, confirmed by pnpm typecheck across all 17 workspace packages and the full unit-core suite (1023 files / 7581 tests).
95354c8 to
472d0db
Compare
Two prior CI runs on this PR saw the seeded email field ("ada@example") end up
containing only a typed suffix (".test") by the time the flow reads it back at
the end — after fill, keyboard dismiss, coordinate refocus, and type. Since this
PR touches executeKeyboardDismiss's response shaping, the reviewer asked to
disprove keyboard dismiss as the cause rather than assume the pre-existing
dropped-keystroke flake pattern applies.
Added two read-back checkpoints: right after seeding (before dismiss runs at
all) and right after dismiss (before the coordinate refocus + type steps that
follow). If both hold "ada@example", the loss happens during refocus/type, not
dismiss — matching the documented flake, not a regression in this PR's diff.
|
Coverage: agreed, confirmed infrastructure. The head that comment referenced ( iOS Smoke — treated as changed-path-owner until disproved, as asked. Traced exactly what this PR changes in the dismiss path: That's the reasoning; here's the disproof mechanism, since reasoning about a flake I can't reproduce locally isn't evidence on its own. Pushed
Both assert Verified at |
|
Clean rerun at |
|
Exact head
The back/home/orientation/tv-remote routes, fact matrices, ADR 0014 pre-invocation expiry, and current practical smoke evidence otherwise review clean. |
…view-round prose KeyboardStatusResult and KeyboardEnterResult were bare objects; executeKeyboardStatus and executeKeyboardEnter derived the wire platform label from device.platform via keyboardPlatformLabel, the same re-derivation already fixed for dismiss. Each owner now tags its own result with a kind (android's status/enter as 'ime-probe' and 'android-acknowledged', harmonyos's enter as 'harmonyos-acknowledged', apple's enter as 'visibility-echo'), and the daemon derives platform from a kind-keyed lookup table for all three actions. keyboardPlatformLabel and its isIosFamily import are gone — nothing derives platform from the device anymore. Android and HarmonyOS's enter acknowledgments are structurally identical (empty besides kind), so the discriminant alone — not result shape — is what tells the daemon which owner actually ran. Added a harmonyos enter test alongside the existing ios/android ones so all three owners are covered for both dismiss and enter's kind-to-platform mapping. Also trimmed several comments that narrated which PR review round motivated them down to just the durable invariant or rationale — the type shape, test names, and assertions already carry the proof.
|
Re-review at exact head Two readiness blockers remain:
No code finding remains in the changed owner-discriminant patch, but do not mark ready while the body is stale and required CI is red. |
|
All three fixed, pushed as 1.
2. PR evidence refreshed, with the itemized justification you asked for. Current head: 109 files, +6,321/−1,532; production TypeScript +3,014/−933 (+2,081 net) across 49 non-test files; 3. Review-round prose trimmed from code. Removed Verified at |
|
iOS Smoke Tests failed again at The failure is at the first checkpoint —
This is now conclusively disproved as a regression in this PR: the corruption is upstream of every line this PR touches. I'm not fixing the underlying fill/type synthesized-commit race here — it's pre-existing, unrelated to this migration, and needs its own investigation (poll-before-read or consuming the runner's unobserved-commit signal, per the existing internal flake notes) — but wanted to close the loop with the evidence rather than assert it again. Happy to open a follow-up issue for the root cause if useful. |
|
iOS Smoke failed again on rerun at the exact same head ( Two failures in a row at the identical checkpoint is enough that I'm not calling this a one-off blip: the underlying Triggered a second rerun. If it fails a third time in the same place, I'd treat that as strong enough signal to stop retrying blindly and defer to you on how to proceed with this required check, rather than keep spending CI minutes on retries that don't touch the actual defect. |
|
All 27 checks green on The |
|
Wave 5 unit for #1739 (ADR 0019) — the five remaining generic-route leaves:
back,home,orientation,tv-remote,keyboard.What changed
One execution path per command. Each moves off
dispatchKnownCommand/Interactorlegacydispatch onto exact-owner runtime facts, admitted and bound exactly once per handler (ADR 0019
§9).
back/home/orientation/tv-remotestaydaemon.route: 'generic';keyboardstaysdaemon.route: 'session'since it can run sessionless, and uses R35's action-selected single-bindpattern —
status/dismiss/entereach resolve and bind their ownRuntimeUserather thanadmitting all three together.
Facts replace the retired admission, restated per owner from the deleted closures:
back/home: no apple-family closure ever gatedbackbeyond device kind (tvOS's Menu-buttonnavigation included);
homeis unavailable only on macOS (an already-running app, nospringboard). Android/HarmonyOS ride the same touch gate as focus/type; Linux/Web/Vega restate
their retired per-platform buckets.
orientation: unavailable on tvOS and macOS (no device orientation there), otherwise mirrorsthe retired
supportsOrientationclosure per Apple OS.tv-remote: available only on tvOS and a real Android TV target (device.target === 'tv') —the mobile-vs-TV gate that used to live in the plugin closure now lives in the owner's fact.
keyboard:statusis Android-only (no live IME read exists elsewhere — the retired in-handlerhint is preserved byte-for-byte);
dismiss/enterare cross-platform wherever the interactorreaches a foreground app.
Registry: all five descriptors flip to
device-runtime.HARMONYOS_SUPPORTED_COMMANDSdropsback/home/keyboard; the apple plugin's
supportsKeyboard/supportsOrientation/supportsTvRemoteclosures and Vega's
VEGA_VVD_ONLY_COMMANDS/target-gating closures are deleted. R42–R46 are thefive new cutover rows.
Shared abstractions (most of these landed in response to review — see below):
resolveBoundGenericRuntime(src/daemon/runtime-admission.ts) collapses the admit-then-wrapboilerplate duplicated across back/home/orientation/tv-remote/focus into one call.
bindAdmittedLocalInteractorOperations/bindAdmittedProviderInteractorOperations(
packages/contracts/src/interactor-operation-catalog.ts) replace the seven owner-packagecopies of "fact-keyed table of interactor binders" with one shared dispatch table. Each owner
hands in its own facts; the table walks the fixed set of navigation operations and binds
whichever the facts admit — no separate, caller-maintained operation list to drift from them.
runSessionOrSelectorDispatch(src/daemon/handlers/session-selector-dispatch.ts) takes apreparestrategy that returns admission-or-a-deferred-invocation, so the orchestrator canexpire the ADR 0014 ref frame between admission and the mutating call regardless of how that
call resolves.
keyboard's bind-and-execute admission and the still-legacydispatchCommandpath both share it instead of forking their own copies.
packages/contracts/src/keyboard-runtime.ts's three near-identical bind functions and sixnear-identical entry points collapsed into one generic dispatcher plus two thin call sites.
Review-driven architecture changes
Three rounds of deep structural review flagged real duplication, an ordering bug, a representable
owner/result mismatch, and over-budget files this migration grew further. Fixed, across the
commits after the initial live-evidence push:
interactor-operation-catalog.tsdispatch table (above)handleKeyboardCommandforkedrunSessionOrSelectorDispatch's orchestrationpreparestrategy; deleted the forkkeyboard-runtime.ts(daemon + contracts) copy-pasted admit-then-wrap three timesGENERIC_MUTATING_LINUX_DEVICE_COMMAND_TRAITSbundled shared traits with the legacy dispatch pair, forcing hand-expansion on every migrated descriptorGENERIC_MUTATING_COMMAND_TRAITS+LEGACY_LINUX_DEVICE_EXECUTIONexecute*helpers hand-restated the bound-runtime operations shapeBoundDeviceRuntime<typeof xRuntimeUse>insteadplatform-apple/runtime.ts'scaptureOperationsbucket named after only part of its contentsoperationsobject once the catalog removed the complexity pressureprovider-limrun/app-log-runtime.tsretained new fact/bind assembly past the 500-line budgetfacts-runtime.ts(fact assembly + lifecycle facts) and moved the shared device-identity predicate todevice.ts, the existing leaf both files needKeyboardDismissResultwas an 11-field optional bag;executeKeyboardDismissseparately re-derived the wireplatformlabel from the devicekinddiscriminant; the daemon derivesplatformfromkindvia a lookup table. Wire output unchanged.KeyboardStatusResult/KeyboardEnterResulthad the same re-derivation gap dismiss's fix didn't coverstatusandenter. Android's and HarmonyOS'senteracknowledgments are structurally identical (empty besides the discriminant), sokind— not result shape — is what tells the daemon which owner ran;keyboardPlatformLabel's device-guessing is gone entirely.runSessionOrSelectorDispatchawaited the mutating call before expiring the ADR 0014 ref frame, so a rejecting/timed-out invocation left a stale frame active (no success-only rollback is allowed)executethunk intoprepare(admission only) + a deferred invocation; the frame now expires between them, unconditionally, before the mutating call runs. Regression test proves the frame still expires when the invocation rejects, asserted from inside the rejecting callback to pin the pre-invocation seam exactly.session.tskept growing (478 → 533 → 571) as this migration's orchestration accumulated in it, past the file's own 500-line budgetrunSessionOrSelectorDispatchand its two callers (handleKeyboardCommand,handleTriggerAppEventCommand) intosession-selector-dispatch.ts, matching the file's own established one-file-per-command-group conventionbindAdmitted*InteractorOperationstook both afactsobject and a hand-maintainedoperationsarray naming the same keys — a second source of truth that could driftNavigationInteractorOperationunion type is itself derived from ((typeof TUPLE)[number]), so the binder records' completeness is type-checked against it — nowhere left for the two to independently driftWhy the production growth can't be materially smaller
Net production TypeScript: +2,081 lines (+3,014/−933 across 49 non-test files). This isn't
padding left over after the review passes above — it's what's structurally required once every
cross-cutting duplication those passes could remove has been removed:
back-runtime.ts,home-runtime.ts,orientation-runtime.ts,tv-remote-runtime.ts,keyboard-runtime.ts), each with its ownfact function and local/provider bind pair. This is the ADR 0019 shape itself — "one execution
path per command" means one contract module per command; collapsing them back into a shared
module is the pre-ADR-0019 design this ships to retire.
provider-webdriver, provider-limrun) for 5 commands. Availability genuinely differs by owner —
tvOS refuses
keyboard, Android is the onlystatusimplementer, only a real Android TV targetadmits
tv-remote— so this is business logic, not boilerplate; the binder wiring acrossthose same 8 owners is exactly what
interactor-operation-catalog.tscollapsed to one sharedtable, which is why the growth is concentrated in facts/types, not in binder call sites.
src/core/dispatch.ts(−210 lines) andsrc/platforms/apple/plugin.ts(−36 lines) lose the closures/dispatch cases these commands nolonger need, so the growth above is already after subtracting what these five commands used to
cost.
None of the four shared-abstraction extractions (
interactor-operation-catalog.ts,keyboard-runtime.ts's table,session-selector-dispatch.ts,facts-runtime.ts) exist to pad thediff — each replaced a specific, named duplication finding from review (table above) and reduced
total lines at its call sites by more than it added at its own definition. What's left is the
combinatorial surface ADR 0019's exact-owner-facts model requires: 5 commands × up to 8 owners,
each with real per-owner availability logic that can't be shared without either fewer owners
supporting these commands (not true today) or abandoning exact-owner facts as the support
authority (the premise this whole migration exists to establish).
Test evidence, with the mutants run up front
Contract binder tests mirror the established mutant-style shape (local binding drives the
interactor with the right args, provider binding drives its own interactor, provider binding
fails closed with no interactor via
UNSUPPORTED_OPERATION/provider-runtime-interactor-missing,an already-cancelled request never resolves an interactor). Representative mutants planted and
killed, source restored byte-identical after each:
back-runtime.tsawait interactor.back(input.mode)home-runtime.tsawait interactor.home()orientation-runtime.ts'portrait'instead ofinput.rotationtv-remote-runtime.tsinput.durationMs, passedundefinedkeyboard-runtime.tsbindKeyboardDismisstointeractor.keyboardEnterinstead ofinteractor.keyboardDismisskeyboard-runtime.tsadditionally covers therequireKeyboardMethodruntime-contract-error path(
COMMAND_FAILED/interactor-method-missing) when an interactor's fact admits an operation itsobject doesn't implement.
Owner fact-cell tests were added across all 8 owner suites —
platform-android,platform-harmonyos, andplatform-vegahad zero coverage of the new operations before this PRand gained full
test.eachfact-cell coverage;platform-apple,platform-linux,platform-web,provider-webdriver, andprovider-limrun(including a new standaloneinteraction-operations.test.tsfor the pure Android/iOS navigation-fact module) were extended.Added
interactor-operation-catalog.test.tsandfacts-runtime.test.tsfor the two new sharedmodules the review-driven fixes introduced.
session-selector-dispatch.test.ts(new, colocatedwith the orchestration it exercises) covers the ADR 0014 pre-invocation seam and keyboard's
session/selector guard;
keyboard-runtime.test.tsnow proves each ofstatus/dismiss/enterderives its wire
platformfrom the owner's resultkindacross all three owners, includingAndroid vs. HarmonyOS
enter, whose acknowledgments are byte-identical except forkind.Six smoke-coverage integration oracles (android/ios-simulator/macos/tvos/web/linux) had their own
independent capability-catalog assertions for these five commands; all were repointed at the new
fact-cell evidence and reclassified
capability-denial→command-contractwhere the command isnow fact-owned rather than catalog-owned, with classification-summary counts updated to match.
Nine pre-existing daemon/capability unit tests broke on the retirement and were fixed: the direct
capability-matrix oracle (
capabilities.test.ts), the descriptor parity oracle(
command-descriptor/parity.test.ts), and — most notably — tworequest-router-replay-scope.test.tstests whose
.adreplay fixtures usedhome/backas throwaway stand-in commands for testingunrelated router mechanics (cost tracking, response-level views, lock policy). Those fixtures
called into real interactor code against a fake iOS simulator device once
home/backstoppedrouting through the mocked
dispatchCommand, spawning realxcodebuildprocesses and timing outat 5s. Swapped the representative command to
app-switcher/scroll, which still route throughlegacy dispatch and don't touch this migration.
A CI-only Coverage failure (
orientation-runtime.test.tsspawning a real, unmockedadbprocessthrough
androidBlockingDialogGuardon a host with no Android SDK) was root-caused and fixed bystubbing the same guard seam
request-router-android-modal.test.tsalready uses, keeping theAndroid fixture rather than swapping it to Apple to dodge the guard.
Live evidence
Real devices at this head — iPhone 17 Pro simulator, Pixel_7_review AVD, a fresh Android TV AVD
(
Television_4K,target=tv), a fresh tvOS 26.2 simulator, and a real Vega Virtual Device:backBack(Menu-equivalent)Back(Menu-equivalent)Back— real remote navigationhomeHomeHomeHomeHome— real remote navigationorientationunsupported-platform-leafwith no hint text)orientation is not supported on Vega OS.tv-remotetv-remote is supported only on Android TV targets.select/right— genuinely admitted (target=tv)select/down— genuinely admittedselect— real remote navigationkeyboard statuskeyboard status/get is currently supported only on Android; use keyboard dismiss or enter on iOSvisible:false(idle) →visible:true(focused) — real IME statekeyboard is not supported on Vega OS.keyboard dismissdismissed:trueafter 2 attempts, verifiedvisible:falsekeyboard enterKeyboard enter pressedKeyboard enter pressedAll sessions closed; the tvOS simulator deleted, both Android AVDs shut down, and the Vega VVD
stopped after the run. CI's own live-device iOS Smoke Tests lane covers the same
fill → dismiss → refocus → type sequence on every push, now with two extra read-back checkpoints
(right after seeding, right after dismiss) that localize a future flake instance to either
keyboard dismissor the coordinate-refocus/type steps that follow it, rather than only theend-of-flow read.
Growth accounting
Current head vs.
main, after every review-driven fix above (no remaining hand-expansion, forkedorchestration, second source of truth, device/result mismatch, or ADR 0014 ordering gap):
packages/platform-apple/src/runtime.tsnavigation/runtime.ts(new, 141 lines)packages/provider-limrun/src/app-log-runtime.tsfacts-runtime.ts(new, 249 lines)packages/provider-limrun/src/interaction-operations.tspackages/contracts/src/keyboard-runtime.tspackages/contracts/src/interactor-operation-catalog.tssrc/daemon/handlers/session.tsmainand well under the file's 500-line budget; the session/selector-route orchestration and its two callers moved to a sibling filesrc/daemon/handlers/session-selector-dispatch.tsrunSessionOrSelectorDispatch,handleKeyboardCommand,handleTriggerAppEventCommand— one file per command-group, matching every other extractedsession-*.tshandler in this directoryEvery file this migration touches is now at or below its size budget; nothing here is deferred as
"predates this PR" — the growth this PR itself introduced is accounted for and none of it is
sitting past budget.
Gate
pnpm check:affected --run: all runnable checks passed on every pushed head, including after allthree rounds of review-driven changes above.
pnpm check:layeringrecognizes R42–R46 among themigrated commands with singular execution proven per operation, including after the orchestration
moved to
session-selector-dispatch.ts.pnpm check:fallow: zero dead-code/complexity/duplicationfindings at current head — every duplication finding the review caught (the seven-owner binder
ternary, the three-way keyboard admit-then-wrap, the forked session orchestration, the
caller-maintained operation list) is fixed by extraction or removal, not suppressed. CI's own
Bundle Size check tracks unpacked-size growth and is green at current head.
109 files, +6321/−1532 at current head.