Skip to content

Commit 17cfd8c

Browse files
authored
feat: add deterministic device selection resolver (#2020)
* feat: add deterministic device selection resolver * test: adapt open selection harnesses * chore: keep context glossary within budget * fix: separate device identity from selection filters * refactor: make the selection resolver the sole owner of selection provenance Simplifies the deterministic device-selection resolver (net -114 lines vs the previous head) while fixing the outstanding app-aware provenance finding: - Move the booted-simulator app-affinity narrowing into the resolver behind an appleSimulatorAppTarget param, with its own typed reason 'single-app-installed-local' (candidateCount 1). This removes the selectedDevice escape hatch that reported 'preferred-local' with candidateCount 2 for the app-narrowed pick, and gives the app-match errors the same platform-aware retry selectors as every other selection failure. - Delete dead code: the allowBootableLocal param (no caller ever passed it, so the eligibleDevices filter was unreachable), the hasExplicitProviderIdentity alias, the duplicated deviceCandidateDetails in dispatch-resolve, and the double candidate computation. - Shrink the public selection contract to what #1777 specifies: drop `booted` (it contradicted its own doc comment once markSelectionBootedAfterPreparation flipped it; bootOccurred plus the reason codes carry the same information) and drop `retrySelectors` from success metadata (the daemon only ever emits retry selectors inside error details). DeviceSelectionRetrySelector leaves the contracts facade. - Replace the typeof-import lazy seam and resolver threading through four context objects with one lazy forwarding wrapper; the dispatch eager closure stays at 83. - Collapse the Apple path to resolve -> optional simulator fallback; the provider branch goes through the generic resolver call directly. - Consolidate the five copy-pasted resolveTargetDeviceSelection test mocks into one shared stub (selectionFromResolveTargetDevice). - Add the requested regression: two booted simulators with the app on one now assert typed selection metadata through resolveTargetDeviceSelection, plus a resolver-level app-affinity provenance test. Validation: typecheck, oxlint, oxfmt, layering (184-check guard OK), DI seams, fallow changed-files, eager-closure 235/235, daemon suite 323 files / 2287 tests, core/commands/mcp/client suites 255 files / 2133 tests.
1 parent f7f22d9 commit 17cfd8c

36 files changed

Lines changed: 1006 additions & 209 deletions

packages/contracts/src/client-app.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,26 @@ import type {
1515
} from './client-connection.ts';
1616
import type { AgentDeviceSessionDevice, StartupPerfSample } from './client-device-view.ts';
1717

18+
export type DeviceSelectionReason =
19+
| 'explicit-selector'
20+
| 'existing-session'
21+
| 'single-booted-local'
22+
| 'single-bootable-local'
23+
| 'single-app-installed-local'
24+
| 'preferred-local'
25+
| 'single-provider-device';
26+
27+
export type DeviceSelectionSource = 'session' | 'local' | 'provider';
28+
29+
/** Deterministic device-selection evidence shared by daemon responses and the published client. */
30+
export type DeviceSelectionMetadata = {
31+
reason: DeviceSelectionReason;
32+
source: DeviceSelectionSource;
33+
candidateCount: number;
34+
/** Whether this request booted a previously stopped local virtual target during open. */
35+
bootOccurred: boolean;
36+
};
37+
1838
export type AppInstallOptions = AgentDeviceRequestOverrides &
1939
AgentDeviceSelectionOptions & {
2040
app?: string;
@@ -73,6 +93,7 @@ export type AppOpenResult = {
7393
appId?: string;
7494
startup?: StartupPerfSample;
7595
runtime?: SessionRuntimeHints;
96+
selection?: DeviceSelectionMetadata;
7697
device?: AgentDeviceSessionDevice;
7798
/**
7899
* Initial interactive snapshot captured immediately after an open that

packages/contracts/src/facades/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export type {
1111
AppOpenResult,
1212
AppPushOptions,
1313
AppTriggerEventOptions,
14+
DeviceSelectionMetadata,
15+
DeviceSelectionReason,
16+
DeviceSelectionSource,
1417
MaterializationReleaseOptions,
1518
MaterializationReleaseResult,
1619
} from '../client-app.ts';

packages/kernel/src/device.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ export type DeviceSelector = {
6868
serial?: string;
6969
};
7070

71+
/** Device identity is narrower than platform/target filtering for precedence decisions. */
72+
export function hasExplicitDeviceIdentitySelector(
73+
selector: Pick<DeviceSelector, 'deviceName' | 'udid' | 'serial'>,
74+
): boolean {
75+
return [selector.deviceName, selector.udid, selector.serial].some(
76+
(value) => typeof value === 'string' && value.trim().length > 0,
77+
);
78+
}
79+
7180
type DeviceSelectionContext = {
7281
simulatorSetPath?: string;
7382
};

src/__tests__/cli-help.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ test('help workflow prints the compact workflow card with a version header and s
8282
assert.match(result.stdout, /no CSS selectors/);
8383
assert.match(result.stdout, /help scripting/);
8484
assert.match(result.stdout, /help gestures/);
85+
assert.match(result.stdout, /Selection: explicit --device\/--udid\/--serial/);
86+
assert.match(result.stdout, /--platform\/--target only filter/);
87+
assert.match(result.stdout, /bounded retry selectors/);
8588
});
8689

8790
test('help workflow encourages chaining confident steps and requires the end state to be on screen', async () => {
@@ -141,7 +144,7 @@ test('help workflow advertises open --foreground and snapshot -i --actions', asy
141144
assert.equal(result.calls.length, 0);
142145
assert.match(
143146
result.stdout,
144-
/Known app: open <app> --foreground -> snapshot\. Bare form needs one running app on one iOS sim; capture failure keeps session open/,
147+
/open --foreground -> snapshot\. Selection: explicit --device\/--udid\/--serial/,
145148
);
146149
assert.match(
147150
result.stdout,

src/__tests__/client-normalizers.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import {
44
normalizeDevice,
5+
normalizeDeviceSelection,
56
normalizeOpenDevice,
67
normalizeSession,
78
} from '../client/client-normalizers.ts';
@@ -24,6 +25,32 @@ test('normalizeOpenDevice accepts exactly the canonical leaf platforms', () => {
2425
);
2526
});
2627

28+
test('normalizeDeviceSelection preserves structured resolver evidence and rejects malformed data', () => {
29+
assert.deepEqual(
30+
normalizeDeviceSelection({
31+
reason: 'single-app-installed-local',
32+
source: 'local',
33+
candidateCount: 1,
34+
bootOccurred: false,
35+
}),
36+
{
37+
reason: 'single-app-installed-local',
38+
source: 'local',
39+
candidateCount: 1,
40+
bootOccurred: false,
41+
},
42+
);
43+
assert.equal(
44+
normalizeDeviceSelection({
45+
reason: 'single-booted-local',
46+
source: 'local',
47+
candidateCount: -1,
48+
bootOccurred: false,
49+
}),
50+
undefined,
51+
);
52+
});
53+
2754
test('normalizeOpenDevice rejects the apple selector and unknown platforms', () => {
2855
// `apple` is a selector, not a concrete device platform, so it must be rejected here.
2956
assert.equal(

src/agent-device-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
buildMeta,
4343
normalizeDeployResult,
4444
normalizeDevice,
45+
normalizeDeviceSelection,
4546
normalizeOpenForegroundComposition,
4647
normalizeInstallFromSourceResult,
4748
normalizeMaterializationReleaseResult,
@@ -277,6 +278,7 @@ export function createAgentDeviceClient(
277278
appName: readOptionalString(data, 'appName'),
278279
appBundleId,
279280
appId,
281+
selection: normalizeDeviceSelection(data.selection),
280282
startup: normalizeStartupSample(data.startup),
281283
runtime: normalizeRuntimeHints(data.runtime),
282284
device,

src/cli-schema/cli-help-topics.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ test('usageForCommand resolves workflow help topic', async () => {
138138
assert.match(help, /Shapes and platform quirks: help gestures/);
139139
assert.match(
140140
help,
141-
/Known app: open <app> --foreground -> snapshot\. Bare form needs one running app on one iOS sim; capture failure keeps session open/,
141+
/open --foreground -> snapshot\. Selection: explicit --device\/--udid\/--serial, then session, booted\/bootable local, or one provider; --platform\/--target only filter\./,
142142
);
143143
assert.match(help, /Never open artifact paths or invent package ids/);
144144
assert.match(

src/cli-schema/cli-help.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,7 @@ Command shape:
134134
Gestures: scroll/swipe for lists/flicks; gesture pan|fling|pinch|rotate|transform|drag for multi-touch. Shapes and platform quirks: help gestures.
135135
136136
Bootstrap:
137-
agent-device devices --platform ios
138-
agent-device open MyApp --platform ios --device "iPhone 17 Pro"
139-
Known app: open <app> --foreground -> snapshot. Bare form needs one running app on one iOS sim; capture failure keeps session open.
137+
open --foreground -> snapshot. Selection: explicit --device/--udid/--serial, then session, booted/bootable local, or one provider; --platform/--target only filter. Ambiguous/empty fails with bounded retry selectors; providers never fall back.
140138
Install arguments are app/package id then artifact path: agent-device install com.example.app ./dist/app.apk --platform android, then open <id> --relaunch for fresh state. Use reinstall only when explicitly requested.
141139
Unknown app id: devices, then apps, then open <discovered-app-id>. Never open artifact paths or invent package ids; ask if lookup misses the target.
142140
Apple CI: prepare ios-runner after boot/install, before replay/test (help prepare). Remote/cloud: connect -> open -> commands -> close -> disconnect (help remote). Reusable scripts, secret-safe fills, replay repair: help scripting.

src/client/client-normalizers.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
InternalRequestOptions,
88
MaterializationReleaseResult,
99
StartupPerfSample,
10+
DeviceSelectionMetadata,
1011
} from '@agent-device/contracts/client';
1112
import type { TargetShutdownResult } from '@agent-device/contracts/device';
1213
import {
@@ -228,6 +229,43 @@ export function normalizeOpenDevice(
228229
};
229230
}
230231

232+
const DEVICE_SELECTION_REASONS: readonly DeviceSelectionMetadata['reason'][] = [
233+
'explicit-selector',
234+
'existing-session',
235+
'single-booted-local',
236+
'single-bootable-local',
237+
'single-app-installed-local',
238+
'preferred-local',
239+
'single-provider-device',
240+
];
241+
242+
const DEVICE_SELECTION_SOURCES: readonly DeviceSelectionMetadata['source'][] = [
243+
'session',
244+
'local',
245+
'provider',
246+
];
247+
248+
export function normalizeDeviceSelection(value: unknown): DeviceSelectionMetadata | undefined {
249+
if (!isRecord(value)) return undefined;
250+
const { reason, source, candidateCount, bootOccurred } = value;
251+
if (
252+
!DEVICE_SELECTION_REASONS.includes(reason as DeviceSelectionMetadata['reason']) ||
253+
!DEVICE_SELECTION_SOURCES.includes(source as DeviceSelectionMetadata['source']) ||
254+
typeof candidateCount !== 'number' ||
255+
!Number.isInteger(candidateCount) ||
256+
candidateCount < 0 ||
257+
typeof bootOccurred !== 'boolean'
258+
) {
259+
return undefined;
260+
}
261+
return {
262+
reason: reason as DeviceSelectionMetadata['reason'],
263+
source: source as DeviceSelectionMetadata['source'],
264+
candidateCount,
265+
bootOccurred,
266+
};
267+
}
268+
231269
export function normalizeStartupSample(value: unknown): StartupPerfSample | undefined {
232270
if (!isRecord(value)) return undefined;
233271
if (

src/commands/management/output.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ describe('openCliOutput', () => {
4646
expect(output.text).toBe(`Opened: authoring\nWarning: ${warning}`);
4747
});
4848

49+
test('preserves device-selection evidence in the CLI payload', () => {
50+
const output = openCliOutput({
51+
session: 'selected',
52+
selection: {
53+
reason: 'single-booted-local',
54+
source: 'local',
55+
candidateCount: 2,
56+
bootOccurred: false,
57+
},
58+
identifiers: { session: 'selected' },
59+
});
60+
61+
expect(output.data).toMatchObject({
62+
selection: {
63+
reason: 'single-booted-local',
64+
source: 'local',
65+
candidateCount: 2,
66+
bootOccurred: false,
67+
},
68+
});
69+
});
70+
4971
// #1671 P1: the composed open --foreground snapshot must render on default
5072
// stdout through the PRODUCTION formatter route (the family-registry entry
5173
// the CLI resolves, not a helper mock), printing the same interactive tree

0 commit comments

Comments
 (0)