androidctl 0.1.0__py3-none-any.whl

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.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,894 @@
1
+ """Mutating action runtime orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol, cast
7
+
8
+ from androidctl_contracts.command_results import (
9
+ ActionTargetEvidence,
10
+ ActionTargetPayload,
11
+ )
12
+ from androidctld.actions.action_target import (
13
+ build_action_target_payload,
14
+ build_same_or_successor_action_target,
15
+ public_ref_for_handle,
16
+ public_ref_for_raw_node,
17
+ )
18
+ from androidctld.actions.capabilities import (
19
+ ensure_action_request_supported,
20
+ ensure_command_supported,
21
+ validate_action_semantics,
22
+ validate_resolved_ref_action,
23
+ )
24
+ from androidctld.actions.focus_confirmation import (
25
+ FocusConfirmationContext,
26
+ FocusConfirmationOutcome,
27
+ build_focus_confirmation_context,
28
+ )
29
+ from androidctld.actions.fresh_current import (
30
+ capture_global_fresh_current_baseline,
31
+ validate_global_fresh_current_evidence,
32
+ )
33
+ from androidctld.actions.postconditions import (
34
+ RefActionPostconditionContext,
35
+ validate_postcondition,
36
+ )
37
+ from androidctld.actions.request_builder import (
38
+ build_action_request,
39
+ build_submit_action_request_for_route,
40
+ resolve_ref_target,
41
+ )
42
+ from androidctld.actions.settle import ActionSettler
43
+ from androidctld.actions.submit_confirmation import (
44
+ SubmitConfirmationContext,
45
+ SubmitConfirmationOutcome,
46
+ build_submit_confirmation_context,
47
+ validate_submit_confirmation,
48
+ )
49
+ from androidctld.actions.submit_routing import (
50
+ SubmitRouteOutcome,
51
+ resolve_submit_route,
52
+ )
53
+ from androidctld.actions.type_confirmation import (
54
+ TypeConfirmationCandidate,
55
+ TypeConfirmationContext,
56
+ build_type_confirmation_context,
57
+ validate_type_confirmation,
58
+ )
59
+ from androidctld.commands.command_models import (
60
+ ActionCommand,
61
+ FocusCommand,
62
+ GlobalCommand,
63
+ LongTapCommand,
64
+ OpenCommand,
65
+ RefBoundActionCommand,
66
+ ScrollCommand,
67
+ SubmitCommand,
68
+ TypeCommand,
69
+ is_ref_bound_action_command,
70
+ )
71
+ from androidctld.commands.models import CommandRecord
72
+ from androidctld.commands.result_builders import app_payload
73
+ from androidctld.commands.result_models import SemanticResultAssemblyInput
74
+ from androidctld.device.action_models import BuiltDeviceActionRequest
75
+ from androidctld.device.interfaces import DeviceClientFactory
76
+ from androidctld.errors import DaemonError, DaemonErrorCode
77
+ from androidctld.protocol import DeviceRpcErrorCode
78
+ from androidctld.refs.models import NodeHandle
79
+ from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease
80
+ from androidctld.runtime.models import WorkspaceRuntime
81
+ from androidctld.runtime.screen_state import (
82
+ current_compiled_screen,
83
+ current_public_screen,
84
+ )
85
+ from androidctld.runtime_policy import (
86
+ DEVICE_RPC_REQUEST_ID_ACTION,
87
+ )
88
+ from androidctld.semantics.compiler import CompiledScreen
89
+ from androidctld.semantics.public_models import (
90
+ PublicNode,
91
+ PublicScreen,
92
+ iter_public_nodes,
93
+ )
94
+ from androidctld.snapshots.models import RawSnapshot
95
+ from androidctld.snapshots.refresh import ScreenRefreshService, settle_screen_signature
96
+
97
+ _POST_DISPATCH_SETTLE_TIMEOUT_WARNING = (
98
+ "post-dispatch observation timed out before stability was confirmed"
99
+ )
100
+ _ACTION_AVAILABILITY_ERROR_CODES = {
101
+ DaemonErrorCode.RUNTIME_NOT_CONNECTED,
102
+ DaemonErrorCode.SCREEN_NOT_READY,
103
+ DaemonErrorCode.DEVICE_DISCONNECTED,
104
+ DaemonErrorCode.DEVICE_AGENT_UNAVAILABLE,
105
+ DaemonErrorCode.DEVICE_AGENT_UNAUTHORIZED,
106
+ DaemonErrorCode.DEVICE_RPC_FAILED,
107
+ DaemonErrorCode.DEVICE_RPC_TRANSPORT_RESET,
108
+ }
109
+ _POST_DISPATCH_TRANSPORT_INVALIDATING_ERROR_CODES = {
110
+ DaemonErrorCode.DEVICE_DISCONNECTED,
111
+ DaemonErrorCode.DEVICE_RPC_TRANSPORT_RESET,
112
+ }
113
+
114
+
115
+ @dataclass
116
+ class ActionExecutionFailure(Exception):
117
+ """Failure after the device action RPC returned and a later phase failed."""
118
+
119
+ original_error: DaemonError
120
+ normalized_error: DaemonError
121
+ dispatch_attempted: bool
122
+ truth_lost_after_dispatch: bool = False
123
+
124
+ def __post_init__(self) -> None:
125
+ Exception.__init__(self, self.normalized_error.message)
126
+
127
+
128
+ def _try_focused_input_noop_success(
129
+ session: WorkspaceRuntime,
130
+ command: ActionCommand,
131
+ ) -> SemanticResultAssemblyInput | None:
132
+ if not isinstance(command, FocusCommand):
133
+ return None
134
+ if command.source_screen_id != session.current_screen_id:
135
+ return None
136
+ snapshot = session.latest_snapshot
137
+ public_screen = current_public_screen(session)
138
+ compiled_screen = current_compiled_screen(session)
139
+ if snapshot is None or public_screen is None or compiled_screen is None:
140
+ return None
141
+ if public_screen.surface.focus.input_ref != command.ref:
142
+ return None
143
+ public_node = _unique_public_node(public_screen, command.ref)
144
+ if public_node is None or public_node.role != "input":
145
+ return None
146
+ focused_node = compiled_screen.focused_input_node()
147
+ if (
148
+ focused_node is None
149
+ or focused_node.ref != command.ref
150
+ or compiled_screen.focused_input_ref() != command.ref
151
+ ):
152
+ return None
153
+ return SemanticResultAssemblyInput(
154
+ app_payload=app_payload(snapshot),
155
+ execution_outcome="notAttempted",
156
+ )
157
+
158
+
159
+ def _unique_public_node(screen: PublicScreen, ref: str) -> PublicNode | None:
160
+ nodes = [
161
+ node
162
+ for group in screen.groups
163
+ for node in iter_public_nodes(group.nodes)
164
+ if node.ref == ref
165
+ ]
166
+ return nodes[0] if len(nodes) == 1 else None
167
+
168
+
169
+ class ActionCommandRepairPort(Protocol):
170
+ def repair_action_command(
171
+ self,
172
+ session: WorkspaceRuntime,
173
+ record: CommandRecord,
174
+ command: RefBoundActionCommand,
175
+ *,
176
+ lifecycle_lease: RuntimeLifecycleLease,
177
+ ) -> BuiltDeviceActionRequest: ...
178
+
179
+ def repair_action_binding(
180
+ self,
181
+ session: WorkspaceRuntime,
182
+ record: CommandRecord,
183
+ command: RefBoundActionCommand,
184
+ *,
185
+ lifecycle_lease: RuntimeLifecycleLease,
186
+ ) -> NodeHandle | None: ...
187
+
188
+
189
+ class ActionExecutor:
190
+ def __init__(
191
+ self,
192
+ *,
193
+ device_client_factory: DeviceClientFactory,
194
+ screen_refresh: ScreenRefreshService,
195
+ settler: ActionSettler,
196
+ repairer: ActionCommandRepairPort,
197
+ runtime_kernel: RuntimeKernel | None = None,
198
+ ) -> None:
199
+ self._device_client_factory = device_client_factory
200
+ self._screen_refresh = screen_refresh
201
+ self._settler = settler
202
+ self._repairer = repairer
203
+ self._runtime_kernel = runtime_kernel or getattr(
204
+ screen_refresh,
205
+ "runtime_kernel",
206
+ None,
207
+ )
208
+
209
+ def execute(
210
+ self,
211
+ session: WorkspaceRuntime,
212
+ record: CommandRecord,
213
+ command: ActionCommand,
214
+ lifecycle_lease: RuntimeLifecycleLease,
215
+ ) -> SemanticResultAssemblyInput:
216
+ kind = record.kind
217
+ if session.connection is None or session.device_token is None:
218
+ raise DaemonError(
219
+ code=DaemonErrorCode.RUNTIME_NOT_CONNECTED,
220
+ message="runtime is not connected to a device",
221
+ retryable=False,
222
+ details={"workspaceRoot": session.workspace_root.as_posix()},
223
+ http_status=200,
224
+ )
225
+ if is_ref_bound_action_command(command) and (
226
+ session.latest_snapshot is None or session.screen_state is None
227
+ ):
228
+ raise DaemonError(
229
+ code=DaemonErrorCode.SCREEN_NOT_READY,
230
+ message="screen is not ready yet",
231
+ retryable=False,
232
+ details={"workspaceRoot": session.workspace_root.as_posix()},
233
+ http_status=200,
234
+ )
235
+
236
+ focused_noop_success = _try_focused_input_noop_success(session, command)
237
+ if focused_noop_success is not None:
238
+ return focused_noop_success
239
+
240
+ client = None
241
+ if session.transport is None:
242
+ client = self._device_client_factory(
243
+ session,
244
+ lifecycle_lease=lifecycle_lease,
245
+ )
246
+ ensure_command_supported(session, command)
247
+ validate_action_semantics(session, command)
248
+ previous_screen = current_public_screen(session)
249
+ previous_snapshot = session.latest_snapshot
250
+ previous_compiled = current_compiled_screen(session)
251
+ settle_baseline_signature = _settle_baseline_signature(
252
+ previous_compiled,
253
+ previous_snapshot,
254
+ )
255
+ fresh_current_baseline = (
256
+ capture_global_fresh_current_baseline(
257
+ action=command.action,
258
+ snapshot=previous_snapshot,
259
+ compiled_screen=previous_compiled,
260
+ )
261
+ if isinstance(command, GlobalCommand)
262
+ else None
263
+ )
264
+ if client is None:
265
+ client = self._device_client_factory(
266
+ session,
267
+ lifecycle_lease=lifecycle_lease,
268
+ )
269
+ repaired_once = False
270
+ submit_route: SubmitRouteOutcome | None = None
271
+ request, submit_route, repaired_once = self._build_dispatch_request(
272
+ session,
273
+ record,
274
+ command,
275
+ lifecycle_lease=lifecycle_lease,
276
+ repaired_once=repaired_once,
277
+ )
278
+ if is_ref_bound_action_command(command) and not isinstance(
279
+ command, SubmitCommand
280
+ ):
281
+ validate_resolved_ref_action(session, command, request.request_handle)
282
+ subject_ref, dispatched_ref = _action_target_request_refs(
283
+ session=session,
284
+ command=command,
285
+ request=request,
286
+ submit_route=submit_route,
287
+ )
288
+ focus_context, type_confirmation_context, submit_context = (
289
+ _confirmation_contexts_for_request(
290
+ session=session,
291
+ command=command,
292
+ request=request,
293
+ )
294
+ )
295
+ ref_postcondition_context = _ref_postcondition_context_for_request(
296
+ session=session,
297
+ command=command,
298
+ request=request,
299
+ )
300
+ type_command = command if isinstance(command, TypeCommand) else None
301
+ submit_command = command if isinstance(command, SubmitCommand) else None
302
+ try:
303
+ action_result = client.action_perform(
304
+ request.payload,
305
+ request_id=DEVICE_RPC_REQUEST_ID_ACTION,
306
+ )
307
+ except DaemonError as error:
308
+ if (
309
+ is_ref_bound_action_command(command)
310
+ and self._is_device_rpc_error(error, DeviceRpcErrorCode.STALE_TARGET)
311
+ and not repaired_once
312
+ ):
313
+ request, submit_route, repaired_once = self._build_dispatch_request(
314
+ session,
315
+ record,
316
+ command,
317
+ lifecycle_lease=lifecycle_lease,
318
+ repaired_once=True,
319
+ required_submit_route=(
320
+ submit_route.route if submit_route is not None else None
321
+ ),
322
+ )
323
+ if is_ref_bound_action_command(command) and not isinstance(
324
+ command, SubmitCommand
325
+ ):
326
+ validate_resolved_ref_action(
327
+ session,
328
+ command,
329
+ request.request_handle,
330
+ )
331
+ subject_ref, dispatched_ref = _action_target_request_refs(
332
+ session=session,
333
+ command=command,
334
+ request=request,
335
+ submit_route=submit_route,
336
+ )
337
+ focus_context, type_confirmation_context, submit_context = (
338
+ _confirmation_contexts_for_request(
339
+ session=session,
340
+ command=command,
341
+ request=request,
342
+ )
343
+ )
344
+ ref_postcondition_context = _ref_postcondition_context_for_request(
345
+ session=session,
346
+ command=command,
347
+ request=request,
348
+ )
349
+ try:
350
+ action_result = client.action_perform(
351
+ request.payload,
352
+ request_id=DEVICE_RPC_REQUEST_ID_ACTION,
353
+ )
354
+ except DaemonError as retry_error:
355
+ raise self._map_action_error(
356
+ retry_error,
357
+ command=command,
358
+ ) from retry_error
359
+ else:
360
+ raise self._map_action_error(error, command=command) from error
361
+
362
+ if isinstance(command, GlobalCommand) and self._runtime_kernel is not None:
363
+ self._runtime_kernel.drop_current_screen_authority(
364
+ session,
365
+ lifecycle_lease,
366
+ )
367
+
368
+ try:
369
+ candidate_validator = None
370
+ if fresh_current_baseline is not None:
371
+
372
+ def candidate_validator(
373
+ candidate_snapshot: RawSnapshot,
374
+ _public_screen: PublicScreen,
375
+ candidate_compiled_screen: CompiledScreen,
376
+ ) -> None:
377
+ validate_global_fresh_current_evidence(
378
+ fresh_current_baseline,
379
+ snapshot=candidate_snapshot,
380
+ compiled_screen=candidate_compiled_screen,
381
+ )
382
+
383
+ settle_result = self._settler.settle(
384
+ session,
385
+ client,
386
+ kind,
387
+ baseline_signature=settle_baseline_signature,
388
+ lifecycle_lease=lifecycle_lease,
389
+ )
390
+ snapshot = settle_result.snapshot
391
+ snapshot, public_screen, _artifacts = self._screen_refresh.refresh(
392
+ session,
393
+ snapshot,
394
+ lifecycle_lease=lifecycle_lease,
395
+ command_kind=kind,
396
+ record=record,
397
+ candidate_validator=candidate_validator,
398
+ )
399
+ type_confirmation: TypeConfirmationCandidate | None = None
400
+ submit_confirmation: SubmitConfirmationOutcome | None = None
401
+ command_target_handle = request.request_handle
402
+
403
+ postcondition = validate_postcondition(
404
+ command,
405
+ previous_snapshot,
406
+ snapshot,
407
+ previous_screen,
408
+ public_screen,
409
+ session=session,
410
+ focus_context=focus_context,
411
+ action_result=action_result,
412
+ ref_context=ref_postcondition_context,
413
+ )
414
+ if type_command is not None:
415
+ if type_confirmation_context is None:
416
+ raise RuntimeError("type confirmation context was not prepared")
417
+ confirmed_candidate = validate_type_confirmation(
418
+ session=session,
419
+ command=type_command,
420
+ snapshot=snapshot,
421
+ context=type_confirmation_context,
422
+ action_result=action_result,
423
+ )
424
+ type_confirmation = confirmed_candidate
425
+ command_target_handle = (
426
+ type_confirmation.target_handle or command_target_handle
427
+ )
428
+ if submit_command is not None:
429
+ if submit_route is None:
430
+ raise RuntimeError("submit route was not resolved")
431
+ submit_confirmation = validate_submit_confirmation(
432
+ session=session,
433
+ route_kind=submit_route.route,
434
+ action_result=action_result,
435
+ previous_snapshot=previous_snapshot,
436
+ snapshot=snapshot,
437
+ previous_screen=previous_screen,
438
+ public_screen=public_screen,
439
+ context=submit_context,
440
+ command_target_handle=command_target_handle,
441
+ )
442
+ if (
443
+ submit_confirmation is not None
444
+ and submit_route is not None
445
+ and submit_route.route == "direct"
446
+ and submit_confirmation.status == "unconfirmed"
447
+ ):
448
+ raise DaemonError(
449
+ code=DaemonErrorCode.SUBMIT_NOT_CONFIRMED,
450
+ message="submit effect could not be confirmed",
451
+ retryable=True,
452
+ details={"reason": "direct_submit_not_confirmed"},
453
+ http_status=200,
454
+ )
455
+ action_target = _build_success_action_target(
456
+ command=command,
457
+ source_ref=getattr(command, "ref", None),
458
+ source_screen_id=getattr(command, "source_screen_id", None),
459
+ subject_ref=subject_ref,
460
+ dispatched_ref=dispatched_ref,
461
+ repaired_once=repaired_once,
462
+ public_screen=public_screen,
463
+ compiled_screen=current_compiled_screen(session),
464
+ focus_confirmation=postcondition.focus_confirmation,
465
+ type_confirmation=type_confirmation,
466
+ submit_confirmation=submit_confirmation,
467
+ submit_route=submit_route,
468
+ )
469
+ except DaemonError as error:
470
+ normalized_error = self._map_action_error(error, command=command)
471
+ credential_invalidated = (
472
+ normalized_error.code == DaemonErrorCode.DEVICE_AGENT_UNAUTHORIZED
473
+ )
474
+ if credential_invalidated:
475
+ if self._runtime_kernel is not None:
476
+ self._runtime_kernel.invalidate_device_credentials(
477
+ session,
478
+ lifecycle_lease,
479
+ )
480
+ truth_lost_after_dispatch = False
481
+ else:
482
+ truth_lost_after_dispatch = _is_action_availability_error(
483
+ normalized_error
484
+ )
485
+ if truth_lost_after_dispatch and self._runtime_kernel is not None:
486
+ self._runtime_kernel.drop_current_screen_authority(
487
+ session,
488
+ lifecycle_lease,
489
+ discard_transport=(
490
+ _should_discard_transport_after_dispatch(normalized_error)
491
+ ),
492
+ )
493
+ raise ActionExecutionFailure(
494
+ original_error=error,
495
+ normalized_error=normalized_error,
496
+ # This means the device action RPC returned successfully; a
497
+ # later settle/refresh/confirmation phase failed.
498
+ dispatch_attempted=True,
499
+ truth_lost_after_dispatch=truth_lost_after_dispatch,
500
+ ) from error
501
+
502
+ return SemanticResultAssemblyInput(
503
+ app_payload=app_payload(
504
+ snapshot,
505
+ app_match=postcondition.app_match,
506
+ ),
507
+ action_target=action_target,
508
+ warnings=(
509
+ (_POST_DISPATCH_SETTLE_TIMEOUT_WARNING,)
510
+ if settle_result.timed_out
511
+ else ()
512
+ ),
513
+ )
514
+
515
+ def _build_dispatch_request(
516
+ self,
517
+ session: WorkspaceRuntime,
518
+ record: CommandRecord,
519
+ command: ActionCommand,
520
+ *,
521
+ lifecycle_lease: RuntimeLifecycleLease,
522
+ repaired_once: bool,
523
+ required_submit_route: str | None = None,
524
+ ) -> tuple[BuiltDeviceActionRequest, SubmitRouteOutcome | None, bool]:
525
+ if isinstance(command, SubmitCommand):
526
+ if repaired_once or command.source_screen_id != session.current_screen_id:
527
+ subject_handle = self._repair_action_binding(
528
+ session,
529
+ record,
530
+ command,
531
+ lifecycle_lease=lifecycle_lease,
532
+ )
533
+ source_evidence: ActionTargetEvidence = "refRepair"
534
+ repaired_once = True
535
+ else:
536
+ subject_handle = resolve_ref_target(
537
+ session,
538
+ command.ref,
539
+ command.source_screen_id,
540
+ )
541
+ source_evidence = "liveRef"
542
+ route = resolve_submit_route(
543
+ session,
544
+ command,
545
+ subject_handle=subject_handle,
546
+ source_evidence=source_evidence,
547
+ )
548
+ if (
549
+ required_submit_route is not None
550
+ and route.route != required_submit_route
551
+ ):
552
+ raise DaemonError(
553
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
554
+ message="submit is not available for the requested target",
555
+ retryable=False,
556
+ details={
557
+ "reason": "submit_route_changed_after_repair",
558
+ "ref": command.ref,
559
+ "requiredRoute": required_submit_route,
560
+ "resolvedRoute": route.route,
561
+ },
562
+ http_status=200,
563
+ )
564
+ request = build_submit_action_request_for_route(route)
565
+ ensure_action_request_supported(
566
+ session,
567
+ command=command.kind,
568
+ request=request.payload,
569
+ )
570
+ return request, route, repaired_once
571
+
572
+ if is_ref_bound_action_command(command) and (
573
+ repaired_once or command.source_screen_id != session.current_screen_id
574
+ ):
575
+ request = self._repairer.repair_action_command(
576
+ session,
577
+ record,
578
+ command,
579
+ lifecycle_lease=lifecycle_lease,
580
+ )
581
+ return request, None, True
582
+ return build_action_request(session, command), None, repaired_once
583
+
584
+ def _repair_action_binding(
585
+ self,
586
+ session: WorkspaceRuntime,
587
+ record: CommandRecord,
588
+ command: RefBoundActionCommand,
589
+ *,
590
+ lifecycle_lease: RuntimeLifecycleLease,
591
+ ) -> NodeHandle | None:
592
+ return self._repairer.repair_action_binding(
593
+ session,
594
+ record,
595
+ command,
596
+ lifecycle_lease=lifecycle_lease,
597
+ )
598
+
599
+ def _map_action_error(
600
+ self, error: DaemonError, *, command: ActionCommand
601
+ ) -> DaemonError:
602
+ if isinstance(command, OpenCommand) and self._is_device_rpc_error(
603
+ error, DeviceRpcErrorCode.ACTION_FAILED
604
+ ):
605
+ return DaemonError(
606
+ code=DaemonErrorCode.OPEN_FAILED,
607
+ message=error.message,
608
+ retryable=error.retryable,
609
+ details=dict(error.details),
610
+ http_status=error.http_status,
611
+ )
612
+ if not self._is_device_rpc_error(
613
+ error, DeviceRpcErrorCode.TARGET_NOT_ACTIONABLE
614
+ ):
615
+ return error
616
+ return DaemonError(
617
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
618
+ message=error.message,
619
+ retryable=error.retryable,
620
+ details=dict(error.details),
621
+ http_status=error.http_status,
622
+ )
623
+
624
+ def _is_device_rpc_error(
625
+ self, error: DaemonError, code: DeviceRpcErrorCode
626
+ ) -> bool:
627
+ return (
628
+ error.code == DaemonErrorCode.DEVICE_RPC_FAILED
629
+ and error.details.get("deviceCode") == code.value
630
+ )
631
+
632
+
633
+ def _settle_baseline_signature(
634
+ compiled_screen: CompiledScreen | None,
635
+ previous_snapshot: RawSnapshot | None,
636
+ ) -> tuple[object, ...]:
637
+ if previous_snapshot is not None:
638
+ return settle_screen_signature(compiled_screen, previous_snapshot)
639
+ return ("connected-without-screen",)
640
+
641
+
642
+ def _action_target_request_refs(
643
+ *,
644
+ session: WorkspaceRuntime,
645
+ command: ActionCommand,
646
+ request: BuiltDeviceActionRequest,
647
+ submit_route: SubmitRouteOutcome | None,
648
+ ) -> tuple[str | None, str | None]:
649
+ if not isinstance(command, (FocusCommand, TypeCommand, SubmitCommand)):
650
+ return None, None
651
+ if submit_route is not None:
652
+ return submit_route.subject_ref, submit_route.dispatched_ref
653
+ subject_ref = public_ref_for_handle(
654
+ compiled_screen=current_compiled_screen(session),
655
+ public_screen=current_public_screen(session),
656
+ handle=request.request_handle,
657
+ )
658
+ dispatched_handle = request.dispatched_handle or request.request_handle
659
+ dispatched_ref = public_ref_for_handle(
660
+ compiled_screen=current_compiled_screen(session),
661
+ public_screen=current_public_screen(session),
662
+ handle=dispatched_handle,
663
+ )
664
+ return subject_ref, dispatched_ref
665
+
666
+
667
+ def _confirmation_contexts_for_request(
668
+ *,
669
+ session: WorkspaceRuntime,
670
+ command: ActionCommand,
671
+ request: BuiltDeviceActionRequest,
672
+ ) -> tuple[
673
+ FocusConfirmationContext | None,
674
+ TypeConfirmationContext | None,
675
+ SubmitConfirmationContext | None,
676
+ ]:
677
+ focus_command = command if isinstance(command, FocusCommand) else None
678
+ submit_command = command if isinstance(command, SubmitCommand) else None
679
+ type_command = command if isinstance(command, TypeCommand) else None
680
+ focus_context = (
681
+ build_focus_confirmation_context(session, focus_command, request.request_handle)
682
+ if focus_command is not None
683
+ else None
684
+ )
685
+ type_confirmation_context = (
686
+ build_type_confirmation_context(session, type_command, request.request_handle)
687
+ if type_command is not None
688
+ else None
689
+ )
690
+ submit_context = (
691
+ build_submit_confirmation_context(
692
+ session,
693
+ submit_command,
694
+ request.request_handle,
695
+ )
696
+ if submit_command is not None
697
+ else None
698
+ )
699
+ return focus_context, type_confirmation_context, submit_context
700
+
701
+
702
+ def _ref_postcondition_context_for_request(
703
+ *,
704
+ session: WorkspaceRuntime,
705
+ command: ActionCommand,
706
+ request: BuiltDeviceActionRequest,
707
+ ) -> RefActionPostconditionContext | None:
708
+ if not isinstance(command, (LongTapCommand, ScrollCommand)):
709
+ return None
710
+ baseline_screen = current_public_screen(session)
711
+ target_ref = public_ref_for_handle(
712
+ compiled_screen=current_compiled_screen(session),
713
+ public_screen=baseline_screen,
714
+ handle=request.request_handle,
715
+ )
716
+ baseline_target = (
717
+ None
718
+ if baseline_screen is None or target_ref is None
719
+ else _unique_public_node(baseline_screen, target_ref)
720
+ )
721
+ return RefActionPostconditionContext(
722
+ target_ref=target_ref,
723
+ baseline_screen=baseline_screen,
724
+ baseline_target=baseline_target,
725
+ )
726
+
727
+
728
+ def _is_action_availability_error(error: DaemonError) -> bool:
729
+ return error.code in _ACTION_AVAILABILITY_ERROR_CODES
730
+
731
+
732
+ def _should_discard_transport_after_dispatch(error: DaemonError) -> bool:
733
+ return error.code in _POST_DISPATCH_TRANSPORT_INVALIDATING_ERROR_CODES
734
+
735
+
736
+ def _build_success_action_target(
737
+ *,
738
+ command: ActionCommand,
739
+ source_ref: object,
740
+ source_screen_id: object,
741
+ subject_ref: str | None,
742
+ dispatched_ref: str | None,
743
+ repaired_once: bool,
744
+ public_screen: PublicScreen,
745
+ compiled_screen: CompiledScreen | None,
746
+ focus_confirmation: FocusConfirmationOutcome | None,
747
+ type_confirmation: TypeConfirmationCandidate | None,
748
+ submit_confirmation: SubmitConfirmationOutcome | None,
749
+ submit_route: SubmitRouteOutcome | None,
750
+ ) -> ActionTargetPayload | None:
751
+ if not isinstance(command, (FocusCommand, TypeCommand, SubmitCommand)):
752
+ return None
753
+ if not isinstance(source_ref, str) or not isinstance(source_screen_id, str):
754
+ return None
755
+ source_evidence: ActionTargetEvidence = (
756
+ submit_route.source_evidence
757
+ if submit_route is not None
758
+ else "refRepair" if repaired_once else "liveRef"
759
+ )
760
+ next_screen_id = public_screen.screen_id
761
+ if isinstance(command, FocusCommand):
762
+ next_ref = public_ref_for_handle(
763
+ compiled_screen=compiled_screen,
764
+ public_screen=public_screen,
765
+ handle=(
766
+ None if focus_confirmation is None else focus_confirmation.target_handle
767
+ ),
768
+ )
769
+ strategy = None if focus_confirmation is None else focus_confirmation.strategy
770
+ return build_same_or_successor_action_target(
771
+ source_ref=source_ref,
772
+ source_screen_id=source_screen_id,
773
+ subject_ref=subject_ref,
774
+ dispatched_ref=dispatched_ref,
775
+ next_screen_id=next_screen_id,
776
+ next_ref=next_ref,
777
+ evidence=_confirmation_evidence(
778
+ source_evidence,
779
+ strategy,
780
+ "focusConfirmation",
781
+ ),
782
+ )
783
+ if isinstance(command, TypeCommand):
784
+ next_ref = public_ref_for_handle(
785
+ compiled_screen=compiled_screen,
786
+ public_screen=public_screen,
787
+ handle=(
788
+ None if type_confirmation is None else type_confirmation.target_handle
789
+ ),
790
+ )
791
+ if next_ref is None and type_confirmation is not None:
792
+ next_ref = public_ref_for_raw_node(
793
+ compiled_screen=compiled_screen,
794
+ public_screen=public_screen,
795
+ node=type_confirmation.node,
796
+ )
797
+ strategy = None if type_confirmation is None else type_confirmation.strategy
798
+ return build_same_or_successor_action_target(
799
+ source_ref=source_ref,
800
+ source_screen_id=source_screen_id,
801
+ subject_ref=subject_ref,
802
+ dispatched_ref=dispatched_ref,
803
+ next_screen_id=next_screen_id,
804
+ next_ref=next_ref,
805
+ evidence=_confirmation_evidence(
806
+ source_evidence,
807
+ strategy,
808
+ "typeConfirmation",
809
+ ),
810
+ )
811
+ if submit_confirmation is None:
812
+ return None
813
+ submit_evidence = _submit_confirmation_evidence(source_evidence, submit_route)
814
+ if submit_confirmation.status == "targetGone":
815
+ return build_action_target_payload(
816
+ source_ref=source_ref,
817
+ source_screen_id=source_screen_id,
818
+ subject_ref=subject_ref,
819
+ dispatched_ref=dispatched_ref,
820
+ next_screen_id=next_screen_id,
821
+ identity_status="gone",
822
+ evidence=(*submit_evidence, "targetGone"),
823
+ )
824
+ if submit_confirmation.status == "sameTarget":
825
+ next_ref = public_ref_for_handle(
826
+ compiled_screen=compiled_screen,
827
+ public_screen=public_screen,
828
+ handle=submit_confirmation.target_handle,
829
+ )
830
+ if next_ref is None:
831
+ next_ref = public_ref_for_raw_node(
832
+ compiled_screen=compiled_screen,
833
+ public_screen=public_screen,
834
+ node=submit_confirmation.node,
835
+ )
836
+ return build_same_or_successor_action_target(
837
+ source_ref=source_ref,
838
+ source_screen_id=source_screen_id,
839
+ subject_ref=subject_ref,
840
+ dispatched_ref=dispatched_ref,
841
+ next_screen_id=next_screen_id,
842
+ next_ref=next_ref,
843
+ evidence=submit_evidence,
844
+ )
845
+ if submit_confirmation.status == "publicChange":
846
+ return build_action_target_payload(
847
+ source_ref=source_ref,
848
+ source_screen_id=source_screen_id,
849
+ subject_ref=subject_ref,
850
+ dispatched_ref=dispatched_ref,
851
+ next_screen_id=next_screen_id,
852
+ identity_status="unconfirmed",
853
+ evidence=(*submit_evidence, "publicChange"),
854
+ )
855
+ if submit_confirmation.status == "unconfirmed":
856
+ if submit_route is not None and submit_route.route == "direct":
857
+ return None
858
+ return build_action_target_payload(
859
+ source_ref=source_ref,
860
+ source_screen_id=source_screen_id,
861
+ subject_ref=subject_ref,
862
+ dispatched_ref=dispatched_ref,
863
+ next_screen_id=next_screen_id,
864
+ identity_status="unconfirmed",
865
+ evidence=(*submit_evidence, "ambiguousSuccessor"),
866
+ )
867
+
868
+
869
+ def _confirmation_evidence(
870
+ source_evidence: ActionTargetEvidence,
871
+ strategy: object,
872
+ command_evidence: ActionTargetEvidence,
873
+ ) -> tuple[ActionTargetEvidence, ...]:
874
+ evidence: list[ActionTargetEvidence] = [source_evidence]
875
+ if strategy in {
876
+ "requestTarget",
877
+ "resolvedTarget",
878
+ "reusedRef",
879
+ "fingerprintRematch",
880
+ }:
881
+ evidence.append(cast(ActionTargetEvidence, strategy))
882
+ evidence.append(command_evidence)
883
+ return tuple(evidence)
884
+
885
+
886
+ def _submit_confirmation_evidence(
887
+ source_evidence: ActionTargetEvidence,
888
+ submit_route: SubmitRouteOutcome | None,
889
+ ) -> tuple[ActionTargetEvidence, ...]:
890
+ evidence: list[ActionTargetEvidence] = [source_evidence]
891
+ if submit_route is not None and submit_route.route == "attributed":
892
+ evidence.append("attributedRoute")
893
+ evidence.append("submitConfirmation")
894
+ return tuple(evidence)