Skip to content

Commit d0547dc

Browse files
authored
refactor: complete the find cutover onto the request-bound runtime (#1944)
* refactor: complete the find cutover onto the request-bound runtime The deferred Wave 4 unit for #1739 (R35), unblocked by focus (R40) and type (R41). Find's read-only legs, focus leg, and type leg already ran bound; the one remaining direct platform execution was the mutating-target capture, which built createSelectorCaptureRuntime without a bound capture and fell through the legacy dispatch branch reserved for "the last one to migrate". - The mutating path now enters resolveBoundSelectorCapture — the selector family's shared admit-then-bind entry, which already named find in its intent table — and threads the bound capture into the target capture. - Find was that last one: `capture` on SelectorCaptureRuntimeParams is now required and the legacy fallback branch is deleted. The backend's `bound` becomes required-to-state, with the observation-free duration wait (`wait 400`) as the one declared absence — its runtime now carries no capture backend at all, so an accidental capture fails loudly instead of falling anywhere. - The descriptor flips to device-runtime with findRuntimePlanUses (the selector-text plans shared with get, plus focusRuntimeUse and typeTextRuntimeUse); the capability bucket and both overlay memberships (HARMONYOS_SUPPORTED_COMMANDS, WEB_QUERY_COMMANDS) are deleted. - R35 lands with the selector family's shared operation owners; the capture and backend tests move off the dispatch mock onto the bound seam, which is where the poll-deadline and private-ax-pin assertions actually live now. Wave 4 is complete: layering recognizes 26 migrated commands. * fix(find): one action-selected bind per mutating handler (ADR 0019 §9) Review P1 on #1944: a mutating find performed up to three separate facts/admit/bind projections — capture, then focus, then type re-admitted per leg. The request handler now resolves ONE action-selected plan and binds once: - New selector intents find-focus / find-type carry combined uses (capture + focusPoint, capture + focusPoint + typeText) through the same admit-then-bind path and plan machinery every selector capture uses; the new bind arms reuse the existing capture selectors, so the shared operation owners stay single. Delegated click/fill resolve targets on the plain capture pair. - The handler threads the one bind's operations to the shared executors: executeFocusPoint is extracted as the single lexical owner of the focusPoint call (R40's owner claim follows it), and executeBoundTypeText's runtime param narrows to the operations it actually uses so find can pass its own broader bind through it. - findRuntimePlanUses becomes the full action-selected set (eight uses), and the descriptor test pins each use's exact requirement list. - Regression: find focus and find type each assert exactly one facts inspection and one bindDevice call — the pre-fix handler fails both (two and three binds respectively). Live re-verified on iPhone 17 Pro at this head: find focus and find type both execute through the single bind, route synthesized-first-responder, typed text visible in the captured tree.
1 parent 34c14a5 commit d0547dc

21 files changed

Lines changed: 489 additions & 193 deletions

packages/contracts/src/facades/platform.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export {
221221
selectorTextCaptureRuntimePlanUses,
222222
snapshotRuntimePlanUses,
223223
waitSelectorCaptureRuntimePlanUses,
224+
findRuntimePlanUses,
224225
focusRuntimeUse,
225226
typeTextRuntimeUse,
226227
viewportRuntimeUse,

packages/contracts/src/platform-runtime-operations.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,39 @@ export const selectorTextCaptureRuntimePlanUses = Object.freeze([
135135
selectorTextCaptureWithoutActiveAppUse,
136136
] as const);
137137

138+
/**
139+
* `find`'s action-selected mutating uses (ADR 0019 §9: one bind per handler). A mutating find
140+
* resolves its target from a capture AND executes at most one direct leg, so each action's use
141+
* carries the complete requirement set and the handler binds exactly once. Click/fill legs
142+
* re-invoke their own commands, so their use is the plain capture pair.
143+
*/
144+
const findFocusCaptureUse = defineUse({ required: ['captureSnapshot', 'focusPoint'] });
145+
const findFocusCaptureWithoutActiveAppUse = defineUse({
146+
required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp', 'focusPoint'],
147+
});
148+
const findTypeCaptureUse = defineUse({
149+
required: ['captureSnapshot', 'focusPoint', 'typeText'],
150+
});
151+
const findTypeCaptureWithoutActiveAppUse = defineUse({
152+
required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp', 'focusPoint', 'typeText'],
153+
});
154+
155+
/**
156+
* `find`'s complete use set: the selector-text plans it shares with `get` for its read-only
157+
* legs, the plain capture pair for target resolution ahead of delegated click/fill, and the
158+
* action-selected combined uses for the focus and type legs it executes directly.
159+
*/
160+
export const findRuntimePlanUses = Object.freeze([
161+
selectorTextCaptureUse,
162+
selectorTextCaptureWithoutActiveAppUse,
163+
selectorCaptureUse,
164+
selectorCaptureWithoutActiveAppUse,
165+
findFocusCaptureUse,
166+
findFocusCaptureWithoutActiveAppUse,
167+
findTypeCaptureUse,
168+
findTypeCaptureWithoutActiveAppUse,
169+
] as const);
170+
138171
export const waitSelectorCaptureRuntimePlanUses = Object.freeze([
139172
waitSelectorCaptureUse,
140173
waitSelectorCaptureWithoutActiveAppUse,
@@ -173,6 +206,9 @@ const selectorUsesByIntent = Object.freeze({
173206
'capture-only': selectorCaptureRuntimePlanUses,
174207
'element-text': selectorTextCaptureRuntimePlanUses,
175208
'wait-observation': waitSelectorCaptureRuntimePlanUses,
209+
// find's action-selected mutating intents: one combined use per directly-executed leg.
210+
'find-focus': Object.freeze([findFocusCaptureUse, findFocusCaptureWithoutActiveAppUse] as const),
211+
'find-type': Object.freeze([findTypeCaptureUse, findTypeCaptureWithoutActiveAppUse] as const),
176212
} as const);
177213

178214
export type SelectorCaptureRuntimeIntent = keyof typeof selectorUsesByIntent;
@@ -228,6 +264,18 @@ export function resolveSelectorCaptureRuntimePlan(
228264
input.intent,
229265
selectorUsesByIntent[input.intent],
230266
);
267+
case 'find-focus':
268+
return selectorCapturePlan(
269+
input.hasActiveApp,
270+
input.intent,
271+
selectorUsesByIntent[input.intent],
272+
);
273+
case 'find-type':
274+
return selectorCapturePlan(
275+
input.hasActiveApp,
276+
input.intent,
277+
selectorUsesByIntent[input.intent],
278+
);
231279
}
232280
}
233281

scripts/layering/runtime-command-cutover-table.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d
2525
* A row id is a report heading, so it must be unique across every stack that adds rows here.
2626
* `cutoverTableDefects` rejects a duplicate; lifecycle starts at R28 after the accepted
2727
* shutdown, install/deploy, and application-lifecycle allocations. Snapshot starts at R32;
28-
* diff follows at R33, viewport at R34, get at R36, is at R37, screenshot at R39, wait at R38,
29-
* focus at R40, and type at R41. R35 stays reserved for find, whose blockers (`focus` R40,
30-
* `type` R41) have both landed; its atomic cutover is the next selector-family unit.
28+
* diff follows at R33, find at R35, get at R36, is at R37, screenshot at R39, wait at R38,
29+
* focus at R40, and type at R41 — the Wave 4 observation family is complete.
3130
*/
3231
export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
3332
{
@@ -508,6 +507,35 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
508507
},
509508
extensions: [diffRetiredDispatchProjectionProof],
510509
},
510+
{
511+
rule: 'R35 find-runtime-cutover',
512+
command: 'find',
513+
subject: 'find target resolution',
514+
tier: 'request-scoped',
515+
execution: 'device-runtime',
516+
legacyRetirement: {
517+
// The whole retirement is admission data: the capability bucket plus the two overlay set
518+
// memberships. Every route name survived — what changed is that the mutating target
519+
// capture now enters resolveBoundSelectorCapture like every selector read, which is why
520+
// the shared owners below carry find's captures too.
521+
staticCommandSets: ['HARMONYOS_SUPPORTED_COMMANDS', 'WEB_QUERY_COMMANDS'],
522+
},
523+
runtimeTypeNames: ['SnapshotRuntimeOperations', 'ElementTextRuntimeOperations'],
524+
operations: {
525+
names: ['captureSnapshot', 'captureSnapshotWithoutActiveApp', 'readTextAtPoint'],
526+
},
527+
singularExecution: {
528+
routes: ['handleFindCommands'],
529+
operations: ['captureSnapshot', 'captureSnapshotWithoutActiveApp', 'readTextAtPoint'],
530+
// Find shares every owner with `get`: its captures enter the same admit-then-bind
531+
// selectors and its read-only get-text leg consumes the same preferred read owner.
532+
operationOwners: {
533+
captureSnapshot: ['selectActiveAppSnapshot'],
534+
captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'],
535+
readTextAtPoint: ['selectElementTextOperation'],
536+
},
537+
},
538+
},
511539
{
512540
rule: 'R36 get-runtime-cutover',
513541
command: 'get',
@@ -607,7 +635,9 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
607635
singularExecution: {
608636
routes: ['dispatchGenericCommand'],
609637
operations: ['focusPoint'],
610-
operationOwners: { focusPoint: ['resolveBoundFocusRuntime'] },
638+
// The call moved into the shared executor when find's R35 single bind began passing its
639+
// own operations through it; one lexical owner still serves both consumers.
640+
operationOwners: { focusPoint: ['executeFocusPoint'] },
611641
},
612642
},
613643
{

src/core/__tests__/capability-plugin-routing-parity.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,6 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', ()
265265
'back',
266266
'click',
267267
'fill',
268-
'find',
269268
'gesture',
270269
'home',
271270
'keyboard',

src/core/capabilities.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set<string>([
4242
'app-switcher',
4343
'click',
4444
'fill',
45-
'find',
4645
'home',
4746
'gesture',
4847
'keyboard',
@@ -52,7 +51,7 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set<string>([
5251
'settings',
5352
'swipe',
5453
]);
55-
const WEB_QUERY_COMMANDS = ['audio', 'find'] as const;
54+
const WEB_QUERY_COMMANDS = ['audio'] as const;
5655
const WEB_INTERACTION_COMMANDS = ['click', 'fill', 'hover', 'press', 'scroll'] as const;
5756
const WEB_SUPPORTED_COMMANDS = new Set<string>([
5857
...WEB_QUERY_COMMANDS,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { expect, test } from 'vitest';
2+
import { findRuntimePlanUses } from '@agent-device/contracts/platform';
3+
import { commandDescriptors } from '../registry.ts';
4+
import { BASE_COMMAND_CAPABILITY_MATRIX } from '../../capabilities.ts';
5+
6+
test('find descriptor declares its complete runtime uses with no legacy projection', () => {
7+
const find = commandDescriptors.find(({ name }) => name === 'find');
8+
9+
expect(find).not.toHaveProperty('capability');
10+
expect(find?.platformExecution).toEqual({
11+
kind: 'device-runtime',
12+
uses: findRuntimePlanUses,
13+
});
14+
// The complete direct-execution surface, action-selected so the handler binds exactly once
15+
// (ADR 0019 §9): the read-only element-text plans shared with `get`, the plain capture pair
16+
// for delegated click/fill target resolution, and the combined capture+leg uses for the focus
17+
// and type legs find executes itself.
18+
expect(findRuntimePlanUses.map((use) => use.required)).toEqual([
19+
['captureSnapshot'],
20+
['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
21+
['captureSnapshot'],
22+
['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
23+
['captureSnapshot', 'focusPoint'],
24+
['captureSnapshot', 'captureSnapshotWithoutActiveApp', 'focusPoint'],
25+
['captureSnapshot', 'focusPoint', 'typeText'],
26+
['captureSnapshot', 'captureSnapshotWithoutActiveApp', 'focusPoint', 'typeText'],
27+
]);
28+
});
29+
30+
test('find leaves the capability matrix and both hand-maintained overlays', () => {
31+
expect(BASE_COMMAND_CAPABILITY_MATRIX).not.toHaveProperty('find');
32+
});

src/core/command-descriptor/__tests__/parity.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set<string>([
5757
PUBLIC_COMMANDS.diff,
5858
PUBLIC_COMMANDS.doctor,
5959
PUBLIC_COMMANDS.events,
60+
PUBLIC_COMMANDS.find,
6061
PUBLIC_COMMANDS.focus,
6162
PUBLIC_COMMANDS.get,
6263
PUBLIC_COMMANDS.install,

src/core/command-descriptor/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
screenRecordingRuntimePlanUses,
3636
screenshotRuntimePlanUses,
3737
shutdownTargetUse,
38+
findRuntimePlanUses,
3839
focusRuntimeUse,
3940
typeTextRuntimeUse,
4041
viewportRuntimeUse,
@@ -1079,10 +1080,9 @@ export const RAW_COMMAND_DESCRIPTORS = [
10791080
recordsSessionAction: true,
10801081
recordingEffect: findRecordingEffect,
10811082
daemon: { route: 'find', refFrameEffect: 'may-invalidate' },
1082-
capability: ALL_DEVICE_COMMAND_CAPABILITY,
10831083
timeoutPolicy: PRESERVE_DAEMON_TIMEOUT_POLICY,
10841084
batchable: true,
1085-
platformExecution: LEGACY_PLATFORM_EXECUTION,
1085+
platformExecution: { kind: 'device-runtime', uses: findRuntimePlanUses },
10861086
},
10871087

10881088
// -- interaction (route: interaction) --

src/daemon/__tests__/selector-capture-runtime.test.ts

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,21 @@
11
import { beforeEach, expect, test, vi } from 'vitest';
2-
import { dispatchCommand } from '../../core/dispatch.ts';
2+
import type { CaptureSnapshotInput, SnapshotResult } from '@agent-device/contracts/platform';
33
import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot';
44
import { makeIosSession } from '../../__tests__/test-utils/index.ts';
55
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
66
import { createSelectorCaptureRuntime } from '../selector-capture-runtime.ts';
77

8-
vi.mock('../../core/dispatch.ts', async (importOriginal) => {
9-
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
10-
return {
11-
...actual,
12-
dispatchCommand: vi.fn(async () => ({})),
13-
};
14-
});
15-
16-
vi.mock('../handlers/snapshot-interactor-capture.ts', async () => {
17-
const fixture = await import('./legacy-snapshot-capture-fixture.ts');
18-
return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture };
19-
});
20-
21-
const mockDispatch = vi.mocked(dispatchCommand);
8+
// R35: the capture runtime executes only through its request-bound capture — there is no
9+
// dispatch seam left to mock, so the tests drive the bound operation the way a real admission
10+
// hands it over.
11+
const boundCapture = vi.fn(async (_input: CaptureSnapshotInput): Promise<SnapshotResult> => ({
12+
backend: 'xctest',
13+
nodes: [],
14+
}));
2215

2316
beforeEach(() => {
24-
mockDispatch.mockReset();
17+
boundCapture.mockReset();
18+
boundCapture.mockResolvedValue({ backend: 'xctest', nodes: [] });
2519
});
2620

2721
test('selector capture cache is keyed by scoped presentation options', async () => {
@@ -35,14 +29,13 @@ test('selector capture cache is keyed by scoped presentation options', async ()
3529
},
3630
});
3731
sessionStore.set(sessionName, session);
38-
mockDispatch.mockImplementation(async (_device, _command, _positionals, _outPath, context) => ({
32+
boundCapture.mockImplementation(async (input) => ({
3933
backend: 'xctest',
4034
nodes: [
4135
{
4236
index: 0,
4337
type: 'Button',
44-
label:
45-
context && typeof context.snapshotScope === 'string' ? context.snapshotScope : 'broad',
38+
label: typeof input.options?.scope === 'string' ? input.options.scope : 'broad',
4639
},
4740
],
4841
}));
@@ -52,6 +45,7 @@ test('selector capture cache is keyed by scoped presentation options', async ()
5245
session,
5346
sessionStore,
5447
sessionName,
48+
capture: boundCapture,
5549
req: {
5650
token: 't',
5751
session: sessionName,
@@ -68,12 +62,12 @@ test('selector capture cache is keyed by scoped presentation options', async ()
6862
expect(first.snapshot.nodes[0]?.label).toBe('A');
6963
expect(second.snapshot.nodes[0]?.label).toBe('B');
7064
expect(cachedSecond.snapshot.nodes[0]?.label).toBe('B');
71-
expect(mockDispatch).toHaveBeenCalledTimes(2);
65+
expect(boundCapture).toHaveBeenCalledTimes(2);
7266
});
7367

7468
test('legacy iOS sparse recovery retries a full snapshot', async () => {
7569
const { runtime } = makeCaptureRuntime('selector-legacy-sparse-recovery');
76-
mockDispatch
70+
boundCapture
7771
.mockResolvedValueOnce({
7872
backend: 'xctest',
7973
nodes: [{ index: 0, type: 'Application' }],
@@ -94,14 +88,14 @@ test('legacy iOS sparse recovery retries a full snapshot', async () => {
9488
});
9589

9690
expect(result.snapshot.nodes[0]?.label).toBe('Recovered');
97-
expect(mockDispatch).toHaveBeenCalledTimes(2);
98-
expect(mockDispatch.mock.calls[0]?.[4]).toMatchObject({ snapshotInteractiveOnly: true });
99-
expect(mockDispatch.mock.calls[1]?.[4]).toMatchObject({ snapshotInteractiveOnly: false });
91+
expect(boundCapture).toHaveBeenCalledTimes(2);
92+
expect(boundCapture.mock.calls[0]?.[0]?.options).toMatchObject({ interactiveOnly: true });
93+
expect(boundCapture.mock.calls[1]?.[0]?.options).toMatchObject({ interactiveOnly: false });
10094
});
10195

10296
test('legacy iOS sparse recovery rethrows full snapshot failure when scoping is disabled', async () => {
10397
const { runtime } = makeCaptureRuntime('selector-legacy-sparse-rethrow');
104-
mockDispatch
98+
boundCapture
10599
.mockResolvedValueOnce({
106100
backend: 'xctest',
107101
nodes: [{ index: 0, type: 'Application' }],
@@ -119,12 +113,12 @@ test('legacy iOS sparse recovery rethrows full snapshot failure when scoping is
119113
},
120114
}),
121115
).rejects.toThrow('full snapshot failed');
122-
expect(mockDispatch).toHaveBeenCalledTimes(2);
116+
expect(boundCapture).toHaveBeenCalledTimes(2);
123117
});
124118

125119
test('sparse verdict recovery retries with query scope and stores recovered snapshot', async () => {
126120
const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-sparse-verdict');
127-
mockDispatch
121+
boundCapture
128122
.mockResolvedValueOnce({
129123
backend: 'xctest',
130124
quality: {
@@ -152,10 +146,10 @@ test('sparse verdict recovery retries with query scope and stores recovered snap
152146

153147
expect(result.snapshot.nodes[0]?.label).toBe('Search');
154148
expect(sessionStore.get(sessionName)?.snapshot?.nodes[0]?.label).toBe('Search');
155-
expect(mockDispatch).toHaveBeenCalledTimes(2);
156-
expect(mockDispatch.mock.calls[1]?.[4]).toMatchObject({
157-
snapshotInteractiveOnly: false,
158-
snapshotScope: 'Search',
149+
expect(boundCapture).toHaveBeenCalledTimes(2);
150+
expect(boundCapture.mock.calls[1]?.[0]?.options).toMatchObject({
151+
interactiveOnly: false,
152+
scope: 'Search',
159153
});
160154
});
161155

@@ -168,6 +162,7 @@ function makeCaptureRuntime(sessionName: string) {
168162
session,
169163
sessionStore,
170164
sessionName,
165+
capture: boundCapture,
171166
req: {
172167
token: 't',
173168
session: sessionName,

src/daemon/focus-runtime.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,26 @@ export async function resolveBoundFocusRuntime(
4242
bindDevice: params.bindDevice,
4343
});
4444
if (admission.type === 'response') return { ok: false, response: admission.response };
45+
const runtime = admission.runtime;
4546
return {
4647
ok: true,
47-
execute: async ({ dispatchContext }) => {
48-
await admission.runtime.operations.focusPoint(focusPointInput(point, dispatchContext));
49-
return { x: point.x, y: point.y, ...successText(`Focused (${point.x}, ${point.y})`) };
50-
},
48+
execute: async ({ dispatchContext }) =>
49+
await executeFocusPoint(runtime, point, dispatchContext),
5150
};
5251
}
52+
53+
/**
54+
* The ONE place a bound `focusPoint` executes (R40; also find's focus leg, which passes the
55+
* operations of its own action-selected bind). Keeping the operation call in a single lexical
56+
* owner is what lets the cutover gate prove no parallel route exists.
57+
*/
58+
export async function executeFocusPoint(
59+
runtime: Readonly<{
60+
operations: Readonly<{ focusPoint: (input: FocusPointInput) => Promise<void> }>;
61+
}>,
62+
point: Point,
63+
context: DaemonCommandContext,
64+
): Promise<Record<string, unknown>> {
65+
await runtime.operations.focusPoint(focusPointInput(point, context));
66+
return { x: point.x, y: point.y, ...successText(`Focused (${point.x}, ${point.y})`) };
67+
}

0 commit comments

Comments
 (0)