Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>; Android native traces: perf trace start|stop --kind perfetto --out <path>. 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}}]'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/recording/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const recordCliSchema = {
'record start [path] [--fps <n>] [--max-size <px>] [--quality <medium|high>] [--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'],
Expand Down
84 changes: 84 additions & 0 deletions src/daemon/handlers/__tests__/record-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
55 changes: 44 additions & 11 deletions src/daemon/handlers/record-trace-android-chunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,29 @@ export function resolveAndroidScreenrecordLimitWarning(
export function scheduleAndroidRecordingRotation(params: {
recording: AndroidRecording;
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
finishCurrentChunk: () => Promise<string | undefined>;
finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise<string | undefined>;
persistRecordingState?: (recording: AndroidRecording) => Promise<void>;
}): 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);
})
.finally(() => {
recording.rotationPromise = undefined;
if (!recording.stopping && !recording.rotationFailedReason) {
scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk });
scheduleAndroidRecordingRotation({
recording,
startNextChunk,
finishCurrentChunk,
persistRecordingState,
});
}
});
}, ANDROID_SCREENRECORD_CHUNK_MS);
Expand All @@ -80,28 +87,54 @@ export function scheduleAndroidRecordingRotation(params: {
async function rotateAndroidRecordingChunk(params: {
recording: AndroidRecording;
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
finishCurrentChunk: () => Promise<string | undefined>;
finishCurrentChunk: (chunk: AndroidScreenrecordChunk) => Promise<string | undefined>;
persistRecordingState?: (recording: AndroidRecording) => Promise<void>;
}): Promise<void> {
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),
remotePath: nextChunk.remotePath,
});
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: {
Expand Down
Loading
Loading