diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index b77386672..c148300cd 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -282,7 +282,7 @@ Validation and evidence: agent-device press 124 817 agent-device snapshot -i Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out ; Android native traces: perf trace start|stop --kind perfetto --out . Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay maintenance: replay -u ./flow.ad. - Recording: record start/stop. Use --max-size to cap the longest edge and --quality medium|high to choose output quality across Android and Apple targets. 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. + Recording: record start/stop. Use --max-size to cap the longest edge and --quality medium|high to choose output quality across Android and Apple targets. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android record start publishes a durable device manifest. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks while the daemon stays alive; after daemon restart, record stop recovers only manifest-owned chunks and warns when gesture overlays are unavailable. 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}}]' diff --git a/src/commands/recording/index.ts b/src/commands/recording/index.ts index e9590aef9..2d4975740 100644 --- a/src/commands/recording/index.ts +++ b/src/commands/recording/index.ts @@ -60,7 +60,7 @@ const recordCliSchema = { 'record start [path] [--fps ] [--max-size ] [--quality ] [--hide-touches] | record stop', listUsageOverride: 'record start [path] | record stop', helpDescription: - 'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks. Use --max-size to limit dimensions and --quality to choose medium or high export quality', + 'Start/stop screen recording; 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. Use --max-size to limit dimensions and --quality to choose medium or high export quality', summary: 'Start or stop screen recording', positionalArgs: ['start|stop', 'path?'], allowedFlags: ['fps', 'screenshotMaxSize', 'quality', 'hideTouches'], diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index 638eb6129..43a038f94 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -185,6 +185,12 @@ function makeIosSimulatorRecordingSession( return session; } +function isAndroidScreenrecordStartCommand(command: string): boolean { + return /^-s emulator-5554 shell screenrecord (?:--size 756x1344 )?--bit-rate (?:8000000|20000000) \/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test( + command, + ); +} + async function runRecordCommand(params: { sessionStore: SessionStore; sessionName: string; @@ -2064,6 +2070,161 @@ test('record stop returns multiple Android recording chunks', async () => { ); }); +test('Android recording rotation retries sequentially when concurrent screenrecord start fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const sessionStore = makeSessionStore(); + const sessionName = 'android-screenrecord-sequential-rotation'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'device', + booted: true, + }), + ); + + const adbCommands: string[] = []; + let startAttempt = 0; + let firstFailedStartIndex = -1; + let oldPidStopped = false; + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + adbCommands.push(command); + if (isAndroidScreenrecordStartCommand(command)) { + startAttempt += 1; + if (startAttempt === 1) return { stdout: '4321\n', stderr: '', exitCode: 0 }; + if (startAttempt <= 3) { + if (firstFailedStartIndex === -1) firstFailedStartIndex = adbCommands.length - 1; + return { stdout: '', stderr: 'encoder busy', exitCode: 1 }; + } + return { stdout: '4322\n', stderr: '', exitCode: 0 }; + } + if ( + /^-s emulator-5554 shell stat -c %s \/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4$/.test( + command, + ) + ) { + return { stdout: '2048\n', stderr: '', exitCode: 0 }; + } + if (command === '-s emulator-5554 shell ps -o pid= -p 4321') { + return oldPidStopped + ? { stdout: '', stderr: '', exitCode: 1 } + : { stdout: '4321\n', stderr: '', exitCode: 0 }; + } + if (command === '-s emulator-5554 shell kill -2 4321') { + oldPidStopped = true; + return { stdout: '', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['start', './android-sequential-rotation.mp4'], + }); + expect(response?.ok).toBe(true); + + await vi.advanceTimersByTimeAsync(170_000); + + const recording = sessionStore.get(sessionName)?.recording; + expect(recording?.platform).toBe('android'); + if (recording?.platform !== 'android') { + throw new Error('expected Android recording'); + } + expect(recording.remotePid).toBe('4322'); + expect(recording.chunks).toHaveLength(2); + const stopOldChunk = adbCommands.findIndex( + (command) => command === '-s emulator-5554 shell kill -2 4321', + ); + const sequentialStart = adbCommands + .slice(stopOldChunk + 1) + .findIndex((command) => isAndroidScreenrecordStartCommand(command)); + expect(firstFailedStartIndex).toBeGreaterThan(-1); + expect(stopOldChunk).toBeGreaterThan(firstFailedStartIndex); + expect(sequentialStart).toBeGreaterThan(-1); +}); + +test('Android recording rotation discards next chunk when manifest commit fails', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const sessionStore = makeSessionStore(); + const sessionName = 'android-screenrecord-rotation-manifest-failure'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Android', + kind: 'device', + booted: true, + }), + ); + + const adbCommands: string[] = []; + let startAttempt = 0; + let manifestWriteCount = 0; + let nextPidStopped = false; + mockRunCmd.mockImplementation(async (_cmd, args) => { + const command = args.join(' '); + adbCommands.push(command); + if (command.includes('agent-device-recording-active.json.tmp')) { + manifestWriteCount += 1; + return manifestWriteCount === 4 + ? { stdout: '', stderr: 'manifest write failed', exitCode: 1 } + : { stdout: '', stderr: '', exitCode: 0 }; + } + if (isAndroidScreenrecordStartCommand(command)) { + startAttempt += 1; + return { stdout: `${4320 + startAttempt}\n`, 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 }; + } + if (command === '-s emulator-5554 shell ps -o pid= -p 4322') { + return nextPidStopped + ? { stdout: '', stderr: '', exitCode: 1 } + : { stdout: '4322\n', stderr: '', exitCode: 0 }; + } + if (command === '-s emulator-5554 shell kill -2 4322') { + nextPidStopped = true; + return { stdout: '', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const response = await runRecordCommand({ + sessionStore, + sessionName, + positionals: ['start', './android-rotation-manifest-failure.mp4'], + }); + expect(response?.ok).toBe(true); + + await vi.advanceTimersByTimeAsync(170_000); + + const recording = sessionStore.get(sessionName)?.recording; + expect(recording?.platform).toBe('android'); + if (recording?.platform !== 'android') { + throw new Error('expected Android recording'); + } + expect(recording.remotePid).toBe('4321'); + expect(recording.chunks).toHaveLength(1); + expect(recording.rotationFailedReason).toMatch( + /failed to write Android recording recovery manifest/, + ); + expect(adbCommands).toContain('-s emulator-5554 shell kill -2 4322'); + expect( + adbCommands.some((command) => + /^-s emulator-5554 shell rm -f \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command), + ), + ).toBe(true); +}); + 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 index 72c4134d0..05d269640 100644 --- a/src/daemon/handlers/record-trace-android-chunks.ts +++ b/src/daemon/handlers/record-trace-android-chunks.ts @@ -53,15 +53,28 @@ export function resolveAndroidScreenrecordLimitWarning( export function scheduleAndroidRecordingRotation(params: { recording: AndroidRecording; - startNextChunk: (preferredRemoteDir: string) => Promise; - finishCurrentChunk: () => Promise; + startNextChunk: ( + preferredRemoteDir: string, + nextIndex: number, + ) => Promise; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; + cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; + persistRecordingState?: (recording: AndroidRecording) => Promise; }): void { - const { recording, startNextChunk, finishCurrentChunk } = params; + const { + recording, + startNextChunk, + finishCurrentChunk, + cleanupStartedChunk, + persistRecordingState, + } = params; const timer = setTimeout(() => { recording.rotationPromise = rotateAndroidRecordingChunk({ recording, startNextChunk, finishCurrentChunk, + cleanupStartedChunk, + persistRecordingState, }) .catch((error: unknown) => { recording.rotationFailedReason = error instanceof Error ? error.message : String(error); @@ -69,7 +82,13 @@ export function scheduleAndroidRecordingRotation(params: { .finally(() => { recording.rotationPromise = undefined; if (!recording.stopping && !recording.rotationFailedReason) { - scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk }); + scheduleAndroidRecordingRotation({ + recording, + startNextChunk, + finishCurrentChunk, + cleanupStartedChunk, + persistRecordingState, + }); } }); }, ANDROID_SCREENRECORD_CHUNK_MS); @@ -79,22 +98,129 @@ export function scheduleAndroidRecordingRotation(params: { async function rotateAndroidRecordingChunk(params: { recording: AndroidRecording; - startNextChunk: (preferredRemoteDir: string) => Promise; - finishCurrentChunk: () => Promise; + startNextChunk: ( + preferredRemoteDir: string, + nextIndex: number, + ) => Promise; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; + cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; + persistRecordingState?: (recording: AndroidRecording) => Promise; }): Promise { - const { recording, startNextChunk, finishCurrentChunk } = params; - if (recording.stopping) return; - const stopError = await finishCurrentChunk(); - if (stopError) { - throw new Error(stopError); - } + const { + recording, + startNextChunk, + finishCurrentChunk, + cleanupStartedChunk, + persistRecordingState, + } = params; if (recording.stopping) return; const chunks = ensureAndroidRecordingChunks(recording); const nextIndex = chunks.length + 1; - const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); + const previousChunk = { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + startedAt: recording.remoteStartedAt ?? recording.startedAt, + }; + const started = await startNextAndroidRecordingChunkWithFallback({ + recording, + nextIndex, + previousChunk, + startNextChunk, + finishCurrentChunk, + }); + if (!started) return; + const { nextChunk, previousChunkFinished } = started; + const previousState = applyNextAndroidRecordingChunk({ + recording, + nextChunk, + }); + await commitNextAndroidRecordingChunk({ + recording, + chunks, + nextChunk, + nextIndex, + previousState, + finishCurrentChunk, + cleanupStartedChunk, + persistRecordingState, + }); + if (previousChunkFinished) { + return; + } + await finishAndroidRecordingChunkOrThrow(finishCurrentChunk, previousChunk); +} + +async function startNextAndroidRecordingChunkWithFallback(params: { + recording: AndroidRecording; + nextIndex: number; + previousChunk: AndroidScreenrecordChunk; + startNextChunk: ( + preferredRemoteDir: string, + nextIndex: number, + ) => Promise; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; +}): Promise<{ nextChunk: AndroidScreenrecordChunk; previousChunkFinished: boolean } | undefined> { + const { recording, nextIndex, previousChunk, startNextChunk, finishCurrentChunk } = params; + const preferredRemoteDir = path.posix.dirname(recording.remotePath); + try { + return { + nextChunk: await startNextChunk(preferredRemoteDir, nextIndex), + previousChunkFinished: false, + }; + } catch (concurrentStartError) { + const stopError = await finishCurrentChunk(previousChunk); + if (stopError) { + throw new Error(stopError); + } + if (recording.stopping) return undefined; + try { + return { + nextChunk: await startNextChunk(preferredRemoteDir, nextIndex), + previousChunkFinished: true, + }; + } catch (sequentialStartError) { + throw sequentialStartError instanceof Error ? sequentialStartError : concurrentStartError; + } + } +} + +function applyNextAndroidRecordingChunk(params: { + recording: AndroidRecording; + nextChunk: AndroidScreenrecordChunk; +}): Pick { + const { recording, nextChunk } = params; + const previousState = { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + remoteStartedAt: recording.remoteStartedAt, + }; recording.remotePath = nextChunk.remotePath; recording.remotePid = nextChunk.remotePid; + recording.remoteStartedAt = nextChunk.startedAt; + return previousState; +} + +async function commitNextAndroidRecordingChunk(params: { + recording: AndroidRecording; + chunks: NonNullable; + nextChunk: AndroidScreenrecordChunk; + nextIndex: number; + previousState: Pick; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; + cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; + persistRecordingState?: (recording: AndroidRecording) => Promise; +}): Promise { + const { + recording, + chunks, + nextChunk, + nextIndex, + previousState, + finishCurrentChunk, + cleanupStartedChunk, + persistRecordingState, + } = params; chunks.push({ index: nextIndex, path: deriveAndroidChunkOutPath(recording.outPath, nextIndex), @@ -102,6 +228,60 @@ async function rotateAndroidRecordingChunk(params: { }); recording.warning ??= 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.'; + try { + await persistRecordingState?.(recording); + } catch (error) { + rollbackNextAndroidRecordingChunk({ recording, chunks, previousState }); + const cleanupError = await discardNextAndroidRecordingChunk({ + nextChunk, + finishCurrentChunk, + cleanupStartedChunk, + }); + if (cleanupError) throw cleanupError; + throw error; + } +} + +function rollbackNextAndroidRecordingChunk(params: { + recording: AndroidRecording; + chunks: NonNullable; + previousState: Pick; +}): void { + const { recording, chunks, previousState } = params; + chunks.pop(); + recording.remotePath = previousState.remotePath; + recording.remotePid = previousState.remotePid; + recording.remoteStartedAt = previousState.remoteStartedAt; +} + +async function finishAndroidRecordingChunkOrThrow( + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise, + chunk: AndroidScreenrecordChunk, +): Promise { + const stopError = await finishCurrentChunk(chunk); + if (stopError) { + throw new Error(stopError); + } +} + +async function discardNextAndroidRecordingChunk(params: { + nextChunk: AndroidScreenrecordChunk; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; + cleanupStartedChunk?: (chunk: AndroidScreenrecordChunk) => Promise; +}): Promise { + const { nextChunk, finishCurrentChunk, cleanupStartedChunk } = params; + let discardError: unknown; + try { + await finishAndroidRecordingChunkOrThrow(finishCurrentChunk, nextChunk); + } catch (error) { + discardError = error; + } + try { + await cleanupStartedChunk?.(nextChunk); + } catch (error) { + discardError ??= error; + } + return discardError; } export async function finalizeAndroidRecordingOutput(params: { diff --git a/src/daemon/handlers/record-trace-android-recovery-manifest.ts b/src/daemon/handlers/record-trace-android-recovery-manifest.ts new file mode 100644 index 000000000..6372f2331 --- /dev/null +++ b/src/daemon/handlers/record-trace-android-recovery-manifest.ts @@ -0,0 +1,310 @@ +import path from 'node:path'; +import type { RecordingChunk, SessionState } from '../types.ts'; + +const ANDROID_RECOVERY_METADATA_FILE = 'agent-device-recording-active.json'; +const ANDROID_RECOVERY_METADATA_DIRS = ['/sdcard', '/data/local/tmp'] as const; +const ANDROID_RECOVERY_MANIFEST_VERSION = 1; + +type AndroidRecording = Extract, { platform: 'android' }>; + +export type AndroidRecordingRecoveryMetadata = { + remotePath: string; + remotePid: string; + startedAt: number; +}; + +type AndroidRecordingRecoveryPending = { + remotePath: string; +}; + +export type AndroidRecordingRecoveryManifest = { + version: 1; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + deviceId: string; + startedAt: number; + showTouches: boolean; + current?: AndroidRecordingRecoveryMetadata; + pending?: AndroidRecordingRecoveryPending; + chunks: AndroidRecordingRecoveryChunk[]; +}; + +export type AndroidRecordingRecoveryChunk = Pick; + +type AndroidRecordingRecoveryManifestRequired = Pick< + AndroidRecordingRecoveryManifest, + 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'showTouches' +>; + +export function parseRecoverableAndroidScreenrecord( + line: string, +): AndroidRecordingRecoveryMetadata | undefined { + const match = line + .trim() + .match( + /^(\d+)\s+.*\bscreenrecord\b.*(\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-(\d+)\.mp4)(?:\s|$)/, + ); + if (!match) { + return undefined; + } + const [, remotePid, remotePath, timestamp] = match; + if (!remotePid || !remotePath) { + return undefined; + } + const startedAt = Number(timestamp); + return { + remotePid, + remotePath, + startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(), + }; +} + +export function parseAndroidRecoveryManifest( + value: string, +): + | { kind: 'manifest'; manifest: AndroidRecordingRecoveryManifest } + | { kind: 'delete' } + | { kind: 'blocked'; reason: string } { + const metadata = parseJsonObject(value); + if (!metadata) return { kind: 'delete' }; + const required = readAndroidRecoveryManifestRequired(metadata); + if (!required) return { kind: 'blocked', reason: 'unsupported_or_malformed_manifest' }; + const parsedCurrent = parseAndroidRecoveryMetadata(metadata.current); + const parsedPending = parseAndroidRecoveryPending(metadata.pending); + const chunks = parseAndroidRecordingChunks(metadata.chunks); + if (!chunks) return { kind: 'blocked', reason: 'invalid_recording_chunks' }; + if (!parsedCurrent && !parsedPending) { + return { kind: 'blocked', reason: 'invalid_recording_state' }; + } + return { + kind: 'manifest', + manifest: { + ...required, + sessionScope: parseSessionScope(metadata.sessionScope), + current: parsedCurrent, + pending: parsedPending, + chunks, + }, + }; +} + +export function androidRecoveryMetadataPathForRemotePath(remotePath: string): string { + return `${path.posix.dirname(remotePath)}/${ANDROID_RECOVERY_METADATA_FILE}`; +} + +export function androidRecoveryMetadataPaths(): string[] { + return ANDROID_RECOVERY_METADATA_DIRS.map((dir) => `${dir}/${ANDROID_RECOVERY_METADATA_FILE}`); +} + +export function buildAndroidRecoveryPendingManifest(params: { + deviceId: string; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + startedAt: number; + showTouches: boolean; + remotePath: string; +}): AndroidRecordingRecoveryManifest { + const { deviceId, sessionName, sessionScope, recordingId, startedAt, showTouches, remotePath } = + params; + return { + version: ANDROID_RECOVERY_MANIFEST_VERSION, + sessionName, + sessionScope, + recordingId, + deviceId, + startedAt, + showTouches, + pending: { remotePath }, + chunks: [{ index: 1, remotePath }], + }; +} + +export function buildAndroidRecoveryManifest(params: { + deviceId: string; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recording: AndroidRecording; +}): AndroidRecordingRecoveryManifest { + const { deviceId, sessionName, sessionScope, recording } = params; + return { + version: ANDROID_RECOVERY_MANIFEST_VERSION, + sessionName, + sessionScope, + recordingId: + recording.recordingId ?? + `android-${recording.remotePid}-${recording.remoteStartedAt ?? recording.startedAt}`, + deviceId, + startedAt: recording.startedAt, + showTouches: recording.showTouches, + current: { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + startedAt: recording.remoteStartedAt ?? recording.startedAt, + }, + chunks: toManifestChunks(recording), + }; +} + +export function buildAndroidRecoveryRotatingManifest(params: { + deviceId: string; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recording: AndroidRecording; + nextRemotePath: string; + nextIndex: number; +}): AndroidRecordingRecoveryManifest { + const { deviceId, sessionName, sessionScope, recording, nextRemotePath, nextIndex } = params; + return { + ...buildAndroidRecoveryManifest({ deviceId, sessionName, sessionScope, recording }), + pending: { remotePath: nextRemotePath }, + chunks: toManifestChunks(recording, { index: nextIndex, remotePath: nextRemotePath }), + }; +} + +function toManifestChunks( + recording: AndroidRecording, + extraChunk?: AndroidRecordingRecoveryChunk, +): AndroidRecordingRecoveryChunk[] { + return [ + ...(recording.chunks ?? [{ index: 1, remotePath: recording.remotePath }]), + ...(extraChunk ? [extraChunk] : []), + ].map((chunk) => ({ + index: chunk.index, + remotePath: chunk.remotePath, + })); +} + +function parseJsonObject(value: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +function readAndroidRecoveryManifestRequired( + metadata: Record, +): AndroidRecordingRecoveryManifestRequired | undefined { + if (metadata.version !== ANDROID_RECOVERY_MANIFEST_VERSION) return undefined; + const strings = readAndroidRecoveryManifestStrings(metadata); + if (!strings) return undefined; + const startedAt = readOptionalNumber(metadata.startedAt); + if (startedAt === undefined) return undefined; + const showTouches = readOptionalBoolean(metadata.showTouches); + if (showTouches === undefined) return undefined; + return { + version: ANDROID_RECOVERY_MANIFEST_VERSION, + ...strings, + startedAt, + showTouches, + }; +} + +function readAndroidRecoveryManifestStrings( + metadata: Record, +): + | Pick + | undefined { + const sessionName = readOptionalString(metadata.sessionName); + const recordingId = readOptionalString(metadata.recordingId); + const deviceId = readOptionalString(metadata.deviceId); + if (!sessionName || !recordingId || !deviceId) return undefined; + return { sessionName, recordingId, deviceId }; +} + +function parseAndroidRecoveryMetadata( + value: unknown, +): AndroidRecordingRecoveryMetadata | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const metadata = value as Partial; + if ( + typeof metadata.remotePid !== 'string' || + !/^\d+$/.test(metadata.remotePid) || + typeof metadata.remotePath !== 'string' || + !isAndroidAgentRecordingPath(metadata.remotePath) + ) { + return undefined; + } + return { + remotePid: metadata.remotePid, + remotePath: metadata.remotePath, + startedAt: + typeof metadata.startedAt === 'number' && Number.isFinite(metadata.startedAt) + ? metadata.startedAt + : Date.now(), + }; +} + +function parseAndroidRecoveryPending(value: unknown): AndroidRecordingRecoveryPending | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const metadata = value as Partial; + if ( + typeof metadata.remotePath !== 'string' || + !isAndroidAgentRecordingPath(metadata.remotePath) + ) { + return undefined; + } + return { remotePath: metadata.remotePath }; +} + +function parseAndroidRecordingChunks(value: unknown): AndroidRecordingRecoveryChunk[] | undefined { + if (!Array.isArray(value)) return undefined; + const chunks = value + .map(parseAndroidRecordingChunk) + .filter((chunk): chunk is AndroidRecordingRecoveryChunk => chunk !== undefined); + return chunks.length > 0 && chunks.length === value.length ? chunks : undefined; +} + +function parseAndroidRecordingChunk(value: unknown): AndroidRecordingRecoveryChunk | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const chunk = value as Partial; + if ( + typeof chunk.index !== 'number' || + !Number.isInteger(chunk.index) || + chunk.index < 1 || + typeof chunk.remotePath !== 'string' || + !isAndroidAgentRecordingPath(chunk.remotePath) + ) { + return undefined; + } + return { + index: chunk.index, + remotePath: chunk.remotePath, + }; +} + +function parseSessionScope(value: unknown): SessionState['sessionScope'] | undefined { + if (!value || typeof value !== 'object') return undefined; + const scope = value as Partial>; + if (scope.kind !== 'cwd' || typeof scope.id !== 'string') return undefined; + return { kind: 'cwd', id: scope.id }; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function isAndroidAgentRecordingPath(remotePath: string): boolean { + return /^\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4$/.test(remotePath); +} diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index 101181423..bcbdb01af 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; import type { AndroidAdbExecutorOptions, @@ -7,13 +6,32 @@ import type { import { shellQuote } from '../../utils/shell-quote.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; +import { formatRecordTraceExecFailure } from '../record-trace-errors.ts'; import { errorResponse } from './response.ts'; +import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; +import { + androidRecoveryMetadataPathForRemotePath, + androidRecoveryMetadataPaths, + buildAndroidRecoveryManifest, + buildAndroidRecoveryPendingManifest, + buildAndroidRecoveryRotatingManifest, + parseAndroidRecoveryManifest, + parseRecoverableAndroidScreenrecord, + type AndroidRecordingRecoveryChunk, + type AndroidRecordingRecoveryManifest, + type AndroidRecordingRecoveryMetadata, +} from './record-trace-android-recovery-manifest.ts'; const ANDROID_RECOVERY_WARNING = - 'Recovered Android recording after daemon recording state was missing; gesture overlays and earlier rotated chunks may be unavailable.'; -const ANDROID_RECOVERY_METADATA_FILE = 'agent-device-recording-active.json'; + 'Recovered Android recording after daemon restart from durable device manifest.'; +const ANDROID_RECOVERY_OVERLAY_WARNING = + 'touch overlay burn-in is unavailable after daemon restart because gesture telemetry is stored in daemon memory'; +const ANDROID_RECOVERY_FINISHED_WARNING = + 'Recovered Android recording after daemon restart from durable device manifest; the screenrecord process was no longer running, so the MP4 may be truncated.'; +const ANDROID_RECOVERY_ROTATION_WARNING = + 'Recovered Android recording from an interrupted chunk rotation; returning chunks known to be safely owned by the durable manifest.'; +const ANDROID_RECOVERY_MANIFEST_STAT_SIZE_BYTES = 1; const ANDROID_RECOVERY_PROBE_TIMEOUT_MS = 5_000; -const ANDROID_RECOVERY_METADATA_DIRS = ['/sdcard', '/data/local/tmp'] as const; type AndroidDevice = SessionState['device']; type AndroidRecording = Extract, { platform: 'android' }>; @@ -29,12 +47,48 @@ type AndroidRecordingBase = Pick< | 'gestureEvents' >; -type AndroidRecordingRecoveryMetadata = { - remotePath: string; - remotePid: string; - startedAt: number; +type AndroidRecordingRecoveryCandidate = Omit< + AndroidRecordingRecoveryManifest, + 'current' | 'chunks' +> & { + current: AndroidRecordingRecoveryMetadata; + chunks: AndroidRecordingRecoveryChunk[]; + recoveryWarning?: string; +}; +type AndroidRecoveryResolution = + | { kind: 'live'; manifest: AndroidRecordingRecoveryCandidate } + | { kind: 'stale' } + | { kind: 'uncertain' }; +type AndroidScreenrecordProbe = AndroidRecordingRecoveryMetadata | 'uncertain' | undefined; + +type AndroidRecoveryManifestScan = { + live: AndroidRecordingRecoveryCandidate[]; + uncertain: AndroidRecordingRecoveryManifest[]; + blocked: AndroidRecoveryBlockedManifest[]; +}; + +type AndroidRecoveryBlockedManifest = { + metadataPath: string; + reason: string; }; +type AndroidActiveRecordingSummary = { + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + remotePid?: string; + remotePath?: string; +}; + +type AndroidOwnedManifestSelection = + | { + kind: 'selected'; + manifest: T; + activeRecordings: AndroidActiveRecordingSummary[]; + } + | { kind: 'owner-mismatch'; activeRecordings: AndroidActiveRecordingSummary[] } + | { kind: 'ambiguous'; activeRecordings: AndroidActiveRecordingSummary[] }; + async function runAndroidRecoveryAdb( deviceId: string, args: string[], @@ -43,86 +97,8 @@ async function runAndroidRecoveryAdb( return await runAndroidAdb(androidDeviceForSerial(deviceId), args, options); } -function parseRecoverableAndroidScreenrecord( - line: string, -): AndroidRecordingRecoveryMetadata | undefined { - const match = line - .trim() - .match( - /^(\d+)\s+.*\bscreenrecord\b.*(\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-(\d+)\.mp4)(?:\s|$)/, - ); - if (!match) { - return undefined; - } - const [, remotePid, remotePath, timestamp] = match; - if (!remotePid || !remotePath) { - return undefined; - } - const startedAt = Number(timestamp); - return { - remotePid, - remotePath, - startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(), - }; -} - -function parseAndroidRecoveryMetadata(value: string): AndroidRecordingRecoveryMetadata | undefined { - const metadata = parseAndroidRecoveryMetadataObject(value); - if (!metadata) { - return undefined; - } - const remotePid = parseAndroidRecoveryRemotePid(metadata.remotePid); - const remotePath = parseAndroidRecoveryRemotePath(metadata.remotePath); - if (!remotePid || !remotePath) { - return undefined; - } - return { - remotePid, - remotePath, - startedAt: parseAndroidRecoveryStartedAt(metadata.startedAt), - }; -} - -function parseAndroidRecoveryMetadataObject(value: string): Record | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - return undefined; - } - if (!parsed || typeof parsed !== 'object') { - return undefined; - } - return parsed as Record; -} - -function parseAndroidRecoveryRemotePid(value: unknown): string | undefined { - return typeof value === 'string' && /^\d+$/.test(value) ? value : undefined; -} - -function parseAndroidRecoveryRemotePath(value: unknown): string | undefined { - return typeof value === 'string' && isAndroidAgentRecordingPath(value) ? value : undefined; -} - -function parseAndroidRecoveryStartedAt(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : Date.now(); -} - -function isAndroidAgentRecordingPath(remotePath: string): boolean { - return /^\/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4$/.test(remotePath); -} - -function androidRecoveryMetadataPathForRemotePath(remotePath: string): string { - return `${path.posix.dirname(remotePath)}/${ANDROID_RECOVERY_METADATA_FILE}`; -} - -function androidRecoveryMetadataPaths(): string[] { - return ANDROID_RECOVERY_METADATA_DIRS.map((dir) => `${dir}/${ANDROID_RECOVERY_METADATA_FILE}`); -} - -async function readAndroidRecoveryMetadata( - deviceId: string, -): Promise { +async function readAndroidRecoveryMetadata(deviceId: string): Promise { + const scan: AndroidRecoveryManifestScan = { live: [], uncertain: [], blocked: [] }; for (const metadataPath of androidRecoveryMetadataPaths()) { const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'cat', metadataPath], { allowFailure: true, @@ -131,8 +107,8 @@ async function readAndroidRecoveryMetadata( if (result.exitCode !== 0) { continue; } - const metadata = parseAndroidRecoveryMetadata(result.stdout); - if (!metadata) { + const parsed = parseAndroidRecoveryManifest(result.stdout); + if (parsed.kind === 'delete') { await cleanupAndroidRecoveryMetadataPath({ deviceId, metadataPath, @@ -140,11 +116,22 @@ async function readAndroidRecoveryMetadata( }); continue; } - const liveness = await checkLiveRecoverableAndroidScreenrecord(deviceId, metadata); - if (liveness === 'live') { - return metadata; + if (parsed.kind === 'blocked') { + scan.blocked.push({ metadataPath, reason: parsed.reason }); + continue; + } + const metadata = parsed.manifest; + if (metadata.deviceId !== deviceId) { + scan.blocked.push({ metadataPath, reason: 'device_mismatch' }); + continue; + } + const recovery = await resolveAndroidRecoveryCandidate(deviceId, metadata); + if (recovery.kind === 'live') { + scan.live.push(recovery.manifest); + continue; } - if (liveness === 'uncertain') { + if (recovery.kind === 'uncertain') { + scan.uncertain.push(metadata); continue; } await cleanupAndroidRecoveryMetadataPath({ @@ -153,13 +140,110 @@ async function readAndroidRecoveryMetadata( phase: 'record_stop_android_recovery_metadata_stale_cleanup_failed', }); } - return undefined; + return scan; } -async function checkLiveRecoverableAndroidScreenrecord( +async function resolveAndroidRecoveryCandidate( + deviceId: string, + manifest: AndroidRecordingRecoveryManifest, +): Promise { + if (manifest.pending) { + return await resolvePendingAndroidRecoveryCandidate(deviceId, manifest, manifest.pending); + } + return await resolveCurrentAndroidRecoveryCandidate(deviceId, manifest); +} + +async function resolvePendingAndroidRecoveryCandidate( + deviceId: string, + manifest: AndroidRecordingRecoveryManifest, + pendingMetadata: { remotePath: string }, +): Promise { + const pending = await findLiveAndroidScreenrecordByPath(deviceId, pendingMetadata.remotePath); + const adoptedPending = resolveLivePendingScreenrecord(manifest, pending); + if (adoptedPending) return adoptedPending; + if (!manifest.current) return pendingOnlyResolution(pending); + return await resolveInterruptedRotationCurrent(deviceId, manifest, manifest.current, pending); +} + +async function resolveInterruptedRotationCurrent( + deviceId: string, + manifest: AndroidRecordingRecoveryManifest, + current: AndroidRecordingRecoveryMetadata, + pending: AndroidScreenrecordProbe, +): Promise { + const liveness = await checkRecoverableAndroidScreenrecord(deviceId, current); + if (liveness === 'uncertain' || pending === 'uncertain') return { kind: 'uncertain' }; + if (liveness === 'stale') return { kind: 'stale' }; + return liveAndroidRecoveryCandidate({ + manifest, + current, + chunks: chunksThroughRemotePath(manifest.chunks, current.remotePath), + recoveryWarning: + liveness === 'finished' + ? `${ANDROID_RECOVERY_ROTATION_WARNING} ${ANDROID_RECOVERY_FINISHED_WARNING}` + : ANDROID_RECOVERY_ROTATION_WARNING, + }); +} + +function resolveLivePendingScreenrecord( + manifest: AndroidRecordingRecoveryManifest, + pending: AndroidScreenrecordProbe, +): AndroidRecoveryResolution | undefined { + if (!pending || pending === 'uncertain') return undefined; + return liveAndroidRecoveryCandidate({ + manifest, + current: pending, + recoveryWarning: manifest.current + ? ANDROID_RECOVERY_ROTATION_WARNING + : ANDROID_RECOVERY_WARNING, + }); +} + +function pendingOnlyResolution(pending: AndroidScreenrecordProbe): AndroidRecoveryResolution { + return pending === 'uncertain' ? { kind: 'uncertain' } : { kind: 'stale' }; +} + +async function resolveCurrentAndroidRecoveryCandidate( + deviceId: string, + manifest: AndroidRecordingRecoveryManifest, +): Promise { + if (!manifest.current) return { kind: 'stale' }; + const liveness = await checkRecoverableAndroidScreenrecord(deviceId, manifest.current); + if (liveness === 'live') { + return liveAndroidRecoveryCandidate({ manifest, current: manifest.current }); + } + if (liveness === 'finished') { + return liveAndroidRecoveryCandidate({ + manifest, + current: manifest.current, + recoveryWarning: ANDROID_RECOVERY_FINISHED_WARNING, + }); + } + return { kind: liveness }; +} + +function liveAndroidRecoveryCandidate(params: { + manifest: AndroidRecordingRecoveryManifest; + current: AndroidRecordingRecoveryMetadata; + chunks?: AndroidRecordingRecoveryChunk[]; + recoveryWarning?: string; +}): { kind: 'live'; manifest: AndroidRecordingRecoveryCandidate } { + const { manifest, current, chunks, recoveryWarning } = params; + return { + kind: 'live', + manifest: { + ...manifest, + current, + chunks: chunks ?? manifest.chunks, + ...(recoveryWarning ? { recoveryWarning } : {}), + }, + }; +} + +async function checkRecoverableAndroidScreenrecord( deviceId: string, metadata: AndroidRecordingRecoveryMetadata, -): Promise<'live' | 'stale' | 'uncertain'> { +): Promise<'live' | 'stale' | 'uncertain' | 'finished'> { const result = await runAndroidRecoveryAdb( deviceId, ['shell', 'ps', '-o', 'pid=,args=', '-p', metadata.remotePid], @@ -183,11 +267,11 @@ async function checkLiveRecoverableAndroidScreenrecord( }); return 'uncertain'; } - const sawPid = result.stdout - .split(/\r?\n/) - .some((line) => line.trim().startsWith(metadata.remotePid)); - const matched = result.stdout - .split(/\r?\n/) + const lines = result.stdout.split(/\r?\n/); + const pidLine = lines + .map((line) => line.trim()) + .find((line) => line.startsWith(metadata.remotePid)); + const matched = lines .map(parseRecoverableAndroidScreenrecord) .some( (candidate) => @@ -196,12 +280,15 @@ async function checkLiveRecoverableAndroidScreenrecord( if (matched) { return 'live'; } - return sawPid ? 'uncertain' : 'stale'; + if (pidLine?.includes('screenrecord')) return 'uncertain'; + if (pidLine) return 'stale'; + return (await androidRemoteFileExists(deviceId, metadata.remotePath)) ? 'finished' : 'stale'; } -async function findRecoverableAndroidScreenrecord( +async function findLiveAndroidScreenrecordByPath( deviceId: string, -): Promise { + remotePath: string, +): Promise { const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'ps', '-A', '-o', 'pid=,args='], { allowFailure: true, timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, @@ -212,59 +299,136 @@ async function findRecoverableAndroidScreenrecord( phase: 'record_stop_android_recovery_ps_failed', data: { deviceId, + remotePath, exitCode: result.exitCode, stdout: result.stdout.trim(), stderr: result.stderr.trim(), }, }); - return undefined; + return 'uncertain'; } - const matches = result.stdout + return result.stdout .split(/\r?\n/) .map(parseRecoverableAndroidScreenrecord) - .filter((match): match is NonNullable => match !== undefined) - .sort((a, b) => b.startedAt - a.startedAt); - if (matches.length === 0) { - return undefined; - } - if (matches.length > 1) { - return errorResponse( - 'INVALID_ARGS', - 'multiple active Android screenrecord processes match agent-device recordings; cannot safely recover missing recording state', - { processes: matches.map(({ remotePid, remotePath }) => ({ remotePid, remotePath })) }, - ); - } - return matches[0]; + .find((match): match is NonNullable => match?.remotePath === remotePath); } -export async function writeAndroidRecoveryMetadata( - deviceId: string, - metadata: AndroidRecordingRecoveryMetadata, -): Promise { - const metadataPath = androidRecoveryMetadataPathForRemotePath(metadata.remotePath); - const payload = JSON.stringify(metadata); +async function androidRemoteFileExists(deviceId: string, remotePath: string): Promise { + const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'stat', '-c', '%s', remotePath], { + allowFailure: true, + timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, + }); + const size = result.exitCode === 0 ? Number(result.stdout.trim()) : NaN; + return Number.isFinite(size) && size >= ANDROID_RECOVERY_MANIFEST_STAT_SIZE_BYTES; +} + +function chunksThroughRemotePath( + chunks: AndroidRecordingRecoveryChunk[], + remotePath: string, +): AndroidRecordingRecoveryChunk[] { + const index = chunks.findIndex((chunk) => chunk.remotePath === remotePath); + return index >= 0 ? chunks.slice(0, index + 1) : chunks; +} + +export async function writeAndroidRecoveryPendingMetadata(params: { + deviceId: string; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + startedAt: number; + showTouches: boolean; + remotePath: string; +}): Promise { + const { deviceId, sessionName, sessionScope, recordingId, startedAt, showTouches, remotePath } = + params; + return await writeAndroidRecoveryManifest({ + deviceId, + manifest: buildAndroidRecoveryPendingManifest({ + deviceId, + sessionName, + sessionScope, + recordingId, + startedAt, + showTouches, + remotePath, + }), + phase: 'record_start_android_recovery_metadata_failed', + }); +} + +export async function writeAndroidRecoveryRotatingMetadata(params: { + deviceId: string; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recording: AndroidRecording; + nextRemotePath: string; + nextIndex: number; +}): Promise { + const { deviceId, sessionName, sessionScope, recording, nextRemotePath, nextIndex } = params; + return await writeAndroidRecoveryManifest({ + deviceId, + manifest: buildAndroidRecoveryRotatingManifest({ + deviceId, + sessionName, + sessionScope, + recording, + nextRemotePath, + nextIndex, + }), + phase: 'record_rotate_android_recovery_metadata_failed', + }); +} + +export async function writeAndroidRecoveryMetadata(params: { + deviceId: string; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recording: AndroidRecording; +}): Promise { + const { deviceId, sessionName, sessionScope, recording } = params; + return await writeAndroidRecoveryManifest({ + deviceId, + manifest: buildAndroidRecoveryManifest({ deviceId, sessionName, sessionScope, recording }), + phase: 'record_start_android_recovery_metadata_failed', + }); +} + +async function writeAndroidRecoveryManifest(params: { + deviceId: string; + manifest: AndroidRecordingRecoveryManifest; + phase: string; +}): Promise { + const { deviceId, manifest, phase } = params; + const currentPath = manifest.current?.remotePath ?? manifest.pending?.remotePath; + if (!currentPath) return 'failed to write Android recording recovery manifest: missing path'; + const metadataPath = androidRecoveryMetadataPathForRemotePath(currentPath); + const metadataTmpPath = `${metadataPath}.tmp`; + const payload = JSON.stringify(manifest); const result = await runAndroidRecoveryAdb( deviceId, - ['shell', `printf %s ${shellQuote(payload)} > ${shellQuote(metadataPath)}`], + [ + 'shell', + `printf %s ${shellQuote(payload)} > ${shellQuote(metadataTmpPath)} && mv -f ${shellQuote(metadataTmpPath)} ${shellQuote(metadataPath)}`, + ], { allowFailure: true, timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, }, ); if (result.exitCode !== 0) { - emitDiagnostic({ - level: 'warn', - phase: 'record_start_android_recovery_metadata_failed', - data: { - deviceId, - metadataPath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, + emitAndroidRecoveryAdbFailure({ + phase, + deviceId, + metadataPath, + result, + }); + await cleanupAndroidRecoveryMetadataPath({ + deviceId, + metadataPath: metadataTmpPath, + phase: `${phase}_tmp_cleanup_failed`, }); - return; + return `failed to write Android recording recovery manifest: ${formatRecordTraceExecFailure(result, 'adb shell write recovery manifest')}`; } for (const staleMetadataPath of androidRecoveryMetadataPaths()) { @@ -276,6 +440,7 @@ export async function writeAndroidRecoveryMetadata( }); } } + return undefined; } export async function cleanupAndroidRecoveryMetadata(deviceId: string): Promise { @@ -299,59 +464,248 @@ async function cleanupAndroidRecoveryMetadataPath(params: { timeoutMs: ANDROID_RECOVERY_PROBE_TIMEOUT_MS, }); if (result.exitCode !== 0) { - emitDiagnostic({ - level: 'warn', + emitAndroidRecoveryAdbFailure({ phase, - data: { - deviceId, - metadataPath, - exitCode: result.exitCode, - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - }, + deviceId, + metadataPath, + result, }); } } +function emitAndroidRecoveryAdbFailure(params: { + phase: string; + deviceId: string; + metadataPath: string; + result: AndroidAdbExecutorResult; +}): void { + const { phase, deviceId, metadataPath, result } = params; + emitDiagnostic({ + level: 'warn', + phase, + data: { + deviceId, + metadataPath, + exitCode: result.exitCode, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }, + }); +} + export async function recoverMissingAndroidRecording(params: { + sessionName: string; + activeSession: SessionState; device: AndroidDevice; recordingBase: AndroidRecordingBase; }): Promise { - const { device, recordingBase } = params; - const recovered = - (await readAndroidRecoveryMetadata(device.id)) ?? - (await findRecoverableAndroidScreenrecord(device.id)); - if (!recovered) { - return null; + const { sessionName, activeSession, device, recordingBase } = params; + const manifests = await readAndroidRecoveryMetadata(device.id); + if (manifests.live.length > 0) { + return recoverAndroidRecordingFromManifest({ + sessionName, + activeSession, + device, + recordingBase, + manifests: manifests.live, + }); } - if ('ok' in recovered) { - return recovered; + if (manifests.uncertain.length > 0) { + return blockAndroidManifestRecoveryForUncertainManifest({ + sessionName, + activeSession, + manifests: manifests.uncertain, + }); + } + if (manifests.blocked.length > 0) { + return blockAndroidManifestRecoveryForBlockedManifest(manifests.blocked); } + return null; +} + +function blockAndroidManifestRecoveryForUncertainManifest(params: { + sessionName: string; + activeSession: SessionState; + manifests: AndroidRecordingRecoveryManifest[]; +}): DaemonResponse { + const { sessionName, activeSession, manifests } = params; + const selection = selectOwnedAndroidRecoveryManifest({ sessionName, activeSession, manifests }); + const details = { + activeRecordings: selection.activeRecordings, + recoveryBlocked: 'manifest_liveness_uncertain', + hint: 'Retry record stop after the device responds. Android recording recovery requires a verified durable manifest.', + }; + if (selection.kind === 'owner-mismatch') { + return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), details); + } + if (selection.kind === 'ambiguous') { + return errorResponse( + 'INVALID_ARGS', + 'multiple active Android recording manifests could not be verified; cannot safely recover missing recording state', + details, + ); + } + return errorResponse( + 'INVALID_ARGS', + 'active Android recording manifest could not be verified; retry record stop after the device responds', + details, + ); +} + +function blockAndroidManifestRecoveryForBlockedManifest( + manifests: AndroidRecoveryBlockedManifest[], +): DaemonResponse { + return errorResponse('INVALID_ARGS', 'active Android recording manifest could not be validated', { + recoveryBlocked: 'manifest_invalid_or_unsupported', + manifests, + hint: 'Retry with the same agent-device version that started the recording, or inspect and remove stale device recovery metadata after confirming no recording is active.', + }); +} + +function recoverAndroidRecordingFromManifest(params: { + sessionName: string; + activeSession: SessionState; + device: AndroidDevice; + recordingBase: AndroidRecordingBase; + manifests: AndroidRecordingRecoveryCandidate[]; +}): DaemonResponse | AndroidRecording { + const { sessionName, activeSession, device, recordingBase, manifests } = params; + const selected = selectAndroidRecoveryManifest({ sessionName, activeSession, manifests }); + if ('ok' in selected) return selected; + emitAndroidRecoveryDiagnostic(device, selected); + return buildAndroidRecordingFromManifest(selected, recordingBase); +} + +function selectAndroidRecoveryManifest(params: { + sessionName: string; + activeSession: SessionState; + manifests: AndroidRecordingRecoveryCandidate[]; +}): DaemonResponse | AndroidRecordingRecoveryCandidate { + const { sessionName, activeSession, manifests } = params; + const selection = selectOwnedAndroidRecoveryManifest({ sessionName, activeSession, manifests }); + if (selection.kind === 'selected') return selection.manifest; + if (selection.kind === 'owner-mismatch') { + return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), { + activeRecordings: selection.activeRecordings, + }); + } + return errorResponse( + 'INVALID_ARGS', + 'multiple active Android recording manifests exist; cannot safely recover missing recording state', + { activeRecordings: selection.activeRecordings }, + ); +} + +function selectOwnedAndroidRecoveryManifest(params: { + sessionName: string; + activeSession: SessionState; + manifests: T[]; +}): AndroidOwnedManifestSelection { + const { sessionName, activeSession, manifests } = params; + const matches = manifests.filter((manifest) => + androidRecoveryManifestMatchesSession(manifest, sessionName, activeSession), + ); + const activeRecordings = summarizeAndroidActiveRecordings(manifests); + if (matches.length === 0) { + return { kind: 'owner-mismatch', activeRecordings }; + } + if (matches.length > 1 || manifests.length > 1) { + return { kind: 'ambiguous', activeRecordings }; + } + return { kind: 'selected', manifest: matches[0]!, activeRecordings }; +} + +function summarizeAndroidActiveRecordings( + manifests: AndroidRecordingRecoveryManifest[], +): AndroidActiveRecordingSummary[] { + return manifests.map((manifest) => ({ + sessionName: manifest.sessionName, + sessionScope: manifest.sessionScope, + recordingId: manifest.recordingId, + remotePid: manifest.current?.remotePid, + remotePath: manifest.current?.remotePath ?? manifest.pending?.remotePath, + })); +} + +function emitAndroidRecoveryDiagnostic( + device: AndroidDevice, + manifest: AndroidRecordingRecoveryCandidate, +): void { emitDiagnostic({ level: 'warn', phase: 'record_stop_android_recovered_missing_state', data: { deviceId: device.id, - remotePath: recovered.remotePath, - remotePid: recovered.remotePid, - outPath: recordingBase.outPath, + sessionName: manifest.sessionName, + recordingId: manifest.recordingId, + remotePath: manifest.current.remotePath, + remotePid: manifest.current.remotePid, + chunks: manifest.chunks.length, }, }); +} +function buildAndroidRecordingFromManifest( + manifest: AndroidRecordingRecoveryCandidate, + recordingBase: AndroidRecordingBase, +): AndroidRecording { + const recoveryWarning = manifest.recoveryWarning ?? ANDROID_RECOVERY_WARNING; return { platform: 'android', - remotePath: recovered.remotePath, - remotePid: recovered.remotePid, - chunks: [ - { - index: 1, - path: recordingBase.outPath, - remotePath: recovered.remotePath, - }, - ], - ...recordingBase, - startedAt: recovered.startedAt, - warning: ANDROID_RECOVERY_WARNING, + recordingId: manifest.recordingId, + remotePath: manifest.current.remotePath, + remotePid: manifest.current.remotePid, + remoteStartedAt: manifest.current.startedAt, + chunks: manifest.chunks.map((chunk) => ({ + index: chunk.index, + path: deriveAndroidChunkOutPath(recordingBase.outPath, chunk.index), + remotePath: chunk.remotePath, + })), + outPath: recordingBase.outPath, + clientOutPath: recordingBase.clientOutPath, + telemetryPath: recordingBase.telemetryPath, + startedAt: manifest.startedAt, + maxSize: recordingBase.maxSize, + exportQuality: recordingBase.exportQuality, + showTouches: false, + gestureEvents: [], + warning: manifest.showTouches + ? `${recoveryWarning} ${ANDROID_RECOVERY_OVERLAY_WARNING}.` + : recoveryWarning, + overlayWarning: manifest.showTouches ? ANDROID_RECOVERY_OVERLAY_WARNING : undefined, }; } + +function androidRecoveryManifestMatchesSession( + manifest: AndroidRecordingRecoveryManifest, + sessionName: string, + activeSession: SessionState, +): boolean { + return ( + manifest.sessionName === sessionName && + sessionScopesEqual(manifest.sessionScope, activeSession.sessionScope) + ); +} + +function sessionScopesEqual( + left: SessionState['sessionScope'] | undefined, + right: SessionState['sessionScope'] | undefined, +): boolean { + if (!left && !right) return true; + if (!left || !right) return false; + return left.kind === right.kind && left.id === right.id; +} + +function formatAndroidRecordingOwnerMismatch( + manifests: AndroidRecordingRecoveryManifest[], +): string { + if (manifests.length === 1) { + const manifest = manifests[0]!; + if (manifest.sessionScope) { + return `active Android recording belongs to session "${manifest.sessionName}" in ${manifest.sessionScope.kind} scope; retry record stop from the original working directory without --session to recover it`; + } + return `active Android recording belongs to session "${manifest.sessionName}"; run record stop --session ${manifest.sessionName} to recover it`; + } + return 'active Android recordings belong to other sessions; cannot safely recover missing recording state'; +} diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index 0f998eb2d..5ba2574df 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { sleep } from '../../utils/timeouts.ts'; import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts'; @@ -23,6 +24,8 @@ import { import { cleanupAndroidRecoveryMetadata, writeAndroidRecoveryMetadata, + writeAndroidRecoveryPendingMetadata, + writeAndroidRecoveryRotatingMetadata, } from './record-trace-android-recovery.ts'; type AndroidRecordingSize = { width: number; height: number }; @@ -39,6 +42,7 @@ const ANDROID_PROCESS_EXIT_POLL_MS = 250; const ANDROID_PROCESS_EXIT_ATTEMPTS = 40; const ANDROID_RECORDING_READY_ATTEMPTS = 8; const ANDROID_RECORDING_READY_MIN_RUNNING_POLLS = 2; +const ANDROID_RECORDING_PROBE_TIMEOUT_MS = 5_000; type AndroidDevice = SessionState['device']; type AndroidRecording = Extract, { platform: 'android' }>; @@ -58,6 +62,14 @@ type AndroidRecordingChunkStart = { remotePid: string; startedAt: number; }; +type AndroidRecordingChunkStartAttempt = + | { kind: 'started'; chunk: AndroidRecordingChunkStart } + | { kind: 'failed'; message: string }; + +type AndroidRecordingChunkStartHooks = { + prepareRemotePath?: (remotePath: string) => Promise; + cleanupPreparedRemotePath?: (remotePath: string) => Promise; +}; async function runAndroidRecordingAdb( deviceId: string, @@ -78,6 +90,7 @@ function parseAndroidRemotePid(stdout: string): string | undefined { async function isAndroidProcessRunning(deviceId: string, pid: string): Promise { const result = await runAndroidRecordingAdb(deviceId, ['shell', 'ps', '-o', 'pid=', '-p', pid], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }); if (result.exitCode !== 0) { return false; @@ -109,7 +122,7 @@ async function waitForAndroidRemoteFileStability( const statResult = await runAndroidRecordingAdb( deviceId, ['shell', 'stat', '-c', '%s', remotePath], - { allowFailure: true }, + { allowFailure: true, timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS }, ); const currentSize = statResult.exitCode === 0 ? statResult.stdout.trim() : ''; if (currentSize.length > 0 && currentSize === previousSize) { @@ -134,7 +147,7 @@ async function waitForAndroidRecordingReady( const statResult = await runAndroidRecordingAdb( deviceId, ['shell', 'stat', '-c', '%s', remotePath], - { allowFailure: true }, + { allowFailure: true, timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS }, ); const currentSize = statResult.exitCode === 0 ? Number(statResult.stdout.trim()) : NaN; if (Number.isFinite(currentSize) && currentSize > 0) { @@ -179,6 +192,7 @@ async function resolveAndroidRecordingSize(params: { const sizeResult = await runAndroidRecordingAdb(deviceId, ['shell', 'wm', 'size'], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }); const match = sizeResult.stdout.match(/Override size:\s*(\d+)x(\d+)/) ?? @@ -229,12 +243,14 @@ function buildAndroidScreenrecordCommand( async function cleanupAndroidRemoteRecording(deviceId: string, remotePath: string): Promise { await runAndroidRecordingAdb(deviceId, ['shell', 'rm', '-f', remotePath], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }); } async function forceStopAndroidProcess(deviceId: string, pid: string): Promise { const forceResult = await runAndroidRecordingAdb(deviceId, ['shell', 'kill', '-9', pid], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }); emitDiagnostic({ level: 'warn', @@ -258,64 +274,105 @@ async function startAndroidScreenrecordChunk(params: { recordingSize: AndroidRecordingSize | undefined; quality: RecordingExportQuality; preferredRemoteDir?: string; + hooks?: AndroidRecordingChunkStartHooks; }): Promise { - const { device, recordingSize, quality, preferredRemoteDir } = params; + const { device, recordingSize, quality, preferredRemoteDir, hooks } = params; let lastStartError = 'failed to start recording: Android screenrecord did not begin producing frames'; for (const remotePath of androidRemoteRecordingPaths(Date.now(), preferredRemoteDir)) { - const startResult = await runAndroidRecordingAdb( - device.id, - ['shell', buildAndroidScreenrecordCommand(remotePath, recordingSize, quality)], - { - allowFailure: true, - }, - ); - if (startResult.exitCode !== 0) { - lastStartError = `failed to start recording: ${formatRecordTraceExecFailure(startResult, 'adb shell screenrecord')}`; - continue; + const attempt = await tryStartAndroidScreenrecordAtPath({ + device, + recordingSize, + quality, + remotePath, + hooks, + }); + if (attempt.kind === 'started') { + return attempt.chunk; } + lastStartError = attempt.message; + } - const remotePid = parseAndroidRemotePid(startResult.stdout); - if (!remotePid) { - lastStartError = - 'failed to start recording: adb did not return a valid Android screenrecord pid'; - await cleanupAndroidRemoteRecording(device.id, remotePath); - continue; - } + return { error: errorResponse('COMMAND_FAILED', lastStartError) }; +} - emitDiagnostic({ - level: 'debug', - phase: 'record_start_android_started', - data: { - deviceId: device.id, - remotePath, - remotePid, - }, - }); +async function tryStartAndroidScreenrecordAtPath(params: { + device: AndroidDevice; + recordingSize: AndroidRecordingSize | undefined; + quality: RecordingExportQuality; + remotePath: string; + hooks?: AndroidRecordingChunkStartHooks; +}): Promise { + const { device, recordingSize, quality, remotePath, hooks } = params; + const prepareError = await hooks?.prepareRemotePath?.(remotePath); + if (prepareError) { + return { kind: 'failed', message: prepareError }; + } + + const startResult = await runAndroidRecordingAdb( + device.id, + ['shell', buildAndroidScreenrecordCommand(remotePath, recordingSize, quality)], + { + allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, + }, + ); + if (startResult.exitCode !== 0) { + await hooks?.cleanupPreparedRemotePath?.(remotePath); + return { + kind: 'failed', + message: `failed to start recording: ${formatRecordTraceExecFailure(startResult, 'adb shell screenrecord')}`, + }; + } + + const remotePid = parseAndroidRemotePid(startResult.stdout); + if (!remotePid) { + await hooks?.cleanupPreparedRemotePath?.(remotePath); + await cleanupAndroidRemoteRecording(device.id, remotePath); + return { + kind: 'failed', + message: 'failed to start recording: adb did not return a valid Android screenrecord pid', + }; + } + + emitDiagnostic({ + level: 'debug', + phase: 'record_start_android_started', + data: { + deviceId: device.id, + remotePath, + remotePid, + }, + }); - if (await waitForAndroidRecordingReady(device.id, remotePath, remotePid)) { - return { + if (await waitForAndroidRecordingReady(device.id, remotePath, remotePid)) { + return { + kind: 'started', + chunk: { remotePath, remotePid, startedAt: Date.now(), - }; - } - - lastStartError = - 'failed to start recording: Android screenrecord did not begin producing frames'; - await forceStopAndroidProcess(device.id, remotePid); - await cleanupAndroidRemoteRecording(device.id, remotePath); + }, + }; } - return { error: errorResponse('COMMAND_FAILED', lastStartError) }; + await forceStopAndroidProcess(device.id, remotePid); + await hooks?.cleanupPreparedRemotePath?.(remotePath); + await cleanupAndroidRemoteRecording(device.id, remotePath); + return { + kind: 'failed', + message: 'failed to start recording: Android screenrecord did not begin producing frames', + }; } export async function startAndroidRecording(params: { + sessionName: string; + activeSession: SessionState; device: AndroidDevice; recordingBase: AndroidRecordingBase; }): Promise { - const { device, recordingBase } = params; + const { sessionName, activeSession, device, recordingBase } = params; let recordingSize: AndroidRecordingSize | undefined; try { recordingSize = await resolveAndroidRecordingSize({ @@ -327,18 +384,47 @@ export async function startAndroidRecording(params: { } const quality = recordingBase.exportQuality ?? DEFAULT_RECORDING_EXPORT_QUALITY; + const recordingId = randomUUID(); const chunk = await startAndroidScreenrecordChunk({ device, recordingSize, quality, + hooks: { + prepareRemotePath: async (remotePath) => + await writeAndroidRecoveryPendingMetadata({ + deviceId: device.id, + sessionName, + sessionScope: activeSession.sessionScope, + recordingId, + startedAt: recordingBase.startedAt, + showTouches: recordingBase.showTouches, + remotePath, + }), + cleanupPreparedRemotePath: async () => { + await cleanupAndroidRecoveryMetadata(device.id); + }, + }, }); if ('error' in chunk) { return chunk.error; } - const recording = buildAndroidRecording({ recordingBase, chunk }); - await writeAndroidRecoveryMetadataForChunk(device.id, chunk); + const recording = buildAndroidRecording({ recordingBase, chunk, recordingId }); + const metadataError = await writeAndroidRecoveryMetadata({ + deviceId: device.id, + sessionName, + sessionScope: activeSession.sessionScope, + recording, + }); + if (metadataError) { + await forceStopAndroidProcess(device.id, recording.remotePid); + await cleanupAndroidRemoteRecording(device.id, recording.remotePath); + await cleanupAndroidRecoveryMetadata(device.id); + return errorResponse('COMMAND_FAILED', `failed to start recording: ${metadataError}`); + } scheduleAndroidRecordingChunks({ + activeSession, + sessionName, device, recording, recordingSize, @@ -350,12 +436,15 @@ export async function startAndroidRecording(params: { function buildAndroidRecording(params: { recordingBase: AndroidRecordingBase; chunk: AndroidRecordingChunkStart; + recordingId: string; }): AndroidRecording { - const { recordingBase, chunk } = params; + const { recordingBase, chunk, recordingId } = params; return { platform: 'android', + recordingId, remotePath: chunk.remotePath, remotePid: chunk.remotePid, + remoteStartedAt: chunk.startedAt, chunks: [ { index: 1, @@ -368,38 +457,53 @@ function buildAndroidRecording(params: { }; } -async function writeAndroidRecoveryMetadataForChunk( - deviceId: string, - chunk: AndroidRecordingChunkStart, -): Promise { - await writeAndroidRecoveryMetadata(deviceId, { - remotePath: chunk.remotePath, - remotePid: chunk.remotePid, - startedAt: chunk.startedAt, - }); -} - function scheduleAndroidRecordingChunks(params: { + activeSession: SessionState; + sessionName: string; device: AndroidDevice; recording: AndroidRecording; recordingSize: AndroidRecordingSize | undefined; quality: RecordingExportQuality; }): void { - const { device, recording, recordingSize, quality } = params; + const { activeSession, sessionName, device, recording, recordingSize, quality } = params; scheduleAndroidRecordingRotation({ recording, - finishCurrentChunk: async () => + finishCurrentChunk: async (chunk) => await finishCurrentAndroidRecordingChunk({ device, recording, + remotePath: chunk.remotePath, + remotePid: chunk.remotePid, waitForRemoteFileStability: false, }), - startNextChunk: async (preferredRemoteDir) => { + cleanupStartedChunk: async (chunk) => { + await cleanupAndroidRemoteRecording(device.id, chunk.remotePath); + }, + startNextChunk: async (preferredRemoteDir, nextIndex) => { const nextChunk = await startAndroidScreenrecordChunk({ device, recordingSize, quality, preferredRemoteDir, + hooks: { + prepareRemotePath: async (remotePath) => + await writeAndroidRecoveryRotatingMetadata({ + deviceId: device.id, + sessionName, + sessionScope: activeSession.sessionScope, + recording, + nextRemotePath: remotePath, + nextIndex, + }), + cleanupPreparedRemotePath: async () => { + await writeAndroidRecoveryMetadata({ + deviceId: device.id, + sessionName, + sessionScope: activeSession.sessionScope, + recording, + }); + }, + }, }); if ('error' in nextChunk) { throw new Error( @@ -408,37 +512,52 @@ function scheduleAndroidRecordingChunks(params: { : nextChunk.error.error.message, ); } - await writeAndroidRecoveryMetadataForChunk(device.id, nextChunk); return nextChunk; }, + persistRecordingState: async (updatedRecording) => { + const metadataError = await writeAndroidRecoveryMetadata({ + deviceId: device.id, + sessionName, + sessionScope: activeSession.sessionScope, + recording: updatedRecording, + }); + if (metadataError) { + throw new Error(metadataError); + } + }, }); } async function finishCurrentAndroidRecordingChunk(params: { device: AndroidDevice; recording: AndroidRecording; + remotePath?: string; + remotePid?: string; waitForRemoteFileStability?: boolean; }): Promise { - const { device, recording, waitForRemoteFileStability = true } = params; - const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, recording.remotePid); + const { + device, + recording, + remotePath = recording.remotePath, + remotePid = recording.remotePid, + waitForRemoteFileStability = true, + } = params; + const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, remotePid); if (!wasRunningBeforeStop) { appendAndroidRecordingWarning(recording, resolveAndroidScreenrecordLimitWarning(recording)); } - const stopResult = await runAndroidRecordingAdb( - device.id, - ['shell', 'kill', '-2', recording.remotePid], - { - allowFailure: true, - }, - ); + const stopResult = await runAndroidRecordingAdb(device.id, ['shell', 'kill', '-2', remotePid], { + allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, + }); emitDiagnostic({ level: 'debug', phase: 'record_stop_android_signal', data: { deviceId: device.id, - remotePath: recording.remotePath, - remotePid: recording.remotePid, + remotePath, + remotePid, exitCode: stopResult.exitCode, stdout: stopResult.stdout.trim(), stderr: stopResult.stderr.trim(), @@ -446,15 +565,15 @@ async function finishCurrentAndroidRecordingChunk(params: { }); if (stopResult.exitCode !== 0) { - return await recoverAndroidStopSignalFailure(device.id, recording.remotePid, stopResult); + return await recoverAndroidStopSignalFailure(device.id, remotePid, stopResult); } - const exitError = await waitForAndroidStopExit(device.id, recording.remotePid); + const exitError = await waitForAndroidStopExit(device.id, remotePid); if (exitError) { return exitError; } if (waitForRemoteFileStability) { - await waitForAndroidRemoteFileStability(device.id, recording.remotePath); + await waitForAndroidRemoteFileStability(device.id, remotePath); } return undefined; } @@ -590,6 +709,7 @@ async function cleanupRemoteAndroidRecordingChunk( ): Promise { const rmResult = await runAndroidRecordingAdb(deviceId, ['shell', 'rm', '-f', remotePath], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }); emitDiagnostic({ level: 'debug', diff --git a/src/daemon/handlers/record-trace-recording-backends.ts b/src/daemon/handlers/record-trace-recording-backends.ts index d4d8e2419..8390d3268 100644 --- a/src/daemon/handlers/record-trace-recording-backends.ts +++ b/src/daemon/handlers/record-trace-recording-backends.ts @@ -58,6 +58,7 @@ type RecordingOutputPathContext = { type RecordingStartContext = { req: DaemonRequest; + sessionName: string; activeSession: SessionState; sessionStore: SessionStore; device: SessionState['device']; @@ -276,10 +277,10 @@ const iosSimulatorRecordingBackend: RecordingBackend<'ios'> = { const androidRecordingBackend: RecordingBackend<'android'> = { resolveOutputPath: resolveNativeRecordingOutputPath, - start: async ({ device, recordingBase }) => - await startAndroidRecording({ device, recordingBase }), - recoverMissingStop: async ({ device, recordingBase }) => - await recoverMissingAndroidRecording({ device, recordingBase }), + start: async ({ sessionName, activeSession, device, recordingBase }) => + await startAndroidRecording({ sessionName, activeSession, device, recordingBase }), + recoverMissingStop: async ({ sessionName, activeSession, device, recordingBase }) => + await recoverMissingAndroidRecording({ sessionName, activeSession, device, recordingBase }), stop: async ({ deps, device, recording, stopRequestedAt }) => await stopAndroidRecording({ deps, diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index a88ed9f68..8d76f631e 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -29,7 +29,7 @@ import { stopActiveRecording, } from './record-trace-recording-backends.ts'; import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts'; -import { resolveImplicitSessionScope, resolvePublicSessionName } from '../session-routing.ts'; +import { resolveImplicitSessionScope } from '../session-routing.ts'; const IOS_DEVICE_RECORD_MIN_FPS = 1; const IOS_DEVICE_RECORD_MAX_FPS = 120; @@ -49,6 +49,7 @@ type StartRecordingParams = { type StopRecordingParams = { req: DaemonRequest; + sessionName: string; sessionStore: SessionStore; activeSession: SessionState; device: SessionState['device']; @@ -114,6 +115,7 @@ async function startRecording(params: StartRecordingParams): Promise | null> { - const { req, sessionStore, activeSession, device, logPath, deps } = params; + const { req, sessionName, sessionStore, activeSession, device, logPath, deps } = params; if (hasActiveRecordingSessionForDevice(sessionStore, device.id)) { return null; } @@ -328,6 +330,7 @@ async function recoverMissingRecordingState( const { resolvedOut, recordingBase } = prepareRecoveredRecording(req, backend); const recovered = await backend.recoverMissingStop({ req, + sessionName, activeSession, sessionStore, device, @@ -490,7 +493,7 @@ export async function handleRecordCommand(params: { const activeSession = session ?? ({ - name: resolvePublicSessionName(req), + name: sessionName, sessionScope: resolveImplicitSessionScope(req), device, createdAt: Date.now(), @@ -507,7 +510,15 @@ export async function handleRecordCommand(params: { return startRecording({ req, sessionName, sessionStore, activeSession, device, logPath, deps }); } - const response = await stopRecording({ req, sessionStore, activeSession, device, logPath, deps }); + const response = await stopRecording({ + req, + sessionName, + sessionStore, + activeSession, + device, + logPath, + deps, + }); if (!response.ok) { await releaseRecordOnlySession(sessionStore, sessionName, activeSession); return response; diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 89e946e25..9b533c9d5 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -325,8 +325,10 @@ export type SessionState = { }) | (SessionRecordingBase & { platform: 'android'; + recordingId?: string; remotePath: string; remotePid: string; + remoteStartedAt?: number; chunks?: RecordingChunk[]; rotationTimer?: NodeJS.Timeout; rotationPromise?: Promise; diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 7d2e897cb..6c4b0152b 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'vitest'; @@ -20,6 +21,7 @@ import { } from './harness.ts'; type ProviderScenarioDaemon = Awaited>; +type ProviderScenarioRpcResult = Awaited>; type PullCall = { remotePath: string; localPath: string }; test('Provider-backed integration Android recording flow uses scripted ADB provider pull capability', async () => { @@ -29,13 +31,211 @@ test('Provider-backed integration Android recording flow uses scripted ADB provi ); }); -test('Provider-backed integration Android record stop recovers missing daemon recording state', async () => { +test('Provider-backed integration Android record stop recovers missing daemon recording state from durable manifest', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-recovery-', - runAndroidRecordingRecoveryScenario, + runAndroidManifestRecoveryScenario, ); }); +test('Provider-backed integration Android record stop recovers cwd-scoped durable manifest', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-scoped-recovery-', + runAndroidScopedManifestRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop gives cwd retry hint for scoped owner mismatch', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-scoped-owner-mismatch-', + runAndroidScopedOwnerMismatchHintScenario, + ); +}); + +test('Provider-backed integration Android record stop recovers opened cwd-scoped recording after daemon state loss', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-open-scoped-recovery-', + runAndroidOpenScopedRecordingRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop does not recover ownerless live screenrecord', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-ownerless-recovery-', + runAndroidOwnerlessRecordingRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop refuses another session durable manifest', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-wrong-session-', + async (tmpDir) => { + const adbCalls: string[][] = []; + const pullCalls: Array<{ remotePath: string; localPath: string }> = []; + const remotePath = '/sdcard/agent-device-recording-223456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'other-session.mp4'), + remotePath, + sessionName: 'checkout', + }); + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + if (args.join(' ') === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (args.join(' ') === 'shell ps -o pid=,args= -p 4321') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + + assertRpcError(recordStop, 'INVALID_ARGS', /belongs to session "checkout"/); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), + false, + ); + assert.equal(pullCalls.length, 0); + } finally { + await daemon.close(); + } + }, + ); +}); + +test('Provider-backed integration Android record stop ignores manifest host output paths', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-host-path-', + runAndroidManifestHostPathScenario, + ); +}); + +test('Provider-backed integration Android record stop refuses another session uncertain manifest', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-wrong-session-uncertain-', + runAndroidOtherSessionUncertainManifestScenario, + ); +}); + +test('Provider-backed integration Android record stop refuses ambiguous durable manifests', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-ambiguous-', + runAndroidAmbiguousManifestRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop cleans stale durable manifest', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-stale-manifest-', + async (tmpDir) => { + const adbCalls: string[][] = []; + const execOptions: Array<{ command: string; timeoutMs?: number }> = []; + const remotePath = '/sdcard/agent-device-recording-423456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'stale.mp4'), + remotePath, + sessionName: 'default', + }); + const adbProvider: AndroidAdbProvider = { + exec: async (args, options) => { + adbCalls.push([...args]); + execOptions.push({ command: args.join(' '), timeoutMs: options?.timeoutMs }); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (command === `shell stat -c %s ${remotePath}`) { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return androidAdbResult(args); + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + + assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); + assertCommandCall(adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); + assert.equal( + execOptions.some((entry) => entry.command === 'shell ps -A -o pid=,args='), + false, + ); + } finally { + await daemon.close(); + } + }, + ); +}); + +test('Provider-backed integration Android record stop cleans stale manifest when pid is reused by another process', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-pid-reuse-', + runAndroidPidReuseManifestScenario, + ); +}); + +test('Provider-backed integration Android record stop keeps mismatched device manifest', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-device-mismatch-', + runAndroidDeviceMismatchManifestScenario, + ); +}); + +test('Provider-backed integration Android record stop recovers manifest chunks after daemon state loss', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-chunk-recovery-', + runAndroidManifestChunkRecoveryScenario, + ); +}, 15_000); + +test('Provider-backed integration Android record stop recovers pending manifest after daemon state loss', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-pending-recovery-', + runAndroidPendingManifestRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop recovers rotating manifest after daemon state loss', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-rotating-recovery-', + runAndroidRotatingManifestRecoveryScenario, + ); +}, 15_000); + test('Provider-backed integration Android record stop recovers while another device is recording', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-cross-device-recovery-', @@ -76,6 +276,530 @@ async function runAndroidRecordingFlowScenario(tmpDir: string): Promise { }); } +async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise { + const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; + const recordingPath = path.join(tmpDir, 'recovered-recording.mp4'); + const context = await createAndroidSingleManifestRecoveryContext({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(context.daemon, recordingPath); + assertAndroidManifestRecovery(recordStop, { ...context, recordingPath, remotePath }); + } finally { + await context.daemon.close(); + } + }); +} + +async function runAndroidManifestHostPathScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-823456789.mp4'; + const requestedPath = path.join(tmpDir, 'requested.mp4'); + const manifestPath = path.join(tmpDir, 'manifest-controlled.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: manifestPath, + remotePath, + sessionName: 'default', + chunks: [{ index: 1, path: manifestPath, remotePath }], + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await stopAndroidRecording(daemon, requestedPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, requestedPath); + assert.deepEqual(pullCalls, [{ remotePath, localPath: requestedPath }]); + assert.equal(fs.existsSync(requestedPath), true); + assert.equal(fs.existsSync(manifestPath), false); + } finally { + await daemon.close(); + } +} + +async function runAndroidScopedManifestRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-923456123.mp4'; + const recordingPath = path.join(tmpDir, 'scoped-recovered-recording.mp4'); + const scopeRoot = path.join(tmpDir, 'worktree'); + fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); + const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: `cwd:${scopeId}:default`, + sessionScope: { kind: 'cwd', id: scopeId }, + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand( + 'record', + ['stop', recordingPath], + { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }, + { meta: { cwd: scopeRoot } }, + ); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); + } finally { + await daemon.close(); + } +} + +async function runAndroidScopedOwnerMismatchHintScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-923456124.mp4'; + const recordingPath = path.join(tmpDir, 'scoped-owner-mismatch.mp4'); + const scopeRoot = path.join(tmpDir, 'worktree'); + fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); + const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); + const effectiveSessionName = `cwd:${scopeId}:default`; + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: effectiveSessionName, + sessionScope: { kind: 'cwd', id: scopeId }, + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop', recordingPath], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + session: effectiveSessionName, + }); + assertRpcError( + recordStop, + 'INVALID_ARGS', + /retry record stop from the original working directory without --session/, + ); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), + false, + ); + assert.equal(pullCalls.length, 0); + } finally { + await daemon.close(); + } +} + +async function runAndroidOpenScopedRecordingRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const scopeRoot = path.join(tmpDir, 'worktree'); + const recordingPath = path.join(tmpDir, 'opened-scoped-recovered.mp4'); + fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); + const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); + const provider = createStatefulAndroidRecordingProvider({ adbCalls, pullCalls }); + const createDaemon = async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + const firstDaemon = await createDaemon(); + try { + const open = await firstDaemon.callCommand( + 'open', + ['settings'], + { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }, + { meta: { cwd: scopeRoot } }, + ); + assertRpcOk(open); + const recordStart = await firstDaemon.callCommand( + 'record', + ['start', recordingPath], + { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }, + { meta: { cwd: scopeRoot } }, + ); + assertRecordingStarted(recordStart, { showTouches: true }); + assert.equal(provider.manifest?.sessionName, `cwd:${scopeId}:default`); + assert.deepEqual(provider.manifest?.sessionScope, { kind: 'cwd', id: scopeId }); + } finally { + await firstDaemon.close(); + } + + const secondDaemon = await createDaemon(); + try { + const recordStop = await secondDaemon.callCommand( + 'record', + ['stop', recordingPath], + { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }, + { meta: { cwd: scopeRoot } }, + ); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.equal(fs.existsSync(recordingPath), true); + assert.equal(pullCalls.length, 1); + } finally { + await secondDaemon.close(); + } +} + +async function runAndroidAmbiguousManifestRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; + const secondRemotePath = '/data/local/tmp/agent-device-recording-323456790.mp4'; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ + adbCalls, + manifests: [ + buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'first.mp4'), + remotePath: firstRemotePath, + sessionName: 'default', + }), + buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'second.mp4'), + remotePath: secondRemotePath, + sessionName: 'default', + remotePid: '9876', + }), + ], + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await stopAndroidRecording(daemon); + assertRpcError(recordStop, 'INVALID_ARGS', /multiple active Android recording manifests/); + assert.equal( + adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')), + false, + ); + } finally { + await daemon.close(); + } +} + +async function runAndroidOtherSessionUncertainManifestScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-723456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'other-session-uncertain.mp4'), + remotePath, + sessionName: 'checkout', + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { stdout: '', stderr: 'transient ps failure', exitCode: 1 }; + } + if (command === 'shell ps -A -o pid=,args=') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + assertRpcError(recordStop, 'INVALID_ARGS', /belongs to session "checkout"/); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), + false, + ); + assert.equal( + adbCalls.some( + (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', + ), + false, + ); + assert.equal(pullCalls.length, 0); + } finally { + await daemon.close(); + } +} + +async function runAndroidManifestChunkRecoveryScenario(tmpDir: string): Promise { + const firstRemotePath = '/sdcard/agent-device-recording-523456789.mp4'; + const secondRemotePath = '/sdcard/agent-device-recording-523456790.mp4'; + const firstLocalPath = path.join(tmpDir, 'chunked.mp4'); + const secondLocalPath = path.join(tmpDir, 'chunked.part-002.mp4'); + const context = await createAndroidSingleManifestRecoveryContext({ + outPath: firstLocalPath, + remotePath: secondRemotePath, + sessionName: 'default', + startedAt: 523456789, + chunks: [ + { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, + { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, + ], + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(context.daemon, firstLocalPath); + assertAndroidManifestChunkRecovery(recordStop, { + ...context, + firstLocalPath, + firstRemotePath, + secondLocalPath, + secondRemotePath, + }); + } finally { + await context.daemon.close(); + } + }); +} + +async function runAndroidPendingManifestRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-623456789.mp4'; + const recordingPath = path.join(tmpDir, 'pending-recovered.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + status: 'pending', + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await stopAndroidRecording(daemon, recordingPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( + recordStop, + ); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.match(String(data.warning), /durable device manifest/); + assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); + assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); + assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); + } finally { + await daemon.close(); + } +} + +async function runAndroidRotatingManifestRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const firstRemotePath = '/sdcard/agent-device-recording-723456789.mp4'; + const secondRemotePath = '/sdcard/agent-device-recording-723456790.mp4'; + const firstLocalPath = path.join(tmpDir, 'rotating.mp4'); + const secondLocalPath = path.join(tmpDir, 'rotating.part-002.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: firstLocalPath, + remotePath: firstRemotePath, + sessionName: 'default', + status: 'rotating', + pendingRemotePath: secondRemotePath, + chunks: [ + { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, + { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, + ], + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await stopAndroidRecording(daemon, firstLocalPath); + const data = assertRpcOk<{ + recording?: unknown; + warning?: unknown; + chunks?: Array<{ index?: unknown; path?: unknown }>; + }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.match(String(data.warning), /interrupted chunk rotation/); + assert.deepEqual(data.chunks, [ + { index: 1, path: firstLocalPath }, + { index: 2, path: secondLocalPath }, + ]); + assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); + assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4322']); + assert.deepEqual(pullCalls, [ + { remotePath: firstRemotePath, localPath: firstLocalPath }, + { remotePath: secondRemotePath, localPath: secondLocalPath }, + ]); + } finally { + await daemon.close(); + } +} + +async function createAndroidSingleManifestRecoveryContext(options: { + outPath: string; + remotePath: string; + sessionName: string; + startedAt?: number; + chunks?: Array<{ index: number; path: string; remotePath: string }>; +}): Promise<{ + adbCalls: string[][]; + pullCalls: PullCall[]; + daemon: ProviderScenarioDaemon; +}> { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const manifest = buildAndroidRecordingManifest(options); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + return { adbCalls, pullCalls, daemon }; +} + +async function stopAndroidRecording( + daemon: ProviderScenarioDaemon, + outPath?: string, +): Promise { + return await daemon.callCommand('record', outPath ? ['stop', outPath] : ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); +} + +function assertAndroidManifestRecovery( + recordStop: ProviderScenarioRpcResult, + context: { + adbCalls: string[][]; + pullCalls: PullCall[]; + recordingPath: string; + remotePath: string; + }, +): void { + const data = assertRpcOk<{ + recording?: unknown; + outPath?: unknown; + warning?: unknown; + overlayWarning?: unknown; + }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, context.recordingPath); + assert.match(String(data.warning), /durable device manifest/); + assert.match(String(data.overlayWarning), /gesture telemetry/); + assert.equal(fs.existsSync(context.recordingPath), true); + assertAndroidManifestRecoveryCommands(context); +} + +function assertAndroidManifestRecoveryCommands(context: { + adbCalls: string[][]; + pullCalls: PullCall[]; + recordingPath: string; + remotePath: string; +}): void { + assertCommandCall(context.adbCalls, [ + 'shell', + 'cat', + '/sdcard/agent-device-recording-active.json', + ]); + assertCommandCall(context.adbCalls, ['shell', 'ps', '-o', 'pid=,args=', '-p', '4321']); + assert.equal( + context.adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); + assertCommandCall(context.adbCalls, ['shell', 'kill', '-2', '4321']); + assert.equal(context.pullCalls.length, 1); + assert.deepEqual(context.pullCalls[0], { + remotePath: context.remotePath, + localPath: context.recordingPath, + }); + assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.remotePath]); + assertCommandCall(context.adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); +} + +function assertAndroidManifestChunkRecovery( + recordStop: ProviderScenarioRpcResult, + context: { + adbCalls: string[][]; + pullCalls: PullCall[]; + firstLocalPath: string; + firstRemotePath: string; + secondLocalPath: string; + secondRemotePath: string; + }, +): void { + const data = assertRpcOk<{ + recording?: unknown; + chunks?: Array<{ index?: unknown; path?: unknown }>; + }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.deepEqual(data.chunks, [ + { index: 1, path: context.firstLocalPath }, + { index: 2, path: context.secondLocalPath }, + ]); + assert.deepEqual(context.pullCalls, [ + { remotePath: context.firstRemotePath, localPath: context.firstLocalPath }, + { remotePath: context.secondRemotePath, localPath: context.secondLocalPath }, + ]); + assertCommandCall(context.adbCalls, ['shell', 'kill', '-2', '4321']); + assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.firstRemotePath]); + assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.secondRemotePath]); +} + async function createAndroidRecordingFlowContext(tmpDir: string): Promise<{ recordingPath: string; adbCalls: string[][]; @@ -138,45 +862,59 @@ async function runAndroidCrossDeviceRecordingRecoveryScenario(tmpDir: string): P const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; const adbCalls: string[][] = []; const pullCalls: PullCall[] = []; + const recoveredPath = path.join(tmpDir, 'recovered-cross-device.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: recoveredPath, + remotePath, + sessionName: 'default', + }); const daemon = await createProviderScenarioHarness({ androidAdbProvider: () => - createPullingAndroidProvider({ + createAndroidManifestProvider({ adbCalls, pullCalls, - exec: (args) => androidRecoveryAdbResult(args, remotePath), + manifests: [manifest], }), deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID, otherAndroid], }); await withAndroidProviderScenarioEnv(tmpDir, async () => { try { + seedAndroidSession(daemon, 'default', PROVIDER_SCENARIO_ANDROID); const busyRecordingPath = path.join(tmpDir, 'busy-recording.mp4'); - const openBusy = await daemon.callCommand( - 'open', - ['settings'], - { platform: 'android', serial: otherAndroid.id }, - { session: 'busy' }, - ); - assertRpcOk(openBusy); - const startBusy = await daemon.callCommand( - 'record', - ['start', busyRecordingPath], - {}, - { session: 'busy' }, - ); - assertRecordingStarted(startBusy); - + const busyRemotePath = '/sdcard/agent-device-recording-623456789.mp4'; + daemon.setSession('busy', { + name: 'busy', + device: otherAndroid, + createdAt: Date.now(), + actions: [], + recording: { + platform: 'android', + recordingId: 'busy-recording', + remotePath: busyRemotePath, + remotePid: '6789', + remoteStartedAt: 623456789, + chunks: [{ index: 1, path: busyRecordingPath, remotePath: busyRemotePath }], + outPath: busyRecordingPath, + startedAt: 623456789, + showTouches: true, + gestureEvents: [], + }, + }); const recordStop = await daemon.callCommand( 'record', - ['stop'], - { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id }, - { meta: { cwd: tmpDir } }, + ['stop', recoveredPath], + {}, + { session: 'default' }, ); const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); assert.equal(data.recording, 'stopped'); - assert.match(String(data.outPath), /\/recording-\d+\.mp4$/); + assert.equal(data.outPath, recoveredPath); assert.equal(daemon.session('busy')?.recording !== undefined, true); - assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); assert.equal(pullCalls.length, 1); } finally { await daemon.close(); @@ -184,12 +922,20 @@ async function runAndroidCrossDeviceRecordingRecoveryScenario(tmpDir: string): P }); } -async function runAndroidRecordingRecoveryScenario(tmpDir: string): Promise { +async function runAndroidOwnerlessRecordingRecoveryScenario(tmpDir: string): Promise { const context = await createAndroidRecordingRecoveryContext(); await withAndroidProviderScenarioEnv(tmpDir, async () => { try { - const outPath = await exerciseAndroidRecordingRecoveryStop(context, tmpDir); - assertAndroidRecordingRecovery(context, outPath); + const outPath = path.join(tmpDir, 'recovered-live.mp4'); + seedAndroidSession(context.daemon, 'default', PROVIDER_SCENARIO_ANDROID); + const recordStop = await context.daemon.callCommand( + 'record', + ['stop', outPath], + {}, + { session: 'default' }, + ); + assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); + assertAndroidOwnerlessRecordingNotRecovered(context); } finally { await context.daemon.close(); } @@ -217,60 +963,148 @@ async function createAndroidRecordingRecoveryContext(): Promise<{ return { remotePath, adbCalls, pullCalls, daemon }; } -async function exerciseAndroidRecordingRecoveryStop( - context: { daemon: ProviderScenarioDaemon }, - tmpDir: string, -): Promise { - const recordStop = await context.daemon.callCommand( - 'record', - ['stop'], - { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }, - { meta: { cwd: tmpDir } }, +function assertAndroidOwnerlessRecordingNotRecovered(context: { + remotePath: string; + adbCalls: string[][]; + pullCalls: PullCall[]; +}): void { + const { adbCalls, pullCalls } = context; + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, ); - const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( - recordStop, + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), + false, ); - assert.equal(data.recording, 'stopped'); - assert.match(String(data.warning), /Recovered Android recording/); - assert.match(String(data.warning), /MP4 may be truncated/); - return requireStringOutPath(data.outPath); + assert.equal(pullCalls.length, 0); } -function assertAndroidRecordingRecovery( - context: { remotePath: string; adbCalls: string[][]; pullCalls: PullCall[] }, - outPath: string, +function seedAndroidSession( + daemon: ProviderScenarioDaemon, + name: string, + device: typeof PROVIDER_SCENARIO_ANDROID, ): void { - const { remotePath, adbCalls, pullCalls } = context; - assert.match(outPath, /\/recording-\d+\.mp4$/); - assert.equal(fs.existsSync(outPath), true); - assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); - assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); - assert.equal(pullCalls.length, 1); - assert.deepEqual(pullCalls[0], { remotePath, localPath: outPath }); - assertCommandCall(adbCalls, ['shell', 'rm', '-f', remotePath]); + daemon.setSession(name, { + name, + device, + createdAt: Date.now(), + actions: [], + }); +} + +async function runAndroidPidReuseManifestScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const remotePath = '/sdcard/agent-device-recording-923456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'pid-reuse.mp4'), + remotePath, + sessionName: 'default', + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { stdout: '4321 sh -c sleep 999\n', stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -A -o pid=,args=') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return androidAdbResult(args); + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + assertRpcError(recordStop, 'INVALID_ARGS', /no active recording/); + assertCommandCall(adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); + } finally { + await daemon.close(); + } +} + +async function runAndroidDeviceMismatchManifestScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const remotePath = '/sdcard/agent-device-recording-933456789.mp4'; + const manifest = { + ...buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'device-mismatch.mp4'), + remotePath, + sessionName: 'default', + }), + deviceId: 'wifi-emulator-5554', + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return androidAdbResult(args); + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + assertRpcError(recordStop, 'INVALID_ARGS', /manifest could not be validated/); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); + assert.equal( + adbCalls.some( + (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', + ), + false, + ); + } finally { + await daemon.close(); + } } -async function runAndroidUncertainMetadataScenario(_tmpDir: string): Promise { +async function runAndroidUncertainMetadataScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'uncertain.mp4'), + remotePath, + sessionName: 'default', + }); const daemon = await createProviderScenarioHarness({ androidAdbProvider: () => ({ exec: async (args) => { adbCalls.push([...args]); const command = args.join(' '); if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { - stdout: JSON.stringify({ - remotePath, - remotePid: '4321', - startedAt: 123456789, - }), - stderr: '', - exitCode: 0, - }; + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; } if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { return { stdout: '', stderr: '', exitCode: 1 }; @@ -292,7 +1126,11 @@ async function runAndroidUncertainMetadataScenario(_tmpDir: string): Promise args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); assert.equal( adbCalls.some( (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', @@ -411,10 +1249,133 @@ function createPullingAndroidProvider(params: { }, pull: async (remotePath, localPath) => { pullCalls.push({ remotePath, localPath }); - fs.writeFileSync(localPath, likelyPlayableMp4Container()); + writePlayableMp4(localPath); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; +} + +function createStatefulAndroidRecordingProvider(params: { + adbCalls: string[][]; + pullCalls: PullCall[]; +}): AndroidAdbProvider & { manifest?: ReturnType } { + const { adbCalls, pullCalls } = params; + const provider: AndroidAdbProvider & { + manifest?: ReturnType; + } = { + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + const manifestPayload = extractManifestWritePayload(command); + if (manifestPayload) { + provider.manifest = JSON.parse(manifestPayload); + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return provider.manifest + ? { stdout: JSON.stringify(provider.manifest), stderr: '', exitCode: 0 } + : { stdout: '', stderr: 'missing manifest', exitCode: 1 }; + } + if (command === 'shell rm -f /sdcard/agent-device-recording-active.json') { + delete provider.manifest; + return { stdout: '', stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid=,args= -p 4321' && provider.manifest?.current) { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${provider.manifest.current.remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (remotePath, localPath) => { + pullCalls.push({ remotePath, localPath }); + writePlayableMp4(localPath); return { stdout: '', stderr: '', exitCode: 0 }; }, }; + return provider; +} + +function extractManifestWritePayload(command: string): string | undefined { + const prefix = "shell printf %s '"; + if (!command.startsWith(prefix)) return undefined; + if (!command.includes('agent-device-recording-active.json.tmp')) return undefined; + const payloadEnd = command.indexOf("' >", prefix.length); + if (payloadEnd === -1) return undefined; + return command.slice(prefix.length, payloadEnd).replace(/'\\''/g, "'"); +} + +function createAndroidManifestProvider(params: { + adbCalls: string[][]; + manifests: Array>; + pullCalls?: PullCall[]; +}): AndroidAdbProvider { + const { adbCalls, manifests, pullCalls } = params; + return { + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + const manifest = findManifestForAdbCommand(manifests, command); + if (manifest) { + return manifest; + } + return androidAdbResult(args); + }, + pull: async (remotePath, localPath) => { + pullCalls?.push({ remotePath, localPath }); + writePlayableMp4(localPath); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; +} + +function findManifestForAdbCommand( + manifests: Array>, + command: string, +) { + for (const manifest of manifests) { + if (command === manifestCatCommand(manifest)) { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === manifestPendingProcessCommand(manifest)) { + return { + stdout: `${manifest.pendingRemotePid} screenrecord --bit-rate 8000000 ${manifest.pending?.remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + if (command === manifestProcessCommand(manifest)) { + assert.ok(manifest.current); + return { + stdout: `${manifest.current.remotePid} screenrecord --bit-rate 8000000 ${manifest.current.remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + } + return undefined; +} + +function manifestCatCommand(manifest: ReturnType): string { + const remotePath = manifest.current?.remotePath ?? manifest.pending?.remotePath; + assert.ok(remotePath); + const metadataPath = `${path.posix.dirname(remotePath)}/agent-device-recording-active.json`; + return `shell cat ${metadataPath}`; +} + +function manifestPendingProcessCommand( + manifest: ReturnType, +): string { + return manifest.pending ? 'shell ps -A -o pid=,args=' : ''; +} + +function manifestProcessCommand( + manifest: ReturnType, +): string { + if (!manifest.current) return ''; + return `shell ps -o pid=,args= -p ${manifest.current.remotePid}`; } function createRecordingOnlyAndroidProvider(adbCalls: string[][]): AndroidAdbProvider { @@ -440,12 +1401,75 @@ function androidRecoveryAdbResult( return androidAdbResult(args); } -function requireStringOutPath(value: unknown): string { - assert.equal(typeof value, 'string'); - if (typeof value !== 'string') { - throw new Error(`expected string outPath, got ${String(value)}`); - } - return value; +type AndroidRecordingManifestFixtureOptions = { + outPath: string; + remotePath: string; + sessionName: string; + sessionScope?: { kind: 'cwd'; id: string }; + status?: 'pending' | 'live' | 'rotating'; + pendingRemotePath?: string; + pendingRemotePid?: string; + remotePid?: string; + startedAt?: number; + chunks?: Array<{ index: number; path: string; remotePath: string }>; +}; + +function buildAndroidRecordingManifest(options: AndroidRecordingManifestFixtureOptions) { + const startedAt = options.startedAt ?? 123456789; + const status = options.status ?? 'live'; + return { + version: 1, + sessionName: options.sessionName, + sessionScope: options.sessionScope, + recordingId: `recording-${startedAt}`, + deviceId: PROVIDER_SCENARIO_ANDROID.id, + startedAt, + outPath: options.outPath, + showTouches: true, + exportQuality: 'medium', + current: buildAndroidRecordingManifestCurrent(options, startedAt, status), + pending: buildAndroidRecordingManifestPending(options, status), + pendingRemotePid: options.pendingRemotePid ?? (status === 'rotating' ? '4322' : '4321'), + chunks: buildAndroidRecordingManifestChunks(options), + }; +} + +function buildAndroidRecordingManifestCurrent( + options: AndroidRecordingManifestFixtureOptions, + startedAt: number, + status: 'pending' | 'live' | 'rotating', +) { + if (status === 'pending') return undefined; + return { + remotePath: options.remotePath, + remotePid: options.remotePid ?? '4321', + startedAt, + }; +} + +function buildAndroidRecordingManifestPending( + options: AndroidRecordingManifestFixtureOptions, + status: 'pending' | 'live' | 'rotating', +) { + return status === 'pending' || status === 'rotating' + ? { remotePath: options.pendingRemotePath ?? options.remotePath } + : undefined; +} + +function buildAndroidRecordingManifestChunks(options: AndroidRecordingManifestFixtureOptions) { + return ( + options.chunks ?? [ + { + index: 1, + path: options.outPath, + remotePath: options.remotePath, + }, + ] + ); +} + +function hashScopeRoot(scopeRoot: string): string { + return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); } function androidAdbResult(args: string[]): { @@ -478,6 +1502,10 @@ function androidAdbResult(args: string[]): { if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { return { stdout: '2048\n', stderr: '', exitCode: 0 }; } + if (args[0] === 'pull' && typeof args[2] === 'string') { + writePlayableMp4(args[2]); + return { stdout: '', stderr: '', exitCode: 0 }; + } if (command === 'shell ps -o pid= -p 4321') { return { stdout: '', stderr: '', exitCode: 1 }; } @@ -510,3 +1538,12 @@ function readLoggedArgs(logPath: string): string[] { .map((line) => line.trim()) .filter(Boolean); } + +function writePlayableMp4(filePath: string): void { + const fixturePath = path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'); + if (fs.existsSync(fixturePath)) { + fs.copyFileSync(fixturePath, filePath); + return; + } + fs.writeFileSync(filePath, likelyPlayableMp4Container()); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index f5abb052a..9c8ae2f04 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -32,6 +32,7 @@ export type ProviderScenarioHarness = { ) => Promise; client: () => AgentDeviceClient; session: (name?: string) => SessionState | undefined; + setSession: (name: string, session: SessionState) => void; close: () => Promise; }; @@ -74,6 +75,7 @@ export async function createProviderScenarioHarness( ), client: () => createAgentDeviceClient({}, { transport }), session: (name = 'default') => sessionStore.get(name), + setSession: (name, session) => sessionStore.set(name, session), close: async () => { fs.rmSync(sessionDir, { recursive: true, force: true }); }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f447e30ab..0a8ad3e50 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -802,6 +802,7 @@ agent-device record stop # Stop active recording - In `--json` mode, each overlay ref also includes a screenshot-space `center` point for coordinate fallback like `press `. - Burned-in touch overlays are exported only on macOS hosts, because the overlay pipeline depends on Swift + AVFoundation helpers. - On Linux or other non-macOS hosts, `record stop` still succeeds and returns the raw video plus telemetry sidecar, and includes `overlayWarning` when burn-in overlays were skipped. +- Android uses `adb shell screenrecord`, which has a 180s platform limit. `record start` publishes a durable device manifest. Longer recordings are split into MP4 chunks while the daemon stays alive; after daemon restart, `record stop` recovers only manifest-owned chunks and warns when gesture overlay telemetry was lost. **Session app logs (token-efficient debugging):** Logging is off by default in normal flows. Enable it on demand for debugging. Logs are written to a file so agents can grep instead of loading full output into context.