Skip to content

Commit 80ccf41

Browse files
committed
refactor(daemon): split touch interaction orchestration
Closes part of #1691: interaction-touch.ts becomes a router; press, fill, direct-iOS, shared runtime, Android readiness, and response projection each own one module. Behavior is unchanged.
1 parent bd233d5 commit 80ccf41

7 files changed

Lines changed: 1145 additions & 1031 deletions
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { emitDiagnostic } from '../../utils/diagnostics.ts';
2+
import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
3+
import {
4+
ensureAndroidBlockingSystemDialogReady,
5+
type AndroidBlockingDialogReadinessResult,
6+
} from '../android-system-dialog.ts';
7+
import type { DaemonResponse, SessionState } from '../types.ts';
8+
import type { InteractionHandlerParams } from './interaction-common.ts';
9+
import { refMutationAdmissionResponse } from './interaction-ref-policy.ts';
10+
import type { CaptureSnapshotForSession } from './interaction-snapshot.ts';
11+
12+
/**
13+
* How Android device readiness composes with `@ref` admission around a touch
14+
* dispatch: the blocking-dialog recovery that must not silently retarget an
15+
* admitted ref, and the ref-refresh capture that keeps the Android freshness
16+
* route comparable.
17+
*/
18+
19+
export type RefAdmissionContext = {
20+
ref: string;
21+
mintedGeneration: number | undefined;
22+
staleRefsWarning: string | undefined;
23+
};
24+
25+
export type ReadinessOutcome<TResult> =
26+
| { aborted: true; response: DaemonResponse }
27+
| {
28+
aborted: false;
29+
readiness: AndroidBlockingDialogReadinessResult;
30+
runtimeResult: TResult;
31+
};
32+
33+
export async function runWithAndroidDialogReadinessCheck<TResult>(
34+
session: SessionState,
35+
command: string,
36+
options: { refContext: RefAdmissionContext | undefined },
37+
run: () => Promise<TResult>,
38+
): Promise<ReadinessOutcome<TResult>> {
39+
if (session.lease?.leaseProvider) {
40+
return { aborted: false, readiness: { status: 'clear' }, runtimeResult: await run() };
41+
}
42+
const readiness = await ensureAndroidBlockingSystemDialogReady({
43+
session,
44+
command,
45+
phase: 'before-command',
46+
});
47+
// ADR 0014: blocking-dialog recovery is itself device-mutating and expires the
48+
// frame at its own seam. A ref action admitted against the pre-recovery frame
49+
// must NOT continue against the recovered UI — abort it through the SHARED
50+
// admission rejection so the failure shape (reason, ref, currentGeneration,
51+
// scope, mintedGeneration, hint) is identical to every other expired-frame
52+
// rejection across platforms. Selector/coordinate actions carry no refContext
53+
// and re-resolve and continue under their own policy.
54+
if (options.refContext && readiness.status === 'recovered') {
55+
const abort = refMutationAdmissionResponse({
56+
session,
57+
ref: options.refContext.ref,
58+
mintedGeneration: options.refContext.mintedGeneration,
59+
staleRefsWarning: options.refContext.staleRefsWarning,
60+
});
61+
if (abort) return { aborted: true, response: abort };
62+
}
63+
const runtimeResult = await run();
64+
await ensureAndroidBlockingSystemDialogReady({
65+
session,
66+
command,
67+
phase: 'after-command',
68+
});
69+
return { aborted: false, readiness, runtimeResult };
70+
}
71+
72+
export async function refreshAndroidRefSnapshotIfFreshnessActive(
73+
params: InteractionHandlerParams & {
74+
captureSnapshotForSession: CaptureSnapshotForSession;
75+
},
76+
session: SessionState,
77+
): Promise<SessionState['snapshot']> {
78+
if (!getActiveAndroidSnapshotFreshness(session)) return undefined;
79+
const freshnessBaseline =
80+
session.snapshot?.comparisonSafe === true ? session.snapshot : undefined;
81+
try {
82+
await params.captureSnapshotForSession(
83+
session,
84+
params.req.flags,
85+
params.sessionStore,
86+
params.contextFromFlags,
87+
{ interactiveOnly: true, androidFreshnessMode: 'ref-refresh' },
88+
);
89+
} catch (error) {
90+
emitDiagnostic({
91+
level: 'warn',
92+
phase: 'android_ref_snapshot_refresh_failed',
93+
data: {
94+
command: params.req.command,
95+
error: error instanceof Error ? error.message : String(error),
96+
},
97+
});
98+
}
99+
return freshnessBaseline;
100+
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import type { CommandFlags } from '@agent-device/contracts/command';
2+
import type { GestureReferenceFrame, InteractionTarget } from '@agent-device/contracts/interaction';
3+
import { normalizeError } from '@agent-device/kernel/errors';
4+
import {
5+
commandSupportsSettleObservation,
6+
commandSupportsVerifyEvidence,
7+
} from '../../core/command-descriptor/registry.ts';
8+
import { dispatchCommand } from '../../core/dispatch.ts';
9+
import { emitDiagnostic } from '../../utils/diagnostics.ts';
10+
import {
11+
isDirectIosSelectorFallbackError,
12+
readSimpleIosSelectorTarget,
13+
type DirectIosSelectorTarget,
14+
} from '../direct-ios-selector.ts';
15+
import { expireRefFrame } from '../ref-frame.ts';
16+
import { isSessionRecording } from '../session-script-publication-capability.ts';
17+
import type { DaemonResponse, SessionState } from '../types.ts';
18+
import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts';
19+
import { corroborateIosTapFailure } from './interaction-ios-tap-outcome.ts';
20+
import type { CaptureSnapshotForSession } from './interaction-snapshot.ts';
21+
import {
22+
buildCorroboratedTapResponseData,
23+
buildInteractionResponseData,
24+
maestroFallbackDisclosure,
25+
pointPositionals,
26+
readInteractionResponseDataTransformCommand,
27+
transformTouchResponseData,
28+
} from './interaction-touch-response.ts';
29+
30+
/**
31+
* When the direct iOS selector fast path runs, and how it dispatches, delegates
32+
* back to the runtime tree path, or corroborates its own failure. The path is
33+
* deliberately narrow (CONTEXT.md, Selector Capture Reliability Contract):
34+
* every exclusion below keeps a capture-backed guarantee on the regular path.
35+
*/
36+
37+
export function readDirectIosSelectorTapTarget(params: {
38+
session: SessionState;
39+
commandLabel: string;
40+
target: InteractionTarget;
41+
flags: CommandFlags | undefined;
42+
}): DirectIosSelectorTarget | null {
43+
const { session, commandLabel, target, flags } = params;
44+
if (commandLabel !== 'click') return null;
45+
if (target.kind !== 'selector') return null;
46+
if (isSessionRecording(session)) return null;
47+
if (hasNonDefaultClickOptions(flags)) return null;
48+
if (commandSupportsVerifyEvidence(commandLabel) && flags?.verify === true) return null;
49+
if (commandSupportsSettleObservation(commandLabel) && flags?.settle === true) return null;
50+
return readDirectSelectorWithMaestroFallback(session, target.selector, flags);
51+
}
52+
53+
function readDirectSelectorWithMaestroFallback(
54+
session: SessionState,
55+
selectorExpression: string,
56+
flags: CommandFlags | undefined,
57+
): DirectIosSelectorTarget | null {
58+
const selector = readSimpleIosSelectorTarget({ session, selectorExpression });
59+
if (!selector) return null;
60+
return {
61+
...selector,
62+
...(flags?.maestro?.allowNonHittableCoordinateFallback
63+
? { allowNonHittableCoordinateFallback: true }
64+
: {}),
65+
...(flags?.maestro?.expectedTapPoint ? { expectedPoint: flags.maestro.expectedTapPoint } : {}),
66+
};
67+
}
68+
69+
function hasNonDefaultClickOptions(flags: CommandFlags | undefined): boolean {
70+
return Boolean(
71+
flags?.count !== undefined ||
72+
flags?.intervalMs !== undefined ||
73+
flags?.holdMs !== undefined ||
74+
flags?.jitterPx !== undefined ||
75+
flags?.doubleTap !== undefined ||
76+
(flags?.clickButton !== undefined && flags.clickButton !== 'primary'),
77+
);
78+
}
79+
80+
export async function dispatchDirectIosSelectorTap(
81+
params: InteractionHandlerParams & { captureSnapshotForSession: CaptureSnapshotForSession },
82+
session: SessionState,
83+
selector: DirectIosSelectorTarget,
84+
): Promise<DaemonResponse | null> {
85+
return await dispatchDirectIosSelectorInteraction({
86+
params,
87+
session,
88+
selector,
89+
command: 'press',
90+
positionals: [],
91+
extra: { selector: selector.raw },
92+
fallbackPhase: 'ios_direct_selector_tap_fallback',
93+
});
94+
}
95+
96+
async function dispatchDirectIosSelectorInteraction(params: {
97+
params: InteractionHandlerParams & { captureSnapshotForSession: CaptureSnapshotForSession };
98+
session: SessionState;
99+
selector: DirectIosSelectorTarget;
100+
command: 'press' | 'fill';
101+
positionals: string[];
102+
extra: Record<string, unknown>;
103+
fallbackPhase: string;
104+
}): Promise<DaemonResponse | null> {
105+
const {
106+
params: handlerParams,
107+
session,
108+
selector,
109+
command,
110+
positionals,
111+
extra,
112+
fallbackPhase,
113+
} = params;
114+
const actionStartedAt = Date.now();
115+
// ADR 0014 side-effect seam: the direct iOS selector path fuses its final
116+
// status/target check and mutation into one runner request and consumes no
117+
// ref, so dispatching that fused request is the conservative seam. A later
118+
// not-found/timeout is post-seam and does not restore the frame.
119+
expireRefFrame(session);
120+
try {
121+
const data =
122+
(await dispatchCommand(session.device, command, positionals, handlerParams.req.flags?.out, {
123+
...handlerParams.contextFromFlags(
124+
handlerParams.req.flags,
125+
session.appBundleId,
126+
session.trace?.outPath,
127+
),
128+
directElementSelector: selector,
129+
surface: session.surface,
130+
})) ?? {};
131+
const actionFinishedAt = Date.now();
132+
const point = readPointFromDirectSelectorTapResult(data);
133+
const publicData = transformTouchResponseData({
134+
session,
135+
command: readInteractionResponseDataTransformCommand(handlerParams.req.command, command),
136+
flags: handlerParams.req.flags,
137+
data,
138+
});
139+
const maestroFallback = maestroFallbackDisclosure(
140+
selector.allowNonHittableCoordinateFallback === true,
141+
data,
142+
);
143+
const { result, responseData } = buildInteractionResponseData({
144+
source: {
145+
kind: 'runner-payload',
146+
targetKind: 'selector',
147+
data,
148+
publicData,
149+
point,
150+
maestroCoordinateFallbackDispatched: maestroFallback.used,
151+
},
152+
referenceFrame: readReferenceFrameFromDirectSelectorTapResult(data),
153+
extra: {
154+
...extra,
155+
...maestroFallback.extra,
156+
},
157+
});
158+
return finalizeTouchInteraction({
159+
session,
160+
sessionStore: handlerParams.sessionStore,
161+
command: handlerParams.req.command,
162+
positionals: handlerParams.req.positionals ?? [],
163+
retryPositionals: pointPositionals(point),
164+
flags: handlerParams.req.flags,
165+
result,
166+
responseData,
167+
actionStartedAt,
168+
actionFinishedAt,
169+
});
170+
} catch (error) {
171+
const corroboratedResponse = await buildDirectIosCorroboratedResponse({
172+
error,
173+
handlerParams,
174+
session,
175+
extra,
176+
positionals,
177+
actionStartedAt,
178+
});
179+
if (corroboratedResponse) return corroboratedResponse;
180+
// ADR 0011 delegation-on-error: semantic runner failures fall back to the
181+
// tree-based runtime path — except for Maestro replay dispatches, whose
182+
// runner-native error shapes must be preserved.
183+
const fallback = isDirectIosSelectorFallbackError(error, {
184+
delegateSemanticFailures: selector.allowNonHittableCoordinateFallback !== true,
185+
});
186+
if (!fallback) {
187+
return { ok: false, error: normalizeError(error) };
188+
}
189+
emitDiagnostic({
190+
level: 'debug',
191+
phase: fallbackPhase,
192+
data: {
193+
selector: selector.raw,
194+
error: error instanceof Error ? error.message : String(error),
195+
},
196+
});
197+
return null;
198+
}
199+
}
200+
201+
async function buildDirectIosCorroboratedResponse(params: {
202+
error: unknown;
203+
handlerParams: InteractionHandlerParams & {
204+
captureSnapshotForSession: CaptureSnapshotForSession;
205+
};
206+
session: SessionState;
207+
extra: Record<string, unknown>;
208+
positionals: string[];
209+
actionStartedAt: number;
210+
}): Promise<DaemonResponse | undefined> {
211+
const { error, handlerParams, session, extra, positionals, actionStartedAt } = params;
212+
const corroboration = await corroborateIosTapFailure({
213+
error,
214+
command: handlerParams.req.command,
215+
requestId: handlerParams.req.meta?.requestId,
216+
flags: handlerParams.req.flags,
217+
session,
218+
sessionStore: handlerParams.sessionStore,
219+
contextFromFlags: handlerParams.contextFromFlags,
220+
captureSnapshotForSession: handlerParams.captureSnapshotForSession,
221+
});
222+
if (!corroboration) return undefined;
223+
224+
const { result, responseData } = buildCorroboratedTapResponseData({
225+
targetKind: 'selector',
226+
warning: corroboration.warning,
227+
resolution: { source: 'direct-ios', kind: 'not-observed' },
228+
extra,
229+
});
230+
return finalizeTouchInteraction({
231+
session,
232+
sessionStore: handlerParams.sessionStore,
233+
command: handlerParams.req.command,
234+
positionals: handlerParams.req.positionals ?? positionals,
235+
flags: handlerParams.req.flags,
236+
result,
237+
responseData,
238+
scheduleInteractionOutcomeRetry: false,
239+
actionStartedAt,
240+
actionFinishedAt: Date.now(),
241+
});
242+
}
243+
244+
function readPointFromDirectSelectorTapResult(data: Record<string, unknown>): {
245+
x: number;
246+
y: number;
247+
} {
248+
const x = typeof data.x === 'number' ? data.x : undefined;
249+
const y = typeof data.y === 'number' ? data.y : undefined;
250+
if (x !== undefined && y !== undefined) {
251+
return { x, y };
252+
}
253+
return { x: 0, y: 0 };
254+
}
255+
256+
function readReferenceFrameFromDirectSelectorTapResult(
257+
data: Record<string, unknown>,
258+
): GestureReferenceFrame | undefined {
259+
return typeof data.referenceWidth === 'number' && typeof data.referenceHeight === 'number'
260+
? { referenceWidth: data.referenceWidth, referenceHeight: data.referenceHeight }
261+
: undefined;
262+
}

0 commit comments

Comments
 (0)