From bfec15b4714a75e50064814527505464b78dd90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 19:30:43 +0100 Subject: [PATCH 1/7] fix(record): warm the MP4 validator without blocking capture --- .../capture-kit/src/recording/video.test.ts | 80 ++++++---- packages/capture-kit/src/recording/video.ts | 97 +++++------- .../command-surface/src/recording/index.ts | 2 +- ...form-runtime-screen-recording-host.test.ts | 147 ++++++++++++++++++ src/platform-runtime-screen-recording-host.ts | 4 + website/docs/docs/commands.md | 1 + 6 files changed, 247 insertions(+), 84 deletions(-) create mode 100644 src/platform-runtime-screen-recording-host.test.ts diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 0c8dd214c1..93a95276fd 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -9,7 +9,12 @@ import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { Deadline } from '@agent-device/host-kit/retry'; import { compileSwiftSourceText } from './swift-cache.ts'; -import { hasVideoContainer, isPlayableVideo, waitForPlayableVideo } from './video.ts'; +import { + hasVideoContainer, + isPlayableVideo, + prepareVideoValidator, + waitForPlayableVideo, +} from './video.ts'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -104,54 +109,77 @@ test('callers share one validator compile, and each waits for it within its own expect(compileSwiftSourceText).toHaveBeenCalledOnce(); const [compile] = vi.mocked(compileSwiftSourceText).mock.calls[0]!; expect(compile.timeoutMs).toBeUndefined(); - expect(compile.signal?.aborted).toBe(false); + expect(compile.signal).toBeUndefined(); } finally { vi.useRealTimers(); } }); -test('the last caller to stop waiting cancels an unfinished compile, and the next caller compiles again', async () => { - // Nothing owns swiftc once its requests end: a daemon that exits while it runs leaves it - // reparented to launchd, and the compile's own temp output behind. - const mp4 = writeFixture('cancel-compile.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/cancel' }, cwd: directory }; +test('an expired waiter leaves the compile running for the next stop', async () => { + const mp4 = writeFixture('pending-compile.mp4', Buffer.concat([ftyp, moov])); + const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/pending' }, cwd: directory }; + let resolveCompile!: (value: string) => void; + const pending = new Promise((resolve) => { + resolveCompile = resolve; + }); + vi.mocked(compileSwiftSourceText).mockClear().mockReturnValueOnce(pending); + void prepareVideoValidator(host); await hasVideoContainer(mp4); - let compileSignal: AbortSignal | undefined; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); try { - vi.mocked(compileSwiftSourceText) - .mockClear() - .mockImplementationOnce(async ({ signal }) => { - compileSignal = signal; - return await new Promise((_, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason)); - }); - }); - const timedOut = expect( isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5_000)), - ).rejects.toMatchObject({ details: { reason: 'video_validator_compile_timeout' } }); + ).rejects.toMatchObject({ + details: { reason: 'video_validator_compile_timeout', retriable: true }, + }); await vi.advanceTimersByTimeAsync(5_000); await timedOut; - expect(compileSignal?.aborted).toBe(true); + expect(runCmd).not.toHaveBeenCalled(); + resolveCompile('/bin/true'); + await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledOnce(); } finally { + resolveCompile('/bin/true'); vi.useRealTimers(); } - - await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); }); -test('a failed compile is dropped, and the next caller compiles again', async () => { +test('a failed background compile stays unavailable without retrying at stop', async () => { const mp4 = writeFixture('failed-compile.mp4', Buffer.concat([ftyp, moov])); const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/failed' }, cwd: directory }; vi.mocked(compileSwiftSourceText) .mockClear() .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'xcrun timed out after 120000ms')); - await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); + await expect(prepareVideoValidator(host)).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); + await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); + expect(compileSwiftSourceText).toHaveBeenCalledOnce(); +}); + +test.each([ + `xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH`, + `validator.swift:2:8: error: no such module 'AVFoundation'`, +])('refuses verification when the validator reports %s', async (stderr) => { + const mp4 = writeFixture('unavailable.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockResolvedValueOnce({ exitCode: 1, stderr, stdout: '' }); + await expect(isPlayableVideo(mp4, createTestHost())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_unavailable', retriable: false }, + }); +}); + +test('an ordinary validator refusal is unplayable, not unavailable', async () => { + const mp4 = writeFixture('refused.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockResolvedValueOnce({ + exitCode: 1, + stderr: "no such module 'OtherModule'", + stdout: '', + }); + await expect(isPlayableVideo(mp4, createTestHost())).resolves.toBe(false); }); test('a budget spent before the validator runs reads as not playable instead of a 1 ms run', async () => { diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index df9c38b350..4a8ea41574 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -40,19 +40,24 @@ exit(exitCode) const VALIDATOR_COMPILE_WAIT_MS = 30_000; const VALIDATOR_RUN_TIMEOUT_MS = 10_000; -type SharedValidatorCompile = { - readonly compiled: Promise; - readonly cancel: AbortController; - waiters: number; - settled: boolean; -}; +const videoValidatorCompiles = new Map>(); -// A compiled helper is valid for the Swift cache it was built into, so each cache root keeps its own. -// Callers share one compile, and each waits for it only within its own budget. The last caller to -// stop waiting cancels an unfinished compile: that kills this process's `swiftc`, or stops waiting -// for another process's compile. Nothing owns `swiftc` once its requests end, so a daemon that -// exits would leave it running. -const videoValidatorCompiles = new Map(); +/** Starts one cached compile per Swift cache root. Callers may warm it without awaiting it. */ +export function prepareVideoValidator(host: HostEnvironment): Promise { + const cacheRoot = getSwiftCacheRoot(host.env); + let compiled = videoValidatorCompiles.get(cacheRoot); + if (!compiled) { + compiled = compileSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + }).catch((error: unknown) => { + throw unavailableVideoValidator(error); + }); + videoValidatorCompiles.set(cacheRoot, compiled); + } + return compiled; +} export async function waitForStableFile( filePath: string, @@ -126,48 +131,20 @@ export async function isPlayableVideo( if (result.exitCode === 0) { return true; } - return isSwiftVideoValidatorUnavailable(result.stderr, result.stdout); + if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { + throw unavailableVideoValidator(new AppError('COMMAND_FAILED', result.stderr)); + } + return false; } catch (error) { if (isSwiftVideoValidatorError(error)) { - return true; + throw unavailableVideoValidator(error); } throw error; } } -function joinValidatorCompile(host: HostEnvironment, cacheRoot: string): SharedValidatorCompile { - const existing = videoValidatorCompiles.get(cacheRoot); - if (existing) return existing; - const cancel = new AbortController(); - const shared: SharedValidatorCompile = { - compiled: compileSwiftSourceText({ - host, - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', - signal: cancel.signal, - }), - cancel, - waiters: 0, - settled: false, - }; - videoValidatorCompiles.set(cacheRoot, shared); - shared.compiled.then( - () => { - shared.settled = true; - }, - () => { - shared.settled = true; - if (videoValidatorCompiles.get(cacheRoot) === shared) - videoValidatorCompiles.delete(cacheRoot); - }, - ); - return shared; -} - async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): Promise { - const cacheRoot = getSwiftCacheRoot(host.env); - const shared = joinValidatorCompile(host, cacheRoot); - shared.waiters += 1; + const compiled = prepareVideoValidator(host); let timer: NodeJS.Timeout | undefined; const expired = new Promise((_, reject) => { timer = setTimeout( @@ -181,9 +158,8 @@ async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): P timeoutMs: waitMs, retriable: true, hint: - 'Run record stop again: the Swift modules this compile already built stay ' + - 'cached, so the next compile finishes sooner, and the recording keeps its ' + - 'evidence either way.', + 'Run record stop again: the validator compile continues in the background, ' + + 'and the recording keeps its evidence.', }, ), ), @@ -191,18 +167,25 @@ async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): P ); }); try { - return await Promise.race([shared.compiled, expired]); + return await Promise.race([compiled, expired]); } finally { clearTimeout(timer); - shared.waiters -= 1; - if (shared.waiters === 0 && !shared.settled) { - if (videoValidatorCompiles.get(cacheRoot) === shared) - videoValidatorCompiles.delete(cacheRoot); - shared.cancel.abort(); - } } } +function unavailableVideoValidator(cause: unknown): AppError { + return new AppError( + 'COMMAND_FAILED', + 'The video validator is unavailable; MP4 playability could not be verified.', + { + reason: 'video_validator_unavailable', + retriable: false, + hint: 'Install the Swift toolchain with AVFoundation on the daemon host, then restart the daemon and retry record stop.', + }, + cause, + ); +} + function isSwiftVideoValidatorError(error: unknown): boolean { if (!(error instanceof AppError)) { return false; @@ -240,7 +223,7 @@ export async function waitForPlayableVideo( function isSwiftVideoValidatorUnavailable(stderr: string, stdout: string): boolean { const combined = `${stderr}\n${stdout}`; - return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["']|xcrun: error: unable to find utility ["']swiftc?["'])\b/i.test( + return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["']|xcrun: error: unable to find utility ["']swiftc?["'])/i.test( combined, ); } diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index 4c9be5500c..71ed4d6dbd 100644 --- a/packages/command-surface/src/recording/index.ts +++ b/packages/command-surface/src/recording/index.ts @@ -28,7 +28,7 @@ const TRACE_COMMAND_NAME = 'trace'; const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; const recordCommandDescription = - 'Start or stop a screen recording for the active app session or, where supported, the selected device, or build a contact-sheet PNG from a recording already exported. Long Android recordings can return multiple video artifacts; HarmonyOS supports whole-screen recording on physical devices.'; + 'Start or stop a screen recording for the active app session or, where supported, the selected device, or build a contact-sheet PNG from a recording already exported. Long Android recordings can return multiple video artifacts; HarmonyOS supports whole-screen recording on physical devices. MP4 verification setup starts in the background at record start without delaying capture. If setup exceeds the stop budget, details.reason is video_validator_compile_timeout (retriable); run record stop again while setup continues. If verification tooling fails, details.reason is video_validator_unavailable; install Swift with AVFoundation on the daemon host, restart the daemon, and retry record stop.'; const traceCommandDescription = 'Start or stop trace-log capture and return the resulting artifact when capture ends. Without a path, start writes a timestamped trace log named after the session in the sessions directory of the daemon state; a path on stop moves the finished trace there.'; diff --git a/src/platform-runtime-screen-recording-host.test.ts b/src/platform-runtime-screen-recording-host.test.ts new file mode 100644 index 0000000000..8be8824976 --- /dev/null +++ b/src/platform-runtime-screen-recording-host.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { runCmd } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import * as video from '@agent-device/capture-kit/recording-video'; +import { createAppleScreenRecordingOperations } from '../packages/platform-apple/src/recording/runtime.ts'; +import { + appleRecordingHost, + coreDevice, + recordingInput, + simulator, + simulatorRecorderStart, +} from '../packages/platform-apple/src/recording/runtime.fixtures.ts'; +import { createScreenRecordingRuntimeHost } from './platform-runtime-screen-recording-host.ts'; + +vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ + ...(await importOriginal()), + runCmd: vi.fn(), +})); +afterEach(() => vi.restoreAllMocks()); + +function harness() { + const root = mkdtempForTestSync('warm-video-validator-'); + const host = createScreenRecordingRuntimeHost({ + host: { ...createLiveTestHostEnvironment(), env: { AGENT_DEVICE_SWIFT_CACHE_DIR: root } }, + }); + return { host, input: recordingInput({ outputPath: path.join(root, 'capture.mp4') }) }; +} + +test.each([simulator, coreDevice])( + 'warms before $kind capture and starts while preparation is pending', + async (device) => { + const { host, input } = harness(); + let resolvePreparation!: (value: string) => void; + const pending = new Promise((resolve) => { + resolvePreparation = resolve; + }); + const preparation = vi.spyOn(video, 'prepareVideoValidator').mockReturnValue(pending); + const launched = vi.fn(() => { + expect(preparation).toHaveBeenCalledOnce(); + return { + runnerSessionId: 'runner-session', + runnerAuthority: 'local-lease' as const, + remotePath: 'tmp/agent-device-recording-123.mp4', + }; + }); + const fake = appleRecordingHost({ + apple: { + ...simulatorRecorderStart(), + runRunner: async () => launched(), + }, + }); + const startSimulator = fake.screenRecording.apple.startSimulator; + const operations = createAppleScreenRecordingOperations({ + host: { + screenRecording: { + ...fake.screenRecording, + outputs: host.outputs, + apple: { + ...fake.screenRecording.apple, + startSimulator: async (...args) => { + launched(); + return await startSimulator(...args); + }, + }, + }, + }, + device, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + let started = false; + const starting = operations.screenRecordingStart(input).then((result) => { + started = true; + return result; + }); + void starting.catch(() => {}); + try { + await expect.poll(() => started, { timeout: 1_000 }).toBe(true); + expect(launched).toHaveBeenCalledOnce(); + } finally { + resolvePreparation('/bin/true'); + await starting; + } + }, +); + +test('a runCmd compile failure survives capture start and reaches the real stop finalizer', async () => { + const { host, input } = harness(); + const failure = new AppError('COMMAND_FAILED', 'xcrun exited with code 72', { + stderr: 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH', + }); + let rejectCompile!: (error: unknown) => void; + const compile = new Promise((_resolve, reject) => { + rejectCompile = reject; + }); + void compile.catch(() => {}); + vi.mocked(runCmd).mockClear().mockReturnValue(compile); + const preparation = vi.spyOn(video, 'prepareVideoValidator'); + const fake = appleRecordingHost({ + apple: { + retrieveRunnerRecording: async (_device, _remote, outputPath) => { + fs.copyFileSync('website/docs/public/agent-device-contacts.mp4', outputPath); + }, + }, + }); + const operations = createAppleScreenRecordingOperations({ + host: { + screenRecording: { ...fake.screenRecording, outputs: host.outputs, finalize: host.finalize }, + }, + device: coreDevice, + owner: localRuntimeOwner('apple'), + signal: new AbortController().signal, + }); + let captureStarted = false; + const starting = operations.screenRecordingStart(input).then((result) => { + captureStarted = true; + return result; + }); + try { + await expect.poll(() => captureStarted, { timeout: 1_000 }).toBe(true); + expect(preparation).toHaveBeenCalledOnce(); + await expect.poll(() => vi.mocked(runCmd).mock.calls.length).toBe(1); + } finally { + rejectCompile(failure); + await starting; + } + const started = await starting; + await expect(preparation.mock.results[0]!.value).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); + const handle = started.pendingHandle.transfer(); + await expect(handle.finish()).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_unavailable', retriable: false }, + cause: failure, + }); + expect(runCmd).toHaveBeenCalledExactlyOnceWith( + 'xcrun', + expect.arrayContaining(['swiftc']), + expect.any(Object), + ); + expect(fs.existsSync(input.outputPath)).toBe(true); +}); diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index 27080d469b..b1efb18af3 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -27,6 +27,10 @@ export function createScreenRecordingRuntimeHost(options: { const { createScreenRecordingOutputHost } = await import('./platform-runtime-screen-recording-output-host.ts'); await createScreenRecordingOutputHost().prepare(outputPath); + if (!outputPath.toLowerCase().endsWith('.webm')) { + const { prepareVideoValidator } = await import('@agent-device/capture-kit/recording-video'); + void prepareVideoValidator(host).catch(() => {}); + } }, copy: async (input) => { const { createScreenRecordingOutputHost } = diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 3590e3b83f..507b71e75a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1075,6 +1075,7 @@ agent-device record stop # Stop active recording - Android uses `adb shell screenrecord` (180s limit per clip). Longer recordings are split into MP4 chunks while the daemon stays alive; after a daemon restart, `record stop` recovers only chunks listed in the device manifest. A clip can end before `record stop` when the screen stopped changing; `durationMs` is wall-clock time, and `capturedDurationMs` plus a warning report how much the video covers. - Limrun sessions always record the whole screen and refuse `--fps` and `--hide-touches` with `INVALID_ARGS`. A failed download is retried by the next `record stop`; nothing survives a daemon restart. - A busy iOS simulator recording slot returns non-retriable `DEVICE_IN_USE` with `details.reason: apple_simulator_recording_busy`. Run `record stop` in the session that owns the recording; if no recording is active, ask the host operator to restart the CoreSimulator stream service. +- MP4 verification setup runs in the background from `record start`; capture does not wait for it. If setup exceeds the stop budget, stop returns retriable `details.reason: video_validator_compile_timeout`. Run `record stop` again; setup continues and your recording is retained. If verification tooling fails, stop returns `details.reason: video_validator_unavailable`. Install Swift with AVFoundation on the daemon host, restart the daemon, and retry `record stop`. - `record stop` is safe to repeat. If the request window ends during export, a second `record stop` in the same session returns the finished recording. A recording whose video file is gone reports `no active recording`. - `record start --scope device` on a session that does not exist yet creates one, and `record stop` ends that session. If anything else takes over the session meanwhile — an `open` or an app event that publishes an app, or an audio probe, perf capture, trace, app log, device claim, or lease it holds — `record stop` ends only the recording; the session stays and `close` releases it. - On a remote daemon, if a leased session ends while a recording runs (lease expiry, release, or `close`), the daemon finishes the recording. It keeps the video and telemetry downloadable by the lease's tenant: `GET /artifacts` lists them with `sessionName`, and `GET /artifacts/` downloads them. Each file is deleted after its download, or after 15 minutes. From 90a65bad1931eb5ed2949198d52275ed8fadca4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 19:41:57 +0100 Subject: [PATCH 2/7] test(record): give scripted providers an isolated validator fixture --- .../integration/provider-scenarios/harness.ts | 22 ++++++++++++++++++- .../provider-scenarios/macos-world.ts | 3 --- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 0d73d55051..a0f86fd44c 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -30,8 +30,9 @@ import { type PlatformProviderResolvers, } from '../../../src/platform-runtime.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; -import { runCmdBackground } from '@agent-device/host-kit/command'; +import { runCmdBackground, withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { prepareVideoValidator } from '@agent-device/capture-kit/recording-video'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; import type { DeviceInventoryProvider, @@ -112,6 +113,7 @@ export async function createProviderScenarioHarness( // A scenario that seeds claims for its daemon names the directory through the process. AGENT_DEVICE_CLAIMS_DIR: process.env.AGENT_DEVICE_CLAIMS_DIR ?? path.join(testRoot, 'claims'), AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', + AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(testRoot, 'swift-cache'), ...deps.env, // Set last: the harness reads its own state dir and transport back from these. AGENT_DEVICE_STATE_DIR: stateDir, @@ -239,6 +241,24 @@ export async function createProviderScenarioHarness( return { callCommand: async (command, positionals = [], flags = {}, options = {}) => { + if (command === 'record') { + await withCommandExecutorOverride( + (command, args) => { + if ( + command !== 'xcrun' || + args[0] !== 'swiftc' || + !args.some((arg) => path.basename(arg).startsWith('video-validator-')) + ) + return undefined; + const output = args[args.indexOf('-o') + 1]; + if (!args.includes('-o') || !output) + throw new Error('Missing validator fixture output'); + fs.writeFileSync(output, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); + }, + async () => await prepareVideoValidator(createTestHostEnvironment({ env })), + ); + } const request = await withClientReplayScriptSources( commandRequest(runtime.token, command, positionals, flags, options), ); diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index be88cce6d8..0a36759c1f 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -48,10 +48,7 @@ export async function createMacOsDesktopWorld( }, }, }); - // Recording finalization runs the Swift validator; the process's warm cache keeps it from compiling. - const swiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; const daemon = await createProviderScenarioHarness({ - ...(swiftCacheDir ? { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: swiftCacheDir } } : {}), appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined, From cf964a3134d3a329626664b734bed45b2054e737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 19:53:31 +0100 Subject: [PATCH 3/7] test(record): exercise warming through the public Apple runtime --- ...form-runtime-screen-recording-host.test.ts | 145 ++++++++++-------- 1 file changed, 82 insertions(+), 63 deletions(-) diff --git a/src/platform-runtime-screen-recording-host.test.ts b/src/platform-runtime-screen-recording-host.test.ts index 8be8824976..dda9b1ee05 100644 --- a/src/platform-runtime-screen-recording-host.test.ts +++ b/src/platform-runtime-screen-recording-host.test.ts @@ -3,18 +3,22 @@ import path from 'node:path'; import { afterEach, expect, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; -import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; import * as video from '@agent-device/capture-kit/recording-video'; -import { createAppleScreenRecordingOperations } from '../packages/platform-apple/src/recording/runtime.ts'; -import { - appleRecordingHost, - coreDevice, - recordingInput, - simulator, - simulatorRecorderStart, -} from '../packages/platform-apple/src/recording/runtime.fixtures.ts'; -import { createScreenRecordingRuntimeHost } from './platform-runtime-screen-recording-host.ts'; +import { runtimeModule } from '@agent-device/platform-apple'; +import { createPlatformRuntimeHost } from './platform-runtime-operation-host.ts'; + +const devices: DeviceInfo[] = ['simulator', 'device'].map((kind) => ({ + platform: 'apple', + appleOs: 'ios', + id: kind, + name: kind, + kind: kind as 'simulator' | 'device', + target: 'mobile', + booted: true, + ...(kind === 'device' ? { iosPhysicalDeviceBackend: 'coredevice' as const } : {}), +})); vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -22,18 +26,77 @@ vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ })); afterEach(() => vi.restoreAllMocks()); -function harness() { +async function harness(device: DeviceInfo, launch = () => {}) { const root = mkdtempForTestSync('warm-video-validator-'); - const host = createScreenRecordingRuntimeHost({ + const unused = async (): Promise => { + throw new Error('unused'); + }; + const host = createPlatformRuntimeHost({ host: { ...createLiveTestHostEnvironment(), env: { AGENT_DEVICE_SWIFT_CACHE_DIR: root } }, + sessionsDir: root, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + snapshot: { captureSurface: unused, presentIosAcquisition: unused }, + shutdownLoaders: { apple: unused, android: unused }, + }); + const runtime = await runtimeModule.loadRuntime({ + ...host, + screenRecording: { + ...host.screenRecording, + apple: { + ...host.screenRecording.apple, + availability: async () => ({ available: true }), + isRunnerBundleId: async () => false, + captureClockAnchor: async () => undefined, + startSimulator: async () => { + launch(); + return { + markers: [{ pid: 42, startTime: 'start', command: 'recorder' }], + wait: new Promise(() => {}), + terminate: async () => {}, + }; + }, + runRunner: async (_device, request) => { + if (request.kind !== 'start') return {}; + launch(); + return { + runnerSessionId: 'runner', + runnerAuthority: 'local-lease', + remotePath: 'tmp/agent-device-recording-123.mp4', + }; + }, + retrieveRunnerRecording: async (_device, _remote, outputPath) => { + fs.copyFileSync('website/docs/public/agent-device-contacts.mp4', outputPath); + }, + }, + }, + }); + const binding = await runtime.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, }); - return { host, input: recordingInput({ outputPath: path.join(root, 'capture.mp4') }) }; + const input = { + sessionId: 'one', + outputPath: path.join(root, 'capture.mp4'), + scope: 'device' as const, + showTouches: false, + hideTouchesRequested: false, + recordOnlySession: false, + activeSessionApp: { bundleId: 'com.example.app' }, + fence: { token: 'fence', generation: 1 }, + }; + return { input, start: () => binding.operations.screenRecordingStart!(input) }; } -test.each([simulator, coreDevice])( +test.each(devices)( 'warms before $kind capture and starts while preparation is pending', async (device) => { - const { host, input } = harness(); let resolvePreparation!: (value: string) => void; const pending = new Promise((resolve) => { resolvePreparation = resolve; @@ -41,39 +104,10 @@ test.each([simulator, coreDevice])( const preparation = vi.spyOn(video, 'prepareVideoValidator').mockReturnValue(pending); const launched = vi.fn(() => { expect(preparation).toHaveBeenCalledOnce(); - return { - runnerSessionId: 'runner-session', - runnerAuthority: 'local-lease' as const, - remotePath: 'tmp/agent-device-recording-123.mp4', - }; - }); - const fake = appleRecordingHost({ - apple: { - ...simulatorRecorderStart(), - runRunner: async () => launched(), - }, - }); - const startSimulator = fake.screenRecording.apple.startSimulator; - const operations = createAppleScreenRecordingOperations({ - host: { - screenRecording: { - ...fake.screenRecording, - outputs: host.outputs, - apple: { - ...fake.screenRecording.apple, - startSimulator: async (...args) => { - launched(); - return await startSimulator(...args); - }, - }, - }, - }, - device, - owner: localRuntimeOwner('apple'), - signal: new AbortController().signal, }); + const { start } = await harness(device, launched); let started = false; - const starting = operations.screenRecordingStart(input).then((result) => { + const starting = start().then((result) => { started = true; return result; }); @@ -89,7 +123,7 @@ test.each([simulator, coreDevice])( ); test('a runCmd compile failure survives capture start and reaches the real stop finalizer', async () => { - const { host, input } = harness(); + const { start, input } = await harness(devices[1]!); const failure = new AppError('COMMAND_FAILED', 'xcrun exited with code 72', { stderr: 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH', }); @@ -100,23 +134,8 @@ test('a runCmd compile failure survives capture start and reaches the real stop void compile.catch(() => {}); vi.mocked(runCmd).mockClear().mockReturnValue(compile); const preparation = vi.spyOn(video, 'prepareVideoValidator'); - const fake = appleRecordingHost({ - apple: { - retrieveRunnerRecording: async (_device, _remote, outputPath) => { - fs.copyFileSync('website/docs/public/agent-device-contacts.mp4', outputPath); - }, - }, - }); - const operations = createAppleScreenRecordingOperations({ - host: { - screenRecording: { ...fake.screenRecording, outputs: host.outputs, finalize: host.finalize }, - }, - device: coreDevice, - owner: localRuntimeOwner('apple'), - signal: new AbortController().signal, - }); let captureStarted = false; - const starting = operations.screenRecordingStart(input).then((result) => { + const starting = start().then((result) => { captureStarted = true; return result; }); From 823b8d1d0154608c66920c68a1c186ef2297aa29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 21:15:12 +0100 Subject: [PATCH 4/7] fix(record): retry rejected preparation and preserve unsupported hosts --- .../capture-kit/src/recording/video.test.ts | 28 +++++- packages/capture-kit/src/recording/video.ts | 16 ++- .../command-surface/src/recording/index.ts | 4 +- ...form-runtime-screen-recording-host.test.ts | 98 +++++++++++-------- src/platform-runtime-screen-recording-host.ts | 6 +- website/docs/docs/commands.md | 2 +- 6 files changed, 100 insertions(+), 54 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 93a95276fd..ad63c6da0b 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -144,7 +144,7 @@ test('an expired waiter leaves the compile running for the next stop', async () } }); -test('a failed background compile stays unavailable without retrying at stop', async () => { +test('a rejected compile is evicted so the next attempt compiles and verifies', async () => { const mp4 = writeFixture('failed-compile.mp4', Buffer.concat([ftyp, moov])); const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/failed' }, cwd: directory }; vi.mocked(compileSwiftSourceText) @@ -154,10 +154,30 @@ test('a failed background compile stays unavailable without retrying at stop', a await expect(prepareVideoValidator(host)).rejects.toMatchObject({ details: { reason: 'video_validator_unavailable' }, }); - await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ - details: { reason: 'video_validator_unavailable' }, + await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); + expect(runCmd).toHaveBeenCalledOnce(); +}); + +test('retains main container-only completion when the Swift compiler is missing', async () => { + const mp4 = writeFixture('missing-compiler.mp4', Buffer.concat([ftyp, moov])); + const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/missing-compiler' }, cwd: directory }; + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( + new AppError('TOOL_MISSING', 'xcrun not found in PATH'), + ); + await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); + expect(runCmd).not.toHaveBeenCalled(); +}); + +test('a missing validator executable after compilation returns the typed unavailable error', async () => { + const mp4 = writeFixture('missing-validator.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockRejectedValueOnce( + new AppError('TOOL_MISSING', 'validator not found in PATH'), + ); + await expect(isPlayableVideo(mp4, createTestHost())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_unavailable', retriable: false }, }); - expect(compileSwiftSourceText).toHaveBeenCalledOnce(); }); test.each([ diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 4a8ea41574..0275a8ba4a 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -52,6 +52,8 @@ export function prepareVideoValidator(host: HostEnvironment): Promise { source: VIDEO_VALIDATION_SCRIPT, cacheName: 'video-validator', }).catch((error: unknown) => { + if (videoValidatorCompiles.get(cacheRoot) === compiled) + videoValidatorCompiles.delete(cacheRoot); throw unavailableVideoValidator(error); }); videoValidatorCompiles.set(cacheRoot, compiled); @@ -112,7 +114,17 @@ export async function isPlayableVideo( const validatorPath = await waitForValidatorCompile( host, deadline?.remainingMs() ?? VALIDATOR_COMPILE_WAIT_MS, - ); + ).catch((error: unknown) => { + if ( + error instanceof AppError && + error.cause instanceof AppError && + error.cause.code === 'TOOL_MISSING' + ) + return undefined; + throw error; + }); + // Without compiler-launch tooling, MP4 completion relies on the container check. + if (!validatorPath) return true; const runTimeoutMs = Math.min( VALIDATOR_RUN_TIMEOUT_MS, deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, @@ -180,7 +192,7 @@ function unavailableVideoValidator(cause: unknown): AppError { { reason: 'video_validator_unavailable', retriable: false, - hint: 'Install the Swift toolchain with AVFoundation on the daemon host, then restart the daemon and retry record stop.', + hint: 'Repair the Swift toolchain or validator executable on the daemon host, then retry record stop.', }, cause, ); diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index 71ed4d6dbd..1ec522891d 100644 --- a/packages/command-surface/src/recording/index.ts +++ b/packages/command-surface/src/recording/index.ts @@ -28,7 +28,7 @@ const TRACE_COMMAND_NAME = 'trace'; const RECORDING_ACTION_VALUES = ['start', 'stop'] as const; const recordCommandDescription = - 'Start or stop a screen recording for the active app session or, where supported, the selected device, or build a contact-sheet PNG from a recording already exported. Long Android recordings can return multiple video artifacts; HarmonyOS supports whole-screen recording on physical devices. MP4 verification setup starts in the background at record start without delaying capture. If setup exceeds the stop budget, details.reason is video_validator_compile_timeout (retriable); run record stop again while setup continues. If verification tooling fails, details.reason is video_validator_unavailable; install Swift with AVFoundation on the daemon host, restart the daemon, and retry record stop.'; + 'Start or stop a screen recording for the active app session or, where supported, the selected device, or build a contact-sheet PNG from a recording already exported. Long Android recordings can return multiple video artifacts; HarmonyOS supports whole-screen recording on physical devices.'; const traceCommandDescription = 'Start or stop trace-log capture and return the resulting artifact when capture ends. Without a path, start writes a timestamped trace log named after the session in the sessions directory of the daemon state; a path on stop moves the finished trace there.'; @@ -127,7 +127,7 @@ export const recordCommandFacet = defineCommandFacet({ text: { summary: 'Start or stop screen recording', cliDetail: - 'The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Android screenrecord encodes a frame only when the screen changes, so a clip can end at the last frame the recorder encoded instead of at record stop; durationMs is host wall clock from record start until the export finished, and capturedDurationMs reports the video timeline when it can be measured, with a warning naming how much of the window that video covers. An Android manifest left by an unreachable recording is retired on the next start once its recorders are proven gone; one still owned refuses with non-retriable DEVICE_IN_USE and reason native_recovery_evidence_open naming the session to run record stop for, and a recorder that is still writing an artifact refuses with DEVICE_IN_USE and reason native_recording_artifact_claimed, which clears itself once that recorder ends at the 180s limit. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. The touch overlay renders at most 30 frames a second, or at --fps when lower; a lower --fps makes record stop faster on a long recording, and an overlay export that runs past its budget returns the raw video with an overlayWarning. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service. On any backend, record start first settles the open recording records on its device that it would refuse on and whose session this daemon no longer holds: it finishes each one as its own record stop would, so the export lands at the output path of that recording and a later record stop in that session returns it, and runs the exact-owner recovery a daemon restart runs, which discards what that recording left, only for a record that cannot finish. A record it cannot confirm cleaned, or one whose session is still held, refuses the start with reason cleanup-unconfirmed and names that session: stop or close it, then retry. A record that another device left under the same session name is never settled from this device: the start refuses and names that device, so stop it in that session or record under another session name. A record stop that cannot produce its export keeps what a retry reads back — on Android the device-side artifact and the native manifest — leaves the recording manifest of that session open, and returns its own error, so retry record stop in that session; while this daemon holds that session, only closing it disposes them, and once it does not, the next record start on that device settles them as described above. Every stopped recording also reports what became of its recorder and of the file that recorder writes to: recorder is confirmed, or lost when the session holding it died, and nativePathDisposition is retirable while that file still owes a removal or retired once its removal was verified. Both are optional disclosures, not failures; ADR 0024 declares further states (unconfirmed, pending) for the steps that gain those probes, and no stop reports them yet. record contact-sheet [--out ] is a local report over a file you already exported: it decodes frames out of that MP4 and writes one PNG holding the cells where the screen visibly changed, each labeled with its elapsed time on the clip timeline. It needs no session and no device, rebuilds any past recording, and works for every backend that exports MP4; a WebM recording is refused with reason contact_sheet_container_unsupported. The sample grid is bounded and spread across the whole clip, so it reports coverage rather than a review: a flash that opens and closes entirely between two sample times is not in the sheet, which is why the result also reports how many times it sampled and how many frames came back. Frame decoding is Apple AVFoundation tooling, so the command runs only on macOS hosts and refuses elsewhere with reason contact_sheet_unsupported_host.', + 'MP4 verification setup starts in the background before recording output preparation, without delaying capture. If setup exceeds the stop budget, details.reason is video_validator_compile_timeout (retriable); run record stop again while setup continues. Hosts without xcrun keep container-only MP4 completion. Other compilation failures, or an unavailable validator executable or AVFoundation during verification, return video_validator_unavailable. Repair the reported failure and retry record stop; a failed compile is retried on the next start or stop. The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Android screenrecord encodes a frame only when the screen changes, so a clip can end at the last frame the recorder encoded instead of at record stop; durationMs is host wall clock from record start until the export finished, and capturedDurationMs reports the video timeline when it can be measured, with a warning naming how much of the window that video covers. An Android manifest left by an unreachable recording is retired on the next start once its recorders are proven gone; one still owned refuses with non-retriable DEVICE_IN_USE and reason native_recovery_evidence_open naming the session to run record stop for, and a recorder that is still writing an artifact refuses with DEVICE_IN_USE and reason native_recording_artifact_claimed, which clears itself once that recorder ends at the 180s limit. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. The touch overlay renders at most 30 frames a second, or at --fps when lower; a lower --fps makes record stop faster on a long recording, and an overlay export that runs past its budget returns the raw video with an overlayWarning. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service. On any backend, record start first settles the open recording records on its device that it would refuse on and whose session this daemon no longer holds: it finishes each one as its own record stop would, so the export lands at the output path of that recording and a later record stop in that session returns it, and runs the exact-owner recovery a daemon restart runs, which discards what that recording left, only for a record that cannot finish. A record it cannot confirm cleaned, or one whose session is still held, refuses the start with reason cleanup-unconfirmed and names that session: stop or close it, then retry. A record that another device left under the same session name is never settled from this device: the start refuses and names that device, so stop it in that session or record under another session name. A record stop that cannot produce its export keeps what a retry reads back — on Android the device-side artifact and the native manifest — leaves the recording manifest of that session open, and returns its own error, so retry record stop in that session; while this daemon holds that session, only closing it disposes them, and once it does not, the next record start on that device settles them as described above. Every stopped recording also reports what became of its recorder and of the file that recorder writes to: recorder is confirmed, or lost when the session holding it died, and nativePathDisposition is retirable while that file still owes a removal or retired once its removal was verified. Both are optional disclosures, not failures; ADR 0024 declares further states (unconfirmed, pending) for the steps that gain those probes, and no stop reports them yet. record contact-sheet [--out ] is a local report over a file you already exported: it decodes frames out of that MP4 and writes one PNG holding the cells where the screen visibly changed, each labeled with its elapsed time on the clip timeline. It needs no session and no device, rebuilds any past recording, and works for every backend that exports MP4; a WebM recording is refused with reason contact_sheet_container_unsupported. The sample grid is bounded and spread across the whole clip, so it reports coverage rather than a review: a flash that opens and closes entirely between two sample times is not in the sheet, which is why the result also reports how many times it sampled and how many frames came back. Frame decoding is Apple AVFoundation tooling, so the command runs only on macOS hosts and refuses elsewhere with reason contact_sheet_unsupported_host.', }, metadata: recordCommandMetadata, run: (client, input) => client.recording.record(input as RecordOptions), diff --git a/src/platform-runtime-screen-recording-host.test.ts b/src/platform-runtime-screen-recording-host.test.ts index dda9b1ee05..adbd0f8eb2 100644 --- a/src/platform-runtime-screen-recording-host.test.ts +++ b/src/platform-runtime-screen-recording-host.test.ts @@ -106,6 +106,11 @@ test.each(devices)( expect(preparation).toHaveBeenCalledOnce(); }); const { start } = await harness(device, launched); + const removeOutput = vi.spyOn(fs, 'rmSync'); + preparation.mockImplementation(() => { + expect(removeOutput).not.toHaveBeenCalled(); + return pending; + }); let started = false; const starting = start().then((result) => { started = true; @@ -115,6 +120,7 @@ test.each(devices)( try { await expect.poll(() => started, { timeout: 1_000 }).toBe(true); expect(launched).toHaveBeenCalledOnce(); + expect(removeOutput).toHaveBeenCalled(); } finally { resolvePreparation('/bin/true'); await starting; @@ -122,45 +128,53 @@ test.each(devices)( }, ); -test('a runCmd compile failure survives capture start and reaches the real stop finalizer', async () => { - const { start, input } = await harness(devices[1]!); - const failure = new AppError('COMMAND_FAILED', 'xcrun exited with code 72', { - stderr: 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH', - }); - let rejectCompile!: (error: unknown) => void; - const compile = new Promise((_resolve, reject) => { - rejectCompile = reject; - }); - void compile.catch(() => {}); - vi.mocked(runCmd).mockClear().mockReturnValue(compile); - const preparation = vi.spyOn(video, 'prepareVideoValidator'); - let captureStarted = false; - const starting = start().then((result) => { - captureStarted = true; - return result; - }); - try { - await expect.poll(() => captureStarted, { timeout: 1_000 }).toBe(true); - expect(preparation).toHaveBeenCalledOnce(); - await expect.poll(() => vi.mocked(runCmd).mock.calls.length).toBe(1); - } finally { - rejectCompile(failure); - await starting; - } - const started = await starting; - await expect(preparation.mock.results[0]!.value).rejects.toMatchObject({ - details: { reason: 'video_validator_unavailable' }, - }); - const handle = started.pendingHandle.transfer(); - await expect(handle.finish()).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'video_validator_unavailable', retriable: false }, - cause: failure, - }); - expect(runCmd).toHaveBeenCalledExactlyOnceWith( - 'xcrun', - expect.arrayContaining(['swiftc']), - expect.any(Object), - ); - expect(fs.existsSync(input.outputPath)).toBe(true); -}); +test.each(['COMMAND_FAILED', 'TOOL_MISSING'])( + 'a %s compiler failure preserves capture and main stop semantics', + async (code) => { + const { start, input } = await harness(devices[1]!); + const failure = new AppError(code, 'xcrun exited with code 72', { + stderr: 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH', + }); + let rejectCompile!: (error: unknown) => void; + const compile = new Promise((_resolve, reject) => { + rejectCompile = reject; + }); + void compile.catch(() => {}); + vi.mocked(runCmd).mockClear().mockReturnValue(compile); + const preparation = vi.spyOn(video, 'prepareVideoValidator'); + let captureStarted = false; + const starting = start().then((result) => { + captureStarted = true; + return result; + }); + try { + await expect.poll(() => captureStarted, { timeout: 1_000 }).toBe(true); + expect(preparation).toHaveBeenCalledOnce(); + await expect.poll(() => vi.mocked(runCmd).mock.calls.length).toBe(1); + } finally { + rejectCompile(failure); + await starting; + } + const started = await starting; + await expect(preparation.mock.results[0]!.value).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); + const handle = started.pendingHandle.transfer(); + if (code === 'TOOL_MISSING') { + await expect(handle.finish()).resolves.toMatchObject({ status: 'completed' }); + } else { + await expect(handle.finish()).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_unavailable', retriable: false }, + cause: failure, + }); + } + expect(runCmd).toHaveBeenCalledTimes(2); + expect(runCmd).toHaveBeenCalledWith( + 'xcrun', + expect.arrayContaining(['swiftc']), + expect.any(Object), + ); + expect(fs.existsSync(input.outputPath)).toBe(true); + }, +); diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index b1efb18af3..5068f04736 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -24,13 +24,13 @@ export function createScreenRecordingRuntimeHost(options: { }); const outputs: ScreenRecordingRuntimeHost['outputs'] = Object.freeze({ prepare: async (outputPath) => { - const { createScreenRecordingOutputHost } = - await import('./platform-runtime-screen-recording-output-host.ts'); - await createScreenRecordingOutputHost().prepare(outputPath); if (!outputPath.toLowerCase().endsWith('.webm')) { const { prepareVideoValidator } = await import('@agent-device/capture-kit/recording-video'); void prepareVideoValidator(host).catch(() => {}); } + const { createScreenRecordingOutputHost } = + await import('./platform-runtime-screen-recording-output-host.ts'); + await createScreenRecordingOutputHost().prepare(outputPath); }, copy: async (input) => { const { createScreenRecordingOutputHost } = diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 507b71e75a..a4231e5f7c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1075,7 +1075,7 @@ agent-device record stop # Stop active recording - Android uses `adb shell screenrecord` (180s limit per clip). Longer recordings are split into MP4 chunks while the daemon stays alive; after a daemon restart, `record stop` recovers only chunks listed in the device manifest. A clip can end before `record stop` when the screen stopped changing; `durationMs` is wall-clock time, and `capturedDurationMs` plus a warning report how much the video covers. - Limrun sessions always record the whole screen and refuse `--fps` and `--hide-touches` with `INVALID_ARGS`. A failed download is retried by the next `record stop`; nothing survives a daemon restart. - A busy iOS simulator recording slot returns non-retriable `DEVICE_IN_USE` with `details.reason: apple_simulator_recording_busy`. Run `record stop` in the session that owns the recording; if no recording is active, ask the host operator to restart the CoreSimulator stream service. -- MP4 verification setup runs in the background from `record start`; capture does not wait for it. If setup exceeds the stop budget, stop returns retriable `details.reason: video_validator_compile_timeout`. Run `record stop` again; setup continues and your recording is retained. If verification tooling fails, stop returns `details.reason: video_validator_unavailable`. Install Swift with AVFoundation on the daemon host, restart the daemon, and retry `record stop`. +- MP4 verification setup runs in the background from `record start`; capture does not wait for it. If setup exceeds the stop budget, stop returns retriable `details.reason: video_validator_compile_timeout`. Run `record stop` again; setup continues and your recording is retained. Hosts without xcrun keep container-only MP4 completion. Other compilation failures, or an unavailable validator executable or AVFoundation during verification, return `details.reason: video_validator_unavailable`. Repair the reported failure and retry `record stop`; a failed compile is retried on the next start or stop. - `record stop` is safe to repeat. If the request window ends during export, a second `record stop` in the same session returns the finished recording. A recording whose video file is gone reports `no active recording`. - `record start --scope device` on a session that does not exist yet creates one, and `record stop` ends that session. If anything else takes over the session meanwhile — an `open` or an app event that publishes an app, or an audio probe, perf capture, trace, app log, device claim, or lease it holds — `record stop` ends only the recording; the session stays and `close` releases it. - On a remote daemon, if a leased session ends while a recording runs (lease expiry, release, or `close`), the daemon finishes the recording. It keeps the video and telemetry downloadable by the lease's tenant: `GET /artifacts` lists them with `sessionName`, and `GET /artifacts/` downloads them. Each file is deleted after its download, or after 15 minutes. From 666fbf16fd881242c379ec4a5dd8f829c0e862a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 21:43:58 +0100 Subject: [PATCH 5/7] fix(record): preserve validator compiler diagnostics --- .../capture-kit/src/recording/video.test.ts | 31 +++++++++++++++++++ packages/capture-kit/src/recording/video.ts | 10 +++++- ...form-runtime-screen-recording-host.test.ts | 12 +++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index ad63c6da0b..67af761250 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -159,6 +159,37 @@ test('a rejected compile is evicted so the next attempt compiles and verifies', expect(runCmd).toHaveBeenCalledOnce(); }); +test('preserves compiler stderr and recovery hint without replacing the typed validator reason', async () => { + const mp4 = writeFixture('compiler-diagnostics.mp4', Buffer.concat([ftyp, moov])); + const host = { + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/compiler-diagnostics' }, + cwd: directory, + }; + const stderr = 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH'; + const hint = + 'Export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer before running agent-device.'; + const failure = new AppError('COMMAND_FAILED', 'xcrun exited with code 72', { + stderr, + stdout: '', + exitCode: 72, + reason: 'xcrun-tool-not-found', + hint, + }); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'video_validator_unavailable', + retriable: false, + stderr, + stdout: '', + exitCode: 72, + hint, + }, + cause: failure, + }); +}); + test('retains main container-only completion when the Swift compiler is missing', async () => { const mp4 = writeFixture('missing-compiler.mp4', Buffer.concat([ftyp, moov])); const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/missing-compiler' }, cwd: directory }; diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 0275a8ba4a..889ac3bad3 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -186,13 +186,21 @@ async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): P } function unavailableVideoValidator(cause: unknown): AppError { + if (cause instanceof AppError && cause.details?.reason === 'video_validator_unavailable') + return cause; + const details = cause instanceof AppError ? cause.details : undefined; return new AppError( 'COMMAND_FAILED', 'The video validator is unavailable; MP4 playability could not be verified.', { + ...details, reason: 'video_validator_unavailable', retriable: false, - hint: 'Repair the Swift toolchain or validator executable on the daemon host, then retry record stop.', + hint: + details?.hint ?? + (isSwiftVideoValidatorUnavailable(details?.stderr ?? '', details?.stdout ?? '') + ? 'Ensure Swift and AVFoundation are available on the daemon host, then retry record stop.' + : 'Repair the Swift toolchain or validator executable on the daemon host, then retry record stop.'), }, cause, ); diff --git a/src/platform-runtime-screen-recording-host.test.ts b/src/platform-runtime-screen-recording-host.test.ts index adbd0f8eb2..44376838a1 100644 --- a/src/platform-runtime-screen-recording-host.test.ts +++ b/src/platform-runtime-screen-recording-host.test.ts @@ -133,7 +133,10 @@ test.each(['COMMAND_FAILED', 'TOOL_MISSING'])( async (code) => { const { start, input } = await harness(devices[1]!); const failure = new AppError(code, 'xcrun exited with code 72', { - stderr: 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH', + stderr: + code === 'TOOL_MISSING' + ? 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH' + : "video-validator.swift:2:8: error: no such module 'AVFoundation'", }); let rejectCompile!: (error: unknown) => void; const compile = new Promise((_resolve, reject) => { @@ -165,7 +168,12 @@ test.each(['COMMAND_FAILED', 'TOOL_MISSING'])( } else { await expect(handle.finish()).rejects.toMatchObject({ code: 'COMMAND_FAILED', - details: { reason: 'video_validator_unavailable', retriable: false }, + details: { + reason: 'video_validator_unavailable', + retriable: false, + stderr: failure.details?.stderr, + hint: expect.stringContaining('AVFoundation'), + }, cause: failure, }); } From ab5a5e567a767c57e2e665c75fa76afb4070839b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 22:37:37 +0100 Subject: [PATCH 6/7] fix(record): retain validator execution diagnostics --- .../capture-kit/src/recording/video.test.ts | 34 +++++++++++++------ packages/capture-kit/src/recording/video.ts | 8 ++++- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 67af761250..d189bb55d4 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -212,16 +212,30 @@ test('a missing validator executable after compilation returns the typed unavail }); test.each([ - `xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH`, - `validator.swift:2:8: error: no such module 'AVFoundation'`, -])('refuses verification when the validator reports %s', async (stderr) => { - const mp4 = writeFixture('unavailable.mp4', Buffer.concat([ftyp, moov])); - vi.mocked(runCmd).mockResolvedValueOnce({ exitCode: 1, stderr, stdout: '' }); - await expect(isPlayableVideo(mp4, createTestHost())).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - details: { reason: 'video_validator_unavailable', retriable: false }, - }); -}); + { + stderr: `xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH`, + stdout: '', + }, + { stderr: `validator.swift:2:8: error: no such module 'AVFoundation'`, stdout: '' }, + { stderr: '', stdout: `validator.swift:2:8: error: no such module 'AVFoundation'` }, +])( + 'preserves unavailable validator output and selects its recovery hint: %j', + async ({ stderr, stdout }) => { + const mp4 = writeFixture('unavailable.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockResolvedValueOnce({ exitCode: 1, stderr, stdout }); + await expect(isPlayableVideo(mp4, createTestHost())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'video_validator_unavailable', + retriable: false, + stderr, + stdout, + exitCode: 1, + hint: expect.stringContaining('AVFoundation'), + }, + }); + }, +); test('an ordinary validator refusal is unplayable, not unavailable', async () => { const mp4 = writeFixture('refused.mp4', Buffer.concat([ftyp, moov])); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index 889ac3bad3..d8d4349448 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -144,7 +144,13 @@ export async function isPlayableVideo( return true; } if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { - throw unavailableVideoValidator(new AppError('COMMAND_FAILED', result.stderr)); + throw unavailableVideoValidator( + new AppError('COMMAND_FAILED', result.stderr, { + stderr: result.stderr, + stdout: result.stdout, + exitCode: result.exitCode, + }), + ); } return false; } catch (error) { From 9dd99cfb2be5a3712aa95adaaab1b1f845138d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 5 Nov 2026 22:58:30 +0100 Subject: [PATCH 7/7] fix(record): mark validator failures as process exits --- .../capture-kit/src/recording/video.test.ts | 28 +++++++++++++++---- packages/capture-kit/src/recording/video.ts | 8 ++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index d189bb55d4..03928bf0e0 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -3,7 +3,7 @@ import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { likelyPlayableWebmContainer } from '../__tests__/test-utils/video-fixtures.ts'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; @@ -215,15 +215,28 @@ test.each([ { stderr: `xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH`, stdout: '', + recoveryHint: 'DEVELOPER_DIR', + messageExcerpt: 'unable to find utility "swiftc"', + }, + { + stderr: `validator.swift:2:8: error: no such module 'AVFoundation'`, + stdout: '', + recoveryHint: 'AVFoundation', + messageExcerpt: "no such module 'AVFoundation'", + }, + { + stderr: '', + stdout: `validator.swift:2:8: error: no such module 'AVFoundation'`, + recoveryHint: 'AVFoundation', + messageExcerpt: 'MP4 playability could not be verified.', }, - { stderr: `validator.swift:2:8: error: no such module 'AVFoundation'`, stdout: '' }, - { stderr: '', stdout: `validator.swift:2:8: error: no such module 'AVFoundation'` }, ])( 'preserves unavailable validator output and selects its recovery hint: %j', - async ({ stderr, stdout }) => { + async ({ stderr, stdout, recoveryHint, messageExcerpt }) => { const mp4 = writeFixture('unavailable.mp4', Buffer.concat([ftyp, moov])); vi.mocked(runCmd).mockResolvedValueOnce({ exitCode: 1, stderr, stdout }); - await expect(isPlayableVideo(mp4, createTestHost())).rejects.toMatchObject({ + const playable = isPlayableVideo(mp4, createTestHost()); + await expect(playable).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'video_validator_unavailable', @@ -231,9 +244,12 @@ test.each([ stderr, stdout, exitCode: 1, - hint: expect.stringContaining('AVFoundation'), + hint: expect.stringContaining(recoveryHint), }, }); + await expect(playable.catch(normalizeError)).resolves.toMatchObject({ + message: expect.stringContaining(messageExcerpt), + }); }, ); diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index d8d4349448..49f92a6053 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; -import { isCommandTimeoutError, runCmd } from '@agent-device/host-kit/command'; +import { execFailureDetails, isCommandTimeoutError, runCmd } from '@agent-device/host-kit/command'; import { type Deadline, sleep } from '@agent-device/host-kit/retry'; import { buildSwiftToolEnv, compileSwiftSourceText, getSwiftCacheRoot } from './swift-cache.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; @@ -145,11 +145,7 @@ export async function isPlayableVideo( } if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { throw unavailableVideoValidator( - new AppError('COMMAND_FAILED', result.stderr, { - stderr: result.stderr, - stdout: result.stdout, - exitCode: result.exitCode, - }), + new AppError('COMMAND_FAILED', result.stderr, execFailureDetails(result)), ); } return false;