Skip to content

refactor: migrate the gesture family to the request-bound device runtime - #1952

Open
thymikee wants to merge 2 commits into
mainfrom
claude/wave5-gestures-cutover
Open

refactor: migrate the gesture family to the request-bound device runtime#1952
thymikee wants to merge 2 commits into
mainfrom
claude/wave5-gestures-cutover

Conversation

@thymikee

@thymikee thymikee commented Aug 21, 2026

Copy link
Copy Markdown
Member

Wave 5 unit 3 of the ADR 0019 migration (tracker #1739). Unit record posted before any code:
#1739 (comment)

Precondition checked: the iOS scroll churn has settled — #1905, #1906, #1901 and #1913 are all
merged and no open PR touches *scroll* / *gesture*.

Unit record recap

Three public descriptors: gesture (R42), scroll (R43), swipe (R44). All three shared one
capability bucket — apple {simulator, device}, android {emulator, device, unknown}, linux
{device} — plus overlays (gesture/swipe harmonyos; scroll harmonyos + web). No platform
plugin carried a supportsByDefault / unsupportedHintByDefault closure for any of them, so the
whole non-bucket admission was one function: requireGestureSupported.

That function was intent-dependent, which is what makes this an action-selected unit (§9). Its
tiers become owner-stated operations:

Tier → operation Admitted cells (unchanged)
performGesturePlan (one contact; every swipe) apple sim+device except watchOS/visionOS; android all kinds; linux device; harmonyos both
performDirectionalFlingPlan (gesture fling --direction) same, minus linux
performMultiTouchGesturePlan (pinch/rotate/transform, 2-pointer pan) apple ios/ipados simulator only; android non-TV
performTargetAuthoredDrag (gesture drag) android non-TV; apple ios/ipados
gestureViewport apple, android, harmonyos — preferred
scrollDirection the scroll bucket, unconditioned
captureSnapshot (already owned) required only by scroll top / scroll bottom

What changed

  • Two contract facets. gesture-runtime.ts and scroll-runtime.ts, both riding the shared
    Interactor seam through localInteractorSource / providerInteractorSource — no new
    local/provider binder pair. The four gesture tiers share one plan executor and are separate keys
    because their cells differ, not their mechanics (the shape SnapshotRuntimeOperations
    already uses for its three capture keys).
  • One bind per handler. gesture classifies its input into one tier and binds once, before
    any plan is built — a drag binds before its targets resolve. swipe binds once for the whole
    --count N series. scroll binds scrollDirection, or scrollDirection + captureSnapshot for
    an edge. Enforced by regression assertions on inspectFacts × 1 and bindDevice × 1.
  • gestureViewport is preferred, and that is parity rather than taste. The client-side
    resolveGestureViewport optional-chains the backend member and falls back to a capture-derived
    frame; Linux implements no gestureViewport, so Linux gestures run on that fallback today.
    Declaring it required would refuse a working cell. ADR §9 measurement, from the iOS runner log:
    gestureViewport median 56 ms (n=14) vs snapshot median 112 ms (n=37) — the fallback costs
    ~2× on the runner alone, before the daemon's presentation work.
  • Refusal wording is preserved exactly. The daemon composes (tier, device) in
    unavailableResponse — the precedented seam session-state.ts already uses — so
    RuntimeOperationFact does not change shape. All eight retired strings reduce to
    gesture ${intent} is not supported on ${subject}, with the three owner-specific subjects the
    old code already special-cased. The refusal is built with normalizeError(new AppError(...))
    because the retired admission threw; going through errorResponse would have dropped the
    code's default hint and retriable classification from the wire shape.

Deliberately left in place

  • augmentScrollVisualizationResult — runs in finalizeGenericCommand on the result, after
    execution. Daemon presentation policy.
  • post-gesture-stability.ts / deferred-interaction-outcome.ts and the selector cache's
    bypassForPostGestureStabilization — session-state and capture policy; they never touch an
    interactor.
  • gesture-no-effect.ts — capture-annotation wording.
  • The Android 10% scroll band and the iOS scroll calibration — platform-internal, not re-tuned.
  • dispatchGestureViewport — deliberately NOT claimed as retired: session-replay-maestro-runtime.ts
    is a second consumer owned by replay/test (Wave 6). ADR §6 keeps a shared mechanic in place
    until its last consumer can move, so the R-rows claim only what genuinely dies.

Deleted

requireGestureSupported + its five private helpers (all of capabilities.ts below
supportedPlatformsForCommand), handleScrollCommand and its scroll-only helpers, the
DISPATCH_HANDLERS.scroll arm, dispatchGesturePlan, three capability buckets, and the overlay
memberships in HARMONYOS_SUPPORTED_COMMANDS / WEB_INTERACTION_COMMANDS.

Parity artifact

src/daemon/__tests__/gesture-admission-parity.test.ts is the retired suite's exact device
matrix
, re-pointed at the real composed runtime gateway and the real daemon admission through
createRequestRuntimeBindings — no stubs, no casts. It caught two genuine drifts while I was
writing it: Vega and Web were reporting their own generic hint for gesture drag where the retired
admission reported the target-authored-drag hint. Both fixed.

Planted mutants

Each mutant applied alone; suite re-run; restored byte-identical (git diff --quiet clean).

# Target Mutation Result
M1 gesture-runtime.ts binder delete interactor.performGesture(...) 3 failed / 6 passed
M2 gesture-runtime.ts binder transpose viewport call onto performGesture 1 failed / 8 passed
M3 scroll-runtime.ts binder delete interactor.scroll(...) 2 failed / 3 passed
M4 scroll-runtime.ts binder transpose (direction, options) 2 failed / 3 passed
M5 bindGestureTier delete the bound drag-tier call 2 failed / 2
M6 bindGestureTier route the drag tier to performGesturePlan 2 failed / 2
M7 executeBoundScroll delete the bound scroll call 4 failed / 5 passed
M8 executeBoundScroll transpose direction / options in the input 2 failed / 7 passed

Live evidence — local devices only

Every effect verified as a committed change (tree node, measured rect, or pixel diff), never
command success. Refusal cells verified by exact message and hint.

Apple — iPhone 17 Pro (iOS 26.2 sim) and the macOS host

Cell Evidence
scroll down first visible cells Apple Account/Ready…/General/AccessibilityGeneral/Accessibility/Action Button/Apple Intelligence & Siri
scroll top (edge tier) "Scrolled to top with 1 up passes"; Camera rect y 21 → 588.3
swipe General/Accessibility/Action ButtonApple Intelligence & Siri/Camera/Home Screen & App Library
gesture pan Camera rect y 588.3 → 253.3
gesture pinch (multi-touch) runner accepted + completed the two-contact gesture (ok=1) after a bound gestureViewport; pixels unchanged — see note
refusal: multi-touch (macOS) gesture pinch is not supported on macOS + "macOS automation has no multi-touch input…"
refusal: drag (macOS) gesture drag is not supported on macos + "…source hold, timed movement, and destination hold…"
admitted beside them (macOS) gesture pan proceeded to start the runner — admission is per-tier, not per-command

The two macOS refusals are worth reading together: multi-touch reports the display name
(macOS), drag reports the raw appleOs (macos), because the retired
assertAppleMultiTouchSupported and gesturePlatformMessage differed exactly that way.

Android — Pixel_7_review

Cell Evidence
scroll down Discover feed Notes From Poland, 1dBBC, 20h
scroll bottom (edge tier) "Scrolled to bottom with 1 down passes"
swipe BBC, 20hVisual Capitalist, 1d
gesture pan Visual Capitalist rect y 549 → 136
gesture pinch (multi-touch) 97.1% of pixels changed (2,517,456 / 2,592,000)

IME restored to com.google.android.inputmethod.latin/...LatinIME; AVD shut down (adb devices
empty, no qemu-system left).

HarmonyOS — nova 14 (physical)

Cell Evidence
scroll down visible identifier set bluetooth_entry/mobile_network_entry/satellite_network_entry/theme_settingsdisplay_settings/volume_settings/systemui_notification_settings/intelligent_scene_entry/system_and_updates
swipe search field → display_settings/volume_settings/...
gesture pan search field → about_device/wifi_entry/bluetooth_entry/...
refusal: multi-touch gesture pinch is not supported on harmonyos, no fact hint (falls through to the code's default) — matching the retired non-Apple/non-Android branch, which threw with no hint
refusal: drag gesture drag is not supported on harmonyos + the target-authored-drag hint

Web — managed backend, local fixture

Cell Evidence
live smoke AGENT_DEVICE_WEB_E2E=1 platform smoke passes (23.4 s)
scroll down 36.8% of pixels changed (271,446 / 738,560) on a tall fixture
refusal: gesture pinch gesture pinch is not supported on web
refusal: swipe gesture fling is not supported on web — swipe reports its normalized intent, exactly as the retired admission did

Vega — local VVD

All four cells refused, and this is the clearest demonstration of the per-tier hint split:

Cell Message Hint
scroll scroll is not supported on this device Vega scroll hint
swipe (fling tier) gesture fling is not supported on vega Vega gesture hint
gesture pinch (multi-touch) gesture pinch is not supported on vega none — the retired branch carried none
gesture drag gesture drag is not supported on vega target-authored-drag hint

VVD stopped afterwards.

Not covered live, and why

Owner Status
platform-linux No Linux target reachable from this macOS host. Its cells are pinned by platform-linux/src/runtime.test.ts, and its bound execution is exercised end-to-end in request-handler-chain.test.ts, which drives a real Linux swipe through the composed gateway into the real createLocalLinuxToolProvider drag primitive and asserts the exact values [10, 20, 110, 20, 100]. The CI Linux lane covers the runtime path.
provider-webdriver Cloud, billed. Covered by platform-runtime.test.ts cells plus the packaged Cloud WebDriver provider scenario — which caught a real defect in this unit (facts advertised scrollDirection with no bound operation).
provider-limrun Cloud, billed. Covered by app-log-runtime.test.ts cells, including the deliberate iOS-session behavior change below.

I did not spend on cloud devices. Tell me if you want the two provider cells run for real and I will
do it, or I will narrow the unit's claims to the six owners with live coverage instead — your call
on which of those two you prefer.

iOS pinch note. The plan reached the device — the runner log shows gestureViewport completed,
then gesture accepted and completed ok=1 — but the screen did not change. That is the recorded
iOS 26.2 simulator multi-touch synthesis issue on this machine (session records, 2026-08-20), not
this migration: the same bound performMultiTouchGesturePlan moves 97% of the pixels on Android.

Size

Exact figures from the CI Bundle Size job on this PR (authoritative; my local measurement was
+10.4 kB because tsdown chunking differs slightly from the CI build):

Metric Base Current Diff
JS raw 2.36 MB 2.37 MB +12.0 kB
JS gzip 791.5 kB 793.8 kB +2.3 kB
npm tarball 910.7 kB 913.2 kB +2.5 kB
npm unpacked 3.15 MB 3.17 MB +12.0 kB

71 files changed. +12.0 kB unpacked across three migrated commands is ~4.0 kB each —
inside the per-command R40 band (+4.5 kB) this unit was budgeted at in its record.

Itemized, from the local per-file diff:

  • src/dispatch.js −3,178 B — the retired handleScrollCommand leaf, its scroll-only helpers,
    the DISPATCH_HANDLERS.scroll arm, and dispatchGesturePlan.
  • src/validation.js −3,383 BrequireGestureSupported and its five private helpers, i.e.
    the whole daemon-owned gesture admission table.
  • The growth is the two contract facets (gesture-runtime.ts, scroll-runtime.ts, ~290 lines
    including both binder pairs and the facts factories) plus gesture/scroll cell tables in all eight
    owners. tsdown rebalances chunk boundaries, so the remaining per-file deltas do not map 1:1 to
    source edits.

Smaller designs considered and rejected. One performGesture operation with the tier passed as
data would have saved roughly 40% of the contract surface, but it puts the cell table back inside
the caller: an owner could no longer declare that it does one-contact gestures and not two, which
is precisely the admission this unit exists to move out of the daemon. Collapsing
gestureViewport into the plan operation would have saved a key, but it is preferred and the
others are required — folding them would force Linux to refuse a working cell. Both rejections
are why the growth is what it is.

Gates

pnpm check:affected --run green (re-run after rebasing onto d2f2dae79). Full unit suite
999 files / 7499 tests pass. check:layering names gesture, scroll and swipe among the commands
keeping exactly one platform-execution path.

Two findings the gates produced that are worth flagging:

  • A real defect, caught by the provider scenario. provider-webdriver declared the gesture and
    scroll cells but never bound the operations — the cloud WebDriver scenario failed with
    "Runtime owner advertised scrollDirection without an implementation". That is the fail-closed
    contract check doing its job; fixed by binding them.
  • Fallow duplication. My first cut added identical androidGestureOperations /
    linuxGestureOperations wrappers. Rather than suppress, the owners now use the same inline shape
    apple/harmonyos already used, and apple's local whenAdmitted helper was promoted to contracts
    so android and linux stop hand-rolling the branch (which also cleared their complexity findings).

Behavior changes, stated rather than hidden

  1. Limrun iOS gesture cells refuse at admission instead of mid-execution. The interactor's own
    wording ("Limrun iOS direct sessions do not expose portable gesture execution yet.") is
    preserved verbatim as the fact hint. ADR §6 asks for exactly this classification.
  2. Vega plan/fling tiers refuse at admission; previously they failed inside dispatchGesturePlan
    with "Gesture execution is unavailable". Vega never carried a gesture bucket.
  3. scroll on a watchOS Apple leaf is declared available, deliberately: scroll had no
    watchOS gate, and it still fails where it fails today — when the Apple interactor refuses to
    construct, not at admission.

Tracker comment after merge is thymikee's call.

Migrates `gesture`, `scroll` and `swipe` onto request-bound platform runtimes
(ADR 0019), retiring the last daemon-owned gesture admission table.

`requireGestureSupported` was intent-dependent platform policy living inside the
daemon. It becomes four owner-stated execution tiers — one-contact plans,
direction-authored flings, two-contact synthesis, and target-authored drag —
each declared per exact device cell by the owner that can meet it. A gesture
input selects exactly one tier, and its handler binds once (§9), before any plan
is built.

`scroll` retires its capability bucket and its dispatch leaf together: an
ordinary scroll requires `scrollDirection`, and `scroll top`/`scroll bottom`
additionally require `captureSnapshot`, so the edge refusal happens at admission
instead of mid-command.

`gestureViewport` is declared `preferred`, not `required`: Linux advertises no
frame read and derives its coordinate frame from a capture today, so requiring
it would refuse a cell that works.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.36 MB 2.37 MB +12.0 kB
JS gzip 791.5 kB 793.9 kB +2.4 kB
npm tarball 910.7 kB 913.2 kB +2.5 kB
npm unpacked 3.15 MB 3.17 MB +12.0 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.51 MB 2.52 MB +12.0 kB
Apple runner source/project 544.4 kB 544.4 kB 0 B
macOS helper source 54.5 kB 54.5 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 44.4 kB 44.4 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.6 ms 26.0 ms +0.4 ms
CLI --help 76.7 ms 79.4 ms +2.6 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/app-inventory-contract.js +3.3 kB +1.0 kB
dist/src/sdk-batch-runner.js +4.2 kB +857 B
dist/src/internal/daemon.js +2.7 kB +767 B
dist/src/interaction2.js +1.9 kB +460 B
dist/src/runtime4.js +1.5 kB +375 B

Top changed packed files

Packed file Base Current Diff
dist/src/sdk-batch-runner.js 92.7 kB 96.9 kB +4.2 kB
dist/src/validation.js 3.7 kB 357 B -3.4 kB
dist/src/app-inventory-contract.js 40.2 kB 43.5 kB +3.3 kB
dist/src/dispatch.js 26.2 kB 23.0 kB -3.2 kB
dist/src/internal/daemon.js 99.0 kB 101.7 kB +2.7 kB
dist/src/interaction2.js 29.7 kB 31.6 kB +1.9 kB
dist/src/runtime4.js 41.1 kB 42.6 kB +1.5 kB
dist/src/app-log-runtime.js 13.5 kB 14.4 kB +848 B
dist/src/platform-runtime2.js 7.2 kB 8.0 kB +829 B
dist/src/runtime2.js 39.4 kB 40.2 kB +815 B

@thymikee

Copy link
Copy Markdown
Member Author

Not ready at da775b2e. The runtime cutover itself is coherent, but two required boundaries remain. First, this 70-file unit claims owner cells across Apple, Android, Linux, HarmonyOS, Web, Vega, Limrun, and WebDriver, while the changed-path live table covers only Apple/Android. ADR 0019’s unit guide requires a real-target result for every claimed family/owner denominator; record the missing Linux/HarmonyOS/Web/provider paths and refusal cells, or formally narrow the unit’s claims. Second, packages/platform-apple/src/runtime.ts grows by ~100 lines to 553, crossing AGENTS.md’s hard ‘extract before adding behavior past 500’ rule. Move the Apple gesture/scroll admission facts into a focused sibling owner with its matching tests, keeping runtime.ts as composition. Also refresh the size/body metadata to exact CI (+12.0 kB unpacked, 70 files) and itemize the final growth/smaller-design rejection. iOS/Android/Coverage are still running.

@thymikee

Copy link
Copy Markdown
Member Author

Addressed at 6cdff5c25.

1. Apple runtime.ts past 500 lines. Extracted the gesture/scroll cell table into
packages/platform-apple/src/gesture-facts.ts (118 lines) with its own gesture-facts.test.ts
(9 tests, moved out of runtime.test.ts). runtime.ts is 459 lines — below the 500 hard rule,
and within 6 lines of its pre-unit size (453 on main), so it stays composition.

2. Live coverage of the claimed denominator. Ran the four owners I can reach locally. Full
tables are in the PR body; the short version:

Owner Live result
HarmonyOS (nova 14) scroll / swipe / pan all move content; both refusal cells — pinch with no fact hint, drag with the target-authored-drag hint
Web (managed backend) platform smoke passes; scroll down changes 36.8% of pixels; gesture pinch and swipe refused — and swipe reports gesture fling is not supported on web, its normalized intent, exactly as the retired admission did
Vega (local VVD) all four cells refused, and it is the cleanest demonstration of the per-tier hint split: scroll→vega scroll hint, fling→vega gesture hint, pinch→no hint, drag→drag hint
Apple / Android as before, plus the macOS refusal pair

That is six owners with live coverage. Three genuinely could not be covered here:

  • platform-linux — no Linux target on this macOS host. Its bound execution is exercised
    end-to-end by request-handler-chain.test.ts, which drives a real Linux swipe through the
    composed gateway into the real createLocalLinuxToolProvider drag primitive and asserts the
    exact values [10, 20, 110, 20, 100]; the CI Linux lane covers the runtime path.
  • provider-webdriver / provider-limrun — cloud, billed. I did not spend without your okay.
    Worth noting the WebDriver provider scenario already earned its keep here: it caught a real
    defect in this unit (facts advertised scrollDirection with no bound operation).

Your call, and I'll do either: authorize the two provider cells and I'll run them for real, or I
narrow the unit's claims to the six owners with live coverage and say so in the record.

3. Size/body metadata. Body now carries the exact CI numbers — +12.0 kB unpacked, +2.5 kB
tarball, +2.3 kB gzip, 71 files
— with the growth itemized against the two real deletions
(dispatch.js −3,178 B, validation.js −3,383 B) and a stated rejection of the two smaller
designs (single performGesture keyed by tier data; folding gestureViewport into the plan
operation). My earlier local figure of +10.4 kB was wrong for the PR body — CI's build is the
authoritative one and that is what is quoted now.

pnpm check:affected --run green after the extraction.

@thymikee

Copy link
Copy Markdown
Member Author

The Apple extraction and local-owner evidence address the earlier points. One blocker remains: this head still changes the direct WebDriver and Limrun runtime cells, while its evidence expressly omits live runs for both. ADR 0019’s unit denominator includes direct provider runtimes, so we cannot narrow the claim in prose while those migrations ship. Please either run one representative changed command/refusal cell through each provider (record exact head and result), or remove those provider migrations from this unit; then re-request review.

@thymikee

Copy link
Copy Markdown
Member Author

Code review is clean at 6cdff5c25: the handler binds once per selected tier, owner facets/facts remain narrow and fail closed, and the superseded gesture/scroll dispatch and admission paths are removed. Exact-head CI is fully green.

Residual blocker to merge-ready status: this unit changes direct provider-webdriver and provider-limrun gesture/scroll facts and bindings, but the PR explicitly records no live run for either owner. ADR 0019 unit guidance requires changed-path evidence on at least one real target per claimed family, and fixture/provider scenarios do not replace it. Please either record representative direct WebDriver and Limrun live gesture/scroll evidence, or remove those provider migrations from this unit. Until then this is published/reported with strong static evidence, not merge-ready.

@thymikee

Copy link
Copy Markdown
Member Author

New blocker after main moved: this head is now 12 commits behind and conflicts across runtime/registry/dispatch surfaces. Rebase must preserve #1969’s granular contracts architecture: the PR’s new value imports from wide @agent-device/contracts/platform and missing granular exports for the new gesture/scroll modules would fail current closure gates if resolved by choosing the PR side.

Also remove the type workaround in scroll-runtime.ts: the plan.kind === edge ? bind(plan.use) : bind(plan.use) branches are identical, then widen to optional captureSnapshot and repair the lost proof at runtime. Bind/execute through the discriminated plan (or a generic plan-admission helper) so edge scroll statically requires capture and ordinary scroll cannot expose it.

All green CI and +12 kB size evidence is against the old base. After rebase, rerun the full exact-head suite and remeasure/rejustify size.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant