Skip to content

Commit 062598f

Browse files
committed
refactor: migrate wait to request-bound runtime
1 parent 9e59a55 commit 062598f

49 files changed

Lines changed: 1362 additions & 653 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/contracts/src/facades/platform.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ export type {
226226
SelectorCaptureRuntimePlan,
227227
SnapshotRuntimePlan,
228228
} from '../platform-runtime-operations.ts';
229+
export { waitObservesDevice } from '../wait-runtime-plan.ts';
230+
export type { WaitRuntimeTarget } from '../wait-runtime-plan.ts';
229231
export type {
230232
PlatformRuntimeHost,
231233
PlatformRuntimeModule,
@@ -258,8 +260,16 @@ export type {
258260
export {
259261
bindLocalSnapshotInteractor,
260262
bindProviderSnapshotInteractor,
263+
captureSnapshotSignal,
261264
snapshotRuntimeOperationFacts,
262265
} from '../snapshot-runtime.ts';
266+
export { findTextRuntimeOperationFacts } from '../find-text-runtime.ts';
267+
export type {
268+
FindTextInput,
269+
FindTextResult,
270+
FindTextRuntimeOperationFacts,
271+
FindTextRuntimeOperations,
272+
} from '../find-text-runtime.ts';
263273
export type {
264274
CaptureSnapshotInput,
265275
LocalSnapshotInteractorResolver,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { SessionSurface } from './session-surface.ts';
2+
import type { RuntimeOperationFact } from './platform-runtime.ts';
3+
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';
4+
5+
/**
6+
* A native, tree-independent answer to "is this text on screen right now".
7+
*
8+
* `surface` and `appBundleId` are the session facts an owner may need to decide it cannot answer
9+
* for this request — the daemon does not pre-filter by family, so an owner that has no reading
10+
* for the current surface reports `found: false` and the caller consults the canonical tree.
11+
*/
12+
export type FindTextInput = Readonly<{
13+
text: string;
14+
options?: Readonly<{ appBundleId?: string; surface?: SessionSurface }>;
15+
execution?: SnapshotRuntimeExecution;
16+
/** Per-capture cancellation; see `CaptureSnapshotInput.signal`. */
17+
signal?: AbortSignal;
18+
}>;
19+
20+
/**
21+
* Deliberately asymmetric, and the reason this is a `preferred` operation rather than a second
22+
* execution path (ADR 0019 §2/§9):
23+
*
24+
* - `found: true` is **authoritative** — the owner observed the text and the wait is satisfied.
25+
* - `found: false` is **not** authoritative. It means "not proven by this owner", and the caller
26+
* must still consult the canonical tree in the same poll. An owner that cannot answer at all
27+
* reports `false` rather than throwing.
28+
*
29+
* So the required tree path remains semantically complete on its own: removing this operation
30+
* changes how fast a satisfied wait returns, never whether it can be satisfied.
31+
*/
32+
export type FindTextResult = Readonly<{ found: boolean }>;
33+
34+
export type FindTextRuntimeOperations = Readonly<{
35+
findText(input: FindTextInput): Promise<FindTextResult>;
36+
}>;
37+
38+
export type FindTextRuntimeOperationFacts = Readonly<{ findText: RuntimeOperationFact }>;
39+
40+
export function findTextRuntimeOperationFacts(
41+
input: FindTextRuntimeOperationFacts,
42+
): FindTextRuntimeOperationFacts {
43+
return Object.freeze({ findText: input.findText });
44+
}

packages/contracts/src/interactor-types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,17 @@ export type Interactor = {
232232
point: Point,
233233
options?: { appBundleId?: string; surface?: SessionSurface; signal?: AbortSignal },
234234
): Promise<string | undefined>;
235+
/**
236+
* Native text-presence reading, when the backend has one that does not require a tree capture.
237+
* A `true` answer is authoritative; anything else means "not proven here" and the caller
238+
* consults the canonical tree (see `FindTextResult`).
239+
*/
240+
findText?(
241+
text: string,
242+
options?: { appBundleId?: string; signal?: AbortSignal },
243+
): Promise<{
244+
found: boolean;
245+
}>;
235246
gestureViewport?(): Promise<Rect>;
236247
back(mode?: BackMode): Promise<void>;
237248
home(): Promise<void>;

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host
1414
import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts';
1515
import type { ScreenshotRuntimeOperations } from './screenshot-runtime.ts';
1616
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
17+
import type { FindTextRuntimeOperations } from './find-text-runtime.ts';
1718
import type { ViewportRuntimeOperations } from './viewport-runtime.ts';
1819
import type { ElementTextRuntimeOperations } from './element-text-runtime.ts';
1920
import type {
@@ -47,6 +48,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
4748
ScreenRecordingRuntimeOperations &
4849
ScreenshotRuntimeOperations &
4950
SnapshotRuntimeOperations &
51+
FindTextRuntimeOperations &
5052
ViewportRuntimeOperations &
5153
ElementTextRuntimeOperations &
5254
DeviceReadinessRuntimeOperations &
@@ -91,11 +93,11 @@ const captureSnapshotWithCustomActionsWithoutActiveAppUse = defineUse({
9193
*/
9294
const selectorCaptureUse = defineUse({
9395
required: ['captureSnapshot'],
94-
preferred: ['readTextAtPoint'],
96+
preferred: ['readTextAtPoint', 'findText'],
9597
});
9698
const selectorCaptureWithoutActiveAppUse = defineUse({
9799
required: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'],
98-
preferred: ['readTextAtPoint'],
100+
preferred: ['readTextAtPoint', 'findText'],
99101
});
100102

101103
/**

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from './platform-runtime.ts';
1313
import { screenshotRuntimeOperationFacts } from './screenshot-runtime.ts';
1414
import { snapshotRuntimeOperationFacts } from './snapshot-runtime.ts';
15+
import { findTextRuntimeOperationFacts } from './find-text-runtime.ts';
1516
import { viewportRuntimeOperationFacts } from './viewport-runtime.ts';
1617
import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts';
1718

@@ -105,6 +106,10 @@ export function createUnavailablePlatformRuntimeFacts(
105106
customActions: snapshot,
106107
withoutActiveApp: snapshot,
107108
}),
109+
// The preferred text reading starts unavailable for every family, on the same sentinel as
110+
// capture: an owner that has a native reading declares it explicitly, and one that does not
111+
// sends every text wait to the canonical tree.
112+
...findTextRuntimeOperationFacts({ findText: snapshot }),
108113
...viewportRuntimeOperationFacts({ setViewport: viewport }),
109114
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementText }),
110115
ensureReady: readiness,

packages/contracts/src/snapshot-runtime.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import type { DeviceInfo } from '@agent-device/kernel/device';
44
import type { Interactor, RunnerContext } from './interactor-types.ts';
5-
import { bindLocalSnapshotInteractor, bindProviderSnapshotInteractor } from './snapshot-runtime.ts';
5+
import {
6+
bindLocalSnapshotInteractor,
7+
bindProviderSnapshotInteractor,
8+
captureSnapshotSignal,
9+
} from './snapshot-runtime.ts';
610

711
const device: DeviceInfo = {
812
id: 'snapshot-device',
@@ -66,3 +70,69 @@ test('provider snapshot binding fails closed when its selected owner loses the i
6670
error.message === 'Provider-owned snapshot operation has no bound provider interactor.',
6771
);
6872
});
73+
74+
// ---------------------------------------------------------------------------
75+
// Per-capture cancellation (`CaptureSnapshotInput.signal`). A `DeviceBinding`'s
76+
// signal is fixed at bind time, but `wait` binds once and polls many times, so
77+
// each poll must be able to cancel its own capture without cancelling the
78+
// binding. These are the contract-level halves of that claim; the poll-deadline
79+
// behaviour itself is proven end to end in `src/daemon/__tests__/wait-runtime.test.ts`.
80+
// ---------------------------------------------------------------------------
81+
82+
test('a capture with no per-capture signal receives the binding signal itself, not a wrapper', () => {
83+
const binding = new AbortController().signal;
84+
85+
// Identity, deliberately: a wrapper would satisfy a deep-equal while quietly changing
86+
// cancellation semantics for every single-capture consumer (`snapshot`, `diff`), which pass
87+
// no signal at all.
88+
assert.equal(captureSnapshotSignal(binding, {}), binding);
89+
assert.equal(captureSnapshotSignal(binding, { options: { appBundleId: 'x' } }), binding);
90+
});
91+
92+
test('binding-level cancellation still aborts a capture that carries its own signal', () => {
93+
const binding = new AbortController();
94+
const perCapture = new AbortController();
95+
96+
const composed = captureSnapshotSignal(binding.signal, { signal: perCapture.signal });
97+
98+
assert.equal(composed.aborted, false);
99+
binding.abort();
100+
// The composition adds a second way to cancel; it must not have replaced the first.
101+
assert.equal(composed.aborted, true);
102+
});
103+
104+
test('a per-capture signal aborts its own capture without aborting the binding', () => {
105+
const binding = new AbortController();
106+
const perCapture = new AbortController();
107+
108+
const composed = captureSnapshotSignal(binding.signal, { signal: perCapture.signal });
109+
perCapture.abort(new DOMException('Wait deadline exceeded', 'TimeoutError'));
110+
111+
assert.equal(composed.aborted, true);
112+
// The binding outlives the poll: the next poll of the same wait still has a live binding.
113+
assert.equal(binding.signal.aborted, false);
114+
});
115+
116+
test('the shared interactor binding composes the per-capture signal it is handed', async () => {
117+
const binding = new AbortController();
118+
const perCapture = new AbortController();
119+
let capturedSignal: AbortSignal | undefined;
120+
const operations = bindLocalSnapshotInteractor({
121+
device,
122+
signal: binding.signal,
123+
resolveInteractor: async () =>
124+
({
125+
snapshot: async (options: Parameters<Interactor['snapshot']>[0]) => {
126+
capturedSignal = options?.signal;
127+
return { backend: 'android', nodes: [] };
128+
},
129+
}) as unknown as Interactor,
130+
});
131+
132+
await operations.captureSnapshot({ signal: perCapture.signal });
133+
134+
assert.ok(capturedSignal, 'the interactor must receive a signal');
135+
assert.equal(capturedSignal.aborted, false);
136+
perCapture.abort(new DOMException('Wait deadline exceeded', 'TimeoutError'));
137+
assert.equal(capturedSignal.aborted, true, 'the poll deadline must reach the platform');
138+
});

packages/contracts/src/snapshot-runtime.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,34 @@ export type { SnapshotResult } from './interactor-types.ts';
1313
/** Runner metadata needed by the selected snapshot implementation, without request-owned state. */
1414
export type SnapshotRuntimeExecution = Readonly<Omit<RunnerContext, 'appBundleId' | 'signal'>>;
1515

16-
/** Neutral snapshot intent. The request binding supplies cancellation and exact-owner authority. */
16+
/** Neutral snapshot intent. The request binding supplies exact-owner authority. */
1717
export type CaptureSnapshotInput = Readonly<{
1818
options?: Readonly<Omit<SnapshotOptions, 'signal'>>;
1919
execution?: SnapshotRuntimeExecution;
20+
/**
21+
* Per-capture cancellation, composed with the binding's own signal. A command that captures
22+
* once needs nothing here. A POLLING command does: `wait` enforces each poll's remaining
23+
* budget by aborting that capture and then waiting for it to quiesce (it deliberately does
24+
* not race-and-abandon, so a late capture cannot mutate session state or keep a helper).
25+
* Without this a stalled capture consumes the whole request instead of producing the poll's
26+
* stalled-capture verdict.
27+
*/
28+
signal?: AbortSignal;
2029
}>;
2130

31+
/**
32+
* The one place a per-capture signal joins its binding's: the binding always cancels, the
33+
* caller may cancel sooner — identically for app and desktop surface captures.
34+
*/
35+
export function captureSnapshotSignal(
36+
bindingSignal: AbortSignal,
37+
input: CaptureSnapshotInput,
38+
): AbortSignal {
39+
return input.signal === undefined
40+
? bindingSignal
41+
: AbortSignal.any([bindingSignal, input.signal]);
42+
}
43+
2244
export type SnapshotRuntimeOperations = Readonly<{
2345
captureSnapshot(input: CaptureSnapshotInput): Promise<SnapshotResult>;
2446
captureSnapshotWithCustomActions(input: CaptureSnapshotInput): Promise<SnapshotResult>;
@@ -81,10 +103,11 @@ function bindSnapshotInteractor(
81103
params: SnapshotInteractorBindingParams,
82104
): SnapshotRuntimeOperations {
83105
const captureSnapshot = async (input: CaptureSnapshotInput) => {
106+
const signal = captureSnapshotSignal(params.signal, input);
84107
const runner: RunnerContext = {
85108
...input.execution,
86109
appBundleId: input.options?.appBundleId,
87-
signal: params.signal,
110+
signal,
88111
};
89112
const interactor =
90113
params.ownership === 'local'
@@ -97,7 +120,7 @@ function bindSnapshotInteractor(
97120
{ reason: 'provider-runtime-interactor-missing', deviceId: params.device.id },
98121
);
99122
}
100-
return await interactor.snapshot({ ...input.options, signal: params.signal });
123+
return await interactor.snapshot({ ...input.options, signal });
101124
};
102125
return Object.freeze({
103126
captureSnapshot,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/** The normalized wait target, independent of the positional grammar that produced it. */
2+
export type WaitRuntimeTarget = 'sleep' | 'text' | 'ref' | 'selector' | 'stable';
3+
4+
/**
5+
* Which wait shapes reach a device at all. A duration wait observes nothing, so it never asks
6+
* for a plan and therefore never admits or binds — the absence of platform execution for that
7+
* shape is stated here rather than left as an incidental branch in the handler.
8+
*
9+
* Every other shape polls the selector family's ordinary capture plan
10+
* (`resolveSelectorCaptureRuntimePlan`); `wait` contributes no plan of its own, and its preferred
11+
* `findText` rides that plan's preferred set beside `get`'s `readTextAtPoint`.
12+
*/
13+
export function waitObservesDevice(target: WaitRuntimeTarget): boolean {
14+
return target !== 'sleep';
15+
}

packages/platform-android/src/runtime.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
elementTextRuntimeOperationFacts,
1616
localRuntimeOwner,
1717
screenshotRuntimeOperationFacts,
18+
findTextRuntimeOperationFacts,
1819
snapshotRuntimeOperationFacts,
1920
viewportRuntimeOperationFacts,
2021
} from '@agent-device/contracts/platform';
@@ -153,6 +154,8 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
153154
...screenshotRuntimeOperationFacts({
154155
capture: device.kind === 'simulator' ? screenshotKindUnavailable : available,
155156
}),
157+
// No native text reading: every text wait on this owner polls the canonical tree.
158+
...findTextRuntimeOperationFacts({ findText: snapshotKindUnavailable }),
156159
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
157160
// uiautomator reads text at a point through the same adb path the snapshot uses, so the
158161
// synthetic `simulator` row is the only Android kind without a live read.

packages/platform-apple/src/runtime-snapshot.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type {
22
CaptureSnapshotInput,
3+
FindTextInput,
4+
FindTextResult,
35
PlatformRuntimeHost,
46
PlatformRuntimeOperations,
57
} from '@agent-device/contracts/platform';
6-
import { bindLocalSnapshotInteractor } from '@agent-device/contracts/platform';
8+
import {
9+
bindLocalSnapshotInteractor,
10+
captureSnapshotSignal,
11+
} from '@agent-device/contracts/platform';
712
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
813

914
/** Apple-owned selection between app snapshots and explicit macOS surface snapshots. */
@@ -22,7 +27,11 @@ export function bindAppleSnapshotRuntime(
2227
input.options?.surface !== undefined &&
2328
input.options.surface !== 'app'
2429
) {
25-
return await host.snapshot.captureSurface(request.device, input.options, request.signal);
30+
return await host.snapshot.captureSurface(
31+
request.device,
32+
input.options,
33+
captureSnapshotSignal(request.signal, input),
34+
);
2635
}
2736
return await appSnapshot.captureSnapshot(input);
2837
};
@@ -37,3 +46,44 @@ type SnapshotRuntimeOperation = Pick<
3746
PlatformRuntimeOperations,
3847
'captureSnapshot' | 'captureSnapshotWithCustomActions' | 'captureSnapshotWithoutActiveApp'
3948
>;
49+
50+
/**
51+
* The runner's native text reading. Every condition under which Apple cannot answer lives here
52+
* rather than in the daemon, which is the point of the migration: no caller inspects the family,
53+
* the surface, or the session to decide whether to consult it.
54+
*
55+
* - No tracked app bundle id: the runner query is scoped to an application, so there is nothing
56+
* to ask about.
57+
* - macOS on an explicit non-app surface: the runner reads the *application*, so a positive
58+
* answer would describe the wrong surface. Reporting `false` sends the poll to the desktop
59+
* surface capture, which is the reading that matches the request.
60+
*
61+
* Both report `found: false` — "not proven here" — never an error, so the caller's canonical tree
62+
* remains the complete path (ADR 0019 section 2).
63+
*/
64+
export function bindAppleFindTextRuntime(
65+
host: PlatformRuntimeHost,
66+
request: Readonly<{ device: DeviceInfo; signal: AbortSignal }>,
67+
): Pick<PlatformRuntimeOperations, 'findText'> {
68+
return Object.freeze({
69+
findText: async (input: FindTextInput): Promise<FindTextResult> => {
70+
const appBundleId = input.options?.appBundleId;
71+
if (appBundleId === undefined) return { found: false };
72+
if (isMacOs(request.device) && input.options?.surface !== undefined) {
73+
if (input.options.surface !== 'app') return { found: false };
74+
}
75+
const signal =
76+
input.signal === undefined
77+
? request.signal
78+
: AbortSignal.any([request.signal, input.signal]);
79+
signal.throwIfAborted();
80+
const interactor = await host.localInteractors.resolve(request.device, {
81+
...input.execution,
82+
appBundleId,
83+
signal,
84+
});
85+
if (!interactor.findText) return { found: false };
86+
return await interactor.findText(input.text, { appBundleId, signal });
87+
},
88+
});
89+
}

0 commit comments

Comments
 (0)