agent-device 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +26 -2
  2. package/dist/src/274.js +1 -0
  3. package/dist/src/bin.js +27 -22
  4. package/dist/src/daemon.js +15 -10
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
  6. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +149 -0
  7. package/package.json +2 -2
  8. package/skills/agent-device/SKILL.md +8 -1
  9. package/src/cli.ts +13 -0
  10. package/src/core/__tests__/capabilities.test.ts +2 -0
  11. package/src/core/capabilities.ts +2 -0
  12. package/src/daemon/handlers/__tests__/replay-heal.test.ts +5 -0
  13. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
  14. package/src/daemon/handlers/__tests__/session.test.ts +122 -0
  15. package/src/daemon/handlers/find.ts +23 -3
  16. package/src/daemon/handlers/session.ts +175 -10
  17. package/src/daemon-client.ts +1 -24
  18. package/src/daemon.ts +1 -24
  19. package/src/platforms/__tests__/boot-diagnostics.test.ts +59 -0
  20. package/src/platforms/android/__tests__/index.test.ts +17 -0
  21. package/src/platforms/android/devices.ts +167 -42
  22. package/src/platforms/android/index.ts +101 -14
  23. package/src/platforms/boot-diagnostics.ts +128 -0
  24. package/src/platforms/ios/index.ts +161 -2
  25. package/src/platforms/ios/runner-client.ts +19 -1
  26. package/src/utils/__tests__/exec.test.ts +16 -0
  27. package/src/utils/__tests__/finders.test.ts +34 -0
  28. package/src/utils/__tests__/retry.test.ts +44 -0
  29. package/src/utils/args.ts +9 -1
  30. package/src/utils/exec.ts +39 -0
  31. package/src/utils/finders.ts +27 -9
  32. package/src/utils/retry.ts +143 -13
  33. package/src/utils/version.ts +26 -0
  34. package/dist/src/861.js +0 -1
@@ -1,6 +1,45 @@
1
1
  import { runCmd, whichCmd } from '../../utils/exec.ts';
2
- import { AppError } from '../../utils/errors.ts';
2
+ import type { ExecResult } from '../../utils/exec.ts';
3
+ import { AppError, asAppError } from '../../utils/errors.ts';
3
4
  import type { DeviceInfo } from '../../utils/device.ts';
5
+ import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
6
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
7
+
8
+ const EMULATOR_SERIAL_PREFIX = 'emulator-';
9
+ const ANDROID_BOOT_POLL_MS = 1000;
10
+ const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
11
+
12
+ function adbArgs(serial: string, args: string[]): string[] {
13
+ return ['-s', serial, ...args];
14
+ }
15
+
16
+ function isEmulatorSerial(serial: string): boolean {
17
+ return serial.startsWith(EMULATOR_SERIAL_PREFIX);
18
+ }
19
+
20
+ async function readAndroidBootProp(
21
+ serial: string,
22
+ timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs,
23
+ ): Promise<ExecResult> {
24
+ return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
25
+ allowFailure: true,
26
+ timeoutMs,
27
+ });
28
+ }
29
+
30
+ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promise<string> {
31
+ const modelName = rawModel.replace(/_/g, ' ').trim();
32
+ if (!isEmulatorSerial(serial)) return modelName || serial;
33
+ const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
34
+ allowFailure: true,
35
+ timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
36
+ });
37
+ const avdName = avd.stdout.trim();
38
+ if (avd.exitCode === 0 && avdName) {
39
+ return avdName.replace(/_/g, ' ');
40
+ }
41
+ return modelName || serial;
42
+ }
4
43
 
5
44
  export async function listAndroidDevices(): Promise<DeviceInfo[]> {
6
45
  const adbAvailable = await whichCmd('adb');
@@ -8,64 +47,150 @@ export async function listAndroidDevices(): Promise<DeviceInfo[]> {
8
47
  throw new AppError('TOOL_MISSING', 'adb not found in PATH');
9
48
  }
10
49
 
11
- const result = await runCmd('adb', ['devices', '-l']);
50
+ const result = await runCmd('adb', ['devices', '-l'], {
51
+ timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
52
+ });
12
53
  const lines = result.stdout.split('\n').map((l: string) => l.trim());
13
- const devices: DeviceInfo[] = [];
14
-
15
- for (const line of lines) {
16
- if (!line || line.startsWith('List of devices')) continue;
17
- const parts = line.split(/\s+/);
18
- const serial = parts[0];
19
- const state = parts[1];
20
- if (state !== 'device') continue;
54
+ const entries = lines
55
+ .filter((line) => line.length > 0 && !line.startsWith('List of devices'))
56
+ .map((line) => line.split(/\s+/))
57
+ .filter((parts) => parts[1] === 'device')
58
+ .map((parts) => ({
59
+ serial: parts[0],
60
+ rawModel: (parts.find((p: string) => p.startsWith('model:')) ?? '').replace('model:', ''),
61
+ }));
21
62
 
22
- const modelPart = parts.find((p: string) => p.startsWith('model:')) ?? '';
23
- const rawModel = modelPart.replace('model:', '').replace(/_/g, ' ').trim();
24
- let name = rawModel || serial;
25
-
26
- if (serial.startsWith('emulator-')) {
27
- const avd = await runCmd('adb', ['-s', serial, 'emu', 'avd', 'name'], {
28
- allowFailure: true,
29
- });
30
- const avdName = (avd.stdout as string).trim();
31
- if (avd.exitCode === 0 && avdName) {
32
- name = avdName.replace(/_/g, ' ');
33
- }
34
- }
35
-
36
- const booted = await isAndroidBooted(serial);
37
-
38
- devices.push({
63
+ const devices = await Promise.all(entries.map(async ({ serial, rawModel }) => {
64
+ const [name, booted] = await Promise.all([
65
+ resolveAndroidDeviceName(serial, rawModel),
66
+ isAndroidBooted(serial),
67
+ ]);
68
+ return {
39
69
  platform: 'android',
40
70
  id: serial,
41
71
  name,
42
- kind: serial.startsWith('emulator-') ? 'emulator' : 'device',
72
+ kind: isEmulatorSerial(serial) ? 'emulator' : 'device',
43
73
  booted,
44
- });
45
- }
74
+ } satisfies DeviceInfo;
75
+ }));
46
76
 
47
77
  return devices;
48
78
  }
49
79
 
50
80
  export async function isAndroidBooted(serial: string): Promise<boolean> {
51
81
  try {
52
- const result = await runCmd('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], {
53
- allowFailure: true,
54
- });
55
- return (result.stdout as string).trim() === '1';
82
+ const result = await readAndroidBootProp(serial);
83
+ return result.stdout.trim() === '1';
56
84
  } catch {
57
85
  return false;
58
86
  }
59
87
  }
60
88
 
61
89
  export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
62
- const start = Date.now();
63
- while (Date.now() - start < timeoutMs) {
64
- if (await isAndroidBooted(serial)) return;
65
- await new Promise((resolve) => setTimeout(resolve, 1000));
90
+ const timeoutBudget = timeoutMs;
91
+ const deadline = Deadline.fromTimeoutMs(timeoutBudget);
92
+ const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
93
+ let lastBootResult: ExecResult | undefined;
94
+ let timedOut = false;
95
+ try {
96
+ await retryWithPolicy(
97
+ async ({ deadline: attemptDeadline }) => {
98
+ if (attemptDeadline?.isExpired()) {
99
+ timedOut = true;
100
+ throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', {
101
+ serial,
102
+ timeoutMs,
103
+ elapsedMs: deadline.elapsedMs(),
104
+ message: 'timeout',
105
+ });
106
+ }
107
+ const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutBudget);
108
+ const result = await readAndroidBootProp(
109
+ serial,
110
+ Math.min(remainingMs, TIMEOUT_PROFILES.android_boot.operationMs),
111
+ );
112
+ lastBootResult = result;
113
+ if (result.stdout.trim() === '1') return;
114
+ throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
115
+ serial,
116
+ stdout: result.stdout,
117
+ stderr: result.stderr,
118
+ exitCode: result.exitCode,
119
+ });
120
+ },
121
+ {
122
+ maxAttempts,
123
+ baseDelayMs: ANDROID_BOOT_POLL_MS,
124
+ maxDelayMs: ANDROID_BOOT_POLL_MS,
125
+ jitter: 0,
126
+ shouldRetry: (error) => {
127
+ const reason = classifyBootFailure({
128
+ error,
129
+ stdout: lastBootResult?.stdout,
130
+ stderr: lastBootResult?.stderr,
131
+ context: { platform: 'android', phase: 'boot' },
132
+ });
133
+ return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT';
134
+ },
135
+ },
136
+ {
137
+ deadline,
138
+ phase: 'boot',
139
+ classifyReason: (error) =>
140
+ classifyBootFailure({
141
+ error,
142
+ stdout: lastBootResult?.stdout,
143
+ stderr: lastBootResult?.stderr,
144
+ context: { platform: 'android', phase: 'boot' },
145
+ }),
146
+ onEvent: (event: RetryTelemetryEvent) => {
147
+ if (!RETRY_LOGS_ENABLED) return;
148
+ process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
149
+ },
150
+ },
151
+ );
152
+ } catch (error) {
153
+ const appErr = asAppError(error);
154
+ const stdout = lastBootResult?.stdout;
155
+ const stderr = lastBootResult?.stderr;
156
+ const exitCode = lastBootResult?.exitCode;
157
+ let reason = classifyBootFailure({
158
+ error,
159
+ stdout,
160
+ stderr,
161
+ context: { platform: 'android', phase: 'boot' },
162
+ });
163
+ if (reason === 'BOOT_COMMAND_FAILED' && appErr.message === 'Android device is still booting') {
164
+ reason = 'ANDROID_BOOT_TIMEOUT';
165
+ }
166
+ const baseDetails = {
167
+ serial,
168
+ timeoutMs: timeoutBudget,
169
+ elapsedMs: deadline.elapsedMs(),
170
+ reason,
171
+ hint: bootFailureHint(reason),
172
+ stdout,
173
+ stderr,
174
+ exitCode,
175
+ };
176
+ if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') {
177
+ throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
178
+ }
179
+ if (appErr.code === 'TOOL_MISSING') {
180
+ throw new AppError('TOOL_MISSING', appErr.message, {
181
+ ...baseDetails,
182
+ ...(appErr.details ?? {}),
183
+ });
184
+ }
185
+ if (reason === 'ADB_TRANSPORT_UNAVAILABLE') {
186
+ throw new AppError('COMMAND_FAILED', appErr.message, {
187
+ ...baseDetails,
188
+ ...(appErr.details ?? {}),
189
+ });
190
+ }
191
+ throw new AppError(appErr.code, appErr.message, {
192
+ ...baseDetails,
193
+ ...(appErr.details ?? {}),
194
+ }, appErr.cause);
66
195
  }
67
- throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', {
68
- serial,
69
- timeoutMs,
70
- });
71
196
  }
@@ -187,22 +187,70 @@ export async function openAndroidApp(
187
187
  );
188
188
  return;
189
189
  }
190
- await runCmd(
190
+ try {
191
+ await runCmd(
192
+ 'adb',
193
+ adbArgs(device, [
194
+ 'shell',
195
+ 'am',
196
+ 'start',
197
+ '-a',
198
+ 'android.intent.action.MAIN',
199
+ '-c',
200
+ 'android.intent.category.DEFAULT',
201
+ '-c',
202
+ 'android.intent.category.LAUNCHER',
203
+ '-p',
204
+ resolved.value,
205
+ ]),
206
+ );
207
+ return;
208
+ } catch (initialError) {
209
+ const component = await resolveAndroidLaunchComponent(device, resolved.value);
210
+ if (!component) throw initialError;
211
+ await runCmd(
212
+ 'adb',
213
+ adbArgs(device, [
214
+ 'shell',
215
+ 'am',
216
+ 'start',
217
+ '-a',
218
+ 'android.intent.action.MAIN',
219
+ '-c',
220
+ 'android.intent.category.DEFAULT',
221
+ '-c',
222
+ 'android.intent.category.LAUNCHER',
223
+ '-n',
224
+ component,
225
+ ]),
226
+ );
227
+ }
228
+ }
229
+
230
+ async function resolveAndroidLaunchComponent(
231
+ device: DeviceInfo,
232
+ packageName: string,
233
+ ): Promise<string | null> {
234
+ const result = await runCmd(
191
235
  'adb',
192
- adbArgs(device, [
193
- 'shell',
194
- 'am',
195
- 'start',
196
- '-a',
197
- 'android.intent.action.MAIN',
198
- '-c',
199
- 'android.intent.category.DEFAULT',
200
- '-c',
201
- 'android.intent.category.LAUNCHER',
202
- '-p',
203
- resolved.value,
204
- ]),
236
+ adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
237
+ { allowFailure: true },
205
238
  );
239
+ if (result.exitCode !== 0) return null;
240
+ return parseAndroidLaunchComponent(result.stdout);
241
+ }
242
+
243
+ export function parseAndroidLaunchComponent(stdout: string): string | null {
244
+ const lines = stdout
245
+ .split('\n')
246
+ .map((line: string) => line.trim())
247
+ .filter(Boolean);
248
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
249
+ const line = lines[index];
250
+ if (!line.includes('/')) continue;
251
+ return line.split(/\s+/)[0];
252
+ }
253
+ return null;
206
254
  }
207
255
 
208
256
  export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
@@ -224,6 +272,45 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
224
272
  await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value]));
225
273
  }
226
274
 
275
+ export async function uninstallAndroidApp(
276
+ device: DeviceInfo,
277
+ app: string,
278
+ ): Promise<{ package: string }> {
279
+ const resolved = await resolveAndroidApp(device, app);
280
+ if (resolved.type === 'intent') {
281
+ throw new AppError('INVALID_ARGS', 'reinstall requires a package name, not an intent');
282
+ }
283
+ const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { allowFailure: true });
284
+ if (result.exitCode !== 0) {
285
+ const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
286
+ if (!output.includes('unknown package') && !output.includes('not installed')) {
287
+ throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, {
288
+ stdout: result.stdout,
289
+ stderr: result.stderr,
290
+ exitCode: result.exitCode,
291
+ });
292
+ }
293
+ }
294
+ return { package: resolved.value };
295
+ }
296
+
297
+ export async function installAndroidApp(device: DeviceInfo, appPath: string): Promise<void> {
298
+ await runCmd('adb', adbArgs(device, ['install', appPath]));
299
+ }
300
+
301
+ export async function reinstallAndroidApp(
302
+ device: DeviceInfo,
303
+ app: string,
304
+ appPath: string,
305
+ ): Promise<{ package: string }> {
306
+ if (!device.booted) {
307
+ await waitForAndroidBoot(device.id);
308
+ }
309
+ const { package: pkg } = await uninstallAndroidApp(device, app);
310
+ await installAndroidApp(device, appPath);
311
+ return { package: pkg };
312
+ }
313
+
227
314
  export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
228
315
  await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
229
316
  }
@@ -0,0 +1,128 @@
1
+ import { asAppError } from '../utils/errors.ts';
2
+
3
+ export type BootFailureReason =
4
+ | 'IOS_BOOT_TIMEOUT'
5
+ | 'IOS_RUNNER_CONNECT_TIMEOUT'
6
+ | 'IOS_TOOL_MISSING'
7
+ | 'ANDROID_BOOT_TIMEOUT'
8
+ | 'ADB_TRANSPORT_UNAVAILABLE'
9
+ | 'CI_RESOURCE_STARVATION_SUSPECTED'
10
+ | 'BOOT_COMMAND_FAILED'
11
+ | 'UNKNOWN';
12
+
13
+ type BootDiagnosticContext = {
14
+ platform?: 'ios' | 'android';
15
+ phase?: 'boot' | 'connect' | 'transport';
16
+ };
17
+
18
+ export function classifyBootFailure(input: {
19
+ error?: unknown;
20
+ message?: string;
21
+ stdout?: string;
22
+ stderr?: string;
23
+ context?: BootDiagnosticContext;
24
+ }): BootFailureReason {
25
+ const appErr = input.error ? asAppError(input.error) : null;
26
+ const platform = input.context?.platform;
27
+ const phase = input.context?.phase;
28
+ if (appErr?.code === 'TOOL_MISSING') {
29
+ return platform === 'android' ? 'ADB_TRANSPORT_UNAVAILABLE' : 'IOS_TOOL_MISSING';
30
+ }
31
+ const details = (appErr?.details ?? {}) as Record<string, unknown>;
32
+ const detailMessage = typeof details.message === 'string' ? details.message : undefined;
33
+ const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
34
+ const detailStderr = typeof details.stderr === 'string' ? details.stderr : undefined;
35
+ const nestedBoot = details.boot && typeof details.boot === 'object'
36
+ ? (details.boot as Record<string, unknown>)
37
+ : null;
38
+ const nestedBootstatus = details.bootstatus && typeof details.bootstatus === 'object'
39
+ ? (details.bootstatus as Record<string, unknown>)
40
+ : null;
41
+
42
+ const haystack = [
43
+ input.message,
44
+ appErr?.message,
45
+ input.stdout,
46
+ input.stderr,
47
+ detailMessage,
48
+ detailStdout,
49
+ detailStderr,
50
+ typeof nestedBoot?.stdout === 'string' ? nestedBoot.stdout : undefined,
51
+ typeof nestedBoot?.stderr === 'string' ? nestedBoot.stderr : undefined,
52
+ typeof nestedBootstatus?.stdout === 'string' ? nestedBootstatus.stdout : undefined,
53
+ typeof nestedBootstatus?.stderr === 'string' ? nestedBootstatus.stderr : undefined,
54
+ ]
55
+ .filter(Boolean)
56
+ .join('\n')
57
+ .toLowerCase();
58
+
59
+ if (
60
+ platform === 'ios' &&
61
+ (
62
+ haystack.includes('runner did not accept connection') ||
63
+ (phase === 'connect' &&
64
+ (
65
+ haystack.includes('timed out') ||
66
+ haystack.includes('timeout') ||
67
+ haystack.includes('econnrefused') ||
68
+ haystack.includes('connection refused') ||
69
+ haystack.includes('fetch failed') ||
70
+ haystack.includes('socket hang up')
71
+ ))
72
+ )
73
+ ) {
74
+ return 'IOS_RUNNER_CONNECT_TIMEOUT';
75
+ }
76
+ if (platform === 'ios' && phase === 'boot' && (haystack.includes('timed out') || haystack.includes('timeout'))) {
77
+ return 'IOS_BOOT_TIMEOUT';
78
+ }
79
+ if (platform === 'android' && phase === 'boot' && (haystack.includes('timed out') || haystack.includes('timeout'))) {
80
+ return 'ANDROID_BOOT_TIMEOUT';
81
+ }
82
+ if (
83
+ haystack.includes('resource temporarily unavailable') ||
84
+ haystack.includes('killed: 9') ||
85
+ haystack.includes('cannot allocate memory') ||
86
+ haystack.includes('system is low on memory')
87
+ ) {
88
+ return 'CI_RESOURCE_STARVATION_SUSPECTED';
89
+ }
90
+ if (
91
+ platform === 'android' &&
92
+ (
93
+ haystack.includes('device not found') ||
94
+ haystack.includes('no devices') ||
95
+ haystack.includes('device offline') ||
96
+ haystack.includes('offline') ||
97
+ haystack.includes('unauthorized') ||
98
+ haystack.includes('not authorized') ||
99
+ haystack.includes('unable to locate device') ||
100
+ haystack.includes('invalid device')
101
+ )
102
+ ) {
103
+ return 'ADB_TRANSPORT_UNAVAILABLE';
104
+ }
105
+ if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
106
+ return 'UNKNOWN';
107
+ }
108
+
109
+ export function bootFailureHint(reason: BootFailureReason): string {
110
+ switch (reason) {
111
+ case 'IOS_BOOT_TIMEOUT':
112
+ return 'Retry simulator boot and inspect simctl bootstatus logs; in CI consider increasing AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS.';
113
+ case 'IOS_RUNNER_CONNECT_TIMEOUT':
114
+ return 'Retry runner startup, inspect xcodebuild logs, and verify simulator responsiveness before command execution.';
115
+ case 'ANDROID_BOOT_TIMEOUT':
116
+ return 'Retry emulator startup and verify sys.boot_completed reaches 1; consider increasing startup budget in CI.';
117
+ case 'ADB_TRANSPORT_UNAVAILABLE':
118
+ return 'Check adb server/device transport (adb devices -l), restart adb, and ensure the target device is online and authorized.';
119
+ case 'CI_RESOURCE_STARVATION_SUSPECTED':
120
+ return 'CI machine may be resource constrained; reduce parallel jobs or use a larger runner.';
121
+ case 'IOS_TOOL_MISSING':
122
+ return 'Xcode command-line tools are missing or not in PATH; run xcode-select --install and verify xcrun works.';
123
+ case 'BOOT_COMMAND_FAILED':
124
+ return 'Inspect command stderr/stdout for the failing boot phase and retry after environment validation.';
125
+ default:
126
+ return 'Retry once and inspect verbose logs for the failing phase.';
127
+ }
128
+ }