diff --git a/packages/contracts/package.json b/packages/contracts/package.json index bf41b8867f..da298ddef8 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -164,6 +164,10 @@ "types": "./src/facades/device.ts", "default": "./src/facades/device.ts" }, + "./device-boot": { + "types": "./src/device-boot.ts", + "default": "./src/device-boot.ts" + }, "./device-readiness-runtime": { "types": "./src/device-readiness-runtime.ts", "default": "./src/device-readiness-runtime.ts" diff --git a/packages/contracts/src/device-boot.ts b/packages/contracts/src/device-boot.ts new file mode 100644 index 0000000000..b69e386736 --- /dev/null +++ b/packages/contracts/src/device-boot.ts @@ -0,0 +1,25 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; + +/** + * Why a device owner could not name the start of its current boot. `unobserved` covers every + * answer that establishes nothing — the device is not running, the tool call failed, or its + * output was unreadable — and keeps the caller's decision binary. + */ +export type DeviceBootObservationFailure = 'unsupported-device' | 'unobserved'; + +export type DeviceBootObservation = + | Readonly<{ observed: true; bootedAtMs: number }> + | Readonly<{ observed: false; reason: DeviceBootObservationFailure }>; + +/** + * When a device's CURRENT boot began, as a host-clock epoch in milliseconds. Owners answer for the + * device kinds they can actually observe and report `unsupported-device` for every other leaf, so a + * caller never has to know which family answered. + * + * A probe answers a question a caller cannot answer from its own state, so its budget must stay far + * below the operation it precedes, and an unanswered probe must leave that operation as cautious as + * it was before the probe existed. + */ +export type DeviceBootObservationService = Readonly<{ + observeBootTimeMs(device: DeviceInfo): Promise; +}>; diff --git a/packages/platform-android/package.json b/packages/platform-android/package.json index c9a03184e2..336b10b9b3 100644 --- a/packages/platform-android/package.json +++ b/packages/platform-android/package.json @@ -24,6 +24,10 @@ "types": "./src/adb-host.ts", "default": "./src/adb-host.ts" }, + "./device-boot": { + "types": "./src/device-boot.ts", + "default": "./src/device-boot.ts" + }, "./mechanics": { "types": "./src/mechanics.ts", "default": "./src/mechanics.ts" diff --git a/packages/platform-android/src/device-boot.test.ts b/packages/platform-android/src/device-boot.test.ts new file mode 100644 index 0000000000..526e368d6e --- /dev/null +++ b/packages/platform-android/src/device-boot.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; +import { observeAndroidBootTimeMs } from './device-boot.ts'; + +const DEVICE: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +const NOW_MS = 1_700_000_000_000; + +function answersUptime(stdout: string, exitCode = 0) { + let received: { serial: string; args: string[] } | undefined; + bindAndroidAdbHostStub({ + execSerialAdb: async (serial, args) => { + received = { serial, args }; + return { exitCode, stdout, stderr: '' }; + }, + }); + return () => received; +} + +beforeEach(() => { + vi.useFakeTimers({ now: NOW_MS }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('derives the boot instant from the uptime duration on the host clock', async () => { + const received = answersUptime('120.45 300.12\n'); + + assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), { + observed: true, + bootedAtMs: NOW_MS - 120_450, + }); + assert.deepEqual(received(), { + serial: 'emulator-5554', + args: ['shell', 'cat', '/proc/uptime'], + }); +}); + +test('a slow uptime answer cannot move the boot instant past the moment the probe began', async () => { + bindAndroidAdbHostStub({ + execSerialAdb: async () => { + vi.setSystemTime(NOW_MS + 4_000); + return { exitCode: 0, stdout: '120.45 0', stderr: '' }; + }, + }); + + assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), { + observed: true, + bootedAtMs: NOW_MS - 120_450, + }); +}); + +test('a refused or unreadable uptime answers nothing', async () => { + for (const stdout of ['', 'cat: /proc/uptime: Permission denied', 'not-a-number 0']) { + answersUptime(stdout); + assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), { + observed: false, + reason: 'unobserved', + }); + } + + answersUptime('120.45 0', 1); + assert.deepEqual(await observeAndroidBootTimeMs(DEVICE), { + observed: false, + reason: 'unobserved', + }); +}); diff --git a/packages/platform-android/src/device-boot.ts b/packages/platform-android/src/device-boot.ts new file mode 100644 index 0000000000..6aa850f102 --- /dev/null +++ b/packages/platform-android/src/device-boot.ts @@ -0,0 +1,38 @@ +import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +/** The probe answers in tens of milliseconds and must not become the reason an `open` waits. */ +const BOOT_PROBE_TIMEOUT_MS = 3_000; + +const UPTIME_FIELDS = /^\s*(\d+(?:\.\d+)?)/; + +/** + * When this device's current boot began, derived from how long it has been up rather than from any + * clock it keeps itself. `/proc/uptime` is a duration, so the host clock supplies the absolute + * instant and a guest wall clock that disagrees with the host cannot move the answer — which is why + * this does not read the guest-clock stamps `/proc/stat`'s `btime` and `ro.runtime.firstboot` offer. + */ +export async function observeAndroidBootTimeMs(device: DeviceInfo): Promise { + const probeStartedAtMs = Date.now(); + const uptimeSeconds = await readUptimeSeconds(device); + if (uptimeSeconds === undefined) return { observed: false, reason: 'unobserved' }; + // The sample was taken at or after the probe began, so this bound is never later than the real + // boot instant. Reading the clock after the response instead would let transport latency push the + // answer forward and condemn a claim taken after the reboot to look stale. + return { observed: true, bootedAtMs: probeStartedAtMs - uptimeSeconds * 1000 }; +} + +async function readUptimeSeconds(device: DeviceInfo): Promise { + try { + const { runAndroidAdb } = await import('./adb.ts'); + const result = await runAndroidAdb(device, ['shell', 'cat', '/proc/uptime'], { + allowFailure: true, + timeoutMs: BOOT_PROBE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return undefined; + const seconds = Number.parseFloat(UPTIME_FIELDS.exec(result.stdout)?.[1] ?? ''); + return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined; + } catch { + return undefined; + } +} diff --git a/packages/platform-apple/package.json b/packages/platform-apple/package.json index a6f1d08043..b1a9cc3868 100644 --- a/packages/platform-apple/package.json +++ b/packages/platform-apple/package.json @@ -84,6 +84,10 @@ "types": "./src/simulator-facade.ts", "default": "./src/simulator-facade.ts" }, + "./simulator-boot": { + "types": "./src/simulator-boot.ts", + "default": "./src/simulator-boot.ts" + }, "./tool-provider": { "types": "./src/tool-provider-facade.ts", "default": "./src/tool-provider-facade.ts" diff --git a/packages/platform-apple/src/simulator-boot.test.ts b/packages/platform-apple/src/simulator-boot.test.ts new file mode 100644 index 0000000000..d073e88681 --- /dev/null +++ b/packages/platform-apple/src/simulator-boot.test.ts @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vitest'; + +vi.mock('@agent-device/host-kit/command', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runCmd: vi.fn() }; +}); + +import { runCmd } from '@agent-device/host-kit/command'; +import { IOS_DEVICE, IOS_SIMULATOR, MACOS_DEVICE } from './__tests__/device-fixtures.ts'; +import { observeSimulatorBootTimeMs } from './simulator-boot.ts'; + +const mockRunCmd = vi.mocked(runCmd); + +const BOOTED_AT = new Date(2024, 0, 15, 10, 20, 30).getTime(); +const LAUNCHD = '/usr/bin/coresimd/launchd_sim'; +const BOOTSTRAP = `/data/users/*/library/developer/core simulator/devices/${IOS_SIMULATOR.id}/data/var/run/launchd_bootstrap.plist`; + +function psRow(pid: number, lstart: string, command: string): string { + return `${pid} ${lstart} ${command}`; +} + +function launchdRow(pid: number, lstart: string, udid: string): string { + return psRow(pid, lstart, `${LAUNCHD} -c ${BOOTSTRAP.replace(IOS_SIMULATOR.id, udid)}`); +} + +function succeed(...stdout: string[]) { + mockRunCmd.mockResolvedValueOnce({ exitCode: 0, stdout: stdout.join('\n'), stderr: '' } as never); +} + +beforeEach(() => { + mockRunCmd.mockReset(); +}); + +test('a device that is not an iOS Simulator is answered without probing the host', async () => { + for (const device of [IOS_DEVICE, MACOS_DEVICE]) { + assert.deepEqual(await observeSimulatorBootTimeMs(device), { + observed: false, + reason: 'unsupported-device', + }); + } + assert.equal(mockRunCmd.mock.calls.length, 0); +}); + +test('reads the boot from the launchd_sim that names this device and no other', async () => { + succeed(String(4242)); + succeed(launchdRow(4242, 'Mon Jan 15 10:20:30 2024', IOS_SIMULATOR.id)); + + assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), { + observed: true, + bootedAtMs: BOOTED_AT, + }); + const [pgrep, ps] = mockRunCmd.mock.calls; + assert.deepEqual(pgrep?.slice(0, 2), ['/usr/bin/pgrep', ['-x', 'launchd_sim']]); + assert.equal(ps?.[0], '/bin/ps'); + assert.deepEqual(ps?.[1], ['-p', '4242', '-o', 'pid=,lstart=,command=']); + // `lstart` spells weekday and month names in the caller's locale, which the parser rejects. + assert.deepEqual((ps?.[2] as { env?: Record })?.env, { LC_ALL: 'C' }); +}); + +test('the newest boot wins when a device appears in more than one row', async () => { + succeed('4242\n4343'); + succeed( + launchdRow(4242, 'Mon Jan 15 10:20:30 2024', IOS_SIMULATOR.id), + launchdRow(4343, 'Tue Jan 16 11:20:30 2024', IOS_SIMULATOR.id), + ); + + const observation = await observeSimulatorBootTimeMs(IOS_SIMULATOR); + assert.equal(observation.observed, true); + if (!observation.observed) return; + assert.ok(observation.bootedAtMs > BOOTED_AT); +}); + +test('another device launchd_sim answers nothing about this one', async () => { + succeed('4242'); + succeed(launchdRow(4242, 'Mon Jan 15 10:20:30 2024', 'OTHER-UDID')); + + assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), { + observed: false, + reason: 'unobserved', + }); +}); + +test('a boot that begins after the probe instant is a moved clock, not evidence', async () => { + succeed('4242'); + succeed(launchdRow(4242, 'Sat Jan 15 10:20:30 2099', IOS_SIMULATOR.id)); + + assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), { + observed: false, + reason: 'unobserved', + }); +}); + +test('an absent launchd_sim or a failed probe leaves the caller as cautious as it was', async () => { + mockRunCmd.mockResolvedValueOnce({ exitCode: 1, stdout: '', stderr: '' } as never); + assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), { + observed: false, + reason: 'unobserved', + }); + assert.equal(mockRunCmd.mock.calls.length, 1); + + succeed('4242'); + mockRunCmd.mockRejectedValueOnce(new Error('ps unavailable')); + assert.deepEqual(await observeSimulatorBootTimeMs(IOS_SIMULATOR), { + observed: false, + reason: 'unobserved', + }); +}); diff --git a/packages/platform-apple/src/simulator-boot.ts b/packages/platform-apple/src/simulator-boot.ts new file mode 100644 index 0000000000..17341d0f8d --- /dev/null +++ b/packages/platform-apple/src/simulator-boot.ts @@ -0,0 +1,133 @@ +import type { DeviceBootObservation } from '@agent-device/contracts/device-boot'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { runCmd } from '@agent-device/host-kit/command'; + +/** + * CoreSimulator runs exactly one `launchd_sim` per booted device, naming that device's + * `data/var/run/launchd_bootstrap.plist` in its arguments. The process therefore IS the boot: its + * start time is when this incarnation of the device began, on the host clock, and no booted + * simulator is missing it. + */ +const SIMULATOR_LAUNCHD_PROCESS = 'launchd_sim'; + +/** The probe answers in tens of milliseconds and must not become the reason an `open` waits. */ +const BOOT_PROBE_TIMEOUT_MS = 2_000; + +/** `ps -o lstart` prints weekday and month names in the caller's locale; only the C form is parsed. */ +const C_LOCALE_ENV = { LC_ALL: 'C' }; + +const PS_PROCESS_ROW = + /^(\d+)\s+([A-Za-z]{3} [A-Za-z]{3} +\d{1,2} \d{2}:\d{2}:\d{2} \d{4})\s+(.+)$/; + +const PS_PROCESS_START = /^[A-Za-z]{3} ([A-Za-z]{3}) +(\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/; + +const PS_MONTHS = new Map([ + ['Jan', 0], + ['Feb', 1], + ['Mar', 2], + ['Apr', 3], + ['May', 4], + ['Jun', 5], + ['Jul', 6], + ['Aug', 7], + ['Sep', 8], + ['Oct', 9], + ['Nov', 10], + ['Dec', 11], +]); + +export async function observeSimulatorBootTimeMs( + device: DeviceInfo, +): Promise { + if (!isIosFamily(device) || device.kind !== 'simulator') { + return { observed: false, reason: 'unsupported-device' }; + } + const pids = await readLaunchdSimPids(); + if (pids.length === 0) return { observed: false, reason: 'unobserved' }; + const bootedAtMs = newestLaunchdSimBootMs(await readProcessRows(pids), device.id); + return bootedAtMs === undefined + ? { observed: false, reason: 'unobserved' } + : { observed: true, bootedAtMs }; +} + +async function readLaunchdSimPids(): Promise { + const result = await runProbe('/usr/bin/pgrep', ['-x', SIMULATOR_LAUNCHD_PROCESS]); + if (result === undefined) return []; + return result + .split('\n') + .map((line) => Number.parseInt(line.trim(), 10)) + .filter((pid) => Number.isInteger(pid) && pid > 0); +} + +async function readProcessRows(pids: readonly number[]): Promise { + return ( + (await runProbe( + '/bin/ps', + ['-p', pids.join(','), '-o', 'pid=,lstart=,command='], + C_LOCALE_ENV, + )) ?? '' + ); +} + +async function runProbe( + executable: string, + args: readonly string[], + env?: Record, +): Promise { + try { + const result = await runCmd(executable, [...args], { + allowFailure: true, + timeoutMs: BOOT_PROBE_TIMEOUT_MS, + ...(env ? { env } : {}), + }); + return result.exitCode === 0 ? result.stdout : undefined; + } catch { + return undefined; + } +} + +/** The newest boot among the rows naming this device's Simulator bootstrap, rejected once observed. */ +function newestLaunchdSimBootMs(psOutput: string, udid: string): number | undefined { + const nowMs = Date.now(); + let newest: number | undefined; + for (const line of psOutput.split('\n')) { + const startedAtMs = readLaunchdSimStartMs(line, udid); + // A boot that begins after this instant is a host clock that moved underneath the probe, which + // proves nothing about the device; the caller stays as cautious as it was before the probe. + if (startedAtMs === undefined || startedAtMs > nowMs) continue; + if (newest === undefined || startedAtMs > newest) newest = startedAtMs; + } + return newest; +} + +function readLaunchdSimStartMs(line: string, udid: string): number | undefined { + const [, lstart, command] = PS_PROCESS_ROW.exec(line)?.slice(1) ?? []; + if (lstart === undefined || command === undefined) return undefined; + if (!isLaunchdSimForDevice(command, udid)) return undefined; + return parseProcessStartTimeMs(lstart); +} + +function isLaunchdSimForDevice(command: string, udid: string): boolean { + const [executable, ...args] = command.trim().split(/\s+/); + return ( + executable !== undefined && + executable.split('/').pop() === SIMULATOR_LAUNCHD_PROCESS && + args.some((arg) => arg.includes(udid)) + ); +} + +/** Reads the `Sun Sep 13 09:43:16 2026` form `ps -o lstart` prints under the C locale, in local time. */ +function parseProcessStartTimeMs(lstart: string): number | undefined { + const [, month, day, hour, minute, second, year] = PS_PROCESS_START.exec(lstart.trim()) ?? []; + const monthIndex = month === undefined ? undefined : PS_MONTHS.get(month); + if (monthIndex === undefined) return undefined; + const startedAtMs = new Date( + Number(year), + monthIndex, + Number(day), + Number(hour), + Number(minute), + Number(second), + ).getTime(); + return Number.isFinite(startedAtMs) ? startedAtMs : undefined; +} diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index ae7bf607f1..76fa10413b 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -38,6 +38,7 @@ "@agent-device/contracts/command-platform-execution", "@agent-device/contracts/daemon-http", "@agent-device/contracts/device", + "@agent-device/contracts/device-boot", "@agent-device/contracts/device-readiness-runtime", "@agent-device/contracts/device-shutdown-runtime", "@agent-device/contracts/divergence", diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index faa8be5417..e048965066 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -518,6 +518,7 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/platform-apple/session-observation', '@agent-device/platform-apple/simctl', '@agent-device/platform-apple/simulator', + '@agent-device/platform-apple/simulator-boot', '@agent-device/platform-apple/snapshot-source', '@agent-device/platform-apple/tool-provider', ]); @@ -536,6 +537,7 @@ test('the real tree parses, declares, and passes R11', () => { assert.deepEqual([...platformAndroidPackage.exportTargets.keys()].sort(), [ '@agent-device/platform-android', '@agent-device/platform-android/adb-host', + '@agent-device/platform-android/device-boot', '@agent-device/platform-android/mechanics', ]); assert.deepEqual([...platformAndroidPackage.workspaceDependencies].sort(), [ diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index e7d01ca2f9..0ca48d2802 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -43,12 +43,14 @@ function declarations(): PlatformPackageDeclaration[] { '@agent-device/platform-apple/simctl', '@agent-device/platform-apple/snapshot-source', '@agent-device/platform-apple/simulator', + '@agent-device/platform-apple/simulator-boot', '@agent-device/platform-apple/tool-provider', ] : family === 'android' ? [ '@agent-device/platform-android', '@agent-device/platform-android/adb-host', + '@agent-device/platform-android/device-boot', '@agent-device/platform-android/mechanics', ] : [`@agent-device/platform-${family}`], diff --git a/scripts/layering/platform-package-policy.ts b/scripts/layering/platform-package-policy.ts index 1a109c22c0..6b50f48f70 100644 --- a/scripts/layering/platform-package-policy.ts +++ b/scripts/layering/platform-package-policy.ts @@ -71,6 +71,7 @@ const APPLE_RUNNER_TEST_HOST_INSTALLER = 'scripts/vitest-apple-runner-host-setup const ANDROID_MECHANICS_FACADE = '@agent-device/platform-android/mechanics'; const ANDROID_HOST_FACET = '@agent-device/platform-android/adb-host'; const ANDROID_HOST_BINDING = 'src/platform-runtime-android-adb-host.ts'; +const ANDROID_DEVICE_BOOT_FACADE = '@agent-device/platform-android/device-boot'; const MECHANICS_FACET_SUBPATHS: Readonly>> = { apple: [ APPLE_RUNNER_FACADE, @@ -89,17 +90,18 @@ const MECHANICS_FACET_SUBPATHS: Readonly ({ + observeSimulatorBootTimeMs: vi.fn(async (): Promise => ({ + observed: true, + bootedAtMs: 1, + })), + observeAndroidBootTimeMs: vi.fn(async (): Promise => ({ + observed: false, + reason: 'unobserved', + })), +})); + +vi.mock('@agent-device/platform-apple/simulator-boot', () => mocks); +vi.mock('@agent-device/platform-android/device-boot', () => mocks); + +import { + ANDROID_EMULATOR, + IPADOS_SIMULATOR, + IOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + TVOS_SIMULATOR, + VISIONOS_SIMULATOR, +} from './__tests__/test-utils/device-fixtures.ts'; +import { deviceBootObservation } from './platform-runtime-device-boot.ts'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('an iOS Simulator is answered by the Apple probe alone', async () => { + expect(await deviceBootObservation.observeBootTimeMs(IOS_SIMULATOR)).toEqual({ + observed: true, + bootedAtMs: 1, + }); + expect(mocks.observeSimulatorBootTimeMs).toHaveBeenCalledWith(IOS_SIMULATOR); + expect(mocks.observeAndroidBootTimeMs).not.toHaveBeenCalled(); +}); + +test('every Simulator of the Apple touch family shares the CoreSimulator boot probe', async () => { + for (const device of [IPADOS_SIMULATOR, TVOS_SIMULATOR, VISIONOS_SIMULATOR]) { + expect(await deviceBootObservation.observeBootTimeMs(device)).toEqual({ + observed: true, + bootedAtMs: 1, + }); + } + expect(mocks.observeAndroidBootTimeMs).not.toHaveBeenCalled(); +}); + +test('an Android device is answered by the Android probe alone', async () => { + expect(await deviceBootObservation.observeBootTimeMs(ANDROID_EMULATOR)).toEqual({ + observed: false, + reason: 'unobserved', + }); + expect(mocks.observeAndroidBootTimeMs).toHaveBeenCalledWith(ANDROID_EMULATOR); + expect(mocks.observeSimulatorBootTimeMs).not.toHaveBeenCalled(); +}); + +test('a device whose family asks no boot question is refused without loading a probe', async () => { + for (const device of [MACOS_DEVICE, LINUX_DEVICE]) { + expect(await deviceBootObservation.observeBootTimeMs(device)).toEqual({ + observed: false, + reason: 'unsupported-device', + }); + } + expect(mocks.observeSimulatorBootTimeMs).not.toHaveBeenCalled(); + expect(mocks.observeAndroidBootTimeMs).not.toHaveBeenCalled(); +}); diff --git a/src/platform-runtime-device-boot.ts b/src/platform-runtime-device-boot.ts new file mode 100644 index 0000000000..aa5f32acf9 --- /dev/null +++ b/src/platform-runtime-device-boot.ts @@ -0,0 +1,26 @@ +import type { + DeviceBootObservation, + DeviceBootObservationService, +} from '@agent-device/contracts/device-boot'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; + +/** + * The single answer to "when did this device's current boot begin?", assembled from whichever + * family owns the device. Families that answer no boot question for a leaf report + * `unsupported-device`, so a caller never branches on platform to decide what an answer means. + */ +export const deviceBootObservation: DeviceBootObservationService = Object.freeze({ + async observeBootTimeMs(device: DeviceInfo): Promise { + if (isIosFamily(device) && device.kind === 'simulator') { + const { observeSimulatorBootTimeMs } = + await import('@agent-device/platform-apple/simulator-boot'); + return await observeSimulatorBootTimeMs(device); + } + if (device.platform === 'android') { + const { observeAndroidBootTimeMs } = + await import('@agent-device/platform-android/device-boot'); + return await observeAndroidBootTimeMs(device); + } + return { observed: false, reason: 'unsupported-device' }; + }, +});