diff --git a/packages/contracts/src/device-readiness-runtime.ts b/packages/contracts/src/device-readiness-runtime.ts index 3271d4c019..c9033193f8 100644 --- a/packages/contracts/src/device-readiness-runtime.ts +++ b/packages/contracts/src/device-readiness-runtime.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppError } from '@agent-device/kernel/errors'; import type { DeviceInventoryRequest } from './device-inventory.ts'; /** Typed `details.reason` every platform reports when the boot deadline expires. */ @@ -21,6 +22,18 @@ export type DeviceReadinessRuntimeOperations = Readonly<{ bootTargetHeadless(input: EnsureReadyInput): Promise; }>; +/** + * One run of the operator's device boot hook: its argv and what it printed. `failure` says why it + * did not exit on its own (it could not start, or ran out of time); `exitCode` is then null. + */ +export type DeviceBootHookRun = Readonly<{ + command: readonly string[]; + exitCode: number | null; + failure?: string; + stdout: string; + stderr: string; +}>; + export type DeviceReadinessRuntimeHost = Readonly<{ applePhysical: Readonly<{ ensureConnected(device: DeviceInfo, signal: AbortSignal): Promise; @@ -41,4 +54,18 @@ export type DeviceReadinessRuntimeHost = Readonly<{ launch(avdName: string, headless: boolean, bootBudgetMs: number): Promise; terminate(pid: number): Promise; }>; + /** + * The operator's boot hook, `boot-hook.json` in the daemon state directory. `run` returns null + * when none is configured, and readiness boots the device itself. After a run, readiness reads + * the device state again and throws `leftDeviceDown` when it is still not booted: it never boots + * the device itself then, because the hook's owner admits devices. + */ + bootHook: Readonly<{ + run( + device: DeviceInfo, + signal?: AbortSignal, + options?: Readonly<{ deadlineAtMs?: number }>, + ): Promise; + leftDeviceDown(run: DeviceBootHookRun, device: DeviceInfo): AppError; + }>; }>; diff --git a/packages/platform-android/src/readiness/runtime.test.ts b/packages/platform-android/src/readiness/runtime.test.ts index 11ed49629c..025bca42ee 100644 --- a/packages/platform-android/src/readiness/runtime.test.ts +++ b/packages/platform-android/src/readiness/runtime.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import type { DeviceReadinessRuntimeHost } from '@agent-device/contracts/device-readiness-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { ensureAndroidReady } from './runtime.ts'; @@ -36,6 +37,7 @@ test('cancellation interrupts Android boot polling and terminates the emulator l launch: async () => 4242, terminate, }, + bootHook: { run: async () => null, leftDeviceDown: () => new Error('left down') as never }, }, } as unknown as PlatformRuntimeHost; @@ -48,7 +50,11 @@ test('cancellation interrupts Android boot polling and terminates the emulator l expect(terminate).toHaveBeenCalledWith(4242); }); -function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInfo[] }) { +function timedHost(overrides: { + bootCompleted: string; + discover: () => DeviceInfo[]; + bootHook?: DeviceReadinessRuntimeHost['bootHook']; +}) { let now = 1_000; const run = vi.fn(async () => ({ stdout: overrides.bootCompleted, stderr: '', exitCode: 0 })); const terminate = vi.fn(async () => {}); @@ -68,6 +74,10 @@ function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInf launch, terminate, }, + bootHook: overrides.bootHook ?? { + run: async () => null, + leftDeviceDown: () => new Error('left down') as never, + }, }, } as unknown as PlatformRuntimeHost; return { host, run, launch, terminate, elapsed: () => now - 1_000 }; @@ -155,6 +165,81 @@ test('without a deadline the default 120s boot wait applies', async () => { expect(elapsed()).toBeGreaterThanOrEqual(120_000); }); +const HOOK_RUN = { + command: ['stim', 'revive', '--json'], + exitCode: 1, + stdout: '{"code":"STIM_DEVICE_LOST"}', + stderr: '', +}; + +const LEFT_DOWN = new Error('boot hook left the device down'); + +function bootHook(run: DeviceReadinessRuntimeHost['bootHook']['run']) { + return { run, leftDeviceDown: vi.fn(() => LEFT_DOWN as never) }; +} + +test('a boot hook starts a shut-down AVD instead of the emulator launch', async () => { + let discoveries = 0; + const hookRun = vi.fn(async () => ({ ...HOOK_RUN, exitCode: 0 })); + const { host, launch } = timedHost({ + bootCompleted: '1', + discover: () => (++discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]), + bootHook: bootHook(hookRun), + }); + + const ready = await ensureAndroidReady( + host, + stoppedAvd(), + { headless: false }, + new AbortController().signal, + ); + + expect(ready).toMatchObject({ id: 'emulator-5554', booted: true }); + expect(hookRun).toHaveBeenCalledWith(stoppedAvd(), expect.any(AbortSignal), { + deadlineAtMs: expect.any(Number), + }); + expect(launch).not.toHaveBeenCalled(); +}); + +test('an emulator a slow boot hook started is still checked once after the boot budget ran out', async () => { + let discoveries = 0; + const hookHost: { clock?: PlatformRuntimeHost['clock'] } = {}; + const { host, run } = timedHost({ + bootCompleted: '1', + discover: () => (++discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]), + bootHook: bootHook(async () => { + await hookHost.clock?.sleep(300_000, new AbortController().signal); + return { ...HOOK_RUN, exitCode: 0 }; + }), + }); + hookHost.clock = host.clock; + + const ready = await ensureAndroidReady( + host, + stoppedAvd(), + { headless: false, deadlineAtMs: 3_000 }, + new AbortController().signal, + ); + + expect(ready).toMatchObject({ id: 'emulator-5554', booted: true }); + expect(run).toHaveBeenCalledOnce(); +}); + +test('a boot hook that leaves no running emulator refuses with its run and never launches one', async () => { + const hook = bootHook(async () => HOOK_RUN); + const { host, launch } = timedHost({ + bootCompleted: '1', + discover: () => [stoppedAvd()], + bootHook: hook, + }); + + await expect( + ensureAndroidReady(host, stoppedAvd(), { headless: false }, new AbortController().signal), + ).rejects.toBe(LEFT_DOWN); + expect(hook.leftDeviceDown).toHaveBeenCalledWith(HOOK_RUN, stoppedAvd()); + expect(launch).not.toHaveBeenCalled(); +}); + function stoppedAvd(): DeviceInfo { return { platform: 'android', diff --git a/packages/platform-android/src/readiness/runtime.ts b/packages/platform-android/src/readiness/runtime.ts index 404cb8e83f..aa0c38bce4 100644 --- a/packages/platform-android/src/readiness/runtime.ts +++ b/packages/platform-android/src/readiness/runtime.ts @@ -44,7 +44,15 @@ async function ensureEmulatorReady( const available = await host.deviceReadiness.androidEmulator.discover(request, signal); const selected = requireAvailableAvd(available, device.name, input.serial); - const existing = isRunningEmulator(selected) ? selected : undefined; + const hookStarted = isRunningEmulator(selected) + ? undefined + : await startedByBootHook( + host, + selected, + { request, serial: input.serial, deadlineAtMs }, + signal, + ); + const existing = isRunningEmulator(selected) ? selected : hookStarted; const launchedPid = existing ? undefined : await host.deviceReadiness.androidEmulator.launch( @@ -56,7 +64,10 @@ async function ensureEmulatorReady( const discovered = existing ?? (await waitForDiscovery(host, selected.name, request, input.serial, deadlineAtMs, signal)); - await waitForBoot(host, discovered.id, deadlineAtMs, signal); + const bootDeadline = hookStarted + ? Math.max(deadlineAtMs, host.clock.now() + POLL_MS) + : deadlineAtMs; + await waitForBoot(host, discovered.id, bootDeadline, signal); const refreshed = (await host.deviceReadiness.androidEmulator.discover(request, signal)).find( (candidate) => candidate.id === discovered.id, ); @@ -70,6 +81,28 @@ async function ensureEmulatorReady( } } +async function startedByBootHook( + host: AndroidReadinessHost, + avd: DeviceInfo, + target: Readonly<{ + request: ReturnType; + serial: string | undefined; + deadlineAtMs: number; + }>, + signal: AbortSignal, +): Promise { + const hookRun = await host.deviceReadiness.bootHook.run(avd, signal, { + deadlineAtMs: target.deadlineAtMs, + }); + if (!hookRun) return undefined; + const devices = await host.deviceReadiness.androidEmulator.discover(target.request, signal); + const started = findByAvdName(devices, avd.name, target.serial); + if (!started || !isRunningEmulator(started)) { + throw host.deviceReadiness.bootHook.leftDeviceDown(hookRun, avd); + } + return started; +} + async function prepareAndroidEmulatorToolchain(host: AndroidReadinessHost): Promise { await host.toolchains.prepare('android'); if (!(await host.commands.which('adb'))) { diff --git a/packages/platform-apple/src/core/__tests__/simulator.test.ts b/packages/platform-apple/src/core/__tests__/simulator.test.ts index 2017a6fa27..e196475ce0 100644 --- a/packages/platform-apple/src/core/__tests__/simulator.test.ts +++ b/packages/platform-apple/src/core/__tests__/simulator.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { ensureBootedSimulator, openIosSimulatorApp, + setSimulatorBootHook, wasSimulatorRecentlyObservedBooted, } from '../simulator.ts'; import { IOS_BOOT_TIMEOUT_MS, IOS_SIMULATOR_SHOW_TIMEOUT_MS } from '../config.ts'; @@ -342,3 +343,40 @@ test('ensureBootedSimulator stops at the boot deadline instead of starting anoth assert.equal(booted, true); }); + +test('ensureBootedSimulator runs the bound boot hook instead of booting a shut-down simulator', async () => { + mockRunCmdResponses({ + 'xcrun simctl list devices -j': simulatorStateSequence('Shutdown', 'Booted'), + }); + const run = vi.fn(async () => ({ + command: ['stim', 'revive'], + exitCode: 0, + stdout: '', + stderr: '', + })); + setSimulatorBootHook({ run, leftDeviceDown: () => new AppError('COMMAND_FAILED', 'left down') }); + try { + await ensureBootedSimulator(IOS_TEST_SIMULATOR); + } finally { + setSimulatorBootHook(undefined); + } + + assert.equal(run.mock.calls.length, 1); + assert.equal(wasSimulatorRecentlyObservedBooted(IOS_TEST_SIMULATOR), true); +}); + +test('ensureBootedSimulator refuses without booting when the boot hook leaves the simulator shut down', async () => { + mockRunCmdResponses({ + 'xcrun simctl list devices -j': simulatorStateSequence('Shutdown'), + }); + const hookRun = { command: ['stim', 'revive', '--json'], exitCode: 1, stdout: '', stderr: '' }; + const leftDown = new AppError('COMMAND_FAILED', 'left down'); + const leftDeviceDown = vi.fn(() => leftDown); + setSimulatorBootHook({ run: async () => hookRun, leftDeviceDown }); + try { + await assert.rejects(ensureBootedSimulator(IOS_TEST_SIMULATOR), (error) => error === leftDown); + } finally { + setSimulatorBootHook(undefined); + } + assert.deepEqual(leftDeviceDown.mock.calls, [[hookRun, IOS_TEST_SIMULATOR]]); +}); diff --git a/packages/platform-apple/src/core/simulator.ts b/packages/platform-apple/src/core/simulator.ts index 30ebb5a873..5ca7b54373 100644 --- a/packages/platform-apple/src/core/simulator.ts +++ b/packages/platform-apple/src/core/simulator.ts @@ -4,6 +4,8 @@ import { execFailureDetails, requireExecSuccess } from '@agent-device/host-kit/c import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; import { createTtlMemo } from '@agent-device/kernel/ttl-memo'; +import { runtimeSlot } from '@agent-device/host-kit/runtime-slots'; +import type { DeviceReadinessRuntimeHost } from '@agent-device/contracts/device-readiness-runtime'; import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics'; import { @@ -58,6 +60,22 @@ export async function openIosSimulatorApp(options: SimulatorCommandOptions = {}) }); } +type SimulatorBootHookSlot = { hook: DeviceReadinessRuntimeHost['bootHook'] | undefined }; + +const BOOT_HOOK_SLOT = Symbol('appleSimulatorBootHook'); +const bootHookSlot = (): SimulatorBootHookSlot => + runtimeSlot(BOOT_HOOK_SLOT, (): SimulatorBootHookSlot => ({ hook: undefined })); + +/** + * Binds the operator's boot hook for every simulator boot this runtime makes. Root composition + * calls it once per runtime; without it, a shut-down simulator is booted with `simctl boot`. + */ +export function setSimulatorBootHook( + hook: DeviceReadinessRuntimeHost['bootHook'] | undefined, +): void { + bootHookSlot().hook = hook; +} + export async function ensureBootedSimulator( device: DeviceInfo, options: SimulatorCommandOptions = {}, @@ -72,6 +90,16 @@ export async function ensureBootedSimulator( return; } + const bootHook = bootHookSlot().hook; + const hookRun = await bootHook?.run(device, options.signal); + if (bootHook && hookRun) { + if ((await getSimulatorState(device, options.signal)) !== 'Booted') { + throw bootHook.leftDeviceDown(hookRun, device); + } + markSimulatorBooted(device); + return; + } + const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS); let bootResult: | { diff --git a/packages/platform-apple/src/readiness/runtime.test.ts b/packages/platform-apple/src/readiness/runtime.test.ts index 1e643ae517..7813ab867a 100644 --- a/packages/platform-apple/src/readiness/runtime.test.ts +++ b/packages/platform-apple/src/readiness/runtime.test.ts @@ -324,6 +324,71 @@ function simctlVerb(args: readonly string[]): string { return args.find((arg) => ['list', 'boot', 'bootstatus', 'shutdown'].includes(arg)) ?? ''; } +function hookHost( + states: string[], + hookRun: PlatformRuntimeHost['deviceReadiness']['bootHook']['run'], +) { + const leftDown = new Error('boot hook left the device down'); + const leftDeviceDown = vi.fn(() => leftDown as never); + const calls: string[][] = []; + const run: PlatformRuntimeHost['appleTools']['run'] = vi.fn(async (request) => { + calls.push([...request.args]); + const state = request.args.includes('list') ? (states.shift() ?? 'Shutdown') : null; + return { + stdout: state ? JSON.stringify({ devices: { ios: [{ udid: 'sim-1', state }] } }) : '', + stderr: '', + exitCode: 0, + }; + }); + const base = platformRuntimeHostFixture(); + const host = { + ...base, + appleTools: { isXcrunAvailable: async () => true, run }, + deviceReadiness: { ...base.deviceReadiness, bootHook: { run: hookRun, leftDeviceDown } }, + } satisfies PlatformRuntimeHost; + return { + host, + leftDown, + leftDeviceDown, + booted: () => calls.some((args) => args.includes('boot')), + }; +} + +test('a boot hook restores a shut-down simulator instead of a simctl boot', async () => { + const hookRun = vi.fn(async () => ({ + command: ['stim', 'revive'], + exitCode: 0, + stdout: '', + stderr: '', + })); + const { host, booted } = hookHost(['Shutdown', 'Booted'], hookRun); + + const ready = await ensureAppleReady(host, simulator(), new AbortController().signal); + + expect(ready.booted).toBe(true); + expect(hookRun).toHaveBeenCalledOnce(); + expect(booted()).toBe(false); +}); + +test('a boot hook that leaves the simulator shut down refuses with its run and never boots it', async () => { + const hookRun = { + command: ['stim', 'revive', '--json'], + exitCode: 1, + stdout: '{"code":"STIM_NO_METRO"}', + stderr: '', + }; + const { host, leftDown, leftDeviceDown, booted } = hookHost( + ['Shutdown', 'Shutdown'], + async () => hookRun, + ); + + await expect(ensureAppleReady(host, simulator(), new AbortController().signal)).rejects.toBe( + leftDown, + ); + expect(leftDeviceDown).toHaveBeenCalledWith(hookRun, simulator()); + expect(booted()).toBe(false); +}); + function simulator(overrides: Partial = {}): DeviceInfo { return { platform: 'apple', diff --git a/packages/platform-apple/src/readiness/runtime.ts b/packages/platform-apple/src/readiness/runtime.ts index b0f3bff0fd..33c604cfba 100644 --- a/packages/platform-apple/src/readiness/runtime.ts +++ b/packages/platform-apple/src/readiness/runtime.ts @@ -45,7 +45,17 @@ export async function ensureAppleReady( const recentlyObservedBooted = await readRecentBootObservation(host, device); const state = recentlyObservedBooted ? 'Booted' : await simulatorState(host, device, signal); - if (state !== 'Booted') { + const hookRun = + state === 'Booted' + ? null + : await host.deviceReadiness.bootHook.run(device, signal, { + deadlineAtMs: options.deadlineAtMs, + }); + if (hookRun) { + if ((await simulatorState(host, device, signal)) !== 'Booted') { + throw host.deviceReadiness.bootHook.leftDeviceDown(hookRun, device); + } + } else if (state !== 'Booted') { options.onColdBootStart?.(); host.deviceReadiness.appleAutomation.keepHot(device); await bootSimulator(host, device, signal, options.deadlineAtMs); diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index f63ec91570..45ae42f152 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -1,3 +1,4 @@ +import { AppError } from '@agent-device/kernel/errors'; import { inertAudioProbeHost } from '@agent-device/host-kit/audio-probe-fixtures'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { Interactor } from '@agent-device/contracts/interactor-types'; @@ -29,6 +30,10 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { launch: async () => 1, terminate: async () => {}, }, + bootHook: { + run: async () => null, + leftDeviceDown: () => new AppError('COMMAND_FAILED', 'boot hook left the device down'), + }, }, localInteractors: { resolve: async () => ({}) as Interactor }, audioProbe: inertAudioProbeHost(), diff --git a/packages/platform-apple/src/simulator-facade.ts b/packages/platform-apple/src/simulator-facade.ts index 32735676e3..61f2ba6820 100644 --- a/packages/platform-apple/src/simulator-facade.ts +++ b/packages/platform-apple/src/simulator-facade.ts @@ -1,5 +1,6 @@ export { ensureBootedSimulator, markSimulatorBooted, + setSimulatorBootHook, wasSimulatorRecentlyObservedBooted, } from './core/simulator.ts'; diff --git a/scripts/layering/root-module-zones.ts b/scripts/layering/root-module-zones.ts index fe4a230613..c4ede5337b 100644 --- a/scripts/layering/root-module-zones.ts +++ b/scripts/layering/root-module-zones.ts @@ -39,6 +39,7 @@ export const ROOT_MODULE_ZONES: Readonly> = { 'src/platform-runtime-daemon-lifecycle.ts', 'src/platform-runtime-daemon-owner-cleanup.ts', 'src/platform-runtime-daemon-services.ts', + 'src/platform-runtime-device-boot-hook.ts', 'src/platform-runtime-device-boot.ts', 'src/platform-runtime-device-inventory.ts', 'src/platform-runtime-device-ready.ts', diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index ba03014ab8..b198d6b997 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -480,6 +480,7 @@ Runner and daemon lifecycle (applies to simulators too): open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch. No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up. close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and daemon shutdown stops idle retained runners rather than handing them off without supervision. + A boot-hook.json in the state dir ({ "command": [argv...], "cwd": "" }) replaces agent-device's own boot of a shut-down iOS simulator or Android emulator: the daemon runs the command with AGENT_DEVICE_BOOT_HOOK_PLATFORM, AGENT_DEVICE_BOOT_HOOK_DEVICE_ID and AGENT_DEVICE_BOOT_HOOK_DEVICE_NAME set, for at most 5 minutes (less under boot/open --timeout or the Android startup budget), then reads the device state again, also when the hook timed out or could not start. A device still not booted fails with details.reason boot_hook_left_device_down and the hook's output; agent-device never boots it itself while the hook is configured. Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap. A client starting a local daemon waits while the daemon process stays alive and keeps publishing startup progress, so an overloaded host makes startup slower, not failed. It stops at a ceiling (default 120 s; set AGENT_DEVICE_DAEMON_STARTUP_TIMEOUT_MS to a positive integer number of milliseconds to override) and names the outcome in error details.reason: startup-exited (the process ended before registering), startup-stalled (alive, no startup progress for a fixed 60 s window the ceiling variable does not change), or startup-ceiling (a startup was still live when the ceiling passed — this client's daemon or another client's; the client stops its own launch and never signals another client's live daemon. Only a progressing own launch outlasts the 60 s stall window, so a ceiling set below 60 s also ends a silent own launch this way). details.phases carries the startup phase timings it observed and details.startupTimeoutMs the effective ceiling. Branch on details.reason, never on the message. The variable is read only when a client must start a daemon: a value that is not a positive integer fails with INVALID_ARGS before the daemon spawns, and a client that only attaches to or reuses a running daemon never reads it. A command that needs the shared local daemon starts it for its state dir when none runs (device status and device release --stale never contact one). When the running daemon comes from another version or build, the command replaces it only if the daemon reports no work: no in-flight request, session, repair session, or live lease (a streaming resumable upload and a host human-control hold that names no lease are not counted); otherwise it fails with details.reason daemon-busy and leaves the daemon alone. A daemon that agreed to leave is waited for up to 60 s and never signaled while it drains; one still running then fails with details.reason daemon-drain-timeout unless it answers nothing and is provably a daemon process, which is force-retired. A daemon from a release that predates this check is never replaced and fails with daemon-incompatible. End a busy or older daemon deliberately with agent-device daemon stop --state-dir . Node clients pick daemonLifecycle 'attach' (never starts or replaces a daemon; fails with daemon-not-running or daemon-incompatible) or 'managed' (the default until 0.22), and ensureDaemon() starts or replaces one explicitly. diff --git a/src/daemon-composition.test.ts b/src/daemon-composition.test.ts index 90115b19bb..da06082024 100644 --- a/src/daemon-composition.test.ts +++ b/src/daemon-composition.test.ts @@ -9,11 +9,20 @@ import { mkdtempForTestSync, createTestHostEnvironment, } from '@agent-device/test-kit'; +import { runCmd } from '@agent-device/host-kit/command'; +import { ensureBootedSimulator } from '@agent-device/platform-apple/simulator'; +import fs from 'node:fs'; +import path from 'node:path'; import { createDaemonRuntimeDeps } from './daemon-composition.ts'; import { createRuntimeStateHandle, runtimeSlot } from '@agent-device/host-kit/runtime-slots'; import { resolveRunnerAppBundleId } from '@agent-device/platform-apple/runner/operations'; import { setActiveProviderDeviceRuntimes } from './provider-device-runtime.ts'; +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(actual.runCmd) }; +}); + const ports = vi.hoisted(() => ({ inventoryGateways: vi.fn(async (): Promise => ({})), shutdownProviders: vi.fn(async () => []), @@ -245,3 +254,52 @@ test('the composed daemon route configures interactors from its host, not from t }, ); }); + +test("each composition binds its state directory's boot hook to the simulator boots of its runtime", async () => { + const commands: string[] = []; + const states = new Map(); + vi.mocked(runCmd).mockImplementation(async (cmd, args) => { + commands.push([cmd, ...args].join(' ')); + const udid = args.at(-1) ?? ''; + if (args.includes('list')) { + const devices = [...states].map(([id, state]) => ({ udid: id, state })); + return { exitCode: 0, stdout: JSON.stringify({ devices: { ios: devices } }), stderr: '' }; + } + if (args.includes('boot')) states.set(udid, 'Booted'); + return { exitCode: 0, stdout: '', stderr: '' }; + }); + const compose = async (name: string, hook: boolean) => { + const baseDir = mkdtempForTestSync(`agent-device-daemon-composition-hook-${name}-`); + if (hook) { + fs.writeFileSync( + path.join(baseDir, 'boot-hook.json'), + JSON.stringify({ command: ['revive-hook'] }), + ); + } + await createDaemonRuntimeDeps({ + host: createTestHostEnvironment({ env: { ...process.env, AGENT_DEVICE_STATE_DIR: baseDir } }), + baseDir, + sessionsDir: `${baseDir}/sessions`, + assertShutdownAllowed: undefined, + resolveSessionDir: (sessionId) => `${baseDir}/sessions/${sessionId}`, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + openWebSessionNames: () => [], + }); + }; + const hooked = createRuntimeStateHandle(); + const plain = createRuntimeStateHandle(); + await hooked.run(() => compose('hooked', true)); + await plain.run(() => compose('plain', false)); + states.set('hooked-sim', 'Shutdown').set('plain-sim', 'Shutdown'); + + await expect( + hooked.run(() => ensureBootedSimulator({ ...IOS_SIMULATOR, id: 'hooked-sim' })), + ).rejects.toMatchObject({ details: { reason: 'boot_hook_left_device_down' } }); + await plain.run(() => ensureBootedSimulator({ ...IOS_SIMULATOR, id: 'plain-sim' })); + + expect(commands).toContain('revive-hook'); + expect(commands.some((command) => command.includes('boot hooked-sim'))).toBe(false); + expect(commands.some((command) => command.includes('boot plain-sim'))).toBe(true); +}); diff --git a/src/daemon-composition.ts b/src/daemon-composition.ts index 00bc7a5cb9..00b15794a6 100644 --- a/src/daemon-composition.ts +++ b/src/daemon-composition.ts @@ -156,6 +156,9 @@ export async function composeDaemonRuntimeDeps( const { setAppleRunnerHostEnvironment } = await import('@agent-device/platform-apple/runner-owner'); setAppleRunnerHostEnvironment(host); + const { setSimulatorBootHook } = await import('@agent-device/platform-apple/simulator'); + const { createDeviceBootHook } = await import('./platform-runtime-device-boot-hook.ts'); + setSimulatorBootHook(createDeviceBootHook(host)); return Object.freeze({ ownedProcessRecords, hostDiagnostics, diff --git a/src/platform-runtime-device-boot-hook.test.ts b/src/platform-runtime-device-boot-hook.test.ts new file mode 100644 index 0000000000..0103a72d8c --- /dev/null +++ b/src/platform-runtime-device-boot-hook.test.ts @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn(actual.runCmd) }; +}); + +import { runCmd } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; +import { IOS_SIMULATOR, mkdtempForTestSync } from '@agent-device/test-kit'; +import { createDeviceBootHook } from './platform-runtime-device-boot-hook.ts'; + +function stateDirWithHook(hook: unknown): string { + const stateDir = mkdtempForTestSync('agent-device-boot-hook-'); + if (hook !== undefined) { + fs.writeFileSync(path.join(stateDir, 'boot-hook.json'), JSON.stringify(hook)); + } + return stateDir; +} + +test('without boot-hook.json in the state directory there is no hook to run', async () => { + const stateDir = stateDirWithHook(undefined); + const hook = createDeviceBootHook({ env: { AGENT_DEVICE_STATE_DIR: stateDir }, cwd: stateDir }); + + await expect(hook.run(IOS_SIMULATOR)).resolves.toBeNull(); +}); + +test('the hook runs in its cwd with the device in its environment, and reports its output', async () => { + const stateDir = stateDirWithHook(undefined); + const workspace = fs.realpathSync(mkdtempForTestSync('agent-device-boot-hook-cwd-')); + const script = [ + 'const env = process.env;', + 'console.log(JSON.stringify([process.cwd(), env.AGENT_DEVICE_BOOT_HOOK_PLATFORM, env.AGENT_DEVICE_BOOT_HOOK_DEVICE_ID, env.AGENT_DEVICE_BOOT_HOOK_DEVICE_NAME]));', + 'console.error("device is gone");', + 'process.exit(3);', + ].join('\n'); + fs.writeFileSync( + path.join(stateDir, 'boot-hook.json'), + JSON.stringify({ command: [process.execPath, '-e', script], cwd: workspace }), + ); + const hook = createDeviceBootHook({ env: { AGENT_DEVICE_STATE_DIR: stateDir }, cwd: stateDir }); + + const run = await hook.run(IOS_SIMULATOR); + + expect(run).toMatchObject({ exitCode: 3, stderr: 'device is gone\n' }); + expect(JSON.parse(run?.stdout ?? '')).toEqual([ + workspace, + 'ios', + IOS_SIMULATOR.id, + IOS_SIMULATOR.name, + ]); +}); + +test.each([ + ['an unknown key', { command: ['stim', 'revive'], timeoutMs: 1 }], + ['an empty command', { command: [] }], + ['a relative cwd', { command: ['stim', 'revive'], cwd: 'app' }], +])('a hook file with %s is refused as an invalid boot hook', async (_name, contents) => { + const stateDir = stateDirWithHook(contents); + const hook = createDeviceBootHook({ env: { AGENT_DEVICE_STATE_DIR: stateDir }, cwd: stateDir }); + + await expect(hook.run(IOS_SIMULATOR)).rejects.toMatchObject({ + code: 'INVALID_ARGS', + details: { reason: 'invalid_boot_hook' }, + }); +}); + +test.each<[string, string | undefined, () => void, RegExp]>([ + ['cannot start', undefined, () => {}, /could not run from .*boot-hook\.json/], + [ + 'runs from a deleted directory', + '/nonexistent/workspace', + () => {}, + /its cwd \/nonexistent\/workspace does not exist/, + ], + [ + 'runs past its limit', + undefined, + () => + vi.mocked(runCmd).mockRejectedValueOnce( + new AppError('COMMAND_FAILED', 'stim timed out after 1000ms', { + timeoutMs: 1000, + stdout: '', + stderr: 'still booting', + }), + ), + /ran past its \d+ ms limit/, + ], +])('a hook that %s is reported as a failed run', async (_name, cwd, arrange, failure) => { + const stateDir = stateDirWithHook({ + command: ['agent-device-missing-boot-hook-binary'], + ...(cwd ? { cwd } : {}), + }); + const hook = createDeviceBootHook({ env: { AGENT_DEVICE_STATE_DIR: stateDir }, cwd: stateDir }); + arrange(); + + const run = await hook.run(IOS_SIMULATOR, undefined, { deadlineAtMs: Date.now() + 1_000 }); + + expect(run).toMatchObject({ exitCode: null, failure: expect.stringMatching(failure) }); +}); + +test('the refusal names the hook and quotes its output, or why it did not finish', () => { + const hook = createDeviceBootHook({ env: { AGENT_DEVICE_STATE_DIR: '/state' }, cwd: '/state' }); + const command = ['stim', 'revive', '--json']; + + const exited = hook.leftDeviceDown( + { command, exitCode: 1, stdout: '{"code":"STIM_NO_METRO"}', stderr: '' }, + IOS_SIMULATOR, + ); + const stopped = hook.leftDeviceDown( + { + command, + exitCode: null, + failure: 'ran past its 1000 ms limit and was stopped', + stdout: '', + stderr: '', + }, + IOS_SIMULATOR, + ); + + expect(exited).toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'boot_hook_left_device_down', hook: command, exitCode: 1 }, + }); + expect(exited.message).toMatch(/stim revive --json\) exited 1[\s\S]*STIM_NO_METRO/); + expect(stopped.message).toContain('ran past its 1000 ms limit and was stopped'); +}); diff --git a/src/platform-runtime-device-boot-hook.ts b/src/platform-runtime-device-boot-hook.ts new file mode 100644 index 0000000000..4a7d777ae9 --- /dev/null +++ b/src/platform-runtime-device-boot-hook.ts @@ -0,0 +1,169 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { + DeviceBootHookRun, + DeviceReadinessRuntimeHost, +} from '@agent-device/contracts/device-readiness-runtime'; +import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolution'; +import { isCommandTimeoutError, runCmd } from '@agent-device/host-kit/command'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { publicPlatformString, type DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { isRecord } from '@agent-device/kernel/record'; +import type { HostEnvironment } from '@agent-device/kernel/source-value'; + +const BOOT_HOOK_FILE = 'boot-hook.json'; +const BOOT_HOOK_TIMEOUT_MS = 300_000; +const BOOT_HOOK_TERM_GRACE_MS = 10_000; + +type BootHook = Readonly<{ command: readonly [string, ...string[]]; cwd?: string }>; + +function invalidHook(file: string, problem: string): AppError { + return new AppError('INVALID_ARGS', `Invalid device boot hook ${file}: ${problem}`, { + reason: 'invalid_boot_hook', + hint: `Fix or remove ${file}. Without it, agent-device boots shut-down devices itself.`, + }); +} + +function readBootHookText(file: string): string | null { + try { + return fs.readFileSync(file, 'utf8'); + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') return null; + throw invalidHook(file, error instanceof Error ? error.message : String(error)); + } +} + +function parseJsonObject(file: string, text: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw invalidHook(file, 'it is not JSON'); + } + if (!isRecord(parsed)) throw invalidHook(file, 'it is not a JSON object'); + return parsed; +} + +function hookCommand(file: string, value: unknown): BootHook['command'] { + const parts = Array.isArray(value) ? value : []; + const argv = parts.filter((part): part is string => typeof part === 'string' && part.length > 0); + const [executable, ...args] = argv; + if (executable === undefined || argv.length !== parts.length) { + throw invalidHook(file, 'command must be a non-empty array of non-empty strings'); + } + return [executable, ...args]; +} + +function readBootHook(file: string): BootHook | null { + const text = readBootHookText(file); + if (text === null) return null; + const parsed = parseJsonObject(file, text); + const unknown = Object.keys(parsed).filter((key) => key !== 'command' && key !== 'cwd'); + if (unknown.length > 0) throw invalidHook(file, `unknown key ${unknown.join(', ')}`); + const command = hookCommand(file, parsed.command); + const { cwd } = parsed; + if (cwd === undefined) return { command }; + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) { + throw invalidHook(file, 'cwd must be an absolute path'); + } + return { command, cwd }; +} + +const HOOK_OUTPUT_TAIL_CHARS = 2_000; + +function leftDeviceDown(run: DeviceBootHookRun, device: DeviceInfo): AppError { + const output = [run.stdout.trim(), run.stderr.trim()].filter(Boolean).join('\n'); + return new AppError( + 'COMMAND_FAILED', + `The device boot hook (${run.command.join(' ')}) ${run.failure ?? `exited ${run.exitCode}`} and left ${device.name} (${device.id}) not booted. agent-device does not boot a device itself while a boot hook is configured.${output ? ` Hook output:\n${output.slice(-HOOK_OUTPUT_TAIL_CHARS)}` : ''}`, + { + reason: 'boot_hook_left_device_down', + hook: run.command, + exitCode: run.exitCode, + deviceId: device.id, + hint: "Follow the remedy in the hook's output. The hook's tool owns this device's boot.", + }, + ); +} + +function hookTimeoutMs(deadlineAtMs: number | undefined): number { + if (deadlineAtMs === undefined) return BOOT_HOOK_TIMEOUT_MS; + return Math.min(BOOT_HOOK_TIMEOUT_MS, Math.max(1_000, deadlineAtMs - Date.now())); +} + +function hookFailure(file: string, error: unknown, timeoutMs: number): string { + if (isCommandTimeoutError(error)) return `ran past its ${timeoutMs} ms limit and was stopped`; + const message = error instanceof Error ? error.message : String(error); + return `could not run from ${file} (${message})`; +} + +export function createDeviceBootHook( + host: HostEnvironment, +): DeviceReadinessRuntimeHost['bootHook'] { + const file = path.join( + resolveDaemonPaths(host.env.AGENT_DEVICE_STATE_DIR, { env: host.env, cwd: host.cwd }).baseDir, + BOOT_HOOK_FILE, + ); + return Object.freeze({ + leftDeviceDown, + run: async (device, signal, options = {}): Promise => { + const hook = readBootHook(file); + if (!hook) return null; + const [executable, ...args] = hook.command; + const platform = publicPlatformString(device); + const timeoutMs = hookTimeoutMs(options.deadlineAtMs); + emitDiagnostic({ + phase: 'device_boot_hook_start', + data: { command: hook.command, deviceId: device.id, platform, timeoutMs }, + }); + const started = Date.now(); + const cwd = hook.cwd ?? host.cwd; + let run: DeviceBootHookRun; + try { + if (!fs.existsSync(cwd)) throw new Error(`its cwd ${cwd} does not exist`); + const result = await runCmd(executable, args, { + cwd, + env: { + ...host.env, + AGENT_DEVICE_BOOT_HOOK_PLATFORM: platform, + AGENT_DEVICE_BOOT_HOOK_DEVICE_ID: device.id, + AGENT_DEVICE_BOOT_HOOK_DEVICE_NAME: device.name, + }, + allowFailure: true, + timeoutMs, + signal, + kill: { signal: 'SIGTERM', graceMs: BOOT_HOOK_TERM_GRACE_MS }, + }); + run = { + command: hook.command, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (error) { + signal?.throwIfAborted(); + const details = isRecord(error) && isRecord(error.details) ? error.details : {}; + run = { + command: hook.command, + exitCode: null, + failure: hookFailure(file, error, timeoutMs), + stdout: typeof details.stdout === 'string' ? details.stdout : '', + stderr: typeof details.stderr === 'string' ? details.stderr : '', + }; + } + emitDiagnostic({ + level: run.exitCode === 0 ? 'info' : 'warn', + phase: 'device_boot_hook_exit', + durationMs: Date.now() - started, + data: { + command: hook.command, + exitCode: run.exitCode, + failure: run.failure, + stderr: run.stderr, + }, + }); + return run; + }, + }); +} diff --git a/src/platform-runtime-operation-host.ts b/src/platform-runtime-operation-host.ts index 47e3f85a78..0586ba9c2f 100644 --- a/src/platform-runtime-operation-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -23,6 +23,7 @@ import { createPerfRuntimeHost } from './platform-runtime-perf-host.ts'; import { createApplePhysicalReadinessHost } from './platform-runtime-apple-physical-readiness.ts'; import { createAppleAutomationKeepHotHost } from './platform-runtime-apple-automation-keep-hot.ts'; import { createAndroidEmulatorHost } from './platform-runtime-android-emulator-host.ts'; +import { createDeviceBootHook } from './platform-runtime-device-boot-hook.ts'; import { createDeviceShutdownRuntimeHost } from './platform-runtime-device-shutdown-host.ts'; import { createAppleAppDeploymentExecutor } from './platform-runtime-apple-deployment-executor.ts'; import { createAndroidAppDeploymentExecutor } from './platform-runtime-android-deployment-executor.ts'; @@ -115,6 +116,7 @@ export function createPlatformRuntimeHost(options: { applePhysical: createApplePhysicalReadinessHost(), appleAutomation: createAppleAutomationKeepHotHost(), androidEmulator: createAndroidEmulatorHost(), + bootHook: createDeviceBootHook(host), }), deviceShutdown: createDeviceShutdownRuntimeHost( { appleTools, commands }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bf9c804a6d..2d750a8a1b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -75,6 +75,7 @@ Boot and shut down: - `boot` gets the selected target ready without launching an app. It needs an active session or a device selector. You mainly need it when `open` fails because no simulator or emulator is booted. - `boot --pick newest` boots one device when the request names none and several equally preferred devices match, all shut down. Without it, `boot` fails with `AMBIGUOUS_MATCH` and its hint names the option. Device ranking still applies first, so a phone beats an iPad. Among the tied devices, it prefers a mobile target over a TV, then the newest OS version, then the first name in name order. Name order compares numbers by value, so iPhone 9 comes before iPhone 17, but it is not device age: with equal OS versions, iPhone 16e comes before iPhone 17. Stopped Android AVDs report no OS version, so the name decides between them. It never picks between booted devices or provider devices. The response names the device and reports `selection.reason: newest-bootable-local`. - Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. On macOS the window opens behind the focused app, which keeps focus (also when `open` boots a stopped AVD). This is best-effort: the emulator can still take focus if the focused app cannot be read, or if its window appears after the startup budget runs out. +- A [device boot hook](/docs/configuration#device-boot-hook) replaces agent-device's own boot of a shut-down iOS simulator or Android emulator for every command that would boot one, `boot` and `open` included. - `shutdown` turns off the selected Apple simulator or Android emulator. It refuses the active session's device; use `close --shutdown` to end the session and turn the device off. - `--timeout ` on `boot`, `open `, and `prepare ios-runner` is the startup budget for booting an iOS Simulator or Android emulator (for `prepare ios-runner`, shared with runner preparation). A never-booted iOS Simulator can take several minutes. When the budget runs out, the command fails with `error.details.reason: boot_timeout` and the device keeps booting, so a retry finds it further along. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index b38c46fc3d..77f10082c2 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -130,6 +130,20 @@ Only the variables below, and those documented on command pages, are supported. | Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls | | macOS app backend | `AGENT_DEVICE_MACOS_APP_BACKEND`, `AGENT_DEVICE_MACOS_GHOST_CURSOR` | Public operator controls, read by the daemon. `native` drives macOS app sessions through the macOS helper instead of XCTest; see [Desktop targets](/docs/commands#desktop-targets). Unset or `xctest` uses the runner. The drawn agent pointer adds about 0.3 s to each native click, fill, type, and scroll; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns it off. Restart the daemon after changing either value. | +## Device boot hook + +A tool that owns device lifecycle, such as a dev environment manager that admits devices, boots them and shuts them down when idle, can take over booting from agent-device. Put `boot-hook.json` in the daemon state directory (`AGENT_DEVICE_STATE_DIR`, or `~/.agent-device` by default): + +```json +{ "command": ["stim", "revive", "--json"], "cwd": "/path/to/app" } +``` + +When a command finds its iOS simulator or Android emulator shut down, agent-device runs `command` instead of booting the device itself. `command` is an argument vector, run without a shell. `cwd` is an absolute directory and defaults to the daemon's working directory. The hook's environment adds `AGENT_DEVICE_BOOT_HOOK_PLATFORM` (`ios` or `android`), `AGENT_DEVICE_BOOT_HOOK_DEVICE_ID` (the UDID, or the AVD name of a stopped emulator) and `AGENT_DEVICE_BOOT_HOOK_DEVICE_NAME`. The hook runs for at most 5 minutes. A `--timeout` on `boot` or `open` shortens that, and an Android emulator boot always stays within its startup budget (120 seconds by default). When the time is up it gets `SIGTERM`, then `SIGKILL` 10 seconds later; `boot` and `open` do the same when the request is cancelled. Its stderr goes to the request diagnostics. + +agent-device then reads the device state again, also when the hook ran out of time or could not start. When the device is still not booted, the command fails with `COMMAND_FAILED` and `details.reason: boot_hook_left_device_down`, and the message quotes the hook's output or says why it did not finish. agent-device never falls back to booting the device itself while a hook is configured, because that would bypass the tool that admits devices. Without the file, agent-device boots devices as before. The file is read each time a boot would happen, so a running daemon picks up a new or removed hook. A file with unknown keys, an empty `command` or a relative `cwd` fails with `INVALID_ARGS` and `details.reason: invalid_boot_hook`. + +Whoever can write the state directory controls the hook, which runs with the daemon's privileges. + ## Command-specific defaults A command-specific key applies only to commands that support it, so one config file works across all commands. For example: