Skip to content

feat(limrun): screen recording through the provider's server-side recorder - #2646

Open
okwasniewski wants to merge 4 commits into
callstack:mainfrom
okwasniewski:feat/limrun-recording
Open

okwasniewski wants to merge 4 commits into
callstack:mainfrom
okwasniewski:feat/limrun-recording

Conversation

@okwasniewski

@okwasniewski okwasniewski commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

The Limrun runtime declared record unavailable ("Limrun does not expose an exact-owner screen-recording runtime") although the Limrun SDK exposes a server-side recorder on both instance clients. This binds a screen-recording runtime for Limrun-owned devices.

  • record start asks the live instance to record; --quality medium maps to Limrun quality 5, high to 8.
  • record stop asks the instance to stop once and memoizes the served URL. The download to the output path is a separate step with a 120 s deadline and a discard signal, done by the host through a new LimrunHostAdapter.downloadFile port. A dropped transfer is retried by the next record stop without a second remote stop; cleanup stops and never downloads.
  • The lifecycle (option admission before the output is touched, start rollback on cancel, memoized stop, retriable collect, finalize, cleanup) lives once in capture-kit's startTransportScreenRecording on its own subpath (@agent-device/capture-kit/screen-recording-transport), so no entry's import-time closure grows; the web recorder rides the same helper with its 12 tests unchanged.
  • Every --scope records the whole simulator or emulator screen; --fps and --hide-touches are refused at admission; reattach and cleanup after a daemon restart stay unavailable. ADR 0024 gains the Limrun row and the --quality help names the mapping.
agent-device record start clip.mp4 --quality high
agent-device longpress @e12 1000
agent-device record stop

Four commits: feature, the shared-transport refactor, the stop/download fix, and a gates commit declaring the new capture-kit subpath for the layering guard.

Validation

  • Tested commit: 5855046 (on main at 6dd57d9). Live runs were recorded at cad4ee2; 5855046 differs only in where the transport helper is exported from (a capture-kit subpath instead of the root barrel), which the first CI run's Coverage job caught as import-closure growth.
  • Live iOS and Android (comment below): record stop wrote playable H.264 MP4s with recorder: confirmed on both; Android gestures during the clip went through the bundled helper (androidSnapshot.backend: android-helper, helperVersion: 0.21.4).
  • Unit: transport helper (7), Limrun recording (10, including the bound-owner path and stop-succeeded/download-failed retry), device session, root downloader against a local HTTP server (4, including deadline and caller abort), web recording (12, unchanged).
  • pnpm check:affected --run --base upstream/main: all runnable checks passed on 5855046 with the full local gate set (444 test files, 3241 tests, including package).

@okwasniewski

Copy link
Copy Markdown
Contributor Author

Live Limrun iOS evidence

Run on Limrun iOS instance ios_euna_01m2mr0avjeebt7v4tbbwf950p (eu-north1) with agent-device built from this head merged locally with #2645 (screenshot fix), on main 6dd57d9ae. App under test: the repo test app (com.callstack.agentdevicelab), Automation lab screen. This time the MP4 is the file record stop wrote itself, not a sidecar capture.

step command result
refused option record start x.mp4 --fps 30 INVALID_ARGS: "Limrun recordings do not support --fps" (nothing started)
start record start limrun-record.mp4 success: true, outPath set
during longpress 'label="Long press canary"' 1000, then press both success: true
stop record stop success: true, recorder: "confirmed", durationMs: 11472
file ffprobe limrun-record.mp4 h264, 602x1310, 10.87 s

recording written by record stop

Frame at 6 s: frame

Full clip as written: limrun-record.mp4. Timeline and commands: timeline.txt, README. The session was closed afterwards and the instance reads terminated. Android was not driven live.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two moderate unresolved findings remain regarding production binding coverage and cleanup status handling.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds Limrun provider-side screen recording through the SDK, with shared finalization and runtime capability integration.

Changes:

  • Implements recording start/stop, quality mapping, download, and cleanup.
  • Wires Limrun device sessions into runtime ownership and updates capability facts.
  • Adds tests, fixtures, and changelog documentation.
File summaries
File Summary Final review comments
packages/provider-limrun/src/runtime.ts Wires device-session access.
packages/provider-limrun/src/recording-runtime.ts Implements recording lifecycle. Moderate (1): Treat refused output removal as cleanup-pending/error.
packages/provider-limrun/src/recording-runtime.test.ts Tests recording behavior.
packages/provider-limrun/src/facts-runtime.ts Updates recording capability facts. Nit (3): Update or supersede ADR 0024.
packages/provider-limrun/src/facts-runtime.test.ts Verifies capability facts.
packages/provider-limrun/src/app-log-runtime.ts Binds recording operations. Moderate (1): Add binding-level regression coverage for the live Limrun path.
packages/provider-limrun/src/app-log-runtime.fixtures.ts Updates owner wiring fixtures.
CHANGELOG.md Documents Limrun recording support.
Review details

Suppressed comments (2)

packages/provider-limrun/src/app-log-runtime.ts:315

  • The new unit tests exercise createLimrunScreenRecordingOperations directly, but no test binds createLimrunPlatformRuntimeOwner and calls binding.operations.screenRecordingStart/stop. This spread is the production seam that supplies getDeviceSession, so the advertised facts can pass while the CLI route is unwired; add a binding-level regression test for the live Limrun path.
    ...createLimrunScreenRecordingOperations({
      host: options.host,
      device,
      owner,
      signal,
      getDeviceSession: options.getDeviceSession,
    }),

packages/provider-limrun/src/recording-runtime.ts:180

  • ScreenRecordingOutputHost.remove is non-throwing and returns present when deletion is refused. Ignoring that result lets forceCleanup return { status: 'cleaned' } while the output still exists, so the durable resource can be marked complete despite failed cleanup; treat present as a cleanup-pending error.
  await host.screenRecording.outputs.remove(outputPath);
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +174 to +176
screenRecordingStart: available,
screenRecordingReattach: available,
screenRecordingCleanup: available,
@thymikee

Copy link
Copy Markdown
Member

Reviewed at 6065c1f. The stop path and the evidence for Android leave this not ready to merge yet.

stop in packages/provider-limrun/src/recording-runtime.ts#L106 joins the server stop with the local download into one step, because device-session.ts:159 passes localPath so the SDK call does stopVideoRecording then downloadFileToLocalPath, and stopped only flips true once the download also succeeds. Take a server stop that succeeds followed by a failed download (dropped network, a 4xx, a write error). The handle does not memo that as finished, so the next record stop calls stopRecording again on an instance with no active recording, and the already-finished file is never fetched again. forceCleanup repeats the same stop, comes back cleanup-pending/transport-failed, and asyncDispose then fails when the session closes. HarmonyOS keeps these separate (platform-harmonyos/src/recording/runtime.ts#L108 memoizes the stop apart from the retriable pull); can Limrun do the same: call stop without localPath so the SDK returns the download URL, memoize that URL, and make collecting the file its own retriable step, with forceCleanup only stopping and never downloading?

Neither finish nor forceCleanup pass a signal or deadline into stopRecording (recording-runtime.ts#L127). The SDK's downloadFileToLocalPath runs fetch and pipeline with no abort signal, no timeout, and no size cap, so a stalled transfer can hang record stop and session close indefinitely, and a cancelled request has no way to stop it. The discard paths, including forceCleanup, download the whole file only to delete it. Once the stop/download split above exists, can the daemon own the fetch with the request's signal and a deadline, so cleanup only stops and never downloads?

startLimrunRecording is close to a line-for-line copy of startWebRecording (platform-web/src/recording/runtime.ts#L73-205): same prepare step, memoized stopped flag, finish body, forceCleanup outcome mapping, and envelope construction, with only the transport calls differing; snapshot() is a third copy of the same builder. That means the stop/download fix above has to land three times. Worth asking: could a shared helper like createTransportScreenRecording({backend, start, stop, collect}) own the handle, envelope, and cleanup outcomes, with Limrun and web only supplying the transport calls?

docs/adr/0024-screen-recording-provable-signal.md#L319 still lists Limrun as unavailable, and this PR does not update that matrix, so the design record no longer matches shipped behavior for live stop, recovery, and collect. Please add a Limrun row (stop is the provider RPC, recovery is unreattachable, collect is an HTTP download, and after daemon loss the state is cleanup-pending with the instance deleted on release), and mention the Limrun quality mapping alongside Android and Apple in the --quality flag help.

The refusal test at recording-runtime.test.ts#L115 checks .rejects.toThrow(message) without asserting the typed code, and the stopRecording stub never fails, so the stop-succeeded/download-failed path above has no coverage. Please assert toMatchObject({code: 'INVALID_ARGS'}), and add a test where stopRecording rejects after a successful server stop, then show a second finish downloads and completes without sending a second remote stop.

CHANGELOG.md#L5 and facts-runtime.ts#L174 say recording is available on Android, but the Android SDK client uses a different command name, download URL, and recorder than the iOS client that was actually run. Please run it live on Android — connect limrun --platform android, record start clip.mp4 --quality high, one gesture, record stop — and show a playable MP4 at the output path with recorder: confirmed. Until then, please narrow screenRecordingStart in facts and the CHANGELOG to iOS.

All three CI jobs were still running with no logs at review time. Smoke Tests drive local simulators and emulators through the Apple and Android recorders and never load the Limrun runtime, so this diff does not reach that route; Coverage runs the unit suites, which do include the new recording-runtime and facts tests, so it does reach this diff.

A few things this review could not confirm: how the server responds to a second stopVideoRecording/stopRecording when nothing is recording (the fix above assumes it errors or returns without a fresh file); the order of forceCleanup versus lease release and instance delete on daemon close, since cleanup always reports transport-failed if release runs first; whether two sessions can hold the same Limrun device and double-start its recorder; and the iOS evidence comes from the author’s run of this branch merged with #2645; it was not re-run here.

Splitting the remote stop from the retriable, cancellable download, and getting real Android evidence (or narrowing the facts to iOS), are what stand between this and merge.

…order

The Limrun runtime declared screen recording unavailable although the
SDK exposes startRecording and stopRecording on both instance clients.
Bind a recording runtime that asks the live session to record, maps the
two export presets onto Limrun's 5..10 quality scale, downloads the
finished MP4 to the output path on stop, and refuses --fps and
--hide-touches. Reattach and cleanup after a daemon restart stay
unavailable: the recording lives and dies with the instance.
okwasniewski pushed a commit to okwasniewski/agent-device that referenced this pull request Sep 16, 2026
Copilot AI review requested due to automatic review settings September 16, 2026 10:35
@okwasniewski

Copy link
Copy Markdown
Contributor Author

Thanks for the review; everything below is at 31a1bf5.

Stop split from download. device-session.ts stopRecording() now calls the SDK without localPath and answers { downloadUrl }; downloadRecording() is a separate call. The shared helper memoizes a successful stop (memoizeStop in packages/capture-kit/src/screen-recording-transport.ts), so the stop-succeeded/download-failed case re-downloads on the next record stop and never sends a second remote stop: recording-runtime.test.ts "a download that fails after the instance stopped is retried by the next stop without a second remote stop" asserts stopRecording called once and the call sequence stop, download:failed, download:<path>, finalize. forceCleanup only stops and aborts an in-flight download; it never downloads (screen-recording-transport.test.ts "cleanup stops the recorder once, never collects, and aborts a collect in flight").

Signal and deadline. LiveResourceHandle.finish() receives only DurableCaptureProgress today, no request signal, so the transport owns the bound: the host port downloadFile takes timeoutMs (120 s) plus the handle's discard signal, streams to disk, and removes a partial file on failure (src/sdk/limrun-download-file.test.ts covers 404, deadline, and caller abort). The port accepts a request signal if the daemon threads one into finish later. The SDK's own inline download had none of this, which is why it is no longer used.

Duplication. startTransportScreenRecording({ transport: { start, stop, collect? } }) in capture-kit owns prepare, start rollback, memoized stop, retriable collect, finalize, cleanup, and the envelope. platform-web/src/recording/runtime.ts is now its own two admission checks plus transport calls, with all 12 web tests unchanged; the Limrun runtime supplies its three transport calls.

ADR and help. ADR 0024 §3 has a Limrun row (stop is the provider RPC with the URL memoized; no signal, unreattachable; bounded cancellable HTTP collect; cleanup-pending after daemon loss, instance deleted on release). --quality help now reads "Limrun sessions map medium to recorder quality 5 and high to 8".

Tests. Refusals assert { code: 'INVALID_ARGS', message }; the bound-owner test drives createLimrunPlatformRuntimeOwnerbindoperations.screenRecordingStartfinish through the real getDeviceSession wiring.

Android, live. connect limrun --platform android, record start --quality high, press text="Catalog", scroll down 400, press text="Home", record stoprecorder: confirmed, durationMs: 10488, H.264 576x1288, 9.4 s. The session's first snapshot -i --json reported androidSnapshot.backend: android-helper, helperVersion: 0.21.4, so the gestures went through the production touch path.

android

iOS, re-run on this branch alone (no #2645 merged in): medium preset → 602x1310, 9.6 s; --quality high → 964x2096, 10.2 s; both recorder: confirmed.

ios

Files as written by record stop: android, ios medium, ios high; timelines and README on the evidence branch.

On the points you could not confirm. A second remote stop is no longer sent, so the server's answer to stopping an idle recorder no longer matters. If release runs before cleanup, cleanup's stop fails and is reported as cleanup-pending/transport-failed; no export is touched. Limrun instances are allocated per lease, so two sessions cannot hold one device's recorder.

…b and Limrun

A recorder behind a provider transport needs the same lifecycle every
time: refuse unsupported options before the output is touched, prepare
it, start the recorder and stop it again if the request is cancelled,
stop exactly once on finish, collect the media when the provider serves
it rather than writes it, finalize, and confirm the recorder. capture-kit
now owns that in startTransportScreenRecording on its own subpath, so no
entry's import-time closure grows; the web runtime keeps only its own
admission rules and transport calls.
…bounded, retriable step

The first cut asked the SDK to stop and download in one call, so a
dropped transfer after a successful server stop left the next record
stop re-stopping an idle recorder and never fetching the finished file.
The device session now stops without a local path and answers the served
URL; the host downloads it with a deadline and the discard signal through
a new LimrunHostAdapter.downloadFile port, and the transport helper
memoizes the stop so only the download is retried. ADR 0024 gains the
Limrun row and --quality help names the Limrun mapping.
@okwasniewski

Copy link
Copy Markdown
Contributor Author

The Coverage job on 31a1bf5 failed in eager-closure-budgets.test.ts: exporting the transport helper from capture-kit's root barrel added two modules to every entry importing that barrel. 5855046 exposes it on its own subpath (@agent-device/capture-kit/screen-recording-transport), imported by the web and Limrun runtimes only; the root index is unchanged from main, the budget suite passes for every entry, and the full local gate set is green. No runtime behaviour changed, so the iOS and Android evidence above still covers this head.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved download cancellation and failed-retry cleanup issues block approval.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

packages/capture-kit/src/screen-recording-transport.ts:132

  • Although this aborts discard, it cannot cancel a collect that is already in flight: createScreenRecordingLiveHandle.forceCleanup waits for the existing finish promise before invoking this implementation. A stalled Limrun download will therefore wait for the full 120-second deadline instead of being interrupted by cleanup. The discard signal needs to be triggered by the handle coordination before it waits for the active finish.
        discard.abort(new Error(`${transport.backend} recording discarded`));
  • Files reviewed: 28/28 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +23 to +25
} catch (error) {
throw downloadFailure(error, options, timeout);
}
Comment on lines +26 to +34
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new AppError('COMMAND_FAILED', `Limrun download failed with HTTP ${response.status}`, {
url: options.url,
statusCode: response.status,
body: body.slice(0, RESPONSE_BODY_PREVIEW_CHARS),
});
}
if (!response.body) {
Copilot AI review requested due to automatic review settings September 16, 2026 10:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved download safety and recording cleanup issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

packages/capture-kit/src/screen-recording-transport.ts:134

  • This cleanup signal cannot abort an in-flight collection: ScreenRecordingLiveHandle.forceCleanup() waits for finish() to settle before invoking this callback, while finish() is blocked in transport.collect(..., discard.signal). A session teardown during a slow Limrun download can therefore wait for the full 120-second deadline instead of canceling the transfer; the cleanup path needs a cancellation hook that is triggered before waiting for the finish promise.
      forceCleanup: async () => {
        discard.abort(new Error(`${transport.backend} recording discarded`));
        try {
          await stopOnce();

src/sdk/limrun-download-file.ts:56

  • When the caller aborts at the same time as the 120-second deadline, this check wins and discards the caller's abort reason, even though the download API promises to preserve caller cancellation. Prefer treating the timeout as authoritative only when options.signal is not also aborted, as the existing HTTP transport code does.
  if (timeout.aborted) {
    return new AppError('COMMAND_FAILED', 'Limrun download timed out', {
      url: options.url,

src/sdk/limrun-download-file.ts:27

  • If a non-2xx response body stalls, response.text() rejects when the combined timeout/caller signal aborts, but this catch discards that error and the code then reports only the HTTP status. A 120 s timeout or cleanup cancellation can therefore surface as, for example, a 404 instead of the timeout or caller reason; preserve the caught error through downloadFailure and only build the HTTP-status error after body consumption succeeds.
    const body = await response.text().catch(() => '');
  • Files reviewed: 28/28 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +27 to +31
const body = await response.text().catch(() => '');
throw new AppError('COMMAND_FAILED', `Limrun download failed with HTTP ${response.status}`, {
url: options.url,
statusCode: response.status,
body: body.slice(0, RESPONSE_BODY_PREVIEW_CHARS),
acquired = true;
signal.throwIfAborted();
} catch (error) {
if (acquired) await stopOnce().catch(() => {});
@thymikee

Copy link
Copy Markdown
Member

Reviewed at 5855046. One cancellation gap remains, and the in-flight cleanup test does not cover it.

The discard signal cannot cancel a running download. collect receives discard.signal at screen-recording-transport.ts#L109, and only the transport's forceCleanup aborts it. The live handle at screen-recording-live-handle.ts#L53 runs cleanup only after a pending finish settles, and collect runs only inside finish. So the abort fires after the download has already ended. A session close during a stalled download waits for the full 120 s deadline. A cancelled record stop waits just as long, because finish never gets the request signal. The ADR 0024 row says the download is cancellable, but the code does not do that yet. The live handle is the place to fix it: its forceCleanup can trigger the abort before it waits on the pending finish, or finish can take the request signal. If that is out of scope here, please remove the discard controller and change the ADR row to say the download is bounded by a 120 s deadline and cannot be cancelled.

The test "aborts a collect in flight" at screen-recording-transport.test.ts#L142 uses a collect that resolves at once. No download is running when cleanup starts, so the test passes although the behavior in its name cannot happen. Can it use a collect that stays pending until its signal aborts, start handle.finish() without awaiting it, await handle.forceCleanup(), and assert that finish rejects with the discard reason? That test should fail today and pass after the fix.

A small one: the refusal test at #L196 takes start from harness() only to write void start;. Can it take only { prepare, calls }?

The shared helper looks like the right size, because web and Limrun had the same prepare, rollback, stop and envelope code. Is there a reason to keep the discard controller if it cannot cancel anything?

The live run you posted covers the successful stop and download. Retry after a failed download and cleanup during a download have unit coverage only.

All 13 checks pass, and there are no conflicts. Next: make the discard signal cancel a running download through the live handle, or remove it and correct the ADR row, and make the in-flight test fail on the current code.

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.

3 participants