From 466a2913391d10afd1b51fe67386a56bc3b2db67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 13:08:05 +0200 Subject: [PATCH 1/6] fix: harden android recording recovery --- src/cli/parser/cli-help.ts | 2 +- src/commands/recording/index.ts | 2 +- .../handlers/record-trace-android-chunks.ts | 16 +- .../handlers/record-trace-android-recovery.ts | 338 +++++++++++++-- src/daemon/handlers/record-trace-android.ts | 43 +- .../record-trace-recording-backends.ts | 8 +- src/daemon/types.ts | 2 + .../android-recording.test.ts | 398 +++++++++++++++++- website/docs/docs/commands.md | 1 + 9 files changed, 722 insertions(+), 88 deletions(-) diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 3a5a5f3da..b74fcad0c 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -270,7 +270,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 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 chunks preserved in the durable Android recording manifest 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..5886f9f46 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 recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and manifest-known chunks can be recovered after daemon restart. 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/record-trace-android-chunks.ts b/src/daemon/handlers/record-trace-android-chunks.ts index 72c4134d0..6e24bb6d5 100644 --- a/src/daemon/handlers/record-trace-android-chunks.ts +++ b/src/daemon/handlers/record-trace-android-chunks.ts @@ -55,13 +55,15 @@ export function scheduleAndroidRecordingRotation(params: { recording: AndroidRecording; startNextChunk: (preferredRemoteDir: string) => Promise; finishCurrentChunk: () => Promise; + persistRecordingState?: (recording: AndroidRecording) => Promise; }): void { - const { recording, startNextChunk, finishCurrentChunk } = params; + const { recording, startNextChunk, finishCurrentChunk, persistRecordingState } = params; const timer = setTimeout(() => { recording.rotationPromise = rotateAndroidRecordingChunk({ recording, startNextChunk, finishCurrentChunk, + persistRecordingState, }) .catch((error: unknown) => { recording.rotationFailedReason = error instanceof Error ? error.message : String(error); @@ -69,7 +71,12 @@ export function scheduleAndroidRecordingRotation(params: { .finally(() => { recording.rotationPromise = undefined; if (!recording.stopping && !recording.rotationFailedReason) { - scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk }); + scheduleAndroidRecordingRotation({ + recording, + startNextChunk, + finishCurrentChunk, + persistRecordingState, + }); } }); }, ANDROID_SCREENRECORD_CHUNK_MS); @@ -81,8 +88,9 @@ async function rotateAndroidRecordingChunk(params: { recording: AndroidRecording; startNextChunk: (preferredRemoteDir: string) => Promise; finishCurrentChunk: () => Promise; + persistRecordingState?: (recording: AndroidRecording) => Promise; }): Promise { - const { recording, startNextChunk, finishCurrentChunk } = params; + const { recording, startNextChunk, finishCurrentChunk, persistRecordingState } = params; if (recording.stopping) return; const stopError = await finishCurrentChunk(); if (stopError) { @@ -95,6 +103,7 @@ async function rotateAndroidRecordingChunk(params: { const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); recording.remotePath = nextChunk.remotePath; recording.remotePid = nextChunk.remotePid; + recording.remoteStartedAt = nextChunk.startedAt; chunks.push({ index: nextIndex, path: deriveAndroidChunkOutPath(recording.outPath, nextIndex), @@ -102,6 +111,7 @@ async function rotateAndroidRecordingChunk(params: { }); recording.warning ??= 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.'; + await persistRecordingState?.(recording); } export async function finalizeAndroidRecordingOutput(params: { diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index 101181423..b5d22d14e 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -6,14 +6,20 @@ import type { } from '../../platforms/android/adb-executor.ts'; import { shellQuote } from '../../utils/shell-quote.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import type { DaemonResponse, SessionState } from '../types.ts'; +import type { DaemonResponse, RecordingChunk, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; +import type { RecordingExportQuality } from '../../core/recording-export-quality.ts'; const ANDROID_RECOVERY_WARNING = - 'Recovered Android recording after daemon recording state was missing; gesture overlays and earlier rotated chunks may be unavailable.'; + '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_OWNERLESS_RECOVERY_WARNING = + 'Recovered Android recording from a live screenrecord process without a durable manifest; session ownership, gesture overlays, and earlier rotated chunks could not be validated.'; const ANDROID_RECOVERY_METADATA_FILE = 'agent-device-recording-active.json'; const ANDROID_RECOVERY_PROBE_TIMEOUT_MS = 5_000; const ANDROID_RECOVERY_METADATA_DIRS = ['/sdcard', '/data/local/tmp'] as const; +const ANDROID_RECOVERY_MANIFEST_VERSION = 1; type AndroidDevice = SessionState['device']; type AndroidRecording = Extract, { platform: 'android' }>; @@ -35,6 +41,23 @@ type AndroidRecordingRecoveryMetadata = { startedAt: number; }; +type AndroidRecordingRecoveryManifest = { + version: 1; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + deviceId: string; + startedAt: number; + outPath: string; + clientOutPath?: string; + telemetryPath?: string; + maxSize?: number; + exportQuality?: RecordingExportQuality; + showTouches: boolean; + current: AndroidRecordingRecoveryMetadata; + chunks: RecordingChunk[]; +}; + async function runAndroidRecoveryAdb( deviceId: string, args: string[], @@ -66,46 +89,124 @@ function parseRecoverableAndroidScreenrecord( }; } -function parseAndroidRecoveryMetadata(value: string): AndroidRecordingRecoveryMetadata | undefined { - const metadata = parseAndroidRecoveryMetadataObject(value); - if (!metadata) { +function parseAndroidRecoveryManifest(value: string): AndroidRecordingRecoveryManifest | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { return undefined; } - const remotePid = parseAndroidRecoveryRemotePid(metadata.remotePid); - const remotePath = parseAndroidRecoveryRemotePath(metadata.remotePath); - if (!remotePid || !remotePath) { + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + const metadata = parsed as Record; + const current = metadata.current; + if ( + metadata.version !== ANDROID_RECOVERY_MANIFEST_VERSION || + typeof metadata.sessionName !== 'string' || + typeof metadata.recordingId !== 'string' || + typeof metadata.deviceId !== 'string' || + typeof metadata.startedAt !== 'number' || + !Number.isFinite(metadata.startedAt) || + typeof metadata.outPath !== 'string' || + typeof metadata.showTouches !== 'boolean' || + !current || + typeof current !== 'object' || + !Array.isArray(metadata.chunks) + ) { return undefined; } + const parsedCurrent = parseAndroidRecoveryMetadata(current); + if (!parsedCurrent) return undefined; + const chunks = metadata.chunks + .map(parseAndroidRecordingChunk) + .filter((chunk): chunk is RecordingChunk => chunk !== undefined); + if (chunks.length === 0 || chunks.length !== metadata.chunks.length) { + return undefined; + } + return { - remotePid, - remotePath, - startedAt: parseAndroidRecoveryStartedAt(metadata.startedAt), + version: ANDROID_RECOVERY_MANIFEST_VERSION, + sessionName: metadata.sessionName, + sessionScope: parseSessionScope(metadata.sessionScope), + recordingId: metadata.recordingId, + deviceId: metadata.deviceId, + startedAt: metadata.startedAt, + outPath: metadata.outPath, + clientOutPath: readOptionalString(metadata.clientOutPath), + telemetryPath: readOptionalString(metadata.telemetryPath), + maxSize: readOptionalNumber(metadata.maxSize), + exportQuality: parseRecordingExportQuality(metadata.exportQuality), + showTouches: metadata.showTouches, + current: parsedCurrent, + chunks, }; } -function parseAndroidRecoveryMetadataObject(value: string): Record | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { +function parseAndroidRecoveryMetadata( + value: unknown, +): AndroidRecordingRecoveryMetadata | undefined { + if (!value || typeof value !== 'object') { return undefined; } - if (!parsed || typeof parsed !== 'object') { + const metadata = value as Partial; + if ( + typeof metadata.remotePid !== 'string' || + !/^\d+$/.test(metadata.remotePid) || + typeof metadata.remotePath !== 'string' || + !isAndroidAgentRecordingPath(metadata.remotePath) + ) { return undefined; } - return parsed as Record; + return { + remotePid: metadata.remotePid, + remotePath: metadata.remotePath, + startedAt: + typeof metadata.startedAt === 'number' && Number.isFinite(metadata.startedAt) + ? metadata.startedAt + : Date.now(), + }; +} + +function parseAndroidRecordingChunk(value: unknown): RecordingChunk | 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.path !== 'string' || + typeof chunk.remotePath !== 'string' || + !isAndroidAgentRecordingPath(chunk.remotePath) + ) { + return undefined; + } + return { + index: chunk.index, + path: chunk.path, + 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 parseAndroidRecoveryRemotePid(value: unknown): string | undefined { - return typeof value === 'string' && /^\d+$/.test(value) ? value : undefined; +function parseRecordingExportQuality(value: unknown): RecordingExportQuality | undefined { + return value === 'medium' || value === 'high' ? value : undefined; } -function parseAndroidRecoveryRemotePath(value: unknown): string | undefined { - return typeof value === 'string' && isAndroidAgentRecordingPath(value) ? value : undefined; +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; } -function parseAndroidRecoveryStartedAt(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : Date.now(); +function readOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } function isAndroidAgentRecordingPath(remotePath: string): boolean { @@ -122,7 +223,8 @@ function androidRecoveryMetadataPaths(): string[] { async function readAndroidRecoveryMetadata( deviceId: string, -): Promise { +): Promise { + const manifests: AndroidRecordingRecoveryManifest[] = []; for (const metadataPath of androidRecoveryMetadataPaths()) { const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'cat', metadataPath], { allowFailure: true, @@ -131,7 +233,7 @@ async function readAndroidRecoveryMetadata( if (result.exitCode !== 0) { continue; } - const metadata = parseAndroidRecoveryMetadata(result.stdout); + const metadata = parseAndroidRecoveryManifest(result.stdout); if (!metadata) { await cleanupAndroidRecoveryMetadataPath({ deviceId, @@ -140,9 +242,18 @@ async function readAndroidRecoveryMetadata( }); continue; } - const liveness = await checkLiveRecoverableAndroidScreenrecord(deviceId, metadata); + if (metadata.deviceId !== deviceId) { + await cleanupAndroidRecoveryMetadataPath({ + deviceId, + metadataPath, + phase: 'record_stop_android_recovery_metadata_device_mismatch_cleanup_failed', + }); + continue; + } + const liveness = await checkLiveRecoverableAndroidScreenrecord(deviceId, metadata.current); if (liveness === 'live') { - return metadata; + manifests.push(metadata); + continue; } if (liveness === 'uncertain') { continue; @@ -153,7 +264,7 @@ async function readAndroidRecoveryMetadata( phase: 'record_stop_android_recovery_metadata_stale_cleanup_failed', }); } - return undefined; + return manifests; } async function checkLiveRecoverableAndroidScreenrecord( @@ -238,12 +349,15 @@ async function findRecoverableAndroidScreenrecord( return matches[0]; } -export async function writeAndroidRecoveryMetadata( - deviceId: string, - metadata: AndroidRecordingRecoveryMetadata, -): Promise { - const metadataPath = androidRecoveryMetadataPathForRemotePath(metadata.remotePath); - const payload = JSON.stringify(metadata); +export async function writeAndroidRecoveryMetadata(params: { + deviceId: string; + activeSession: SessionState; + recording: AndroidRecording; +}): Promise { + const { deviceId, activeSession, recording } = params; + const metadataPath = androidRecoveryMetadataPathForRemotePath(recording.remotePath); + const manifest = buildAndroidRecoveryManifest({ deviceId, activeSession, recording }); + const payload = JSON.stringify(manifest); const result = await runAndroidRecoveryAdb( deviceId, ['shell', `printf %s ${shellQuote(payload)} > ${shellQuote(metadataPath)}`], @@ -278,6 +392,42 @@ export async function writeAndroidRecoveryMetadata( } } +function buildAndroidRecoveryManifest(params: { + deviceId: string; + activeSession: SessionState; + recording: AndroidRecording; +}): AndroidRecordingRecoveryManifest { + const { deviceId, activeSession, recording } = params; + return { + version: ANDROID_RECOVERY_MANIFEST_VERSION, + sessionName: activeSession.name, + sessionScope: activeSession.sessionScope, + recordingId: + recording.recordingId ?? + `android-${recording.remotePid}-${recording.remoteStartedAt ?? recording.startedAt}`, + deviceId, + startedAt: recording.startedAt, + outPath: recording.outPath, + clientOutPath: recording.clientOutPath, + telemetryPath: recording.telemetryPath, + maxSize: recording.maxSize, + exportQuality: recording.exportQuality, + showTouches: recording.showTouches, + current: { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + startedAt: recording.remoteStartedAt ?? recording.startedAt, + }, + chunks: recording.chunks ?? [ + { + index: 1, + path: recording.outPath, + remotePath: recording.remotePath, + }, + ], + }; +} + export async function cleanupAndroidRecoveryMetadata(deviceId: string): Promise { for (const metadataPath of androidRecoveryMetadataPaths()) { await cleanupAndroidRecoveryMetadataPath({ @@ -314,13 +464,17 @@ async function cleanupAndroidRecoveryMetadataPath(params: { } export async function recoverMissingAndroidRecording(params: { + activeSession: SessionState; device: AndroidDevice; recordingBase: AndroidRecordingBase; }): Promise { - const { device, recordingBase } = params; - const recovered = - (await readAndroidRecoveryMetadata(device.id)) ?? - (await findRecoverableAndroidScreenrecord(device.id)); + const { activeSession, device, recordingBase } = params; + const manifests = await readAndroidRecoveryMetadata(device.id); + if (manifests.length > 0) { + return recoverAndroidRecordingFromManifest({ activeSession, device, manifests }); + } + + const recovered = await findRecoverableAndroidScreenrecord(device.id); if (!recovered) { return null; } @@ -341,8 +495,10 @@ export async function recoverMissingAndroidRecording(params: { return { platform: 'android', + recordingId: `recovered-${recovered.remotePid}-${recovered.startedAt}`, remotePath: recovered.remotePath, remotePid: recovered.remotePid, + remoteStartedAt: recovered.startedAt, chunks: [ { index: 1, @@ -352,6 +508,108 @@ export async function recoverMissingAndroidRecording(params: { ], ...recordingBase, startedAt: recovered.startedAt, - warning: ANDROID_RECOVERY_WARNING, + showTouches: false, + warning: ANDROID_OWNERLESS_RECOVERY_WARNING, + }; +} + +function recoverAndroidRecordingFromManifest(params: { + activeSession: SessionState; + device: AndroidDevice; + manifests: AndroidRecordingRecoveryManifest[]; +}): DaemonResponse | AndroidRecording { + const { activeSession, device, manifests } = params; + const matches = manifests.filter((manifest) => + androidRecoveryManifestMatchesSession(manifest, activeSession), + ); + if (matches.length === 0) { + return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), { + activeRecordings: manifests.map((manifest) => ({ + sessionName: manifest.sessionName, + sessionScope: manifest.sessionScope, + recordingId: manifest.recordingId, + remotePid: manifest.current.remotePid, + remotePath: manifest.current.remotePath, + })), + }); + } + if (matches.length > 1 || manifests.length > 1) { + return errorResponse( + 'INVALID_ARGS', + 'multiple active Android recording manifests exist; cannot safely recover missing recording state', + { + activeRecordings: manifests.map((manifest) => ({ + sessionName: manifest.sessionName, + sessionScope: manifest.sessionScope, + recordingId: manifest.recordingId, + remotePid: manifest.current.remotePid, + remotePath: manifest.current.remotePath, + })), + }, + ); + } + + const manifest = matches[0]!; + emitDiagnostic({ + level: 'warn', + phase: 'record_stop_android_recovered_missing_state', + data: { + deviceId: device.id, + sessionName: manifest.sessionName, + recordingId: manifest.recordingId, + remotePath: manifest.current.remotePath, + remotePid: manifest.current.remotePid, + outPath: manifest.outPath, + chunks: manifest.chunks.length, + }, + }); + + return { + platform: 'android', + recordingId: manifest.recordingId, + remotePath: manifest.current.remotePath, + remotePid: manifest.current.remotePid, + remoteStartedAt: manifest.current.startedAt, + chunks: manifest.chunks, + outPath: manifest.outPath, + clientOutPath: manifest.clientOutPath, + telemetryPath: manifest.telemetryPath, + startedAt: manifest.startedAt, + maxSize: manifest.maxSize, + exportQuality: manifest.exportQuality, + showTouches: false, + gestureEvents: [], + warning: manifest.showTouches + ? `${ANDROID_RECOVERY_WARNING} ${ANDROID_RECOVERY_OVERLAY_WARNING}.` + : ANDROID_RECOVERY_WARNING, + overlayWarning: manifest.showTouches ? ANDROID_RECOVERY_OVERLAY_WARNING : undefined, }; } + +function androidRecoveryManifestMatchesSession( + manifest: AndroidRecordingRecoveryManifest, + activeSession: SessionState, +): boolean { + return ( + manifest.sessionName === activeSession.name && + 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) { + return `active Android recording belongs to session "${manifests[0]!.sessionName}"; run record stop --session ${manifests[0]!.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..e5d04eed8 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'; @@ -39,6 +40,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' }>; @@ -78,6 +80,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 +112,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 +137,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 +182,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 +233,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', @@ -269,6 +275,7 @@ async function startAndroidScreenrecordChunk(params: { ['shell', buildAndroidScreenrecordCommand(remotePath, recordingSize, quality)], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }, ); if (startResult.exitCode !== 0) { @@ -312,10 +319,11 @@ async function startAndroidScreenrecordChunk(params: { } export async function startAndroidRecording(params: { + activeSession: SessionState; device: AndroidDevice; recordingBase: AndroidRecordingBase; }): Promise { - const { device, recordingBase } = params; + const { activeSession, device, recordingBase } = params; let recordingSize: AndroidRecordingSize | undefined; try { recordingSize = await resolveAndroidRecordingSize({ @@ -337,8 +345,9 @@ export async function startAndroidRecording(params: { } const recording = buildAndroidRecording({ recordingBase, chunk }); - await writeAndroidRecoveryMetadataForChunk(device.id, chunk); + await writeAndroidRecoveryMetadata({ deviceId: device.id, activeSession, recording }); scheduleAndroidRecordingChunks({ + activeSession, device, recording, recordingSize, @@ -354,8 +363,10 @@ function buildAndroidRecording(params: { const { recordingBase, chunk } = params; return { platform: 'android', + recordingId: randomUUID(), remotePath: chunk.remotePath, remotePid: chunk.remotePid, + remoteStartedAt: chunk.startedAt, chunks: [ { index: 1, @@ -368,24 +379,14 @@ 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; device: AndroidDevice; recording: AndroidRecording; recordingSize: AndroidRecordingSize | undefined; quality: RecordingExportQuality; }): void { - const { device, recording, recordingSize, quality } = params; + const { activeSession, device, recording, recordingSize, quality } = params; scheduleAndroidRecordingRotation({ recording, finishCurrentChunk: async () => @@ -408,9 +409,15 @@ function scheduleAndroidRecordingChunks(params: { : nextChunk.error.error.message, ); } - await writeAndroidRecoveryMetadataForChunk(device.id, nextChunk); return nextChunk; }, + persistRecordingState: async (updatedRecording) => { + await writeAndroidRecoveryMetadata({ + deviceId: device.id, + activeSession, + recording: updatedRecording, + }); + }, }); } @@ -430,6 +437,7 @@ async function finishCurrentAndroidRecordingChunk(params: { ['shell', 'kill', '-2', recording.remotePid], { allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, }, ); emitDiagnostic({ @@ -590,6 +598,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..07ea504e7 100644 --- a/src/daemon/handlers/record-trace-recording-backends.ts +++ b/src/daemon/handlers/record-trace-recording-backends.ts @@ -276,10 +276,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 ({ activeSession, device, recordingBase }) => + await startAndroidRecording({ activeSession, device, recordingBase }), + recoverMissingStop: async ({ activeSession, device, recordingBase }) => + await recoverMissingAndroidRecording({ activeSession, device, recordingBase }), stop: async ({ deps, device, recording, stopRequestedAt }) => await stopAndroidRecording({ deps, 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..950dd9898 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -29,10 +29,353 @@ 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, + async (tmpDir) => { + const adbCalls: string[][] = []; + const pullCalls: Array<{ remotePath: string; localPath: string }> = []; + const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; + const recordingPath = path.join(tmpDir, 'recovered-recording.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + 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 }); + fs.writeFileSync(to, likelyPlayableMp4Container()); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + const previousPath = process.env.PATH; + const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; + process.env.PATH = tmpDir; + process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + const data = assertRpcOk<{ + recording?: unknown; + outPath?: unknown; + warning?: unknown; + overlayWarning?: unknown; + }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.match(String(data.warning), /durable device manifest/); + assert.match(String(data.overlayWarning), /gesture telemetry/); + assert.equal(fs.existsSync(recordingPath), true); + + assertCommandCall(adbCalls, ['shell', 'cat', '/sdcard/agent-device-recording-active.json']); + assertCommandCall(adbCalls, ['shell', 'ps', '-o', 'pid=,args=', '-p', '4321']); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); + assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); + assert.equal(pullCalls.length, 1); + assert.deepEqual(pullCalls[0], { remotePath, localPath: recordingPath }); + assertCommandCall(adbCalls, ['shell', 'rm', '-f', remotePath]); + assertCommandCall(adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); + } finally { + await daemon.close(); + restoreEnv('PATH', previousPath); + restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); + } + }, + ); +}); + +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 refuses ambiguous durable manifests', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-ambiguous-', + async (tmpDir) => { + const adbCalls: string[][] = []; + const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; + const secondRemotePath = '/data/local/tmp/agent-device-recording-323456790.mp4'; + const firstManifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'first.mp4'), + remotePath: firstRemotePath, + sessionName: 'default', + }); + const secondManifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'second.mp4'), + remotePath: secondRemotePath, + sessionName: 'default', + remotePid: '9876', + }); + const adbProvider: 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(firstManifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { + return { stdout: JSON.stringify(secondManifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${firstRemotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + if (command === 'shell ps -o pid=,args= -p 9876') { + return { + stdout: `9876 screenrecord --bit-rate 8000000 ${secondRemotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + 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', /multiple active Android recording manifests/); + assert.equal( + adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')), + false, + ); + } finally { + await daemon.close(); + } + }, + ); +}); + +test('Provider-backed integration Android record stop cleans stale durable manifest before fallback scan', 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: 1 }; + } + if (command === 'shell ps -A -o pid=,args=') { + return { stdout: '', stderr: '', exitCode: 0 }; + } + 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.find((entry) => entry.command === 'shell ps -A -o pid=,args=')?.timeoutMs, + 5_000, + ); + } finally { + await daemon.close(); + } + }, + ); +}); + +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-', + async (tmpDir) => { + const adbCalls: string[][] = []; + const pullCalls: Array<{ remotePath: string; localPath: string }> = []; + 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 manifest = buildAndroidRecordingManifest({ + outPath: firstLocalPath, + remotePath: secondRemotePath, + sessionName: 'default', + startedAt: 523456789, + chunks: [ + { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, + { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, + ], + }); + const adbProvider: 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 ps -o pid=,args= -p 4321') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${secondRemotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + fs.writeFileSync(to, likelyPlayableMp4Container()); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + const previousPath = process.env.PATH; + const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; + process.env.PATH = tmpDir; + process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + const data = assertRpcOk<{ + recording?: unknown; + chunks?: Array<{ index?: unknown; path?: unknown }>; + }>(recordStop); + + assert.equal(data.recording, 'stopped'); + assert.deepEqual(data.chunks, [ + { index: 1, path: firstLocalPath }, + { index: 2, path: secondLocalPath }, + ]); + assert.deepEqual(pullCalls, [ + { remotePath: firstRemotePath, localPath: firstLocalPath }, + { remotePath: secondRemotePath, localPath: secondLocalPath }, + ]); + assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); + assertCommandCall(adbCalls, ['shell', 'rm', '-f', firstRemotePath]); + assertCommandCall(adbCalls, ['shell', 'rm', '-f', secondRemotePath]); + } finally { + await daemon.close(); + restoreEnv('PATH', previousPath); + restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); + } + }, ); }); @@ -426,26 +769,37 @@ function createRecordingOnlyAndroidProvider(adbCalls: string[][]): AndroidAdbPro }; } -function androidRecoveryAdbResult( - args: string[], - remotePath: string, -): ReturnType { - if (args.join(' ') === 'shell ps -A -o pid=,args=') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - 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; +function buildAndroidRecordingManifest(options: { + outPath: string; + remotePath: string; + sessionName: string; + remotePid?: string; + startedAt?: number; + chunks?: Array<{ index: number; path: string; remotePath: string }>; +}) { + const startedAt = options.startedAt ?? 123456789; + return { + version: 1, + sessionName: options.sessionName, + recordingId: `recording-${startedAt}`, + deviceId: PROVIDER_SCENARIO_ANDROID.id, + startedAt, + outPath: options.outPath, + showTouches: true, + exportQuality: 'medium', + current: { + remotePath: options.remotePath, + remotePid: options.remotePid ?? '4321', + startedAt, + }, + chunks: options.chunks ?? [ + { + index: 1, + path: options.outPath, + remotePath: options.remotePath, + }, + ], + }; } function androidAdbResult(args: string[]): { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 6322b360b..0a8ad1bab 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -798,6 +798,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. Longer recordings are split into MP4 chunks while the daemon stays alive; after daemon restart, `record stop` recovers only chunks listed in the durable Android recording manifest 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. From 21355e5e8f9c275285cce78f0ec090ad031865f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 13:32:46 +0200 Subject: [PATCH 2/6] fix: reduce android recording recovery fallow complexity --- .../handlers/record-trace-android-recovery.ts | 174 ++++--- .../android-recording.test.ts | 478 ++++++++++-------- 2 files changed, 376 insertions(+), 276 deletions(-) diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index b5d22d14e..248a4a9b5 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -58,6 +58,19 @@ type AndroidRecordingRecoveryManifest = { chunks: RecordingChunk[]; }; +type AndroidRecordingRecoveryManifestRequired = Pick< + AndroidRecordingRecoveryManifest, + 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'outPath' | 'showTouches' +>; + +type AndroidActiveRecordingSummary = { + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + remotePid: string; + remotePath: string; +}; + async function runAndroidRecoveryAdb( deviceId: string, args: string[], @@ -90,59 +103,73 @@ function parseRecoverableAndroidScreenrecord( } function parseAndroidRecoveryManifest(value: string): AndroidRecordingRecoveryManifest | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - return undefined; - } - if (!parsed || typeof parsed !== 'object') { - return undefined; - } - const metadata = parsed as Record; - const current = metadata.current; - if ( - metadata.version !== ANDROID_RECOVERY_MANIFEST_VERSION || - typeof metadata.sessionName !== 'string' || - typeof metadata.recordingId !== 'string' || - typeof metadata.deviceId !== 'string' || - typeof metadata.startedAt !== 'number' || - !Number.isFinite(metadata.startedAt) || - typeof metadata.outPath !== 'string' || - typeof metadata.showTouches !== 'boolean' || - !current || - typeof current !== 'object' || - !Array.isArray(metadata.chunks) - ) { - return undefined; - } - const parsedCurrent = parseAndroidRecoveryMetadata(current); + const metadata = parseJsonObject(value); + if (!metadata) return undefined; + const required = readAndroidRecoveryManifestRequired(metadata); + if (!required) return undefined; + const parsedCurrent = parseAndroidRecoveryMetadata(metadata.current); if (!parsedCurrent) return undefined; - const chunks = metadata.chunks - .map(parseAndroidRecordingChunk) - .filter((chunk): chunk is RecordingChunk => chunk !== undefined); - if (chunks.length === 0 || chunks.length !== metadata.chunks.length) { - return undefined; - } - + const chunks = parseAndroidRecordingChunks(metadata.chunks); + if (!chunks) return undefined; return { - version: ANDROID_RECOVERY_MANIFEST_VERSION, - sessionName: metadata.sessionName, + ...required, sessionScope: parseSessionScope(metadata.sessionScope), - recordingId: metadata.recordingId, - deviceId: metadata.deviceId, - startedAt: metadata.startedAt, - outPath: metadata.outPath, clientOutPath: readOptionalString(metadata.clientOutPath), telemetryPath: readOptionalString(metadata.telemetryPath), maxSize: readOptionalNumber(metadata.maxSize), exportQuality: parseRecordingExportQuality(metadata.exportQuality), - showTouches: metadata.showTouches, current: parsedCurrent, chunks, }; } +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< + AndroidRecordingRecoveryManifestRequired, + 'sessionName' | 'recordingId' | 'deviceId' | 'outPath' + > + | undefined { + const sessionName = readOptionalString(metadata.sessionName); + const recordingId = readOptionalString(metadata.recordingId); + const deviceId = readOptionalString(metadata.deviceId); + const outPath = readOptionalString(metadata.outPath); + if (!sessionName || !recordingId || !deviceId || !outPath) return undefined; + return { sessionName, recordingId, deviceId, outPath }; +} + function parseAndroidRecoveryMetadata( value: unknown, ): AndroidRecordingRecoveryMetadata | undefined { @@ -168,6 +195,14 @@ function parseAndroidRecoveryMetadata( }; } +function parseAndroidRecordingChunks(value: unknown): RecordingChunk[] | undefined { + if (!Array.isArray(value)) return undefined; + const chunks = value + .map(parseAndroidRecordingChunk) + .filter((chunk): chunk is RecordingChunk => chunk !== undefined); + return chunks.length > 0 && chunks.length === value.length ? chunks : undefined; +} + function parseAndroidRecordingChunk(value: unknown): RecordingChunk | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -209,6 +244,10 @@ 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); } @@ -519,37 +558,52 @@ function recoverAndroidRecordingFromManifest(params: { manifests: AndroidRecordingRecoveryManifest[]; }): DaemonResponse | AndroidRecording { const { activeSession, device, manifests } = params; + const selected = selectAndroidRecoveryManifest({ activeSession, manifests }); + if ('ok' in selected) return selected; + emitAndroidRecoveryDiagnostic(device, selected); + return buildAndroidRecordingFromManifest(selected); +} + +function selectAndroidRecoveryManifest(params: { + activeSession: SessionState; + manifests: AndroidRecordingRecoveryManifest[]; +}): DaemonResponse | AndroidRecordingRecoveryManifest { + const { activeSession, manifests } = params; const matches = manifests.filter((manifest) => androidRecoveryManifestMatchesSession(manifest, activeSession), ); + const activeRecordings = summarizeAndroidActiveRecordings(manifests); if (matches.length === 0) { return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), { - activeRecordings: manifests.map((manifest) => ({ - sessionName: manifest.sessionName, - sessionScope: manifest.sessionScope, - recordingId: manifest.recordingId, - remotePid: manifest.current.remotePid, - remotePath: manifest.current.remotePath, - })), + activeRecordings, }); } if (matches.length > 1 || manifests.length > 1) { return errorResponse( 'INVALID_ARGS', 'multiple active Android recording manifests exist; cannot safely recover missing recording state', - { - activeRecordings: manifests.map((manifest) => ({ - sessionName: manifest.sessionName, - sessionScope: manifest.sessionScope, - recordingId: manifest.recordingId, - remotePid: manifest.current.remotePid, - remotePath: manifest.current.remotePath, - })), - }, + { activeRecordings }, ); } + return matches[0]!; +} - const manifest = matches[0]!; +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, + })); +} + +function emitAndroidRecoveryDiagnostic( + device: AndroidDevice, + manifest: AndroidRecordingRecoveryManifest, +): void { emitDiagnostic({ level: 'warn', phase: 'record_stop_android_recovered_missing_state', @@ -563,7 +617,11 @@ function recoverAndroidRecordingFromManifest(params: { chunks: manifest.chunks.length, }, }); +} +function buildAndroidRecordingFromManifest( + manifest: AndroidRecordingRecoveryManifest, +): AndroidRecording { return { platform: 'android', recordingId: manifest.recordingId, diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 950dd9898..4e07b3edf 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -20,6 +20,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 () => { @@ -32,86 +33,7 @@ test('Provider-backed integration Android recording flow uses scripted ADB provi 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-', - async (tmpDir) => { - const adbCalls: string[][] = []; - const pullCalls: Array<{ remotePath: string; localPath: string }> = []; - const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; - const recordingPath = path.join(tmpDir, 'recovered-recording.mp4'); - const manifest = buildAndroidRecordingManifest({ - outPath: recordingPath, - remotePath, - sessionName: 'default', - }); - 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 }); - fs.writeFileSync(to, likelyPlayableMp4Container()); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - const data = assertRpcOk<{ - recording?: unknown; - outPath?: unknown; - warning?: unknown; - overlayWarning?: unknown; - }>(recordStop); - assert.equal(data.recording, 'stopped'); - assert.equal(data.outPath, recordingPath); - assert.match(String(data.warning), /durable device manifest/); - assert.match(String(data.overlayWarning), /gesture telemetry/); - assert.equal(fs.existsSync(recordingPath), true); - - assertCommandCall(adbCalls, ['shell', 'cat', '/sdcard/agent-device-recording-active.json']); - assertCommandCall(adbCalls, ['shell', 'ps', '-o', 'pid=,args=', '-p', '4321']); - assert.equal( - adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), - false, - ); - assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); - assert.equal(pullCalls.length, 1); - assert.deepEqual(pullCalls[0], { remotePath, localPath: recordingPath }); - assertCommandCall(adbCalls, ['shell', 'rm', '-f', remotePath]); - assertCommandCall(adbCalls, [ - 'shell', - 'rm', - '-f', - '/sdcard/agent-device-recording-active.json', - ]); - } finally { - await daemon.close(); - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } - }, + runAndroidManifestRecoveryScenario, ); }); @@ -174,68 +96,7 @@ test('Provider-backed integration Android record stop refuses another session du test('Provider-backed integration Android record stop refuses ambiguous durable manifests', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-ambiguous-', - async (tmpDir) => { - const adbCalls: string[][] = []; - const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; - const secondRemotePath = '/data/local/tmp/agent-device-recording-323456790.mp4'; - const firstManifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'first.mp4'), - remotePath: firstRemotePath, - sessionName: 'default', - }); - const secondManifest = buildAndroidRecordingManifest({ - outPath: path.join(tmpDir, 'second.mp4'), - remotePath: secondRemotePath, - sessionName: 'default', - remotePid: '9876', - }); - const adbProvider: 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(firstManifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { - return { stdout: JSON.stringify(secondManifest), stderr: '', exitCode: 0 }; - } - if (command === 'shell ps -o pid=,args= -p 4321') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${firstRemotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - if (command === 'shell ps -o pid=,args= -p 9876') { - return { - stdout: `9876 screenrecord --bit-rate 8000000 ${secondRemotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - 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', /multiple active Android recording manifests/); - assert.equal( - adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')), - false, - ); - } finally { - await daemon.close(); - } - }, + runAndroidAmbiguousManifestRecoveryScenario, ); }); @@ -300,82 +161,7 @@ test('Provider-backed integration Android record stop cleans stale durable manif 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-', - async (tmpDir) => { - const adbCalls: string[][] = []; - const pullCalls: Array<{ remotePath: string; localPath: string }> = []; - 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 manifest = buildAndroidRecordingManifest({ - outPath: firstLocalPath, - remotePath: secondRemotePath, - sessionName: 'default', - startedAt: 523456789, - chunks: [ - { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, - { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, - ], - }); - const adbProvider: 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 ps -o pid=,args= -p 4321') { - return { - stdout: `4321 screenrecord --bit-rate 8000000 ${secondRemotePath}\n`, - stderr: '', - exitCode: 0, - }; - } - return androidAdbResult(args); - }, - pull: async (from, to) => { - pullCalls.push({ remotePath: from, localPath: to }); - fs.writeFileSync(to, likelyPlayableMp4Container()); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }; - const daemon = await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }); - const previousPath = process.env.PATH; - const previousSwiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; - process.env.PATH = tmpDir; - process.env.AGENT_DEVICE_SWIFT_CACHE_DIR = path.join(tmpDir, 'swift-cache'); - - try { - const recordStop = await daemon.callCommand('record', ['stop'], { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }); - const data = assertRpcOk<{ - recording?: unknown; - chunks?: Array<{ index?: unknown; path?: unknown }>; - }>(recordStop); - - assert.equal(data.recording, 'stopped'); - assert.deepEqual(data.chunks, [ - { index: 1, path: firstLocalPath }, - { index: 2, path: secondLocalPath }, - ]); - assert.deepEqual(pullCalls, [ - { remotePath: firstRemotePath, localPath: firstLocalPath }, - { remotePath: secondRemotePath, localPath: secondLocalPath }, - ]); - assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); - assertCommandCall(adbCalls, ['shell', 'rm', '-f', firstRemotePath]); - assertCommandCall(adbCalls, ['shell', 'rm', '-f', secondRemotePath]); - } finally { - await daemon.close(); - restoreEnv('PATH', previousPath); - restoreEnv('AGENT_DEVICE_SWIFT_CACHE_DIR', previousSwiftCacheDir); - } - }, + runAndroidManifestChunkRecoveryScenario, ); }); @@ -419,6 +205,208 @@ 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); + assertAndroidManifestRecovery(recordStop, { ...context, recordingPath, remotePath }); + } finally { + await context.daemon.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 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); + assertAndroidManifestChunkRecovery(recordStop, { + ...context, + firstLocalPath, + firstRemotePath, + secondLocalPath, + secondRemotePath, + }); + } finally { + await context.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, +): Promise { + return await daemon.callCommand('record', ['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[][]; @@ -760,6 +748,60 @@ function createPullingAndroidProvider(params: { }; } +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 }); + fs.writeFileSync(localPath, likelyPlayableMp4Container()); + 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 === manifestProcessCommand(manifest)) { + return { + stdout: `${manifest.current.remotePid} screenrecord --bit-rate 8000000 ${manifest.current.remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + } + return undefined; +} + +function manifestCatCommand(manifest: ReturnType): string { + const metadataPath = `${path.posix.dirname(manifest.current.remotePath)}/agent-device-recording-active.json`; + return `shell cat ${metadataPath}`; +} + +function manifestProcessCommand( + manifest: ReturnType, +): string { + return `shell ps -o pid=,args= -p ${manifest.current.remotePid}`; +} + function createRecordingOnlyAndroidProvider(adbCalls: string[][]): AndroidAdbProvider { return { exec: async (args) => { From e055f5a33f3b2b468a70ee50f30f3497df94ac55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 14:30:35 +0200 Subject: [PATCH 3/6] test: fix android recording recovery rebase --- .../android-recording.test.ts | 120 ++++++++++++------ .../integration/provider-scenarios/harness.ts | 2 + 2 files changed, 80 insertions(+), 42 deletions(-) diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 4e07b3edf..a9bb93283 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -37,6 +37,13 @@ test('Provider-backed integration Android record stop recovers missing daemon re ); }); +test('Provider-backed integration Android record stop recovers missing daemon recording state from live screenrecord fallback', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-live-recovery-', + runAndroidRecordingRecoveryScenario, + ); +}); + test('Provider-backed integration Android record stop refuses another session durable manifest', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-wrong-session-', @@ -121,7 +128,7 @@ test('Provider-backed integration Android record stop cleans stale durable manif return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; } if (command === 'shell ps -o pid=,args= -p 4321') { - return { stdout: '', stderr: '', exitCode: 1 }; + return { stdout: '', stderr: '', exitCode: 0 }; } if (command === 'shell ps -A -o pid=,args=') { return { stdout: '', stderr: '', exitCode: 0 }; @@ -481,31 +488,37 @@ async function runAndroidCrossDeviceRecordingRecoveryScenario(tmpDir: string): P await withAndroidProviderScenarioEnv(tmpDir, async () => { try { + const recoveredPath = path.join(tmpDir, 'recovered-cross-device.mp4'); + 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(pullCalls.length, 1); @@ -519,7 +532,9 @@ async function runAndroidRecordingRecoveryScenario(tmpDir: string): Promise { try { - const outPath = await exerciseAndroidRecordingRecoveryStop(context, tmpDir); + const outPath = path.join(tmpDir, 'recovered-live.mp4'); + seedAndroidSession(context.daemon, 'default', PROVIDER_SCENARIO_ANDROID); + await exerciseAndroidRecordingRecoveryStop(context, outPath); assertAndroidRecordingRecovery(context, outPath); } finally { await context.daemon.close(); @@ -550,24 +565,21 @@ async function createAndroidRecordingRecoveryContext(): Promise<{ async function exerciseAndroidRecordingRecoveryStop( context: { daemon: ProviderScenarioDaemon }, - tmpDir: string, -): Promise { + outPath: string, +): Promise { const recordStop = await context.daemon.callCommand( 'record', - ['stop'], - { - platform: 'android', - serial: PROVIDER_SCENARIO_ANDROID.id, - }, - { meta: { cwd: tmpDir } }, + ['stop', outPath], + {}, + { session: 'default' }, ); const data = assertRpcOk<{ recording?: unknown; outPath?: unknown; warning?: unknown }>( recordStop, ); 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.match(String(data.warning), /without a durable manifest/); + assert.equal(data.outPath, outPath); } function assertAndroidRecordingRecovery( @@ -575,7 +587,7 @@ function assertAndroidRecordingRecovery( outPath: string, ): void { const { remotePath, adbCalls, pullCalls } = context; - assert.match(outPath, /\/recording-\d+\.mp4$/); + assert.equal(path.extname(outPath), '.mp4'); assert.equal(fs.existsSync(outPath), true); assertCommandCall(adbCalls, ['shell', 'ps', '-A', '-o', 'pid=,args=']); assertCommandCall(adbCalls, ['shell', 'kill', '-2', '4321']); @@ -584,24 +596,34 @@ function assertAndroidRecordingRecovery( assertCommandCall(adbCalls, ['shell', 'rm', '-f', remotePath]); } -async function runAndroidUncertainMetadataScenario(_tmpDir: string): Promise { +function seedAndroidSession( + daemon: ProviderScenarioDaemon, + name: string, + device: typeof PROVIDER_SCENARIO_ANDROID, +): void { + daemon.setSession(name, { + name, + device, + createdAt: Date.now(), + actions: [], + }); +} + +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 }; @@ -811,6 +833,20 @@ function createRecordingOnlyAndroidProvider(adbCalls: string[][]): AndroidAdbPro }; } +function androidRecoveryAdbResult( + args: string[], + remotePath: string, +): ReturnType { + if (args.join(' ') === 'shell ps -A -o pid=,args=') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); +} + function buildAndroidRecordingManifest(options: { outPath: string; remotePath: string; 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 }); }, From 18623dc791069df8cab41fe384de6b95a6bf77cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 14:46:45 +0200 Subject: [PATCH 4/6] fix: block uncertain android recording fallback --- .../handlers/record-trace-android-recovery.ts | 61 ++++++++++++-- .../android-recording.test.ts | 79 ++++++++++++++++++- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index 248a4a9b5..01bf16013 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -58,6 +58,11 @@ type AndroidRecordingRecoveryManifest = { chunks: RecordingChunk[]; }; +type AndroidRecoveryManifestScan = { + live: AndroidRecordingRecoveryManifest[]; + uncertain: AndroidRecordingRecoveryManifest[]; +}; + type AndroidRecordingRecoveryManifestRequired = Pick< AndroidRecordingRecoveryManifest, 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'outPath' | 'showTouches' @@ -260,10 +265,8 @@ function androidRecoveryMetadataPaths(): string[] { return ANDROID_RECOVERY_METADATA_DIRS.map((dir) => `${dir}/${ANDROID_RECOVERY_METADATA_FILE}`); } -async function readAndroidRecoveryMetadata( - deviceId: string, -): Promise { - const manifests: AndroidRecordingRecoveryManifest[] = []; +async function readAndroidRecoveryMetadata(deviceId: string): Promise { + const scan: AndroidRecoveryManifestScan = { live: [], uncertain: [] }; for (const metadataPath of androidRecoveryMetadataPaths()) { const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'cat', metadataPath], { allowFailure: true, @@ -291,10 +294,11 @@ async function readAndroidRecoveryMetadata( } const liveness = await checkLiveRecoverableAndroidScreenrecord(deviceId, metadata.current); if (liveness === 'live') { - manifests.push(metadata); + scan.live.push(metadata); continue; } if (liveness === 'uncertain') { + scan.uncertain.push(metadata); continue; } await cleanupAndroidRecoveryMetadataPath({ @@ -303,7 +307,7 @@ async function readAndroidRecoveryMetadata( phase: 'record_stop_android_recovery_metadata_stale_cleanup_failed', }); } - return manifests; + return scan; } async function checkLiveRecoverableAndroidScreenrecord( @@ -509,8 +513,18 @@ export async function recoverMissingAndroidRecording(params: { }): Promise { const { activeSession, device, recordingBase } = params; const manifests = await readAndroidRecoveryMetadata(device.id); - if (manifests.length > 0) { - return recoverAndroidRecordingFromManifest({ activeSession, device, manifests }); + if (manifests.live.length > 0) { + return recoverAndroidRecordingFromManifest({ + activeSession, + device, + manifests: manifests.live, + }); + } + if (manifests.uncertain.length > 0) { + return blockAndroidOwnerlessRecoveryForUncertainManifest({ + activeSession, + manifests: manifests.uncertain, + }); } const recovered = await findRecoverableAndroidScreenrecord(device.id); @@ -552,6 +566,37 @@ export async function recoverMissingAndroidRecording(params: { }; } +function blockAndroidOwnerlessRecoveryForUncertainManifest(params: { + activeSession: SessionState; + manifests: AndroidRecordingRecoveryManifest[]; +}): DaemonResponse { + const { activeSession, manifests } = params; + const matches = manifests.filter((manifest) => + androidRecoveryManifestMatchesSession(manifest, activeSession), + ); + const activeRecordings = summarizeAndroidActiveRecordings(manifests); + const details = { + activeRecordings, + recoveryBlocked: 'manifest_liveness_uncertain', + hint: 'Retry record stop after the device responds. Ownerless Android recording recovery is disabled while a valid durable manifest remains unverified.', + }; + if (matches.length === 0) { + return errorResponse('INVALID_ARGS', formatAndroidRecordingOwnerMismatch(manifests), details); + } + if (matches.length > 1 || manifests.length > 1) { + 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 recoverAndroidRecordingFromManifest(params: { activeSession: SessionState; device: AndroidDevice; diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index a9bb93283..4c4749e3d 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -100,6 +100,13 @@ test('Provider-backed integration Android record stop refuses another session du ); }); +test('Provider-backed integration Android record stop refuses another session uncertain manifest before ownerless fallback', 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-', @@ -268,6 +275,72 @@ async function runAndroidAmbiguousManifestRecoveryScenario(tmpDir: string): Prom } } +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'; @@ -645,7 +718,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', From 704174493b2b793cdea23f21967e179996cdaf49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 16:10:46 +0200 Subject: [PATCH 5/6] fix: address android recording recovery review --- .../handlers/record-trace-android-chunks.ts | 18 +- .../handlers/record-trace-android-recovery.ts | 105 +++++++---- src/daemon/handlers/record-trace-android.ts | 38 ++-- .../android-recording.test.ts | 175 +++++++++++++++++- 4 files changed, 275 insertions(+), 61 deletions(-) diff --git a/src/daemon/handlers/record-trace-android-chunks.ts b/src/daemon/handlers/record-trace-android-chunks.ts index 6e24bb6d5..7bd4d0385 100644 --- a/src/daemon/handlers/record-trace-android-chunks.ts +++ b/src/daemon/handlers/record-trace-android-chunks.ts @@ -54,7 +54,7 @@ export function resolveAndroidScreenrecordLimitWarning( export function scheduleAndroidRecordingRotation(params: { recording: AndroidRecording; startNextChunk: (preferredRemoteDir: string) => Promise; - finishCurrentChunk: () => Promise; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; persistRecordingState?: (recording: AndroidRecording) => Promise; }): void { const { recording, startNextChunk, finishCurrentChunk, persistRecordingState } = params; @@ -87,19 +87,19 @@ export function scheduleAndroidRecordingRotation(params: { async function rotateAndroidRecordingChunk(params: { recording: AndroidRecording; startNextChunk: (preferredRemoteDir: string) => Promise; - finishCurrentChunk: () => Promise; + finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise; persistRecordingState?: (recording: AndroidRecording) => Promise; }): Promise { const { recording, startNextChunk, finishCurrentChunk, persistRecordingState } = params; if (recording.stopping) return; - const stopError = await finishCurrentChunk(); - if (stopError) { - throw new Error(stopError); - } - if (recording.stopping) return; const chunks = ensureAndroidRecordingChunks(recording); const nextIndex = chunks.length + 1; + const previousChunk = { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + startedAt: recording.remoteStartedAt ?? recording.startedAt, + }; const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); recording.remotePath = nextChunk.remotePath; recording.remotePid = nextChunk.remotePid; @@ -112,6 +112,10 @@ async function rotateAndroidRecordingChunk(params: { recording.warning ??= 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.'; await persistRecordingState?.(recording); + const stopError = await finishCurrentChunk(previousChunk); + if (stopError) { + throw new Error(stopError); + } } export async function finalizeAndroidRecordingOutput(params: { diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index 01bf16013..ea9deb75d 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -9,6 +9,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonResponse, RecordingChunk, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; import type { RecordingExportQuality } from '../../core/recording-export-quality.ts'; +import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; const ANDROID_RECOVERY_WARNING = 'Recovered Android recording after daemon restart from durable device manifest.'; @@ -61,6 +62,12 @@ type AndroidRecordingRecoveryManifest = { type AndroidRecoveryManifestScan = { live: AndroidRecordingRecoveryManifest[]; uncertain: AndroidRecordingRecoveryManifest[]; + blocked: AndroidRecoveryBlockedManifest[]; +}; + +type AndroidRecoveryBlockedManifest = { + metadataPath: string; + reason: string; }; type AndroidRecordingRecoveryManifestRequired = Pick< @@ -107,24 +114,32 @@ function parseRecoverableAndroidScreenrecord( }; } -function parseAndroidRecoveryManifest(value: string): AndroidRecordingRecoveryManifest | undefined { +function parseAndroidRecoveryManifest( + value: string, +): + | { kind: 'manifest'; manifest: AndroidRecordingRecoveryManifest } + | { kind: 'delete' } + | { kind: 'blocked'; reason: string } { const metadata = parseJsonObject(value); - if (!metadata) return undefined; + if (!metadata) return { kind: 'delete' }; const required = readAndroidRecoveryManifestRequired(metadata); - if (!required) return undefined; + if (!required) return { kind: 'blocked', reason: 'unsupported_or_malformed_manifest' }; const parsedCurrent = parseAndroidRecoveryMetadata(metadata.current); - if (!parsedCurrent) return undefined; + if (!parsedCurrent) return { kind: 'blocked', reason: 'invalid_current_recording' }; const chunks = parseAndroidRecordingChunks(metadata.chunks); - if (!chunks) return undefined; + if (!chunks) return { kind: 'blocked', reason: 'invalid_recording_chunks' }; return { - ...required, - sessionScope: parseSessionScope(metadata.sessionScope), - clientOutPath: readOptionalString(metadata.clientOutPath), - telemetryPath: readOptionalString(metadata.telemetryPath), - maxSize: readOptionalNumber(metadata.maxSize), - exportQuality: parseRecordingExportQuality(metadata.exportQuality), - current: parsedCurrent, - chunks, + kind: 'manifest', + manifest: { + ...required, + sessionScope: parseSessionScope(metadata.sessionScope), + clientOutPath: readOptionalString(metadata.clientOutPath), + telemetryPath: readOptionalString(metadata.telemetryPath), + maxSize: readOptionalNumber(metadata.maxSize), + exportQuality: parseRecordingExportQuality(metadata.exportQuality), + current: parsedCurrent, + chunks, + }, }; } @@ -266,7 +281,7 @@ function androidRecoveryMetadataPaths(): string[] { } async function readAndroidRecoveryMetadata(deviceId: string): Promise { - const scan: AndroidRecoveryManifestScan = { live: [], uncertain: [] }; + const scan: AndroidRecoveryManifestScan = { live: [], uncertain: [], blocked: [] }; for (const metadataPath of androidRecoveryMetadataPaths()) { const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'cat', metadataPath], { allowFailure: true, @@ -275,8 +290,8 @@ async function readAndroidRecoveryMetadata(deviceId: string): Promise line.trim().startsWith(metadata.remotePid)); + .map((line) => line.trim()) + .find((line) => line.startsWith(metadata.remotePid)); const matched = result.stdout .split(/\r?\n/) .map(parseRecoverableAndroidScreenrecord) @@ -350,7 +367,7 @@ async function checkLiveRecoverableAndroidScreenrecord( if (matched) { return 'live'; } - return sawPid ? 'uncertain' : 'stale'; + return pidLine?.includes('screenrecord') ? 'uncertain' : 'stale'; } async function findRecoverableAndroidScreenrecord( @@ -517,6 +534,7 @@ export async function recoverMissingAndroidRecording(params: { return recoverAndroidRecordingFromManifest({ activeSession, device, + recordingBase, manifests: manifests.live, }); } @@ -526,6 +544,9 @@ export async function recoverMissingAndroidRecording(params: { manifests: manifests.uncertain, }); } + if (manifests.blocked.length > 0) { + return blockAndroidOwnerlessRecoveryForBlockedManifest(manifests.blocked); + } const recovered = await findRecoverableAndroidScreenrecord(device.id); if (!recovered) { @@ -597,16 +618,31 @@ function blockAndroidOwnerlessRecoveryForUncertainManifest(params: { ); } +function blockAndroidOwnerlessRecoveryForBlockedManifest( + manifests: AndroidRecoveryBlockedManifest[], +): DaemonResponse { + return errorResponse( + 'INVALID_ARGS', + 'active Android recording manifest could not be validated; ownerless recovery is disabled while durable recovery state remains', + { + 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: { activeSession: SessionState; device: AndroidDevice; + recordingBase: AndroidRecordingBase; manifests: AndroidRecordingRecoveryManifest[]; }): DaemonResponse | AndroidRecording { - const { activeSession, device, manifests } = params; + const { activeSession, device, recordingBase, manifests } = params; const selected = selectAndroidRecoveryManifest({ activeSession, manifests }); if ('ok' in selected) return selected; emitAndroidRecoveryDiagnostic(device, selected); - return buildAndroidRecordingFromManifest(selected); + return buildAndroidRecordingFromManifest(selected, recordingBase); } function selectAndroidRecoveryManifest(params: { @@ -666,6 +702,7 @@ function emitAndroidRecoveryDiagnostic( function buildAndroidRecordingFromManifest( manifest: AndroidRecordingRecoveryManifest, + recordingBase: AndroidRecordingBase, ): AndroidRecording { return { platform: 'android', @@ -673,13 +710,17 @@ function buildAndroidRecordingFromManifest( remotePath: manifest.current.remotePath, remotePid: manifest.current.remotePid, remoteStartedAt: manifest.current.startedAt, - chunks: manifest.chunks, - outPath: manifest.outPath, - clientOutPath: manifest.clientOutPath, - telemetryPath: manifest.telemetryPath, + 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: manifest.maxSize, - exportQuality: manifest.exportQuality, + maxSize: recordingBase.maxSize, + exportQuality: recordingBase.exportQuality, showTouches: false, gestureEvents: [], warning: manifest.showTouches diff --git a/src/daemon/handlers/record-trace-android.ts b/src/daemon/handlers/record-trace-android.ts index e5d04eed8..d1c91a72e 100644 --- a/src/daemon/handlers/record-trace-android.ts +++ b/src/daemon/handlers/record-trace-android.ts @@ -389,10 +389,12 @@ function scheduleAndroidRecordingChunks(params: { const { activeSession, 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) => { @@ -424,29 +426,33 @@ function scheduleAndroidRecordingChunks(params: { 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, - timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, - }, - ); + 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(), @@ -454,15 +460,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; } diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index 4c4749e3d..cfda6aa7c 100644 --- a/test/integration/provider-scenarios/android-recording.test.ts +++ b/test/integration/provider-scenarios/android-recording.test.ts @@ -100,6 +100,13 @@ test('Provider-backed integration Android record stop refuses another session du ); }); +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 before ownerless fallback', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-wrong-session-uncertain-', @@ -172,12 +179,26 @@ test('Provider-backed integration Android record stop cleans stale durable manif ); }); +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 while another device is recording', async () => { await withProviderScenarioTempDir( @@ -230,7 +251,7 @@ async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise await withAndroidProviderScenarioEnv(tmpDir, async () => { try { - const recordStop = await stopAndroidRecording(context.daemon); + const recordStop = await stopAndroidRecording(context.daemon, recordingPath); assertAndroidManifestRecovery(recordStop, { ...context, recordingPath, remotePath }); } finally { await context.daemon.close(); @@ -238,6 +259,37 @@ async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise }); } +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 runAndroidAmbiguousManifestRecoveryScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; @@ -359,7 +411,7 @@ async function runAndroidManifestChunkRecoveryScenario(tmpDir: string): Promise< await withAndroidProviderScenarioEnv(tmpDir, async () => { try { - const recordStop = await stopAndroidRecording(context.daemon); + const recordStop = await stopAndroidRecording(context.daemon, firstLocalPath); assertAndroidManifestChunkRecovery(recordStop, { ...context, firstLocalPath, @@ -397,8 +449,9 @@ async function createAndroidSingleManifestRecoveryContext(options: { async function stopAndroidRecording( daemon: ProviderScenarioDaemon, + outPath?: string, ): Promise { - return await daemon.callCommand('record', ['stop'], { + return await daemon.callCommand('record', outPath ? ['stop', outPath] : ['stop'], { platform: 'android', serial: PROVIDER_SCENARIO_ANDROID.id, }); @@ -682,6 +735,103 @@ function seedAndroidSession( }); } +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 { const adbCalls: string[][] = []; const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; @@ -841,7 +991,7 @@ function createPullingAndroidProvider(params: { }, pull: async (remotePath, localPath) => { pullCalls.push({ remotePath, localPath }); - fs.writeFileSync(localPath, likelyPlayableMp4Container()); + writePlayableMp4(localPath); return { stdout: '', stderr: '', exitCode: 0 }; }, }; @@ -865,7 +1015,7 @@ function createAndroidManifestProvider(params: { }, pull: async (remotePath, localPath) => { pullCalls?.push({ remotePath, localPath }); - fs.writeFileSync(localPath, likelyPlayableMp4Container()); + writePlayableMp4(localPath); return { stdout: '', stderr: '', exitCode: 0 }; }, }; @@ -987,6 +1137,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 }; } @@ -1019,3 +1173,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()); +} From a8633cbc83cb16dd38c94787c3c9022979023813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 6 Jul 2026 17:01:53 +0200 Subject: [PATCH 6/6] fix: address android recording recovery review followup --- .../handlers/__tests__/record-trace.test.ts | 84 +++++++++++++++++++ .../handlers/record-trace-android-chunks.ts | 21 ++++- .../handlers/record-trace-android-recovery.ts | 55 ++++-------- src/daemon/handlers/record-trace-recording.ts | 4 +- .../android-recording.test.ts | 53 ++++++++++++ 5 files changed, 173 insertions(+), 44 deletions(-) diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index 638eb6129..715117700 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,84 @@ 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('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 7bd4d0385..397651dbc 100644 --- a/src/daemon/handlers/record-trace-android-chunks.ts +++ b/src/daemon/handlers/record-trace-android-chunks.ts @@ -100,7 +100,23 @@ async function rotateAndroidRecordingChunk(params: { remotePid: recording.remotePid, startedAt: recording.remoteStartedAt ?? recording.startedAt, }; - const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); + let previousChunkFinished = false; + let nextChunk: AndroidScreenrecordChunk; + try { + nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); + } catch (concurrentStartError) { + const stopError = await finishCurrentChunk(previousChunk); + previousChunkFinished = true; + if (stopError) { + throw new Error(stopError); + } + if (recording.stopping) return; + try { + nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); + } catch (sequentialStartError) { + throw sequentialStartError instanceof Error ? sequentialStartError : concurrentStartError; + } + } recording.remotePath = nextChunk.remotePath; recording.remotePid = nextChunk.remotePid; recording.remoteStartedAt = nextChunk.startedAt; @@ -112,6 +128,9 @@ async function rotateAndroidRecordingChunk(params: { recording.warning ??= 'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.'; await persistRecordingState?.(recording); + if (previousChunkFinished) { + return; + } const stopError = await finishCurrentChunk(previousChunk); if (stopError) { throw new Error(stopError); diff --git a/src/daemon/handlers/record-trace-android-recovery.ts b/src/daemon/handlers/record-trace-android-recovery.ts index ea9deb75d..6ae18dab3 100644 --- a/src/daemon/handlers/record-trace-android-recovery.ts +++ b/src/daemon/handlers/record-trace-android-recovery.ts @@ -8,7 +8,6 @@ import { shellQuote } from '../../utils/shell-quote.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import type { DaemonResponse, RecordingChunk, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; -import type { RecordingExportQuality } from '../../core/recording-export-quality.ts'; import { deriveAndroidChunkOutPath } from './record-trace-android-chunks.ts'; const ANDROID_RECOVERY_WARNING = @@ -49,16 +48,13 @@ type AndroidRecordingRecoveryManifest = { recordingId: string; deviceId: string; startedAt: number; - outPath: string; - clientOutPath?: string; - telemetryPath?: string; - maxSize?: number; - exportQuality?: RecordingExportQuality; showTouches: boolean; current: AndroidRecordingRecoveryMetadata; - chunks: RecordingChunk[]; + chunks: AndroidRecordingRecoveryChunk[]; }; +type AndroidRecordingRecoveryChunk = Pick; + type AndroidRecoveryManifestScan = { live: AndroidRecordingRecoveryManifest[]; uncertain: AndroidRecordingRecoveryManifest[]; @@ -72,7 +68,7 @@ type AndroidRecoveryBlockedManifest = { type AndroidRecordingRecoveryManifestRequired = Pick< AndroidRecordingRecoveryManifest, - 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'outPath' | 'showTouches' + 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'showTouches' >; type AndroidActiveRecordingSummary = { @@ -133,10 +129,6 @@ function parseAndroidRecoveryManifest( manifest: { ...required, sessionScope: parseSessionScope(metadata.sessionScope), - clientOutPath: readOptionalString(metadata.clientOutPath), - telemetryPath: readOptionalString(metadata.telemetryPath), - maxSize: readOptionalNumber(metadata.maxSize), - exportQuality: parseRecordingExportQuality(metadata.exportQuality), current: parsedCurrent, chunks, }, @@ -177,17 +169,13 @@ function readAndroidRecoveryManifestRequired( function readAndroidRecoveryManifestStrings( metadata: Record, ): - | Pick< - AndroidRecordingRecoveryManifestRequired, - 'sessionName' | 'recordingId' | 'deviceId' | 'outPath' - > + | Pick | undefined { const sessionName = readOptionalString(metadata.sessionName); const recordingId = readOptionalString(metadata.recordingId); const deviceId = readOptionalString(metadata.deviceId); - const outPath = readOptionalString(metadata.outPath); - if (!sessionName || !recordingId || !deviceId || !outPath) return undefined; - return { sessionName, recordingId, deviceId, outPath }; + if (!sessionName || !recordingId || !deviceId) return undefined; + return { sessionName, recordingId, deviceId }; } function parseAndroidRecoveryMetadata( @@ -215,15 +203,15 @@ function parseAndroidRecoveryMetadata( }; } -function parseAndroidRecordingChunks(value: unknown): RecordingChunk[] | undefined { +function parseAndroidRecordingChunks(value: unknown): AndroidRecordingRecoveryChunk[] | undefined { if (!Array.isArray(value)) return undefined; const chunks = value .map(parseAndroidRecordingChunk) - .filter((chunk): chunk is RecordingChunk => chunk !== undefined); + .filter((chunk): chunk is AndroidRecordingRecoveryChunk => chunk !== undefined); return chunks.length > 0 && chunks.length === value.length ? chunks : undefined; } -function parseAndroidRecordingChunk(value: unknown): RecordingChunk | undefined { +function parseAndroidRecordingChunk(value: unknown): AndroidRecordingRecoveryChunk | undefined { if (!value || typeof value !== 'object') { return undefined; } @@ -232,7 +220,6 @@ function parseAndroidRecordingChunk(value: unknown): RecordingChunk | undefined typeof chunk.index !== 'number' || !Number.isInteger(chunk.index) || chunk.index < 1 || - typeof chunk.path !== 'string' || typeof chunk.remotePath !== 'string' || !isAndroidAgentRecordingPath(chunk.remotePath) ) { @@ -240,7 +227,6 @@ function parseAndroidRecordingChunk(value: unknown): RecordingChunk | undefined } return { index: chunk.index, - path: chunk.path, remotePath: chunk.remotePath, }; } @@ -252,10 +238,6 @@ function parseSessionScope(value: unknown): SessionState['sessionScope'] | undef return { kind: 'cwd', id: scope.id }; } -function parseRecordingExportQuality(value: unknown): RecordingExportQuality | undefined { - return value === 'medium' || value === 'high' ? value : undefined; -} - function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } @@ -467,24 +449,16 @@ function buildAndroidRecoveryManifest(params: { `android-${recording.remotePid}-${recording.remoteStartedAt ?? recording.startedAt}`, deviceId, startedAt: recording.startedAt, - outPath: recording.outPath, - clientOutPath: recording.clientOutPath, - telemetryPath: recording.telemetryPath, - maxSize: recording.maxSize, - exportQuality: recording.exportQuality, showTouches: recording.showTouches, current: { remotePath: recording.remotePath, remotePid: recording.remotePid, startedAt: recording.remoteStartedAt ?? recording.startedAt, }, - chunks: recording.chunks ?? [ - { - index: 1, - path: recording.outPath, - remotePath: recording.remotePath, - }, - ], + chunks: (recording.chunks ?? [{ index: 1, remotePath: recording.remotePath }]).map((chunk) => ({ + index: chunk.index, + remotePath: chunk.remotePath, + })), }; } @@ -694,7 +668,6 @@ function emitAndroidRecoveryDiagnostic( recordingId: manifest.recordingId, remotePath: manifest.current.remotePath, remotePid: manifest.current.remotePid, - outPath: manifest.outPath, chunks: manifest.chunks.length, }, }); diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index a88ed9f68..9cad59014 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; @@ -490,7 +490,7 @@ export async function handleRecordCommand(params: { const activeSession = session ?? ({ - name: resolvePublicSessionName(req), + name: sessionName, sessionScope: resolveImplicitSessionScope(req), device, createdAt: Date.now(), diff --git a/test/integration/provider-scenarios/android-recording.test.ts b/test/integration/provider-scenarios/android-recording.test.ts index cfda6aa7c..53d12db07 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'; @@ -37,6 +38,13 @@ test('Provider-backed integration Android record stop recovers missing daemon re ); }); +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 recovers missing daemon recording state from live screenrecord fallback', async () => { await withProviderScenarioTempDir( 'agent-device-provider-scenario-android-record-live-recovery-', @@ -290,6 +298,45 @@ async function runAndroidManifestHostPathScenario(tmpDir: string): Promise } } +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 runAndroidAmbiguousManifestRecoveryScenario(tmpDir: string): Promise { const adbCalls: string[][] = []; const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; @@ -1078,6 +1125,7 @@ function buildAndroidRecordingManifest(options: { outPath: string; remotePath: string; sessionName: string; + sessionScope?: { kind: 'cwd'; id: string }; remotePid?: string; startedAt?: number; chunks?: Array<{ index: number; path: string; remotePath: string }>; @@ -1086,6 +1134,7 @@ function buildAndroidRecordingManifest(options: { return { version: 1, sessionName: options.sessionName, + sessionScope: options.sessionScope, recordingId: `recording-${startedAt}`, deviceId: PROVIDER_SCENARIO_ANDROID.id, startedAt, @@ -1107,6 +1156,10 @@ function buildAndroidRecordingManifest(options: { }; } +function hashScopeRoot(scopeRoot: string): string { + return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); +} + function androidAdbResult(args: string[]): { stdout: string; stderr: string;