diff --git a/src/commands/__tests__/client-output.test.ts b/src/commands/__tests__/client-output.test.ts new file mode 100644 index 000000000..66c2600d6 --- /dev/null +++ b/src/commands/__tests__/client-output.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; +import { recordCliOutput } from '../client-output.ts'; + +describe('recordCliOutput', () => { + test('prints chunked Android recording paths clearly for human stdout', () => { + const output = recordCliOutput({ + recording: 'stopped', + outPath: '/tmp/recording.mp4', + telemetryPath: '/tmp/recording.gesture-telemetry.json', + warning: + 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.', + overlayWarning: + 'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry', + chunks: [ + { index: 1, path: '/tmp/recording.mp4' }, + { index: 2, path: '/tmp/recording.part-002.mp4' }, + ], + }); + + expect(output.text).toBe( + [ + 'Recording chunks:', + ' 1: /tmp/recording.mp4', + ' 2: /tmp/recording.part-002.mp4', + 'Telemetry: /tmp/recording.gesture-telemetry.json', + 'Warning: Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.', + 'Overlay warning: touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry', + ].join('\n'), + ); + expect(output.data).toMatchObject({ + chunks: [ + { index: 1, path: '/tmp/recording.mp4' }, + { index: 2, path: '/tmp/recording.part-002.mp4' }, + ], + }); + }); +}); diff --git a/src/commands/client-output.ts b/src/commands/client-output.ts index f3ac35c8a..ba4698cc3 100644 --- a/src/commands/client-output.ts +++ b/src/commands/client-output.ts @@ -179,13 +179,53 @@ export function tapCliOutput(result: CommandRequestResult): CliOutput { export function recordCliOutput(result: CommandRequestResult): CliOutput { const data = result as Record; const outPath = typeof data.outPath === 'string' ? data.outPath : ''; - return { data, text: outPath }; + const chunks = readRecordingChunks(data); + if (chunks.length <= 1) { + return { data, text: formatRecordSingleOutput(data, outPath) }; + } + + const lines = ['Recording chunks:']; + for (const chunk of chunks) { + lines.push(` ${chunk.index}: ${chunk.path}`); + } + if (typeof data.telemetryPath === 'string') { + lines.push(`Telemetry: ${data.telemetryPath}`); + } + if (typeof data.warning === 'string') { + lines.push(`Warning: ${data.warning}`); + } + if (typeof data.overlayWarning === 'string') { + lines.push(`Overlay warning: ${data.overlayWarning}`); + } + return { data, text: lines.join('\n') }; } function defaultCommandCliOutput(result: CommandRequestResult): CliOutput { return messageOutput(result as Record); } +function formatRecordSingleOutput(data: Record, outPath: string): string { + const lines: string[] = []; + if (outPath) lines.push(outPath); + if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`); + if (typeof data.overlayWarning === 'string') + lines.push(`Overlay warning: ${data.overlayWarning}`); + return lines.join('\n'); +} + +function readRecordingChunks( + data: Record, +): Array<{ index: number; path: string }> { + const rawChunks = data.chunks; + if (!Array.isArray(rawChunks)) return []; + return rawChunks.flatMap((chunk) => { + if (!chunk || typeof chunk !== 'object') return []; + const candidate = chunk as Record; + if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return []; + return [{ index: candidate.index, path: candidate.path }]; + }); +} + function messageOutput(data: Record): CliOutput { return { data, text: readCommandMessage(data) }; } diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index 5a6c79cb9..ed5e97183 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -1331,6 +1331,164 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process ).toBe(true); }); +test('record stop warns when Android screenrecord hit the 180s platform limit', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-screenrecord-limit'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'device', + booted: true, + }), + ); + + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + if ( + /^-s emulator-5554 shell screenrecord \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( + command, + ) + ) { + return { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if ( + /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) + ) { + return { stdout: '1024\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['start', './android-limit.mp4'], + }); + + const recording = sessionStore.get(sessionName)?.recording; + if (recording) { + recording.startedAt = Date.now() - 181_000; + } + + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + if (command === '-s emulator-5554 shell kill -2 4321') { + return { stdout: '', stderr: 'No such process', exitCode: 1 }; + } + if ( + /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) + ) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['stop'], + }); + + expect(response?.ok).toBe(true); + expect((response as any).data?.warning).toMatch(/180s platform limit/); +}); + +test('record stop returns multiple Android recording chunks', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-screenrecord-chunks'; + const session = makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'device', + booted: true, + }); + session.recording = { + platform: 'android', + outPath: path.resolve('./android-long.mp4'), + startedAt: Date.now() - 172_000, + showTouches: true, + gestureEvents: [{ kind: 'tap', tMs: 120, x: 90, y: 180 }], + remotePath: '/sdcard/agent-device-recording-2.mp4', + remotePid: '4322', + warning: + 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.', + chunks: [ + { + index: 1, + path: path.resolve('./android-long.mp4'), + remotePath: '/sdcard/agent-device-recording-1.mp4', + }, + { + index: 2, + path: path.resolve('./android-long.part-002.mp4'), + remotePath: '/sdcard/agent-device-recording-2.mp4', + }, + ], + }; + sessionStore.set(sessionName, session); + + const adbCommands: string[] = []; + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + adbCommands.push(command); + if (command === '-s emulator-5554 shell ps -o pid= -p 4322') { + return adbCommands.includes('-s emulator-5554 shell kill -2 4322') + ? { stdout: '', stderr: '', exitCode: 1 } + : { stdout: '4322\n', stderr: '', exitCode: 0 }; + } + if (command === '-s emulator-5554 shell kill -2 4322') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + if ( + /^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command) + ) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['stop'], + }); + + expect(response?.ok).toBe(true); + if (response?.ok !== true) { + throw new Error('expected successful Android record stop response'); + } + expect(response.data?.warning).toMatch(/split into multiple MP4 chunks/); + expect(response.data?.overlayWarning).toMatch(/skipped for chunked Android recordings/); + expect(response.data?.chunks).toEqual([ + expect.objectContaining({ index: 1, path: path.resolve('./android-long.mp4') }), + expect.objectContaining({ index: 2, path: path.resolve('./android-long.part-002.mp4') }), + ]); + expect(response.data?.artifacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'outPath', path: path.resolve('./android-long.mp4') }), + expect.objectContaining({ + field: 'chunkPath', + path: path.resolve('./android-long.part-002.mp4'), + }), + ]), + ); + expect(adbCommands).toEqual( + expect.arrayContaining([ + '-s emulator-5554 pull /sdcard/agent-device-recording-1.mp4 ' + + path.resolve('./android-long.mp4'), + '-s emulator-5554 pull /sdcard/agent-device-recording-2.mp4 ' + + path.resolve('./android-long.part-002.mp4'), + ]), + ); +}); + test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-invalidated-recording'; diff --git a/src/daemon/handlers/record-trace-android-chunks.ts b/src/daemon/handlers/record-trace-android-chunks.ts new file mode 100644 index 000000000..72c4134d0 --- /dev/null +++ b/src/daemon/handlers/record-trace-android-chunks.ts @@ -0,0 +1,127 @@ +import path from 'node:path'; +import type { SessionState } from '../types.ts'; +import type { RecordTraceDeps } from './record-trace-types.ts'; +import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; +import { persistRecordingTelemetry } from '../recording-telemetry.ts'; + +const ANDROID_SCREENRECORD_TIME_LIMIT_MS = 180_000; +const ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS = 2_000; +const ANDROID_SCREENRECORD_CHUNK_MS = 170_000; + +type AndroidRecording = Extract, { platform: 'android' }>; + +type AndroidScreenrecordChunk = { + remotePath: string; + remotePid: string; + startedAt: number; +}; + +export function deriveAndroidChunkOutPath(outPath: string, chunkIndex: number): string { + if (chunkIndex === 1) { + return outPath; + } + const parsed = path.parse(outPath); + const extension = parsed.ext || '.mp4'; + return path.join( + parsed.dir, + `${parsed.name}.part-${String(chunkIndex).padStart(3, '0')}${extension}`, + ); +} + +export function ensureAndroidRecordingChunks( + recording: AndroidRecording, +): NonNullable { + recording.chunks ??= [ + { + index: 1, + path: recording.outPath, + remotePath: recording.remotePath, + }, + ]; + return recording.chunks; +} + +export function resolveAndroidScreenrecordLimitWarning( + recording: AndroidRecording, +): string | undefined { + const elapsedMs = Date.now() - recording.startedAt; + if (elapsedMs < ANDROID_SCREENRECORD_TIME_LIMIT_MS - ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS) { + return undefined; + } + return 'Android adb screenrecord stopped before record stop, likely after reaching the 180s platform limit. The MP4 may be truncated; final interactions after the limit are not in the video.'; +} + +export function scheduleAndroidRecordingRotation(params: { + recording: AndroidRecording; + startNextChunk: (preferredRemoteDir: string) => Promise; + finishCurrentChunk: () => Promise; +}): void { + const { recording, startNextChunk, finishCurrentChunk } = params; + const timer = setTimeout(() => { + recording.rotationPromise = rotateAndroidRecordingChunk({ + recording, + startNextChunk, + finishCurrentChunk, + }) + .catch((error: unknown) => { + recording.rotationFailedReason = error instanceof Error ? error.message : String(error); + }) + .finally(() => { + recording.rotationPromise = undefined; + if (!recording.stopping && !recording.rotationFailedReason) { + scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk }); + } + }); + }, ANDROID_SCREENRECORD_CHUNK_MS); + timer.unref?.(); + recording.rotationTimer = timer; +} + +async function rotateAndroidRecordingChunk(params: { + recording: AndroidRecording; + startNextChunk: (preferredRemoteDir: string) => Promise; + finishCurrentChunk: () => Promise; +}): Promise { + const { recording, startNextChunk, finishCurrentChunk } = params; + if (recording.stopping) return; + const stopError = await finishCurrentChunk(); + if (stopError) { + throw new Error(stopError); + } + if (recording.stopping) return; + + const chunks = ensureAndroidRecordingChunks(recording); + const nextIndex = chunks.length + 1; + const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); + recording.remotePath = nextChunk.remotePath; + recording.remotePid = nextChunk.remotePid; + chunks.push({ + index: nextIndex, + path: deriveAndroidChunkOutPath(recording.outPath, nextIndex), + remotePath: nextChunk.remotePath, + }); + recording.warning ??= + 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.'; +} + +export async function finalizeAndroidRecordingOutput(params: { + recording: AndroidRecording; + deps: RecordTraceDeps; +}): Promise { + const { recording, deps } = params; + const chunks = ensureAndroidRecordingChunks(recording); + if (chunks.length <= 1) { + await finalizeRecordingOverlay({ + recording, + deps, + targetLabel: 'Android recording', + }); + return; + } + + persistRecordingTelemetry({ recording }); + if (recording.showTouches && recording.gestureEvents.length > 0) { + recording.overlayWarning ??= + 'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry'; + } +} diff --git a/src/daemon/handlers/record-trace-android-copy.ts b/src/daemon/handlers/record-trace-android-copy.ts new file mode 100644 index 000000000..2ea4e3178 --- /dev/null +++ b/src/daemon/handlers/record-trace-android-copy.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { sleep } from '../../utils/timeouts.ts'; +import { androidDeviceForSerial } from '../../platforms/android/adb.ts'; +import { pullAndroidAdbFile } from '../../platforms/android/adb-executor.ts'; +import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; +import type { SessionState } from '../types.ts'; +import type { RecordTraceDeps } from './record-trace-types.ts'; + +const ANDROID_REMOTE_FILE_POLL_MS = 250; +const ANDROID_REMOTE_FILE_ATTEMPTS = 20; +const ANDROID_LOCAL_VIDEO_ATTEMPTS = 2; +const ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS = 750; + +type AndroidRecording = Extract, { platform: 'android' }>; + +export async function copyAndroidRecordingChunksWithValidation(params: { + deps: RecordTraceDeps; + deviceId: string; + chunks: NonNullable; +}): Promise { + for (const chunk of params.chunks) { + const copyError = await copyAndroidRecordingWithValidation({ + deps: params.deps, + deviceId: params.deviceId, + remotePath: chunk.remotePath, + outPath: chunk.path, + }); + if (copyError) { + return `failed to copy recording chunk ${chunk.index}: ${copyError}`; + } + } + return undefined; +} + +async function copyAndroidRecordingWithValidation(params: { + deps: RecordTraceDeps; + deviceId: string; + remotePath: string; + outPath: string; +}): Promise { + const { deps, deviceId, remotePath, outPath } = params; + let lastCopyError: string | undefined; + + for (let attempt = 0; attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS; attempt += 1) { + try { + fs.rmSync(outPath, { force: true }); + } catch { + // Ignore stale local file cleanup issues and let adb pull report the real failure. + } + + const device = androidDeviceForSerial(deviceId); + const pullResult = await pullAndroidAdbFile(remotePath, outPath, { + allowFailure: true, + device, + }); + if (pullResult.exitCode !== 0) { + lastCopyError = formatRecordTraceExecFailure(pullResult, 'adb pull'); + } else { + await deps.waitForStableFile(outPath, { + pollMs: ANDROID_REMOTE_FILE_POLL_MS, + attempts: ANDROID_REMOTE_FILE_ATTEMPTS, + }); + const playable = await deps.isPlayableVideo(outPath); + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_android_pull_validation', + data: { + deviceId, + remotePath, + outPath, + attempt: attempt + 1, + fileSize: readFileSize(outPath), + playable, + }, + }); + if (playable) { + return undefined; + } + + emitDiagnostic({ + level: 'warn', + phase: 'record_stop_android_invalid_video_retry', + data: { + deviceId, + remotePath, + outPath, + attempt: attempt + 1, + }, + }); + } + + if (attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS - 1) { + await sleep(ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS); + } + } + + if (lastCopyError) { + return `failed to copy recording from device: ${lastCopyError}`; + } + return 'failed to copy recording from device: pulled file is not a playable MP4'; +} + +function readFileSize(filePath: string): number { + try { + return fs.statSync(filePath).size; + } catch { + return 0; + } +} diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index 8e4a95934..138e01ff7 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -1,23 +1,25 @@ -import fs from 'node:fs'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { sleep } from '../../utils/timeouts.ts'; import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; import type { RecordTraceDeps } from './record-trace-types.ts'; -import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; import type { AndroidAdbExecutorOptions, AndroidAdbExecutorResult, } from '../../platforms/android/adb-executor.ts'; -import { pullAndroidAdbFile } from '../../platforms/android/adb-executor.ts'; +import { + ensureAndroidRecordingChunks, + finalizeAndroidRecordingOutput, + resolveAndroidScreenrecordLimitWarning, + scheduleAndroidRecordingRotation, +} from './record-trace-android-chunks.ts'; +import { copyAndroidRecordingChunksWithValidation } from './record-trace-android-copy.ts'; const ANDROID_REMOTE_FILE_POLL_MS = 250; const ANDROID_REMOTE_FILE_ATTEMPTS = 20; const ANDROID_REMOTE_FILE_STABLE_POLLS = 4; -const ANDROID_LOCAL_VIDEO_ATTEMPTS = 2; -const ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS = 750; const ANDROID_PROCESS_EXIT_POLL_MS = 250; const ANDROID_PROCESS_EXIT_ATTEMPTS = 40; const ANDROID_RECORDING_READY_ATTEMPTS = 8; @@ -135,83 +137,14 @@ async function waitForAndroidRecordingReady( return false; } -async function copyAndroidRecordingWithValidation(params: { - deps: RecordTraceDeps; - deviceId: string; - remotePath: string; - outPath: string; -}): Promise { - const { deps, deviceId, remotePath, outPath } = params; - let lastCopyError: string | undefined; - - for (let attempt = 0; attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS; attempt += 1) { - try { - fs.rmSync(outPath, { force: true }); - } catch { - // Ignore stale local file cleanup issues and let adb pull report the real failure. - } - - const device = androidDeviceForSerial(deviceId); - const pullResult = await pullAndroidAdbFile(remotePath, outPath, { - allowFailure: true, - device, - }); - if (pullResult.exitCode !== 0) { - lastCopyError = formatRecordTraceExecFailure(pullResult, 'adb pull'); - } else { - await deps.waitForStableFile(outPath, { - pollMs: ANDROID_REMOTE_FILE_POLL_MS, - attempts: ANDROID_REMOTE_FILE_ATTEMPTS, - }); - const playable = await deps.isPlayableVideo(outPath); - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_pull_validation', - data: { - deviceId, - remotePath, - outPath, - attempt: attempt + 1, - fileSize: (() => { - try { - return fs.statSync(outPath).size; - } catch { - return 0; - } - })(), - playable, - }, - }); - if (playable) { - return undefined; - } - - emitDiagnostic({ - level: 'warn', - phase: 'record_stop_android_invalid_video_retry', - data: { - deviceId, - remotePath, - outPath, - attempt: attempt + 1, - }, - }); - } - - if (attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS - 1) { - await sleep(ANDROID_LOCAL_VIDEO_RETRY_DELAY_MS); - } - } - - if (lastCopyError) { - return `failed to copy recording from device: ${lastCopyError}`; - } - return 'failed to copy recording from device: pulled file is not a playable MP4'; -} - -function androidRemoteRecordingPaths(timestamp: number): string[] { +function androidRemoteRecordingPaths(timestamp: number, preferredDir?: string): string[] { const fileName = `agent-device-recording-${timestamp}.mp4`; - return [`/sdcard/${fileName}`, `/data/local/tmp/${fileName}`]; + const dirs = ['/sdcard', '/data/local/tmp']; + const orderedDirs = + preferredDir && dirs.includes(preferredDir) + ? [preferredDir, ...dirs.filter((dir) => dir !== preferredDir)] + : dirs; + return orderedDirs.map((dir) => `${dir}/${fileName}`); } async function resolveAndroidRecordingSize(params: { @@ -284,24 +217,18 @@ async function forceStopAndroidProcess(deviceId: string, pid: string): Promise { - const { device, recordingBase } = params; + recordingSize: { width: number; height: number } | undefined; + preferredRemoteDir?: string; +}): Promise< + { remotePath: string; remotePid: string; startedAt: number } | { error: DaemonResponse } +> { + const { device, recordingSize, preferredRemoteDir } = params; let lastStartError = 'failed to start recording: Android screenrecord did not begin producing frames'; - let recordingSize: { width: number; height: number } | undefined; - try { - recordingSize = await resolveAndroidRecordingSize({ - deviceId: device.id, - quality: recordingBase.quality, - }); - } catch (error) { - return errorResponse('COMMAND_FAILED', error instanceof Error ? error.message : String(error)); - } - for (const remotePath of androidRemoteRecordingPaths(Date.now())) { + for (const remotePath of androidRemoteRecordingPaths(Date.now(), preferredRemoteDir)) { const startResult = await runAndroidRecordingAdb( device.id, ['shell', buildAndroidScreenrecordCommand(remotePath, recordingSize)], @@ -334,10 +261,8 @@ export async function startAndroidRecording(params: { if (await waitForAndroidRecordingReady(device.id, remotePath, remotePid)) { return { - platform: 'android', remotePath, remotePid, - ...recordingBase, startedAt: Date.now(), }; } @@ -348,24 +273,81 @@ export async function startAndroidRecording(params: { await cleanupAndroidRemoteRecording(device.id, remotePath); } - return errorResponse('COMMAND_FAILED', lastStartError); + return { error: errorResponse('COMMAND_FAILED', lastStartError) }; } -export async function stopAndroidRecording(params: { - deps: RecordTraceDeps; +export async function startAndroidRecording(params: { device: AndroidDevice; - recording: AndroidRecording; -}): Promise { - const { deps, device, recording } = params; - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_enter', - data: { + recordingBase: AndroidRecordingBase; +}): Promise { + const { device, recordingBase } = params; + let recordingSize: { width: number; height: number } | undefined; + try { + recordingSize = await resolveAndroidRecordingSize({ deviceId: device.id, - remotePath: recording.remotePath, - remotePid: recording.remotePid, + quality: recordingBase.quality, + }); + } catch (error) { + return errorResponse('COMMAND_FAILED', error instanceof Error ? error.message : String(error)); + } + + const chunk = await startAndroidScreenrecordChunk({ device, recordingSize }); + if ('error' in chunk) { + return chunk.error; + } + + const recording: AndroidRecording = { + platform: 'android', + remotePath: chunk.remotePath, + remotePid: chunk.remotePid, + chunks: [ + { + index: 1, + path: recordingBase.outPath, + remotePath: chunk.remotePath, + }, + ], + ...recordingBase, + startedAt: chunk.startedAt, + }; + scheduleAndroidRecordingRotation({ + recording, + finishCurrentChunk: async () => + await finishCurrentAndroidRecordingChunk({ + device, + recording, + waitForRemoteFileStability: false, + }), + startNextChunk: async (preferredRemoteDir) => { + const nextChunk = await startAndroidScreenrecordChunk({ + device, + recordingSize, + preferredRemoteDir, + }); + if ('error' in nextChunk) { + throw new Error( + nextChunk.error.ok + ? 'failed to start next Android recording chunk' + : nextChunk.error.error.message, + ); + } + return nextChunk; }, }); + return recording; +} + +async function finishCurrentAndroidRecordingChunk(params: { + device: AndroidDevice; + recording: AndroidRecording; + waitForRemoteFileStability?: boolean; +}): Promise { + const { device, recording, waitForRemoteFileStability = true } = params; + const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, recording.remotePid); + if (!wasRunningBeforeStop) { + recording.warning ??= resolveAndroidScreenrecordLimitWarning(recording); + } + const stopResult = await runAndroidRecordingAdb( device.id, ['shell', 'kill', '-2', recording.remotePid], @@ -385,38 +367,87 @@ export async function stopAndroidRecording(params: { stderr: stopResult.stderr.trim(), }, }); - let stopError: string | undefined; + if (stopResult.exitCode !== 0) { - if (await isAndroidProcessRunning(device.id, recording.remotePid)) { - if (!(await forceStopAndroidProcess(device.id, recording.remotePid))) { - stopError = `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'adb shell kill')}`; - } - } - } else if (!(await waitForAndroidProcessExit(device.id, recording.remotePid))) { - if (!(await forceStopAndroidProcess(device.id, recording.remotePid))) { - stopError = `failed to stop recording: Android screenrecord pid ${recording.remotePid} did not exit`; - } + return await recoverAndroidStopSignalFailure(device.id, recording.remotePid, stopResult); + } + const exitError = await waitForAndroidStopExit(device.id, recording.remotePid); + if (exitError) { + return exitError; + } + + if (waitForRemoteFileStability) { + await waitForAndroidRemoteFileStability(device.id, recording.remotePath); + } + return undefined; +} + +async function recoverAndroidStopSignalFailure( + deviceId: string, + remotePid: string, + stopResult: AndroidAdbExecutorResult, +): Promise { + if (!(await isAndroidProcessRunning(deviceId, remotePid))) { + return undefined; + } + if (await forceStopAndroidProcess(deviceId, remotePid)) { + return undefined; + } + return `failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'adb shell kill')}`; +} + +async function waitForAndroidStopExit( + deviceId: string, + remotePid: string, +): Promise { + if (await waitForAndroidProcessExit(deviceId, remotePid)) { + return undefined; + } + if (await forceStopAndroidProcess(deviceId, remotePid)) { + return undefined; + } + return `failed to stop recording: Android screenrecord pid ${remotePid} did not exit`; +} + +export async function stopAndroidRecording(params: { + deps: RecordTraceDeps; + device: AndroidDevice; + recording: AndroidRecording; +}): Promise { + const { deps, device, recording } = params; + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_android_enter', + data: { + deviceId: device.id, + remotePath: recording.remotePath, + remotePid: recording.remotePid, + }, + }); + recording.stopping = true; + if (recording.rotationTimer) { + clearTimeout(recording.rotationTimer); + recording.rotationTimer = undefined; + } + await recording.rotationPromise; + const stopError = await finishCurrentAndroidRecordingChunk({ device, recording }); + if (recording.rotationFailedReason && !stopError) { + recording.warning ??= `Android recording chunk rotation failed: ${recording.rotationFailedReason}`; } let cleanupError: string | undefined; if (!stopError) { - await waitForAndroidRemoteFileStability(device.id, recording.remotePath); - const copyError = await copyAndroidRecordingWithValidation({ + const copyError = await copyAndroidRecordingChunksWithValidation({ deps, deviceId: device.id, - remotePath: recording.remotePath, - outPath: recording.outPath, + chunks: ensureAndroidRecordingChunks(recording), }); if (copyError) { await cleanupRemoteRecording(); return errorResponse('COMMAND_FAILED', copyError); } - await finalizeRecordingOverlay({ - recording, - deps, - targetLabel: 'Android recording', - }); + await finalizeAndroidRecordingOutput({ recording, deps }); } await cleanupRemoteRecording(); @@ -432,26 +463,28 @@ export async function stopAndroidRecording(params: { return null; async function cleanupRemoteRecording(): Promise { - const rmResult = await runAndroidRecordingAdb( - device.id, - ['shell', 'rm', '-f', recording.remotePath], - { - allowFailure: true, - }, - ); - emitDiagnostic({ - level: 'debug', - phase: 'record_stop_android_cleanup', - data: { - deviceId: device.id, - remotePath: recording.remotePath, - exitCode: rmResult.exitCode, - stdout: rmResult.stdout.trim(), - stderr: rmResult.stderr.trim(), - }, - }); - if (rmResult.exitCode !== 0 && !stopError) { - cleanupError = `failed to clean up remote recording: ${formatRecordTraceExecFailure(rmResult, 'adb shell rm')}`; + for (const chunk of ensureAndroidRecordingChunks(recording)) { + const rmResult = await runAndroidRecordingAdb( + device.id, + ['shell', 'rm', '-f', chunk.remotePath], + { + allowFailure: true, + }, + ); + emitDiagnostic({ + level: 'debug', + phase: 'record_stop_android_cleanup', + data: { + deviceId: device.id, + remotePath: chunk.remotePath, + exitCode: rmResult.exitCode, + stdout: rmResult.stdout.trim(), + stderr: rmResult.stderr.trim(), + }, + }); + if (rmResult.exitCode !== 0 && !stopError) { + cleanupError = `failed to clean up remote recording: ${formatRecordTraceExecFailure(rmResult, 'adb shell rm')}`; + } } } } diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 19b19b98a..8ba9e48ef 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -21,6 +21,7 @@ import { resolveRecordingProvider } from '../recording-provider.ts'; import { finalizeRecordingOverlay } from './record-trace-finalize.ts'; import { errorResponse } from './response.ts'; import { startAndroidRecording, stopAndroidRecording } from './record-trace-android.ts'; +import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; import { getIosRunnerOptions, @@ -436,6 +437,7 @@ async function stopRecording(params: { function buildRecordStopResponse( recording: NonNullable, ): DaemonResponse { + const chunks = recording.platform === 'android' ? recording.chunks : undefined; const artifacts: DaemonArtifact[] = [ { field: 'outPath', @@ -444,6 +446,16 @@ function buildRecordStopResponse( fileName: path.basename(recording.clientOutPath ?? recording.outPath), }, ]; + if (chunks && chunks.length > 1) { + artifacts.push( + ...chunks.slice(1).map((chunk) => ({ + field: 'chunkPath', + path: chunk.path, + localPath: deriveAndroidChunkClientPath(recording, chunk.index), + fileName: path.basename(deriveAndroidChunkClientPath(recording, chunk.index) ?? chunk.path), + })), + ); + } if (recording.telemetryPath) { artifacts.push({ field: 'telemetryPath', @@ -461,11 +473,26 @@ function buildRecordStopResponse( telemetryPath: recording.telemetryPath, artifacts, showTouches: recording.showTouches, + warning: recording.warning, overlayWarning: recording.overlayWarning, + chunks: chunks?.map((chunk) => ({ + index: chunk.index, + path: deriveAndroidChunkClientPath(recording, chunk.index) ?? chunk.path, + })), }, }; } +function deriveAndroidChunkClientPath( + recording: NonNullable, + chunkIndex: number, +): string | undefined { + if (recording.platform !== 'android' || !recording.clientOutPath) { + return undefined; + } + return deriveAndroidChunkOutPath(recording.clientOutPath, chunkIndex); +} + function deriveClientTelemetryPath( recording: NonNullable, ): string | undefined { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index aa5df7f59..b943f6832 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -146,6 +146,7 @@ type SessionRecordingBase = { outPath: string; clientOutPath?: string; telemetryPath?: string; + warning?: string; overlayWarning?: string; startedAt: number; quality?: number; @@ -161,6 +162,12 @@ type SessionRecordingBase = { invalidatedReason?: string; }; +export type RecordingChunk = { + index: number; + path: string; + remotePath: string; +}; + type SessionRecordingProcessChild = Pick; export type SessionState = { @@ -194,6 +201,11 @@ export type SessionState = { platform: 'android'; remotePath: string; remotePid: string; + chunks?: RecordingChunk[]; + rotationTimer?: NodeJS.Timeout; + rotationPromise?: Promise; + rotationFailedReason?: string; + stopping?: boolean; }) | (SessionRecordingBase & { platform: 'ios-device-runner'; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index d9f4933b1..ee1acf633 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1440,6 +1440,7 @@ test('command usage shows record touch-overlay opt-out flag', () => { assert.match(help, /--quality <5-10>/); assert.match(help, /--hide-touches/); assert.match(help, /skip touch-overlay post-processing/); + assert.match(help, /multiple MP4 chunks/); }); test('command usage keeps detailed descriptions', () => { diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index b9a2478b9..78b26b25b 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -279,7 +279,8 @@ const CLI_COMMAND_OVERRIDES = { usageOverride: 'record start [path] [--fps ] [--quality <5-10>] [--hide-touches] | record stop', listUsageOverride: 'record start [path] | record stop', - helpDescription: 'Start/stop screen recording', + helpDescription: + 'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks', summary: 'Start or stop screen recording', positionalArgs: ['start|stop', 'path?'], allowedFlags: ['fps', 'quality', 'hideTouches'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index d08e84eaf..f1c78acf3 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -202,7 +202,7 @@ Validation and evidence: If task says snapshot, use snapshot. If it asks visual evidence, use screenshot. Icon/tappable visual proof: screenshot --overlay-refs. Flag is --overlay-refs. Startup/frame health/CPU/memory: perf --json or metrics. Replay maintenance: replay -u ./flow.ad. - Recording: record start/stop. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. + Recording: record start/stop. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. Stable known flow: batch ./steps.json, not workflow batch. Inline batch JSON example: agent-device batch --steps '[{"command":"open","input":{"app":"settings"}},{"command":"wait","input":{"kind":"duration","durationMs":100}}]'