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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SESSION_SURFACES } from '@agent-device/contracts/session';
import { PLATFORM_SELECTORS } from '@agent-device/kernel/device';
import { BOOT_PICK_POLICIES, PLATFORM_SELECTORS } from '@agent-device/kernel/device';
import { PERF_KIND_VALUES } from '@agent-device/contracts/observability';
import type { FlagDefinition } from './flag-types.ts';

Expand Down Expand Up @@ -116,6 +116,17 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: false,
},
{
key: 'pick',
names: ['--pick'],
type: 'enum',
enumValues: BOOT_PICK_POLICIES,
usageLabel: `--pick ${BOOT_PICK_POLICIES.join('|')}`,
usageDescription:
'Boot: when several equally preferred matches are all shut down, boot one (mobile over TV, newest OS, then name) instead of failing; booted ties still fail',
projectConfig: false,
recorded: false,
},
{
key: 'targetApp',
names: ['--app', '--target-app'],
Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { RecordingExportQuality } from './recording-export-quality.ts';
import type { BackMode } from './back-mode.ts';
import type { ClickButton } from './click-button.ts';
import type { SwipePattern } from './scroll-gesture.ts';
import type { DeviceTarget, PlatformSelector } from '@agent-device/kernel/device';
import type { BootPickPolicy, DeviceTarget, PlatformSelector } from '@agent-device/kernel/device';
import type { SnapshotPreferredBackend } from '@agent-device/kernel/snapshot';
import type {
DaemonInstallSource,
Expand Down Expand Up @@ -140,6 +140,7 @@ export type CliFlags = CloudProviderProfileFields &
foreground?: boolean;
surface?: SessionSurface;
headless?: boolean;
pick?: BootPickPolicy;
restart?: boolean;
noRecord?: boolean;
/**
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type DeviceSelectionReason =
| 'single-booted-local'
| 'single-bootable-local'
| 'single-app-installed-local'
/** `boot --pick newest` chose among several equally preferred, shut-down local devices. */
| 'newest-bootable-local'
| 'preferred-local'
| 'single-provider-device';

Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/client-device-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type {
AppleOS,
BootPickPolicy,
DeviceKind,
DeviceTarget,
PublicPlatform,
Expand Down Expand Up @@ -106,6 +107,14 @@ export type StartupPerfSample = {

export type DeviceBootOptions = DeviceCommandBaseOptions & {
headless?: boolean;
/**
* When the request names no device and several equally preferred devices match, all shut down,
* boot one instead of failing with `AMBIGUOUS_MATCH`. `newest`: mobile over TV, newest OS
* version, then the first name in name order, with numbers compared by value; that is not device
* age (stopped Android AVDs report no OS version, so the name decides). Several booted matches
* still fail. `selection.reason` reports the pick.
*/
pick?: BootPickPolicy;
/** Startup budget in milliseconds: bounds the boot wait on a cold Simulator or emulator. */
timeoutMs?: number;
};
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import type {
PublicPlatform,
} from '@agent-device/kernel/device';
import type { TargetShutdownResult } from './target-shutdown-contract.ts';
import type { DeviceSelectionMetadata } from './client-app.ts';

/**
* Closed result of the `boot` command. Mirrors the daemon handler's only
* success return EXACTLY (packages/daemon/src/sessions/handlers/session-boot.ts via
* session-device-status-response.ts) — the fixed
* object literal `{ platform, target, device, id, kind, booted }` plus the
* additive `appleOs` discriminant, emitted only for Apple devices.
* additive `appleOs` discriminant, emitted only for Apple devices, and the
* additive `selection` evidence.
*/
export type BootCommandResult = {
platform: PublicPlatform;
Expand All @@ -29,6 +31,8 @@ export type BootCommandResult = {
* leaf (`ios`/`macos`) — this is an extra field, not a replacement.
*/
appleOs?: AppleOS;
/** How the daemon chose this device, as on `open`. */
selection?: DeviceSelectionMetadata;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/request-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
SessionRuntimeHints,
} from '@agent-device/kernel/contracts';
import type { SnapshotCommandOptionFields } from '@agent-device/kernel/snapshot';
import type { BootPickPolicy } from '@agent-device/kernel/device';
import type { DaemonBatchStep } from './batch-step.ts';
import type { ReplayRequestFields } from './replay-request-fields.ts';
import type { AgentDeviceClientConfig, AgentDeviceSelectionOptions } from './client-connection.ts';
Expand Down Expand Up @@ -50,6 +51,7 @@ export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> &
pauseMs?: number;
pattern?: SwipePattern;
headless?: boolean;
pick?: BootPickPolicy;
restart?: boolean;
replayBackend?: string;
findFirst?: boolean;
Expand Down
33 changes: 31 additions & 2 deletions packages/daemon/src/resources/session-device-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
import { AppError, sessionOrDeviceSelectorRequiredDetails } from '@agent-device/kernel/errors';
import { isActiveProviderDevice } from '../core/provider-device-admission.ts';
import type { DaemonPlatformServices } from '../core/platform-services.ts';
import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve';
import {
resolveTargetDevice,
resolveTargetDeviceSelection,
} from '@agent-device/device-selection/dispatch-resolve';
import {
resolveExistingSessionDeviceSelection,
type DeviceSelectionResult,
} from '@agent-device/device-selection/device-selection-resolver';
import type { DaemonRequest } from '../core/daemon-request.ts';
import type { SessionState } from '../core/session-state.ts';
import {
Expand Down Expand Up @@ -69,13 +76,35 @@ export async function resolveCommandDevice(params: {
return hostApp && device.id === hostApp.hostDeviceId ? params.session!.device : device;
}

type PowerCommandDeviceParams = Omit<
Parameters<typeof resolveCommandDevice>[0],
'androidAvdSelection'
>;

/** Boot and shutdown both allow a stopped Android AVD to be selected by name. */
export async function resolvePowerCommandDevice(
params: Omit<Parameters<typeof resolveCommandDevice>[0], 'androidAvdSelection'>,
params: PowerCommandDeviceParams,
): Promise<DeviceInfo> {
return await resolveCommandDevice({ ...params, androidAvdSelection: 'include-stopped' });
}

/**
* Boot's device with its selection evidence. Boot alone reads the `--pick` opt-in; shutdown shares
* the stopped-AVD scope above and keeps refusing a tie.
*/
export async function resolveBootCommandSelection(
params: PowerCommandDeviceParams,
): Promise<DeviceSelectionResult> {
if (params.session && !hasExplicitDeviceSelector(params.flags)) {
return resolveExistingSessionDeviceSelection(await resolvePowerCommandDevice(params));
}
return await resolveTargetDeviceSelection(params.flags ?? {}, {
env: params.env,
androidAvdSelection: 'include-stopped',
boot: { pick: params.flags?.pick },
});
}

export async function refreshSessionDeviceIfNeeded(
device: DeviceInfo,
platformServices: DaemonPlatformServices,
Expand Down
17 changes: 10 additions & 7 deletions packages/daemon/src/server/request-execution-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,12 @@ async function resolveBoundedClaimDevice(
env: EnvMap,
platformServices: DaemonPlatformServices,
): Promise<DeviceInfo> {
const { resolveCommandDevice, resolvePowerCommandDevice, requireSessionOrExplicitSelector } =
await import('../resources/session-device-resolution.ts');
const {
resolveBootCommandSelection,
resolveCommandDevice,
resolvePowerCommandDevice,
requireSessionOrExplicitSelector,
} = await import('../resources/session-device-resolution.ts');
if (req.command !== 'open') {
const guard = requireSessionOrExplicitSelector(req.command, session, req.flags);
if (guard && !guard.ok) {
Expand All @@ -147,11 +151,10 @@ async function resolveBoundedClaimDevice(
}
if (req.command === 'open') return await resolveFreshBoundedOpenDevice(req, env);
if (req.command === 'install_source' && session) return session.device;
const resolve =
req.command === 'boot' || req.command === 'shutdown'
? resolvePowerCommandDevice
: resolveCommandDevice;
return await resolve({ session, flags: req.flags, env, platformServices });
const params = { session, flags: req.flags, env, platformServices };
if (req.command === 'boot') return (await resolveBootCommandSelection(params)).device;
const resolve = req.command === 'shutdown' ? resolvePowerCommandDevice : resolveCommandDevice;
return await resolve(params);
}

// Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts';
import { test, expect } from 'vitest';
import { test, expect, vi } from 'vitest';
import * as path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import {
Expand All @@ -13,6 +13,7 @@ import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/p
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime';
import { handleBootCommand } from '../session-boot.ts';
import { resolveTargetDeviceSelection } from '@agent-device/device-selection/dispatch-resolve';
import type { SessionState } from '../../../core/session-state.ts';
import {
handleSessionCommands,
Expand Down Expand Up @@ -112,6 +113,59 @@ test('boot prefers explicit device selector over active session device', async (
}
});

test('boot --pick reaches selection and reports the picked device it started', async () => {
const picked: SessionState['device'] = {
platform: 'apple',
id: 'sim-picked',
name: 'iPhone 17',
kind: 'simulator',
target: 'mobile',
booted: false,
};
const mockSelection = vi.mocked(resolveTargetDeviceSelection);
mockSelection.mockResolvedValueOnce({
device: picked,
reason: 'newest-bootable-local',
source: 'local',
candidateCount: 3,
bootOccurred: false,
});

const response = await handleSessionCommands({
host: createTestHostEnvironment(),
createCommandSurface: fakeCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'ios', pick: 'newest' },
},
sessionName: 'default',
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
sessionStore: makeSessionStore(),
invoke: noopInvoke,
});

expect(response?.ok, JSON.stringify(response)).toBe(true);
expect(mockSelection.mock.calls.at(-1)?.[1]).toMatchObject({
androidAvdSelection: 'include-stopped',
boot: { pick: 'newest' },
});
if (!response?.ok) return;
expect(response.data).toMatchObject({
id: 'sim-picked',
booted: true,
selection: {
reason: 'newest-bootable-local',
source: 'local',
candidateCount: 3,
bootOccurred: true,
},
});
});

test('boot --timeout forwards a startup deadline to bootTarget (#3004)', async () => {
const sessionStore = makeSessionStore();
const selectedDevice: SessionState['device'] = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import type { TargetShutdownResult } from '@agent-device/contracts/device';
import type { DeviceSelectionMetadata } from '@agent-device/contracts/client';
import { ANDROID_EMULATOR, IOS_SIMULATOR } from '@agent-device/test-kit';
import { deviceStatusReportResponse } from '../session-device-status-response.ts';

Expand All @@ -10,11 +11,18 @@ const shutdownResult: TargetShutdownResult = {
stderr: '',
};

const selection: DeviceSelectionMetadata = {
reason: 'single-bootable-local',
source: 'local',
candidateCount: 1,
bootOccurred: true,
};

describe('deviceStatusReportResponse', () => {
test('emits the public identity fields with the boot status', () => {
const response = deviceStatusReportResponse(
{ ...IOS_SIMULATOR, target: 'mobile' },
{ booted: true },
{ booted: true, selection },
);
expect(response).toEqual({
ok: true,
Expand All @@ -25,6 +33,7 @@ describe('deviceStatusReportResponse', () => {
id: 'sim-1',
kind: 'simulator',
booted: true,
selection,
appleOs: 'ios',
},
});
Expand All @@ -37,6 +46,7 @@ describe('deviceStatusReportResponse', () => {
'id',
'kind',
'booted',
'selection',
'appleOs',
]);
});
Expand All @@ -52,7 +62,7 @@ describe('deviceStatusReportResponse', () => {
test('never surfaces a stray appleOs on a non-Apple platform', () => {
const response = deviceStatusReportResponse(
{ ...ANDROID_EMULATOR, appleOs: 'ios' },
{ booted: true },
{ booted: true, selection },
);
expect(response.ok).toBe(true);
if (!response.ok) return;
Expand Down
23 changes: 15 additions & 8 deletions packages/daemon/src/sessions/handlers/session-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
import { asAppError } from '@agent-device/kernel/errors';
import { errorResponse } from '@agent-device/kernel/contracts';
import { resolveAndroidSerialAllowlist } from '@agent-device/kernel/device-isolation';
import {
markSelectionBootOccurred,
type DeviceSelectionResult,
} from '@agent-device/device-selection/device-selection-resolver';
import {
requireSessionOrExplicitSelector,
resolvePowerCommandDevice,
resolveBootCommandSelection,
} from '../../resources/session-device-resolution.ts';
import {
admitRuntimeOperations,
Expand Down Expand Up @@ -44,20 +48,21 @@ function hasAndroidAvdIdentity(
* turns the resolver's `DEVICE_NOT_FOUND` into the argument error that names the missing
* `--device <avd-name>`; every other resolution failure propagates unchanged.
*/
async function resolveBootTargetDevice(
async function resolveBootTargetSelection(
params: RuntimeCommandHandlerParams,
headless: boolean,
): Promise<DeviceInfo | { refusal: DaemonResponse }> {
): Promise<DeviceSelectionResult | { refusal: DaemonResponse }> {
const { sessionName, sessionStore, req, platformServices } = params;
const session = sessionStore.get(sessionName);
const flags = req.flags ?? {};
try {
const device = await resolvePowerCommandDevice({
const selection = await resolveBootCommandSelection({
session,
flags,
env: sessionStore.host.env,
platformServices,
});
const { device } = selection;
if (flags.target && (device.target ?? 'mobile') !== flags.target) {
return {
refusal: errorResponse(
Expand All @@ -66,7 +71,7 @@ async function resolveBootTargetDevice(
),
};
}
return device;
return selection;
} catch (error) {
if (
headless &&
Expand Down Expand Up @@ -105,8 +110,9 @@ export async function handleBootCommand(
: undefined;
const plan = resolveDeviceReadinessRuntimePlan({ headless: flags.headless === true });

const target = await resolveBootTargetDevice(params, plan.kind === 'boot-target-headless');
if ('refusal' in target) return target.refusal;
const selection = await resolveBootTargetSelection(params, plan.kind === 'boot-target-headless');
if ('refusal' in selection) return selection.refusal;
const target = selection.device;

const admitted = await admitRuntimeOperations({
command: 'boot',
Expand All @@ -129,5 +135,6 @@ export async function handleBootCommand(
? await (await admitted.bind(target, plan.use)).operations.bootTargetHeadless(input)
: await (await admitted.bind(target, plan.use)).operations.bootTarget(input);

return deviceStatusReportResponse(device, { booted: true });
const { device: _selected, ...metadata } = markSelectionBootOccurred(selection);
return deviceStatusReportResponse(device, { booted: true, selection: metadata });
}
Loading
Loading