Skip to content

Commit 82b09c5

Browse files
committed
refactor: isolate Android snapshot timeout evidence
1 parent 4ce54da commit 82b09c5

2 files changed

Lines changed: 182 additions & 140 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { promises as fs } from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import type { DaemonResponse, SessionState } from './types.ts';
5+
import { dispatchCommand } from '../core/dispatch.ts';
6+
import { emitDiagnostic } from '../utils/diagnostics.ts';
7+
import { normalizeError } from '../utils/errors.ts';
8+
import { contextFromFlags } from './context.ts';
9+
import { annotateScreenshotWithRefs } from './screenshot-overlay.ts';
10+
11+
type AndroidSnapshotTimeoutEvidence = {
12+
path?: string;
13+
overlayRefsRequested?: boolean;
14+
overlayRefsAnnotated?: boolean;
15+
overlayRefCount?: number;
16+
overlayRefSource?: 'session-snapshot' | 'unavailable';
17+
overlayRefs?: unknown[];
18+
overlayAnnotationError?: string;
19+
captureFailed?: boolean;
20+
error?: string;
21+
};
22+
23+
export async function maybeBuildAndroidSnapshotTimeoutFailure(params: {
24+
error: unknown;
25+
command: 'snapshot' | 'diff';
26+
logPath: string;
27+
session: SessionState | undefined;
28+
device: SessionState['device'];
29+
}): Promise<Extract<DaemonResponse, { ok: false }> | undefined> {
30+
if (params.command !== 'snapshot') return undefined;
31+
if (params.device.platform !== 'android') return undefined;
32+
if (!isAndroidSnapshotTimeoutError(params.error)) return undefined;
33+
34+
const normalized = normalizeError(params.error);
35+
return {
36+
ok: false,
37+
error: {
38+
...normalized,
39+
details: {
40+
...(normalized.details ?? {}),
41+
androidSnapshotTimeoutScreenshot: await captureAndroidSnapshotTimeoutEvidence(params),
42+
},
43+
},
44+
};
45+
}
46+
47+
async function captureAndroidSnapshotTimeoutEvidence(params: {
48+
logPath: string;
49+
session: SessionState | undefined;
50+
device: SessionState['device'];
51+
}): Promise<AndroidSnapshotTimeoutEvidence> {
52+
try {
53+
const tempDir = await fs.mkdtemp(
54+
path.join(os.tmpdir(), 'agent-device-android-snapshot-timeout-'),
55+
);
56+
const screenshotPath = path.join(tempDir, 'snapshot-timeout-overlay-refs.png');
57+
const data = await dispatchCommand(params.device, 'screenshot', [screenshotPath], undefined, {
58+
...contextFromFlags(
59+
params.logPath,
60+
{ screenshotNoStabilize: true },
61+
params.session?.appBundleId,
62+
params.session?.trace?.outPath,
63+
),
64+
surface: params.session?.surface,
65+
});
66+
const resolvedPath = resolveCapturedScreenshotPath(data, screenshotPath);
67+
const evidence = await annotateAndroidSnapshotTimeoutEvidence(resolvedPath, params.session);
68+
69+
emitDiagnostic({
70+
level: 'warn',
71+
phase: 'android_snapshot_timeout_screenshot_captured',
72+
data: {
73+
path: resolvedPath,
74+
overlayRefCount: evidence.overlayRefCount,
75+
overlayRefsAnnotated: evidence.overlayRefsAnnotated,
76+
},
77+
});
78+
return evidence;
79+
} catch (error) {
80+
const normalized = normalizeError(error);
81+
emitDiagnostic({
82+
level: 'warn',
83+
phase: 'android_snapshot_timeout_screenshot_failed',
84+
data: { error: normalized.message },
85+
});
86+
return {
87+
captureFailed: true,
88+
error: normalized.message,
89+
};
90+
}
91+
}
92+
93+
async function annotateAndroidSnapshotTimeoutEvidence(
94+
screenshotPath: string,
95+
session: SessionState | undefined,
96+
): Promise<AndroidSnapshotTimeoutEvidence> {
97+
const evidence: AndroidSnapshotTimeoutEvidence = {
98+
path: screenshotPath,
99+
overlayRefsRequested: true,
100+
overlayRefsAnnotated: false,
101+
};
102+
103+
if (!session?.snapshot) {
104+
return {
105+
...evidence,
106+
overlayRefSource: 'unavailable',
107+
overlayRefCount: 0,
108+
};
109+
}
110+
111+
try {
112+
const overlayRefs = await annotateScreenshotWithRefs({
113+
screenshotPath,
114+
snapshot: session.snapshot,
115+
});
116+
return {
117+
...evidence,
118+
overlayRefsAnnotated: overlayRefs.length > 0,
119+
overlayRefCount: overlayRefs.length,
120+
overlayRefSource: 'session-snapshot',
121+
overlayRefs,
122+
};
123+
} catch (error) {
124+
const normalized = normalizeError(error);
125+
emitDiagnostic({
126+
level: 'warn',
127+
phase: 'android_snapshot_timeout_screenshot_overlay_failed',
128+
data: { path: screenshotPath, error: normalized.message },
129+
});
130+
return {
131+
...evidence,
132+
overlayAnnotationError: normalized.message,
133+
};
134+
}
135+
}
136+
137+
function resolveCapturedScreenshotPath(data: unknown, fallbackPath: string): string {
138+
return typeof data === 'object' &&
139+
data !== null &&
140+
typeof (data as Record<string, unknown>).path === 'string'
141+
? ((data as Record<string, unknown>).path as string)
142+
: fallbackPath;
143+
}
144+
145+
function isAndroidSnapshotTimeoutError(error: unknown): boolean {
146+
const normalized = normalizeError(error);
147+
if (normalized.code !== 'COMMAND_FAILED') return false;
148+
149+
const text = `${normalized.message}\n${normalized.hint ?? ''}`;
150+
if (/Android UI hierarchy dump timed out/i.test(text)) return true;
151+
if (/Stock UIAutomator fallback was skipped/i.test(text)) return true;
152+
if (/Android accessibility snapshots can be blocked/i.test(text)) return true;
153+
154+
const details = normalized.details;
155+
const helper = details?.helper;
156+
if (helper && typeof helper === 'object') {
157+
const helperRecord = helper as Record<string, unknown>;
158+
const errorType = String(helperRecord.errorType ?? '');
159+
const message = String(helperRecord.message ?? '');
160+
if (/TimeoutException/i.test(errorType) || /timed out/i.test(message)) return true;
161+
}
162+
163+
const timeoutMs = details?.timeoutMs;
164+
const cmd = details?.cmd;
165+
const rawArgs = details?.args;
166+
const args = Array.isArray(rawArgs)
167+
? rawArgs.map(String)
168+
: typeof rawArgs === 'string'
169+
? rawArgs.split(/\s+/)
170+
: [];
171+
return (
172+
typeof timeoutMs === 'number' &&
173+
cmd === 'adb' &&
174+
args.includes('uiautomator') &&
175+
args.includes('dump')
176+
);
177+
}

src/daemon/snapshot-runtime.ts

Lines changed: 5 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
import { promises as fs } from 'node:fs';
2-
import os from 'node:os';
3-
import path from 'node:path';
41
import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts';
52
import type { CommandSessionRecord } from '../runtime.ts';
63
import { createAgentDevice } from '../runtime.ts';
74
import { isCommandSupportedOnDevice } from '../core/capabilities.ts';
8-
import { dispatchCommand } from '../core/dispatch.ts';
9-
import { AppError, normalizeError } from '../utils/errors.ts';
10-
import { emitDiagnostic } from '../utils/diagnostics.ts';
5+
import { AppError } from '../utils/errors.ts';
116
import type { SnapshotDiffSummary } from '../utils/snapshot-diff.ts';
127
import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from './types.ts';
138
import { SessionStore } from './session-store.ts';
@@ -18,10 +13,9 @@ import {
1813
resolveSessionDevice,
1914
withSessionlessRunnerCleanup,
2015
} from './handlers/snapshot-session.ts';
21-
import { contextFromFlags } from './context.ts';
2216
import { createDaemonRuntimePolicy } from './runtime-policy.ts';
2317
import { createDaemonRuntimeSessionStore } from './runtime-session.ts';
24-
import { annotateScreenshotWithRefs } from './screenshot-overlay.ts';
18+
import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts';
2519

2620
export async function dispatchSnapshotViaRuntime(params: {
2721
req: DaemonRequest;
@@ -141,25 +135,15 @@ async function dispatchSnapshotRuntimeCommand(
141135
snapshotScope: resolvedScope.scope,
142136
});
143137
} catch (error) {
144-
const timeoutEvidence = await maybeCaptureAndroidSnapshotTimeoutEvidence({
138+
const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({
145139
error,
146140
command: params.command,
147141
logPath,
148142
session,
149143
device,
150144
});
151-
if (!timeoutEvidence) throw error;
152-
const normalized = normalizeError(error);
153-
return {
154-
ok: false,
155-
error: {
156-
...normalized,
157-
details: {
158-
...(normalized.details ?? {}),
159-
androidSnapshotTimeoutScreenshot: timeoutEvidence,
160-
},
161-
},
162-
};
145+
if (!timeoutResponse) throw error;
146+
return timeoutResponse;
163147
}
164148
recordSnapshotRuntimeAction({
165149
req,
@@ -174,125 +158,6 @@ async function dispatchSnapshotRuntimeCommand(
174158
});
175159
}
176160

177-
async function maybeCaptureAndroidSnapshotTimeoutEvidence(params: {
178-
error: unknown;
179-
command: SnapshotRuntimeCommandParams['command'];
180-
logPath: string;
181-
session: SessionState | undefined;
182-
device: SessionState['device'];
183-
}): Promise<Record<string, unknown> | undefined> {
184-
if (params.command !== 'snapshot') return undefined;
185-
if (params.device.platform !== 'android') return undefined;
186-
if (!isAndroidSnapshotTimeoutError(params.error)) return undefined;
187-
188-
try {
189-
const tempDir = await fs.mkdtemp(
190-
path.join(os.tmpdir(), 'agent-device-android-snapshot-timeout-'),
191-
);
192-
const screenshotPath = path.join(tempDir, 'snapshot-timeout-overlay-refs.png');
193-
const data = await dispatchCommand(params.device, 'screenshot', [screenshotPath], undefined, {
194-
...contextFromFlags(
195-
params.logPath,
196-
{ screenshotNoStabilize: true },
197-
params.session?.appBundleId,
198-
params.session?.trace?.outPath,
199-
),
200-
surface: params.session?.surface,
201-
});
202-
const resolvedPath =
203-
typeof data === 'object' &&
204-
data !== null &&
205-
typeof (data as Record<string, unknown>).path === 'string'
206-
? ((data as Record<string, unknown>).path as string)
207-
: screenshotPath;
208-
const evidence: Record<string, unknown> = {
209-
path: resolvedPath,
210-
overlayRefsRequested: true,
211-
overlayRefsAnnotated: false,
212-
};
213-
214-
if (params.session?.snapshot) {
215-
try {
216-
const overlayRefs = await annotateScreenshotWithRefs({
217-
screenshotPath: resolvedPath,
218-
snapshot: params.session.snapshot,
219-
});
220-
evidence.overlayRefsAnnotated = overlayRefs.length > 0;
221-
evidence.overlayRefCount = overlayRefs.length;
222-
evidence.overlayRefSource = 'session-snapshot';
223-
evidence.overlayRefs = overlayRefs;
224-
} catch (error) {
225-
const normalized = normalizeError(error);
226-
evidence.overlayAnnotationError = normalized.message;
227-
emitDiagnostic({
228-
level: 'warn',
229-
phase: 'android_snapshot_timeout_screenshot_overlay_failed',
230-
data: { path: resolvedPath, error: normalized.message },
231-
});
232-
}
233-
} else {
234-
evidence.overlayRefSource = 'unavailable';
235-
evidence.overlayRefCount = 0;
236-
}
237-
238-
emitDiagnostic({
239-
level: 'warn',
240-
phase: 'android_snapshot_timeout_screenshot_captured',
241-
data: {
242-
path: resolvedPath,
243-
overlayRefCount: evidence.overlayRefCount,
244-
overlayRefsAnnotated: evidence.overlayRefsAnnotated,
245-
},
246-
});
247-
return evidence;
248-
} catch (error) {
249-
const normalized = normalizeError(error);
250-
emitDiagnostic({
251-
level: 'warn',
252-
phase: 'android_snapshot_timeout_screenshot_failed',
253-
data: { error: normalized.message },
254-
});
255-
return {
256-
captureFailed: true,
257-
error: normalized.message,
258-
};
259-
}
260-
}
261-
262-
function isAndroidSnapshotTimeoutError(error: unknown): boolean {
263-
const normalized = normalizeError(error);
264-
if (normalized.code !== 'COMMAND_FAILED') return false;
265-
266-
const text = `${normalized.message}\n${normalized.hint ?? ''}`;
267-
if (/Android UI hierarchy dump timed out/i.test(text)) return true;
268-
if (/Stock UIAutomator fallback was skipped/i.test(text)) return true;
269-
if (/Android accessibility snapshots can be blocked/i.test(text)) return true;
270-
271-
const details = normalized.details;
272-
const helper = details?.helper;
273-
if (helper && typeof helper === 'object') {
274-
const helperRecord = helper as Record<string, unknown>;
275-
const errorType = String(helperRecord.errorType ?? '');
276-
const message = String(helperRecord.message ?? '');
277-
if (/TimeoutException/i.test(errorType) || /timed out/i.test(message)) return true;
278-
}
279-
280-
const timeoutMs = details?.timeoutMs;
281-
const cmd = details?.cmd;
282-
const rawArgs = details?.args;
283-
const args = Array.isArray(rawArgs)
284-
? rawArgs.map(String)
285-
: typeof rawArgs === 'string'
286-
? rawArgs.split(/\s+/)
287-
: [];
288-
return (
289-
typeof timeoutMs === 'number' &&
290-
cmd === 'adb' &&
291-
args.includes('uiautomator') &&
292-
args.includes('dump')
293-
);
294-
}
295-
296161
function createSnapshotRuntime(params: {
297162
req: DaemonRequest;
298163
sessionName: string;

0 commit comments

Comments
 (0)