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/__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 72c4134d0..397651dbc 100644 --- a/src/daemon/handlers/record-trace-android-chunks.ts +++ b/src/daemon/handlers/record-trace-android-chunks.ts @@ -54,14 +54,16 @@ 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 } = 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); @@ -80,21 +87,39 @@ 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 } = params; - if (recording.stopping) return; - const stopError = await finishCurrentChunk(); - if (stopError) { - throw new Error(stopError); - } + const { recording, startNextChunk, finishCurrentChunk, persistRecordingState } = params; if (recording.stopping) return; const chunks = ensureAndroidRecordingChunks(recording); const nextIndex = chunks.length + 1; - const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath)); + const previousChunk = { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + startedAt: recording.remoteStartedAt ?? recording.startedAt, + }; + 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; chunks.push({ index: nextIndex, path: deriveAndroidChunkOutPath(recording.outPath, nextIndex), @@ -102,6 +127,14 @@ 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); + } } 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..6ae18dab3 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 { deriveAndroidChunkOutPath } from './record-trace-android-chunks.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,44 @@ type AndroidRecordingRecoveryMetadata = { startedAt: number; }; +type AndroidRecordingRecoveryManifest = { + version: 1; + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + deviceId: string; + startedAt: number; + showTouches: boolean; + current: AndroidRecordingRecoveryMetadata; + chunks: AndroidRecordingRecoveryChunk[]; +}; + +type AndroidRecordingRecoveryChunk = Pick; + +type AndroidRecoveryManifestScan = { + live: AndroidRecordingRecoveryManifest[]; + uncertain: AndroidRecordingRecoveryManifest[]; + blocked: AndroidRecoveryBlockedManifest[]; +}; + +type AndroidRecoveryBlockedManifest = { + metadataPath: string; + reason: string; +}; + +type AndroidRecordingRecoveryManifestRequired = Pick< + AndroidRecordingRecoveryManifest, + 'version' | 'sessionName' | 'recordingId' | 'deviceId' | 'startedAt' | 'showTouches' +>; + +type AndroidActiveRecordingSummary = { + sessionName: string; + sessionScope?: SessionState['sessionScope']; + recordingId: string; + remotePid: string; + remotePath: string; +}; + async function runAndroidRecoveryAdb( deviceId: string, args: string[], @@ -66,46 +110,144 @@ function parseRecoverableAndroidScreenrecord( }; } -function parseAndroidRecoveryMetadata(value: string): AndroidRecordingRecoveryMetadata | undefined { - const metadata = parseAndroidRecoveryMetadataObject(value); - if (!metadata) { +function parseAndroidRecoveryManifest( + value: string, +): + | { kind: 'manifest'; manifest: AndroidRecordingRecoveryManifest } + | { kind: 'delete' } + | { kind: 'blocked'; reason: string } { + const metadata = parseJsonObject(value); + if (!metadata) return { kind: 'delete' }; + const required = readAndroidRecoveryManifestRequired(metadata); + if (!required) return { kind: 'blocked', reason: 'unsupported_or_malformed_manifest' }; + const parsedCurrent = parseAndroidRecoveryMetadata(metadata.current); + if (!parsedCurrent) return { kind: 'blocked', reason: 'invalid_current_recording' }; + const chunks = parseAndroidRecordingChunks(metadata.chunks); + if (!chunks) return { kind: 'blocked', reason: 'invalid_recording_chunks' }; + return { + kind: 'manifest', + manifest: { + ...required, + sessionScope: parseSessionScope(metadata.sessionScope), + current: parsedCurrent, + chunks, + }, + }; +} + +function parseJsonObject(value: string): Record | undefined { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : undefined; + } catch { return undefined; } - const remotePid = parseAndroidRecoveryRemotePid(metadata.remotePid); - const remotePath = parseAndroidRecoveryRemotePath(metadata.remotePath); - if (!remotePid || !remotePath) { +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} + +function readAndroidRecoveryManifestRequired( + metadata: Record, +): AndroidRecordingRecoveryManifestRequired | undefined { + if (metadata.version !== ANDROID_RECOVERY_MANIFEST_VERSION) return undefined; + const strings = readAndroidRecoveryManifestStrings(metadata); + if (!strings) return undefined; + const startedAt = readOptionalNumber(metadata.startedAt); + if (startedAt === undefined) return undefined; + const showTouches = readOptionalBoolean(metadata.showTouches); + if (showTouches === undefined) return undefined; + return { + version: ANDROID_RECOVERY_MANIFEST_VERSION, + ...strings, + startedAt, + showTouches, + }; +} + +function readAndroidRecoveryManifestStrings( + metadata: Record, +): + | Pick + | undefined { + const sessionName = readOptionalString(metadata.sessionName); + const recordingId = readOptionalString(metadata.recordingId); + const deviceId = readOptionalString(metadata.deviceId); + if (!sessionName || !recordingId || !deviceId) return undefined; + return { sessionName, recordingId, deviceId }; +} + +function parseAndroidRecoveryMetadata( + value: unknown, +): AndroidRecordingRecoveryMetadata | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const metadata = value as Partial; + if ( + typeof metadata.remotePid !== 'string' || + !/^\d+$/.test(metadata.remotePid) || + typeof metadata.remotePath !== 'string' || + !isAndroidAgentRecordingPath(metadata.remotePath) + ) { return undefined; } return { - remotePid, - remotePath, - startedAt: parseAndroidRecoveryStartedAt(metadata.startedAt), + remotePid: metadata.remotePid, + remotePath: metadata.remotePath, + startedAt: + typeof metadata.startedAt === 'number' && Number.isFinite(metadata.startedAt) + ? metadata.startedAt + : Date.now(), }; } -function parseAndroidRecoveryMetadataObject(value: string): Record | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { +function parseAndroidRecordingChunks(value: unknown): AndroidRecordingRecoveryChunk[] | undefined { + if (!Array.isArray(value)) return undefined; + const chunks = value + .map(parseAndroidRecordingChunk) + .filter((chunk): chunk is AndroidRecordingRecoveryChunk => chunk !== undefined); + return chunks.length > 0 && chunks.length === value.length ? chunks : undefined; +} + +function parseAndroidRecordingChunk(value: unknown): AndroidRecordingRecoveryChunk | undefined { + if (!value || typeof value !== 'object') { return undefined; } - if (!parsed || typeof parsed !== 'object') { + const chunk = value as Partial; + if ( + typeof chunk.index !== 'number' || + !Number.isInteger(chunk.index) || + chunk.index < 1 || + typeof chunk.remotePath !== 'string' || + !isAndroidAgentRecordingPath(chunk.remotePath) + ) { return undefined; } - return parsed as Record; + return { + index: chunk.index, + remotePath: chunk.remotePath, + }; +} + +function parseSessionScope(value: unknown): SessionState['sessionScope'] | undefined { + if (!value || typeof value !== 'object') return undefined; + const scope = value as Partial>; + if (scope.kind !== 'cwd' || typeof scope.id !== 'string') return undefined; + return { kind: 'cwd', id: scope.id }; } -function parseAndroidRecoveryRemotePid(value: unknown): string | undefined { - return typeof value === 'string' && /^\d+$/.test(value) ? value : undefined; +function readOptionalString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; } -function parseAndroidRecoveryRemotePath(value: unknown): string | undefined { - return typeof value === 'string' && isAndroidAgentRecordingPath(value) ? value : undefined; +function readOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function parseAndroidRecoveryStartedAt(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : Date.now(); +function readOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; } function isAndroidAgentRecordingPath(remotePath: string): boolean { @@ -120,9 +262,8 @@ function androidRecoveryMetadataPaths(): string[] { return ANDROID_RECOVERY_METADATA_DIRS.map((dir) => `${dir}/${ANDROID_RECOVERY_METADATA_FILE}`); } -async function readAndroidRecoveryMetadata( - deviceId: string, -): Promise { +async function readAndroidRecoveryMetadata(deviceId: string): Promise { + const scan: AndroidRecoveryManifestScan = { live: [], uncertain: [], blocked: [] }; for (const metadataPath of androidRecoveryMetadataPaths()) { const result = await runAndroidRecoveryAdb(deviceId, ['shell', 'cat', metadataPath], { allowFailure: true, @@ -131,8 +272,8 @@ async function readAndroidRecoveryMetadata( if (result.exitCode !== 0) { continue; } - const metadata = parseAndroidRecoveryMetadata(result.stdout); - if (!metadata) { + const parsed = parseAndroidRecoveryManifest(result.stdout); + if (parsed.kind === 'delete') { await cleanupAndroidRecoveryMetadataPath({ deviceId, metadataPath, @@ -140,11 +281,22 @@ async function readAndroidRecoveryMetadata( }); continue; } - const liveness = await checkLiveRecoverableAndroidScreenrecord(deviceId, metadata); + if (parsed.kind === 'blocked') { + scan.blocked.push({ metadataPath, reason: parsed.reason }); + continue; + } + const metadata = parsed.manifest; + if (metadata.deviceId !== deviceId) { + scan.blocked.push({ metadataPath, reason: 'device_mismatch' }); + continue; + } + const liveness = await checkLiveRecoverableAndroidScreenrecord(deviceId, metadata.current); if (liveness === 'live') { - return metadata; + scan.live.push(metadata); + continue; } if (liveness === 'uncertain') { + scan.uncertain.push(metadata); continue; } await cleanupAndroidRecoveryMetadataPath({ @@ -153,7 +305,7 @@ async function readAndroidRecoveryMetadata( phase: 'record_stop_android_recovery_metadata_stale_cleanup_failed', }); } - return undefined; + return scan; } async function checkLiveRecoverableAndroidScreenrecord( @@ -183,9 +335,10 @@ async function checkLiveRecoverableAndroidScreenrecord( }); return 'uncertain'; } - const sawPid = result.stdout + const pidLine = result.stdout .split(/\r?\n/) - .some((line) => line.trim().startsWith(metadata.remotePid)); + .map((line) => line.trim()) + .find((line) => line.startsWith(metadata.remotePid)); const matched = result.stdout .split(/\r?\n/) .map(parseRecoverableAndroidScreenrecord) @@ -196,7 +349,7 @@ async function checkLiveRecoverableAndroidScreenrecord( if (matched) { return 'live'; } - return sawPid ? 'uncertain' : 'stale'; + return pidLine?.includes('screenrecord') ? 'uncertain' : 'stale'; } async function findRecoverableAndroidScreenrecord( @@ -238,12 +391,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 +434,34 @@ 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, + showTouches: recording.showTouches, + current: { + remotePath: recording.remotePath, + remotePid: recording.remotePid, + startedAt: recording.remoteStartedAt ?? recording.startedAt, + }, + chunks: (recording.chunks ?? [{ index: 1, remotePath: recording.remotePath }]).map((chunk) => ({ + index: chunk.index, + remotePath: chunk.remotePath, + })), + }; +} + export async function cleanupAndroidRecoveryMetadata(deviceId: string): Promise { for (const metadataPath of androidRecoveryMetadataPaths()) { await cleanupAndroidRecoveryMetadataPath({ @@ -314,13 +498,31 @@ 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.live.length > 0) { + return recoverAndroidRecordingFromManifest({ + activeSession, + device, + recordingBase, + manifests: manifests.live, + }); + } + if (manifests.uncertain.length > 0) { + return blockAndroidOwnerlessRecoveryForUncertainManifest({ + activeSession, + manifests: manifests.uncertain, + }); + } + if (manifests.blocked.length > 0) { + return blockAndroidOwnerlessRecoveryForBlockedManifest(manifests.blocked); + } + + const recovered = await findRecoverableAndroidScreenrecord(device.id); if (!recovered) { return null; } @@ -341,8 +543,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 +556,177 @@ export async function recoverMissingAndroidRecording(params: { ], ...recordingBase, startedAt: recovered.startedAt, - warning: ANDROID_RECOVERY_WARNING, + showTouches: false, + warning: ANDROID_OWNERLESS_RECOVERY_WARNING, + }; +} + +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 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, recordingBase, manifests } = params; + const selected = selectAndroidRecoveryManifest({ activeSession, manifests }); + if ('ok' in selected) return selected; + emitAndroidRecoveryDiagnostic(device, selected); + return buildAndroidRecordingFromManifest(selected, recordingBase); +} + +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, + }); + } + if (matches.length > 1 || manifests.length > 1) { + return errorResponse( + 'INVALID_ARGS', + 'multiple active Android recording manifests exist; cannot safely recover missing recording state', + { activeRecordings }, + ); + } + return 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', + data: { + deviceId: device.id, + sessionName: manifest.sessionName, + recordingId: manifest.recordingId, + remotePath: manifest.current.remotePath, + remotePid: manifest.current.remotePid, + chunks: manifest.chunks.length, + }, + }); +} + +function buildAndroidRecordingFromManifest( + manifest: AndroidRecordingRecoveryManifest, + recordingBase: AndroidRecordingBase, +): AndroidRecording { + return { + platform: 'android', + recordingId: manifest.recordingId, + remotePath: manifest.current.remotePath, + remotePid: manifest.current.remotePid, + remoteStartedAt: manifest.current.startedAt, + chunks: manifest.chunks.map((chunk) => ({ + index: chunk.index, + path: deriveAndroidChunkOutPath(recordingBase.outPath, chunk.index), + remotePath: chunk.remotePath, + })), + outPath: recordingBase.outPath, + clientOutPath: recordingBase.clientOutPath, + telemetryPath: recordingBase.telemetryPath, + startedAt: manifest.startedAt, + maxSize: recordingBase.maxSize, + exportQuality: recordingBase.exportQuality, + showTouches: false, + gestureEvents: [], + warning: manifest.showTouches + ? `${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..d1c91a72e 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,30 +379,22 @@ 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 () => + finishCurrentChunk: async (chunk) => await finishCurrentAndroidRecordingChunk({ device, recording, + remotePath: chunk.remotePath, + remotePid: chunk.remotePid, waitForRemoteFileStability: false, }), startNextChunk: async (preferredRemoteDir) => { @@ -408,37 +411,48 @@ 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, + }); + }, }); } async function finishCurrentAndroidRecordingChunk(params: { device: AndroidDevice; recording: AndroidRecording; + remotePath?: string; + remotePid?: string; waitForRemoteFileStability?: boolean; }): Promise { - const { device, recording, waitForRemoteFileStability = true } = params; - const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, recording.remotePid); + const { + device, + recording, + remotePath = recording.remotePath, + remotePid = recording.remotePid, + waitForRemoteFileStability = true, + } = params; + const wasRunningBeforeStop = await isAndroidProcessRunning(device.id, remotePid); if (!wasRunningBeforeStop) { appendAndroidRecordingWarning(recording, resolveAndroidScreenrecordLimitWarning(recording)); } - const stopResult = await runAndroidRecordingAdb( - device.id, - ['shell', 'kill', '-2', recording.remotePid], - { - allowFailure: true, - }, - ); + const stopResult = await runAndroidRecordingAdb(device.id, ['shell', 'kill', '-2', remotePid], { + allowFailure: true, + timeoutMs: ANDROID_RECORDING_PROBE_TIMEOUT_MS, + }); emitDiagnostic({ level: 'debug', phase: 'record_stop_android_signal', data: { deviceId: device.id, - remotePath: recording.remotePath, - remotePid: recording.remotePid, + remotePath, + remotePid, exitCode: stopResult.exitCode, stdout: stopResult.stdout.trim(), stderr: stopResult.stderr.trim(), @@ -446,15 +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; } @@ -590,6 +604,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/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/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..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'; @@ -20,6 +21,7 @@ import { } from './harness.ts'; type ProviderScenarioDaemon = Awaited>; +type ProviderScenarioRpcResult = Awaited>; type PullCall = { remotePath: string; localPath: string }; test('Provider-backed integration Android recording flow uses scripted ADB provider pull capability', async () => { @@ -29,13 +31,183 @@ 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-', + runAndroidManifestRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop recovers cwd-scoped durable manifest', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-scoped-recovery-', + runAndroidScopedManifestRecoveryScenario, + ); +}); + +test('Provider-backed integration Android record stop 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-', + async (tmpDir) => { + const adbCalls: string[][] = []; + const pullCalls: Array<{ remotePath: string; localPath: string }> = []; + const remotePath = '/sdcard/agent-device-recording-223456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'other-session.mp4'), + remotePath, + sessionName: 'checkout', + }); + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + if (args.join(' ') === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (args.join(' ') === 'shell ps -o pid=,args= -p 4321') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + + assertRpcError(recordStop, 'INVALID_ARGS', /belongs to session "checkout"/); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), + false, + ); + assert.equal(pullCalls.length, 0); + } finally { + await daemon.close(); + } + }, + ); +}); + +test('Provider-backed integration Android record stop ignores manifest host output paths', async () => { + await withProviderScenarioTempDir( + 'agent-device-provider-scenario-android-record-host-path-', + runAndroidManifestHostPathScenario, + ); +}); + +test('Provider-backed integration Android record stop refuses another session uncertain manifest 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-', + runAndroidAmbiguousManifestRecoveryScenario, + ); +}); + +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: 0 }; + } + 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 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( 'agent-device-provider-scenario-android-record-cross-device-recovery-', @@ -76,6 +248,345 @@ async function runAndroidRecordingFlowScenario(tmpDir: string): Promise { }); } +async function runAndroidManifestRecoveryScenario(tmpDir: string): Promise { + const remotePath = '/sdcard/agent-device-recording-123456789.mp4'; + const recordingPath = path.join(tmpDir, 'recovered-recording.mp4'); + const context = await createAndroidSingleManifestRecoveryContext({ + outPath: recordingPath, + remotePath, + sessionName: 'default', + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(context.daemon, recordingPath); + assertAndroidManifestRecovery(recordStop, { ...context, recordingPath, remotePath }); + } finally { + await context.daemon.close(); + } + }); +} + +async function runAndroidManifestHostPathScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-823456789.mp4'; + const requestedPath = path.join(tmpDir, 'requested.mp4'); + const manifestPath = path.join(tmpDir, 'manifest-controlled.mp4'); + const manifest = buildAndroidRecordingManifest({ + outPath: manifestPath, + remotePath, + sessionName: 'default', + chunks: [{ index: 1, path: manifestPath, remotePath }], + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await stopAndroidRecording(daemon, requestedPath); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, requestedPath); + assert.deepEqual(pullCalls, [{ remotePath, localPath: requestedPath }]); + assert.equal(fs.existsSync(requestedPath), true); + assert.equal(fs.existsSync(manifestPath), false); + } finally { + await daemon.close(); + } +} + +async function runAndroidScopedManifestRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-923456123.mp4'; + const recordingPath = path.join(tmpDir, 'scoped-recovered-recording.mp4'); + const scopeRoot = path.join(tmpDir, 'worktree'); + fs.mkdirSync(path.join(scopeRoot, '.git'), { recursive: true }); + const scopeId = hashScopeRoot(fs.realpathSync.native(scopeRoot)); + const manifest = buildAndroidRecordingManifest({ + outPath: recordingPath, + remotePath, + sessionName: `cwd:${scopeId}:default`, + sessionScope: { kind: 'cwd', id: scopeId }, + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand( + 'record', + ['stop', recordingPath], + { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }, + { meta: { cwd: scopeRoot } }, + ); + const data = assertRpcOk<{ recording?: unknown; outPath?: unknown }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, recordingPath); + assert.deepEqual(pullCalls, [{ remotePath, localPath: recordingPath }]); + } finally { + await daemon.close(); + } +} + +async function runAndroidAmbiguousManifestRecoveryScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const firstRemotePath = '/sdcard/agent-device-recording-323456789.mp4'; + const secondRemotePath = '/data/local/tmp/agent-device-recording-323456790.mp4'; + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ + adbCalls, + manifests: [ + buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'first.mp4'), + remotePath: firstRemotePath, + sessionName: 'default', + }), + buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'second.mp4'), + remotePath: secondRemotePath, + sessionName: 'default', + remotePid: '9876', + }), + ], + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await stopAndroidRecording(daemon); + assertRpcError(recordStop, 'INVALID_ARGS', /multiple active Android recording manifests/); + assert.equal( + adbCalls.some((args) => args.join(' ').startsWith('shell kill -2')), + false, + ); + } finally { + await daemon.close(); + } +} + +async function runAndroidOtherSessionUncertainManifestScenario(tmpDir: string): Promise { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const remotePath = '/sdcard/agent-device-recording-723456789.mp4'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'other-session-uncertain.mp4'), + remotePath, + sessionName: 'checkout', + }); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + if (command === 'shell cat /sdcard/agent-device-recording-active.json') { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { + return { stdout: '', stderr: '', exitCode: 1 }; + } + if (command === 'shell ps -o pid=,args= -p 4321') { + return { stdout: '', stderr: 'transient ps failure', exitCode: 1 }; + } + if (command === 'shell ps -A -o pid=,args=') { + return { + stdout: `4321 screenrecord --bit-rate 8000000 ${remotePath}\n`, + stderr: '', + exitCode: 0, + }; + } + return androidAdbResult(args); + }, + pull: async (from, to) => { + pullCalls.push({ remotePath: from, localPath: to }); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + + try { + const recordStop = await daemon.callCommand('record', ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); + assertRpcError(recordStop, 'INVALID_ARGS', /belongs to session "checkout"/); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); + assert.equal( + adbCalls.some((args) => args.join(' ') === 'shell kill -2 4321'), + false, + ); + assert.equal( + adbCalls.some( + (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', + ), + false, + ); + assert.equal(pullCalls.length, 0); + } finally { + await daemon.close(); + } +} + +async function runAndroidManifestChunkRecoveryScenario(tmpDir: string): Promise { + const firstRemotePath = '/sdcard/agent-device-recording-523456789.mp4'; + const secondRemotePath = '/sdcard/agent-device-recording-523456790.mp4'; + const firstLocalPath = path.join(tmpDir, 'chunked.mp4'); + const secondLocalPath = path.join(tmpDir, 'chunked.part-002.mp4'); + const context = await createAndroidSingleManifestRecoveryContext({ + outPath: firstLocalPath, + remotePath: secondRemotePath, + sessionName: 'default', + startedAt: 523456789, + chunks: [ + { index: 1, path: firstLocalPath, remotePath: firstRemotePath }, + { index: 2, path: secondLocalPath, remotePath: secondRemotePath }, + ], + }); + + await withAndroidProviderScenarioEnv(tmpDir, async () => { + try { + const recordStop = await stopAndroidRecording(context.daemon, firstLocalPath); + assertAndroidManifestChunkRecovery(recordStop, { + ...context, + firstLocalPath, + firstRemotePath, + secondLocalPath, + secondRemotePath, + }); + } finally { + await context.daemon.close(); + } + }); +} + +async function createAndroidSingleManifestRecoveryContext(options: { + outPath: string; + remotePath: string; + sessionName: string; + startedAt?: number; + chunks?: Array<{ index: number; path: string; remotePath: string }>; +}): Promise<{ + adbCalls: string[][]; + pullCalls: PullCall[]; + daemon: ProviderScenarioDaemon; +}> { + const adbCalls: string[][] = []; + const pullCalls: PullCall[] = []; + const manifest = buildAndroidRecordingManifest(options); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => + createAndroidManifestProvider({ adbCalls, pullCalls, manifests: [manifest] }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + return { adbCalls, pullCalls, daemon }; +} + +async function stopAndroidRecording( + daemon: ProviderScenarioDaemon, + outPath?: string, +): Promise { + return await daemon.callCommand('record', outPath ? ['stop', outPath] : ['stop'], { + platform: 'android', + serial: PROVIDER_SCENARIO_ANDROID.id, + }); +} + +function assertAndroidManifestRecovery( + recordStop: ProviderScenarioRpcResult, + context: { + adbCalls: string[][]; + pullCalls: PullCall[]; + recordingPath: string; + remotePath: string; + }, +): void { + const data = assertRpcOk<{ + recording?: unknown; + outPath?: unknown; + warning?: unknown; + overlayWarning?: unknown; + }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.equal(data.outPath, context.recordingPath); + assert.match(String(data.warning), /durable device manifest/); + assert.match(String(data.overlayWarning), /gesture telemetry/); + assert.equal(fs.existsSync(context.recordingPath), true); + assertAndroidManifestRecoveryCommands(context); +} + +function assertAndroidManifestRecoveryCommands(context: { + adbCalls: string[][]; + pullCalls: PullCall[]; + recordingPath: string; + remotePath: string; +}): void { + assertCommandCall(context.adbCalls, [ + 'shell', + 'cat', + '/sdcard/agent-device-recording-active.json', + ]); + assertCommandCall(context.adbCalls, ['shell', 'ps', '-o', 'pid=,args=', '-p', '4321']); + assert.equal( + context.adbCalls.some((args) => args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); + assertCommandCall(context.adbCalls, ['shell', 'kill', '-2', '4321']); + assert.equal(context.pullCalls.length, 1); + assert.deepEqual(context.pullCalls[0], { + remotePath: context.remotePath, + localPath: context.recordingPath, + }); + assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.remotePath]); + assertCommandCall(context.adbCalls, [ + 'shell', + 'rm', + '-f', + '/sdcard/agent-device-recording-active.json', + ]); +} + +function assertAndroidManifestChunkRecovery( + recordStop: ProviderScenarioRpcResult, + context: { + adbCalls: string[][]; + pullCalls: PullCall[]; + firstLocalPath: string; + firstRemotePath: string; + secondLocalPath: string; + secondRemotePath: string; + }, +): void { + const data = assertRpcOk<{ + recording?: unknown; + chunks?: Array<{ index?: unknown; path?: unknown }>; + }>(recordStop); + assert.equal(data.recording, 'stopped'); + assert.deepEqual(data.chunks, [ + { index: 1, path: context.firstLocalPath }, + { index: 2, path: context.secondLocalPath }, + ]); + assert.deepEqual(context.pullCalls, [ + { remotePath: context.firstRemotePath, localPath: context.firstLocalPath }, + { remotePath: context.secondRemotePath, localPath: context.secondLocalPath }, + ]); + assertCommandCall(context.adbCalls, ['shell', 'kill', '-2', '4321']); + assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.firstRemotePath]); + assertCommandCall(context.adbCalls, ['shell', 'rm', '-f', context.secondRemotePath]); +} + async function createAndroidRecordingFlowContext(tmpDir: string): Promise<{ recordingPath: string; adbCalls: string[][]; @@ -150,31 +661,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); @@ -188,7 +705,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(); @@ -219,24 +738,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( @@ -244,7 +760,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']); @@ -253,24 +769,131 @@ 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 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'; + const manifest = buildAndroidRecordingManifest({ + outPath: path.join(tmpDir, 'uncertain.mp4'), + remotePath, + sessionName: 'default', + }); const daemon = await createProviderScenarioHarness({ androidAdbProvider: () => ({ exec: async (args) => { adbCalls.push([...args]); const command = args.join(' '); if (command === 'shell cat /sdcard/agent-device-recording-active.json') { - return { - stdout: JSON.stringify({ - remotePath, - remotePid: '4321', - startedAt: 123456789, - }), - stderr: '', - exitCode: 0, - }; + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; } if (command === 'shell cat /data/local/tmp/agent-device-recording-active.json') { return { stdout: '', stderr: '', exitCode: 1 }; @@ -292,7 +915,11 @@ async function runAndroidUncertainMetadataScenario(_tmpDir: string): Promise args.join(' ') === 'shell ps -A -o pid=,args='), + false, + ); assert.equal( adbCalls.some( (args) => args.join(' ') === 'shell rm -f /sdcard/agent-device-recording-active.json', @@ -411,12 +1038,66 @@ function createPullingAndroidProvider(params: { }, pull: async (remotePath, localPath) => { pullCalls.push({ remotePath, localPath }); - fs.writeFileSync(localPath, likelyPlayableMp4Container()); + writePlayableMp4(localPath); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }; +} + +function createAndroidManifestProvider(params: { + adbCalls: string[][]; + manifests: Array>; + pullCalls?: PullCall[]; +}): AndroidAdbProvider { + const { adbCalls, manifests, pullCalls } = params; + return { + exec: async (args) => { + adbCalls.push([...args]); + const command = args.join(' '); + const manifest = findManifestForAdbCommand(manifests, command); + if (manifest) { + return manifest; + } + return androidAdbResult(args); + }, + pull: async (remotePath, localPath) => { + pullCalls?.push({ remotePath, localPath }); + writePlayableMp4(localPath); return { stdout: '', stderr: '', exitCode: 0 }; }, }; } +function findManifestForAdbCommand( + manifests: Array>, + command: string, +) { + for (const manifest of manifests) { + if (command === manifestCatCommand(manifest)) { + return { stdout: JSON.stringify(manifest), stderr: '', exitCode: 0 }; + } + if (command === 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) => { @@ -440,12 +1121,43 @@ function androidRecoveryAdbResult( return androidAdbResult(args); } -function requireStringOutPath(value: unknown): string { - assert.equal(typeof value, 'string'); - if (typeof value !== 'string') { - throw new Error(`expected string outPath, got ${String(value)}`); - } - return value; +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 }>; +}) { + const startedAt = options.startedAt ?? 123456789; + return { + version: 1, + sessionName: options.sessionName, + sessionScope: options.sessionScope, + recordingId: `recording-${startedAt}`, + deviceId: PROVIDER_SCENARIO_ANDROID.id, + startedAt, + outPath: options.outPath, + showTouches: true, + exportQuality: 'medium', + current: { + remotePath: options.remotePath, + remotePid: options.remotePid ?? '4321', + startedAt, + }, + chunks: options.chunks ?? [ + { + index: 1, + path: options.outPath, + remotePath: options.remotePath, + }, + ], + }; +} + +function hashScopeRoot(scopeRoot: string): string { + return crypto.createHash('sha256').update(scopeRoot).digest('hex').slice(0, 16); } function androidAdbResult(args: string[]): { @@ -478,6 +1190,10 @@ function androidAdbResult(args: string[]): { if (/^shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)) { return { stdout: '2048\n', stderr: '', exitCode: 0 }; } + if (args[0] === 'pull' && typeof args[2] === 'string') { + writePlayableMp4(args[2]); + return { stdout: '', stderr: '', exitCode: 0 }; + } if (command === 'shell ps -o pid= -p 4321') { return { stdout: '', stderr: '', exitCode: 1 }; } @@ -510,3 +1226,12 @@ function readLoggedArgs(logPath: string): string[] { .map((line) => line.trim()) .filter(Boolean); } + +function writePlayableMp4(filePath: string): void { + const fixturePath = path.join(process.cwd(), 'website/docs/public/agent-device-contacts.mp4'); + if (fs.existsSync(fixturePath)) { + fs.copyFileSync(fixturePath, filePath); + return; + } + fs.writeFileSync(filePath, likelyPlayableMp4Container()); +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index f5abb052a..9c8ae2f04 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -32,6 +32,7 @@ export type ProviderScenarioHarness = { ) => Promise; client: () => AgentDeviceClient; session: (name?: string) => SessionState | undefined; + setSession: (name: string, session: SessionState) => void; close: () => Promise; }; @@ -74,6 +75,7 @@ export async function createProviderScenarioHarness( ), client: () => createAgentDeviceClient({}, { transport }), session: (name = 'default') => sessionStore.get(name), + setSession: (name, session) => sessionStore.set(name, session), close: async () => { fs.rmSync(sessionDir, { recursive: true, force: true }); }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 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.