@phone-use/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/actions.ts ADDED
@@ -0,0 +1,545 @@
1
+ import type { Rect, ScrollDirection } from './device.ts';
2
+ import {
3
+ AbortedError,
4
+ ActionFailedError,
5
+ PhoneUseError,
6
+ type PhoneUseErrorCode,
7
+ TimeoutError,
8
+ } from './errors.ts';
9
+ import {
10
+ type DeviceCore,
11
+ matchInElements,
12
+ type Resolution,
13
+ type ResolveOpts,
14
+ TAPPABLE,
15
+ type UiElement,
16
+ } from './observe.ts';
17
+ import { SecretStore } from './secrets.ts';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // The observe→act seam (docs/19 §API shape; docs/20 item 4): observe() returns
21
+ // portable Action descriptors carrying RE-RESOLVABLE element queries; act()
22
+ // re-resolves against the live tree and executes with no re-inference. A
23
+ // compiled skill is a stored Action[]. The resolution ladder (DeviceCore) is
24
+ // the one query engine — this module contains dispatch, auto-wait, abort, and
25
+ // secrets wiring, never matching logic.
26
+ //
27
+ // Never-throw contract: executeAction returns {success:false,...} for every
28
+ // device-legible outcome (not found, ambiguous, wait deadline, gesture
29
+ // failure). It throws PhoneUseError subclasses only for infrastructure:
30
+ // aborts, closed sessions on non-observe verbs, unsupported capabilities,
31
+ // unknown errors.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * A RE-RESOLVABLE element query — how a portable {@link Action} names its
36
+ * target. Resolved against the live tree by the resolution ladder at act()
37
+ * time; no stale handles.
38
+ */
39
+ export type ElementQuery = {
40
+ /** Label to match (exact → substring → fuzzy, the ladder's rungs). */
41
+ label?: string | undefined;
42
+ /** Accessibility identifier — rung 0 of the ladder, wins when present. */
43
+ id?: string | undefined;
44
+ /** Disambiguator: restrict matches to this role (e.g. "Button"). */
45
+ role?: string | undefined;
46
+ /** Disambiguator: label of another element; pick the geometrically closest match. */
47
+ near?: string | undefined;
48
+ };
49
+
50
+ /** Verbs a portable {@link Action} can carry. */
51
+ export type ActionVerb =
52
+ | 'tap'
53
+ | 'longPress'
54
+ | 'fill'
55
+ | 'type'
56
+ | 'pressKey'
57
+ | 'scroll'
58
+ | 'openApp'
59
+ | 'openUrl'
60
+ | 'back'
61
+ | 'home'
62
+ | 'alert'
63
+ | 'waitForText';
64
+
65
+ /**
66
+ * The observe→act seam (docs/19 §API shape; docs/20 item 4): a portable action
67
+ * descriptor carrying a re-resolvable {@link ElementQuery}. `observe()` returns
68
+ * these; `act()` re-resolves against the live tree and executes with no
69
+ * re-inference. A compiled skill is a stored `Action[]`.
70
+ */
71
+ export type Action = {
72
+ /** Versioned, documented UNSTABLE pre-1.0 (docs/20 open-question 3). */
73
+ formatVersion: 0;
74
+ /** What to do. */
75
+ verb: ActionVerb;
76
+ /** Target query for element-directed verbs (tap/longPress/fill). */
77
+ target?: ElementQuery | undefined;
78
+ /** Verb parameters (text, direction, app, url, ...). */
79
+ params?:
80
+ | {
81
+ /** fill/type text — may contain %name% secret references. */
82
+ text?: string | undefined;
83
+ direction?: ScrollDirection | undefined;
84
+ app?: string | undefined;
85
+ url?: string | undefined;
86
+ durationMs?: number | undefined;
87
+ key?: 'return' | undefined;
88
+ alertAction?: 'accept' | 'dismiss' | undefined;
89
+ submit?: boolean | undefined;
90
+ relaunch?: boolean | undefined;
91
+ }
92
+ | undefined;
93
+ /**
94
+ * Provenance from observe/record time. ADVISORY ONLY — act() always
95
+ * re-resolves; this exists for traces, drift diagnosis, and human review.
96
+ */
97
+ observed?:
98
+ | {
99
+ via?: string | undefined;
100
+ label?: string | undefined;
101
+ role?: string | undefined;
102
+ rect?: Rect | undefined;
103
+ app?: string | undefined;
104
+ screenTitle?: string | undefined;
105
+ }
106
+ | undefined;
107
+ };
108
+
109
+ /** The stored-Action[] artifact (public TYPE, unstable FORMAT — see docs/20). */
110
+ export type CompiledSkill = {
111
+ formatVersion: 0;
112
+ name: string;
113
+ description?: string | undefined;
114
+ params?: string[] | undefined;
115
+ precondition?: string | undefined;
116
+ actions: Action[];
117
+ };
118
+
119
+ /** An interactive element as surfaced by `observe()` (alias of {@link UiElement}). */
120
+ export type ObservedElement = UiElement;
121
+
122
+ /** What `observe()` returns: elements, rendered text, and portable actions. */
123
+ export type ObserveResult = {
124
+ /** Always true for a completed observation. */
125
+ success: boolean;
126
+ /** Human-readable summary of the observation. */
127
+ message: string;
128
+ /** Frontmost app name, when known. */
129
+ app?: string | undefined;
130
+ /** Frontmost app bundle id, when known. */
131
+ bundleId?: string | undefined;
132
+ /** Navigation-bar title of the current screen, when present. */
133
+ screenTitle?: string | undefined;
134
+ /** Structured channel (secret-redacted values). */
135
+ elements: ObservedElement[];
136
+ /** Compressed text channel (secret-redacted). */
137
+ rendered: string;
138
+ /** Portable descriptors: a tap per tappable, a fill per input field. */
139
+ actions: Action[];
140
+ };
141
+
142
+ /**
143
+ * Structured outcome of every action verb. Never-throw contract: device-legible
144
+ * failures (not found, ambiguous, wait deadline, gesture failure) come back as
145
+ * `{success: false, ...}`; only infrastructure errors (abort, closed session,
146
+ * unsupported capability) throw PhoneUseError subclasses.
147
+ */
148
+ export type ActionResult = {
149
+ /** Did the action execute as intended. */
150
+ success: boolean;
151
+ /** Human-readable outcome (secret-redacted). */
152
+ message: string;
153
+ /** tapAndDiff verdict where applicable: did the screen actually change. */
154
+ changed?: boolean | undefined;
155
+ /** How the target resolved (match provenance, ref, label, role, rect). */
156
+ resolved?: { via: string; ref: string; label: string; role: string; rect?: Rect | undefined } | undefined;
157
+ /** The ambiguity contract, surfaced structurally. */
158
+ candidates?: ObservedElement[] | undefined;
159
+ /** Auto-wait cost when the slow path ran (elapsed ms, poll count). */
160
+ waited?: { ms: number; polls: number } | undefined;
161
+ /** Set on structured failures with an error flavor (e.g. TIMEOUT). */
162
+ code?: PhoneUseErrorCode | undefined;
163
+ };
164
+
165
+ /** Per-call options accepted by every action verb. */
166
+ export type ActOptions = {
167
+ /** Abort the call; the in-flight gesture may still land (state indeterminate). */
168
+ signal?: AbortSignal | undefined;
169
+ /** Auto-wait deadline (default 5000 ms). */
170
+ timeoutMs?: number | undefined;
171
+ /** Per-call secret overrides, layered on the device store. */
172
+ vars?: Record<string, string> | undefined;
173
+ };
174
+
175
+ // --- abort plumbing ---------------------------------------------------------
176
+
177
+ function throwIfAborted(signal: AbortSignal | undefined): void {
178
+ if (signal?.aborted) throw new AbortedError();
179
+ }
180
+
181
+ /** Race a backend promise against the caller's abort. The abandoned in-flight
182
+ * call may still land on the device — documented abort semantics (state
183
+ * indeterminate); no retry follows an abort. */
184
+ async function raceWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
185
+ if (!signal) return promise;
186
+ throwIfAborted(signal);
187
+ let onAbort: (() => void) | undefined;
188
+ const aborted = new Promise<never>((_, reject) => {
189
+ onAbort = () => reject(new AbortedError());
190
+ signal.addEventListener('abort', onAbort, { once: true });
191
+ });
192
+ try {
193
+ return await Promise.race([promise, aborted]);
194
+ } finally {
195
+ if (onAbort) signal.removeEventListener('abort', onAbort);
196
+ }
197
+ }
198
+
199
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
200
+ return raceWithAbort(new Promise<void>((r) => setTimeout(r, ms)), signal);
201
+ }
202
+
203
+ // --- observe-side synthesis ---------------------------------------------------
204
+
205
+ function queryFor(el: UiElement): ElementQuery {
206
+ return el.id ? { id: el.id } : { label: el.label };
207
+ }
208
+
209
+ function provenance(core: DeviceCore, el: UiElement, via?: string) {
210
+ return {
211
+ ...(via === undefined ? {} : { via }),
212
+ label: el.label,
213
+ role: el.role,
214
+ ...(el.rect === undefined ? {} : { rect: el.rect }),
215
+ ...(core.currentApp() === undefined ? {} : { app: core.currentApp() }),
216
+ ...(core.screenTitle() ? { screenTitle: core.screenTitle() } : {}),
217
+ };
218
+ }
219
+
220
+ /** Synthesize portable actions from the CURRENT cache (observe-time). */
221
+ export function toActions(core: DeviceCore): Action[] {
222
+ const actions: Action[] = [];
223
+ for (const el of core.interactiveElements()) {
224
+ const kind = el.role;
225
+ if (TAPPABLE.has(kind)) {
226
+ actions.push({
227
+ formatVersion: 0,
228
+ verb: 'tap',
229
+ target: queryFor(el),
230
+ observed: provenance(core, el, el.id ? 'id' : 'exact label'),
231
+ });
232
+ }
233
+ }
234
+ for (const el of core.inputFields(true)) {
235
+ actions.push({
236
+ formatVersion: 0,
237
+ verb: 'fill',
238
+ target: queryFor(el),
239
+ params: { text: '' },
240
+ observed: provenance(core, el, el.id ? 'id' : 'exact label'),
241
+ });
242
+ }
243
+ return actions;
244
+ }
245
+
246
+ const NO_SECRETS = new SecretStore();
247
+
248
+ /**
249
+ * Take one fresh snapshot and assemble the full {@link ObserveResult}:
250
+ * deduped element channel, secret-redacted rendered text, and a portable
251
+ * Action per tappable / input field.
252
+ */
253
+ export async function buildObserveResult(
254
+ core: DeviceCore,
255
+ secrets: SecretStore = NO_SECRETS,
256
+ ): Promise<ObserveResult> {
257
+ const obs = await core.observe();
258
+ const redact = (s: string) => secrets.redact(s);
259
+ // Tappables + input fields: one structured element channel (deduped by ref).
260
+ const seen = new Set<string>();
261
+ const elements: ObservedElement[] = [];
262
+ for (const el of [...core.interactiveElements(), ...core.inputFields(true)]) {
263
+ if (seen.has(el.ref)) continue;
264
+ seen.add(el.ref);
265
+ elements.push({
266
+ ...el,
267
+ label: redact(el.label),
268
+ ...(el.value === undefined || el.value === null ? {} : { value: redact(el.value) }),
269
+ });
270
+ }
271
+ return {
272
+ success: true,
273
+ message: elements.length
274
+ ? `observed ${elements.length} interactive elements`
275
+ : obs.elements.slice(0, 120),
276
+ ...(obs.app === undefined ? {} : { app: obs.app }),
277
+ ...(obs.bundleId === undefined ? {} : { bundleId: obs.bundleId }),
278
+ ...(core.screenTitle() ? { screenTitle: core.screenTitle() } : {}),
279
+ elements,
280
+ rendered: redact(core.renderObservation(true)),
281
+ actions: toActions(core),
282
+ };
283
+ }
284
+
285
+ // --- act-side dispatch ---------------------------------------------------------
286
+
287
+ function toResolveOpts(q: ElementQuery): ResolveOpts {
288
+ return {
289
+ ...(q.role === undefined ? {} : { role: q.role }),
290
+ ...(q.near === undefined ? {} : { near: q.near }),
291
+ };
292
+ }
293
+
294
+ function queryText(q: ElementQuery): string {
295
+ const t = q.id ?? q.label;
296
+ if (t === undefined || t === '') {
297
+ throw new ActionFailedError('action target needs an id or label');
298
+ }
299
+ return t;
300
+ }
301
+
302
+ function isActionable(el: UiElement): boolean {
303
+ return el.enabled !== false && !el.blocked;
304
+ }
305
+
306
+ const CACHE_FRESH_MS = 2000;
307
+ const POLL_STEPS_MS = [150, 300, 600, 800];
308
+
309
+ type WaitOutcome =
310
+ | { ok: true; el: UiElement; via: string; waited: { ms: number; polls: number } | undefined }
311
+ | { ok: false; result: ActionResult };
312
+
313
+ /**
314
+ * Resolve + auto-wait (docs/19: visible+hittable+enabled+settled on every
315
+ * action, no caller sleep; docs/20 risk 2: must not double latency).
316
+ * Fast path: a fresh cache resolving to an actionable target executes with
317
+ * ZERO extra snapshots. Slow path: poll in place (never scroll — scrolling is
318
+ * the ladder's job, and polling must not dismiss transient menus) until the
319
+ * target is actionable AND the tree signature is stable between polls.
320
+ */
321
+ async function resolveWithWait(
322
+ core: DeviceCore,
323
+ q: ElementQuery,
324
+ opts: ActOptions,
325
+ pool: 'tappable' | 'fields',
326
+ ): Promise<WaitOutcome> {
327
+ const signal = opts.signal;
328
+ const deadline = Date.now() + (opts.timeoutMs ?? 5000);
329
+ const text = queryText(q);
330
+ const rOpts = toResolveOpts(q);
331
+ // Tap targets resolve against interactive (tappable) elements; fill targets
332
+ // against input fields — two pools, one matcher (the ladder's rungs).
333
+ const inCache = (): Resolution =>
334
+ pool === 'fields'
335
+ ? matchInElements(core.inputFields(true), text, rOpts)
336
+ : core.resolveInCache(text, rOpts);
337
+
338
+ // Fast path: fresh cache + actionable target → go now, zero snapshots.
339
+ if (core.cacheAgeMs() < CACHE_FRESH_MS) {
340
+ const r = inCache();
341
+ if (r.el && isActionable(r.el)) return { ok: true, el: r.el, via: r.via ?? 'cache', waited: undefined };
342
+ if (r.candidates?.length) return { ok: false, result: ambiguityResult(text, r) };
343
+ }
344
+
345
+ // Not in the (possibly stale) cache at all. For tappables, run one full
346
+ // ladder pass (scroll search) — not-on-screen is a search problem;
347
+ // not-yet-enabled is a wait problem. Fields skip the scroll ladder (fields
348
+ // live on the current form) and go straight to the poll.
349
+ throwIfAborted(signal);
350
+ let r: Resolution;
351
+ if (pool === 'tappable') {
352
+ r = await raceWithAbort(core.resolveElement(text, rOpts), signal);
353
+ } else {
354
+ await raceWithAbort(core.observe(), signal);
355
+ r = inCache();
356
+ }
357
+ if (!r.el && r.candidates?.length) return { ok: false, result: ambiguityResult(text, r) };
358
+ if (r.el && isActionable(r.el)) return { ok: true, el: r.el, via: r.via ?? 'ladder', waited: undefined };
359
+
360
+ // Wait loop: poll in place until actionable + settled, or deadline.
361
+ const started = Date.now();
362
+ let polls = 0;
363
+ let lastSig = core.stateSignature();
364
+ let step = 0;
365
+ while (Date.now() < deadline) {
366
+ await sleep(POLL_STEPS_MS[Math.min(step, POLL_STEPS_MS.length - 1)]!, signal);
367
+ step++;
368
+ polls++;
369
+ throwIfAborted(signal);
370
+ await raceWithAbort(core.observe(), signal);
371
+ const sig = core.stateSignature();
372
+ const settled = sig === lastSig;
373
+ lastSig = sig;
374
+ const rr = inCache();
375
+ if (rr.el && isActionable(rr.el) && settled) {
376
+ return { ok: true, el: rr.el, via: rr.via ?? 'wait', waited: { ms: Date.now() - started, polls } };
377
+ }
378
+ if (!rr.el && rr.candidates?.length) return { ok: false, result: ambiguityResult(text, rr) };
379
+ r = rr;
380
+ }
381
+ return {
382
+ ok: false,
383
+ result: {
384
+ success: false,
385
+ code: 'TIMEOUT',
386
+ message: r.el
387
+ ? `timed out after ${opts.timeoutMs ?? 5000}ms waiting for "${text}" to become enabled/settled`
388
+ : `timed out after ${opts.timeoutMs ?? 5000}ms — no element matching "${text}" on this screen`,
389
+ waited: { ms: Date.now() - started, polls },
390
+ },
391
+ };
392
+ }
393
+
394
+ function ambiguityResult(text: string, r: Resolution): ActionResult {
395
+ const list = (r.candidates ?? [])
396
+ .map(
397
+ (c) => `${c.role} "${c.label}"${c.rect ? ` at (${Math.round(c.rect.x)},${Math.round(c.rect.y)})` : ''}`,
398
+ )
399
+ .join('; ');
400
+ return {
401
+ success: false,
402
+ message: `"${text}" is ambiguous — ${r.candidates?.length ?? 0} matches: ${list}. Disambiguate with role or near.`,
403
+ candidates: r.candidates ?? [],
404
+ };
405
+ }
406
+
407
+ function resolvedOf(el: UiElement, via: string) {
408
+ return {
409
+ via,
410
+ ref: el.ref,
411
+ label: el.label,
412
+ role: el.role,
413
+ ...(el.rect === undefined ? {} : { rect: el.rect }),
414
+ };
415
+ }
416
+
417
+ /**
418
+ * THE dispatcher: resolve → auto-wait → execute → diff. One brain — used by
419
+ * device.tap/type/act, and (items 5-6) by the harness and the skill runner.
420
+ */
421
+ export async function executeAction(
422
+ core: DeviceCore,
423
+ action: Action,
424
+ opts: ActOptions = {},
425
+ secrets: SecretStore = NO_SECRETS,
426
+ ): Promise<ActionResult> {
427
+ const signal = opts.signal;
428
+ throwIfAborted(signal);
429
+ const store = secrets.withOverrides(opts.vars);
430
+ const redact = (s: string) => store.redact(s);
431
+
432
+ try {
433
+ switch (action.verb) {
434
+ case 'tap':
435
+ case 'longPress':
436
+ case 'fill': {
437
+ if (!action.target) return { success: false, message: `${action.verb} needs a target query` };
438
+ const wait = await resolveWithWait(
439
+ core,
440
+ action.target,
441
+ opts,
442
+ action.verb === 'fill' ? 'fields' : 'tappable',
443
+ );
444
+ if (!wait.ok) return wait.result;
445
+ const { el, via, waited } = wait;
446
+ if (action.verb === 'longPress') {
447
+ await raceWithAbort(core.longPress(el.ref, action.params?.durationMs), signal);
448
+ return {
449
+ success: true,
450
+ message: redact(`long-pressed "${el.label}"`),
451
+ resolved: resolvedOf(el, via),
452
+ waited,
453
+ };
454
+ }
455
+ if (action.verb === 'fill') {
456
+ const text = store.substitute(action.params?.text ?? '');
457
+ const ev = await raceWithAbort(core.fill(el.ref, text), signal);
458
+ if (action.params?.submit) await raceWithAbort(core.pressReturn(), signal);
459
+ return {
460
+ success: true,
461
+ message: redact(`filled "${el.label}"${action.params?.submit ? ', pressed Return' : ''}`),
462
+ ...(ev.changed === undefined ? {} : { changed: ev.changed }),
463
+ resolved: resolvedOf(el, via),
464
+ waited,
465
+ };
466
+ }
467
+ const ev = await raceWithAbort(core.press(el.ref), signal);
468
+ return {
469
+ success: true,
470
+ message: redact(`tapped "${el.label}"${ev.changed === false ? ' (no change)' : ''}`),
471
+ ...(ev.changed === undefined ? {} : { changed: ev.changed }),
472
+ resolved: resolvedOf(el, via),
473
+ waited,
474
+ };
475
+ }
476
+ case 'type': {
477
+ const text = store.substitute(action.params?.text ?? '');
478
+ await raceWithAbort(core.typeText(text), signal);
479
+ if (action.params?.submit) await raceWithAbort(core.pressReturn(), signal);
480
+ return { success: true, message: redact(`typed ${JSON.stringify(action.params?.text ?? '')}`) };
481
+ }
482
+ case 'pressKey': {
483
+ await raceWithAbort(core.pressReturn(), signal);
484
+ return { success: true, message: 'pressed Return' };
485
+ }
486
+ case 'scroll': {
487
+ const direction = action.params?.direction ?? 'down';
488
+ await raceWithAbort(core.scroll(direction), signal);
489
+ await raceWithAbort(core.observe(), signal);
490
+ return { success: true, message: `scrolled ${direction}` };
491
+ }
492
+ case 'openApp': {
493
+ if (!action.params?.app) return { success: false, message: 'openApp needs params.app' };
494
+ const note = await raceWithAbort(
495
+ core.openApp(action.params.app, action.params.relaunch ?? false),
496
+ signal,
497
+ );
498
+ return { success: true, message: note };
499
+ }
500
+ case 'openUrl': {
501
+ if (!action.params?.url) return { success: false, message: 'openUrl needs params.url' };
502
+ const note = await raceWithAbort(core.openUrl(action.params.url, action.params.app), signal);
503
+ return { success: true, message: note };
504
+ }
505
+ case 'back': {
506
+ await raceWithAbort(core.goBack(), signal);
507
+ return { success: true, message: 'went back' };
508
+ }
509
+ case 'home': {
510
+ await raceWithAbort(core.goHome(), signal);
511
+ return { success: true, message: 'went home' };
512
+ }
513
+ case 'alert': {
514
+ const outcome = await raceWithAbort(core.handleAlert(action.params?.alertAction ?? 'accept'), signal);
515
+ if (!outcome.present) return { success: false, message: 'no system alert is showing' };
516
+ return {
517
+ success: outcome.handled !== false,
518
+ message: `alert ${outcome.handled ? `handled via "${outcome.button}"` : 'NOT handled'}: ${outcome.description ?? ''}`,
519
+ };
520
+ }
521
+ case 'waitForText': {
522
+ if (!action.params?.text) return { success: false, message: 'waitForText needs params.text' };
523
+ const note = await raceWithAbort(
524
+ core.waitForText(action.params.text, opts.timeoutMs ?? 5000),
525
+ signal,
526
+ );
527
+ const ok = !/did not appear|not found|timed out/i.test(note);
528
+ return { success: ok, message: redact(note), ...(ok ? {} : { code: 'TIMEOUT' as const }) };
529
+ }
530
+ default:
531
+ return { success: false, message: `unknown verb ${String((action as { verb?: unknown }).verb)}` };
532
+ }
533
+ } catch (error) {
534
+ // Infrastructure failures propagate; device-legible gesture failures are
535
+ // structured results.
536
+ if (error instanceof AbortedError) throw error;
537
+ if (error instanceof PhoneUseError) {
538
+ if (error instanceof ActionFailedError || error instanceof TimeoutError) {
539
+ return { success: false, message: redact(error.message), code: error.code };
540
+ }
541
+ throw error;
542
+ }
543
+ throw error;
544
+ }
545
+ }