Skip to content

feat: add direct Limrun provider runtime#1278

Open
thymikee wants to merge 8 commits into
mainfrom
codex/limrun-direct-rebase
Open

feat: add direct Limrun provider runtime#1278
thymikee wants to merge 8 commits into
mainfrom
codex/limrun-direct-rebase

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Adds agent-device connect limrun backed by a direct Limrun provider runtime for iOS and Android.

The shared provider runtime remains limited to lease lifecycle, inventory, interactor dispatch, install hooks, optional port reverse hooks, and shutdown. Limrun API calls, assets, ADB tunnel handling, Android snapshot/helper reuse, and iOS tree mapping remain local to src/providers/limrun.

Android provider interactor composition now lives in core so future direct Android providers can reuse the same ADB-backed interaction stack without importing platform internals. The runtime tags Limrun requests and instance metadata with agent-device identity.

Validation

  • pnpm check:affected --base origin/main --run
  • pnpm check:layering
  • pnpm exec vitest run src/__tests__/limrun-runtime.test.ts src/platforms/android/__tests__/app-lifecycle-open.test.ts
  • Live Limrun Android Gesture Lab and helper-backed snapshot verification completed before this rebase; iOS and Android direct sessions were also exercised.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB +14.6 kB
JS gzip 550.9 kB 555.8 kB +4.9 kB
npm tarball 665.2 kB 669.5 kB +4.3 kB
npm unpacked 2.3 MB 2.3 MB +14.7 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.3 ms 26.3 ms -0.0 ms
CLI --help 56.6 ms 57.3 ms +0.7 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/cli.js +1.2 kB +234 B
dist/src/internal/daemon.js +345 B +144 B
dist/src/session.js +205 B +57 B
dist/src/runner-client.js 0 B +12 B
dist/src/context.js +6 B +10 B

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-1278/

Built to branch gh-pages at 2026-07-15 19:40 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@thymikee

Copy link
Copy Markdown
Member Author

Thermo-nuclear code-quality review

Overall this is a well-shaped PR. The ProviderDeviceRuntime seam is the right home for this, splitting device.ts/snapshot.ts out of the runtime was the right instinct, and hoisting the withAndroidAdbProvider Proxy from the Limrun runtime into createAndroidInteractor (core/interactors/android.ts) is exactly the canonical-layer move — the ADB-provider scope now composes generically for any future direct Android provider without importing platform internals. Good.

That said, there are a few structural issues I'd want addressed before merge. Two are load-bearing.


1. configureDirectPortReverse is inert in production — a de-facto test-only seam

react-devtools.ts grows an option (configureDirectPortReverse?, L30), a gate (shouldConfigureDirectReverse, L159), and a call site (L180). But the only caller of runReactDevtoolsCommandcli.ts:212 — never passes configureDirectPortReverse. So the gate's options.configureDirectPortReverse !== undefined clause (L169) is always false in production, the branch never fires, and the only thing that exercises it is the unit test that injects a vi.fn().

This is precisely the pattern the No test-only DI seams guard exists to catch; it passed here only because the seam is an optional callback rather than a flagged shape. Functionally it's dead branching that reads as "direct RDT port-reverse is wired" when it isn't.

Please either wire it at the cli.ts call site (the daemon already exposes configureProviderPortReverse, so the callback has a real home) or drop it from this PR until the consumer exists. Shipping the gate + option + branch with no production producer is debt, and it's the kind of thing that quietly becomes permanent.


2. Android port-reverse: bespoke ownership tracking duplicates the canonical helpers, and stacks on top of them

runtime.ts hand-rolls a full port-reverse ownership/idempotency/conflict layer:

  • session.reversedPorts: Map<number, string> (L77)
  • ensureAndroidPortReverse (L656) — idempotency (existing === name → return) + conflict throw (already exists)
  • removeAndroidPortReverse (L699)
  • createLimrunAndroidPortReverseProvider (L486)
  • portReverseResult (L331)

All of this already exists canonically in platforms/android/adb-executor.ts: createExecAndroidPortReverseProvider (owner tracking) + createAndroidPortReverseManager (idempotency + "already owned by" conflict). The Limrun exec (runLimrunAndroidAdb) already runs adb -s <serial> …, which is exactly the executor those helpers expect.

Worse than plain duplication: because the runtime defines its own reverse provider, the app-lifecycle path now double-books the same ports. ensureAndroidLocalhostReverse (app-lifecycle.ts:278) does createAndroidPortReverseManager(resolveAndroidAdbProvider(device)) — inside a Limrun scope resolveAndroidAdbProvider returns the Limrun provider, so the canonical manager's active map is layered directly on top of the bespoke reversedPorts map. Two trackers, same ports, same idempotency/conflict logic implemented twice.

Code-judo move: build one canonical reverse provider per session and back both entry points with it. Roughly:

// once, in createAndroidSession (closes over session, lazily starts the tunnel via runLimrunAndroidAdb):
session.reverse = createExecAndroidPortReverseProvider(
  (args, options) => runLimrunAndroidAdb(session, args, options),
);

Then createLimrunAndroidAdbProvider sets reverse: session.reverse, configurePortReverse/removePortReverse (L167/L179) delegate to session.reverse.ensure/remove with tcp:${port} endpoints, and cleanupAndroidAdbTunnel uses list()/removeAllOwned() instead of iterating reversedPorts. That deletes ~90 lines (the four helpers + the map + most of tcpEndpointPort) and removes the stacked double-tracking. The per-session persistence you (correctly) need is preserved by storing the single provider instance on the session — which is the actual reason the bespoke map exists, and the one thing the ephemeral per-call managers don't give you.


3. inferAndroidAppName collides with — and shadows — the canonical export

runtime.ts:763 defines a private inferAndroidAppName(packageName): string | undefined (last-segment heuristic). platforms/android/app-lifecycle.ts:176 already exports inferAndroidAppName(packageName): string, and it's the more capable version (filters com/android/google/… noise tokens). Reuse the canonical one; a same-named, weaker private copy is the textbook bespoke-duplicate smell and will drift.


4. runtime.ts (775 lines, new) mixes runtime orchestration + iOS interactor + Android transport

Under the 1k line, so not a hard blocker — but this new file already spans three concerns that want to be separate, and you've established the split pattern with device.ts/snapshot.ts:

  • the LimrunRuntime class + lease/inventory/install dispatch/shutdown
  • LimrunIosInteractor + iOS install/asset packaging (L345–L430, L522–L654)
  • Android adb exec + tunnel + port-reverse + install (L371–L520, L656–L745)

Splitting into runtime.ts + ios.ts + android.ts drops the orchestration file well under ~350 lines and stops the iOS/Android tagged-union from forcing both platforms to share a module. Worth doing while the code is fresh.


Minor

  • readLimrunLeaseIdFromInventoryRequest (device.ts:42) is an identity wrapper — it returns request.leaseId. Inline it at the one call site; the named helper adds indirection without buying clarity.
  • parseLimrunDeviceId (device.ts:32) returns null while its siblings (platformForLimrunLeaseBackend, getSessionForDevice, etc.) return undefined. Pick one absent-value convention.
  • ensurePersistentAndroidAdbSerial (L712) isn't concurrency-safe: two parallel adb calls in one session (the Android platform code does fan out) can both see !adbTunnel and each call startAdbTunnel(), leaking a tunnel. Memoize the in-flight promise on the session. The AndroidAdbExecutor contract explicitly says implementations must be safe to call concurrently for one request.

None of these are correctness-critical to the happy path, and the core architecture is sound — but #1 and #2 are real structural debt (inert branch; duplicated + stacked port-reverse), and both have a clean path to deleting code rather than rearranging it. I'd resolve those two before merge.

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