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,539 @@
1
+ """Command capability validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import NamedTuple
6
+
7
+ from androidctld.actions.focused_input_admissibility import (
8
+ blocked_by_group_fields,
9
+ focus_mismatch_fields,
10
+ keyboard_blocker_allows_public_type,
11
+ keyboard_blocker_allows_semantic_type,
12
+ public_focused_input_ref,
13
+ public_node_is_focused_input,
14
+ semantic_focused_input_ref,
15
+ semantic_node_is_focused_input,
16
+ )
17
+ from androidctld.commands.command_models import (
18
+ ActionCommand,
19
+ FocusCommand,
20
+ GlobalCommand,
21
+ LongTapCommand,
22
+ OpenCommand,
23
+ RefBoundActionCommand,
24
+ ScreenshotCommand,
25
+ ScrollCommand,
26
+ SubmitCommand,
27
+ TapCommand,
28
+ TypeCommand,
29
+ is_ref_bound_action_command,
30
+ )
31
+ from androidctld.commands.open_targets import (
32
+ OpenAppTarget,
33
+ OpenUrlTarget,
34
+ validate_open_target,
35
+ )
36
+ from androidctld.device.action_models import (
37
+ GlobalActionRequest,
38
+ LaunchAppActionRequest,
39
+ LongTapActionRequest,
40
+ NodeActionRequest,
41
+ OpenUrlActionRequest,
42
+ ScrollActionRequest,
43
+ SwipeActionRequest,
44
+ TapActionRequest,
45
+ TypeActionRequest,
46
+ required_action_kind_for_request,
47
+ )
48
+ from androidctld.errors import DaemonError, DaemonErrorCode
49
+ from androidctld.protocol import CommandKind
50
+ from androidctld.refs.models import NodeHandle
51
+ from androidctld.refs.repair import failed_repair_decision, ref_repair_error
52
+ from androidctld.runtime.models import WorkspaceRuntime
53
+ from androidctld.runtime.screen_state import (
54
+ current_artifacts,
55
+ current_compiled_screen,
56
+ current_public_screen,
57
+ )
58
+ from androidctld.semantics.compiler import CompiledScreen, SemanticNode
59
+ from androidctld.semantics.public_models import PublicNode, PublicScreen
60
+
61
+
62
+ class BoundRefNode(NamedTuple):
63
+ node: PublicNode
64
+ group_name: str
65
+
66
+
67
+ class BoundSemanticNode(NamedTuple):
68
+ node: SemanticNode
69
+ blocking_group: str | None
70
+
71
+
72
+ def ensure_command_supported(
73
+ session: WorkspaceRuntime, command: ActionCommand | ScreenshotCommand
74
+ ) -> None:
75
+ capabilities = session.device_capabilities
76
+ if capabilities is None:
77
+ return
78
+ if isinstance(command, ScreenshotCommand):
79
+ if capabilities.supports_screenshot:
80
+ return
81
+ raise unsupported_command_capability(
82
+ command=command.kind,
83
+ missing_capabilities=["supportsScreenshot"],
84
+ )
85
+ if isinstance(command, SubmitCommand):
86
+ return
87
+ required_action_kind = required_action_kind_for(command)
88
+ if capabilities.supports_action(required_action_kind):
89
+ return
90
+ raise unsupported_command_capability(
91
+ command=command.kind,
92
+ missing_action_kinds=_public_missing_action_kinds(
93
+ command=command.kind,
94
+ missing_action_kinds=[required_action_kind],
95
+ ),
96
+ )
97
+
98
+
99
+ def ensure_action_request_supported(
100
+ session: WorkspaceRuntime,
101
+ *,
102
+ command: CommandKind,
103
+ request: (
104
+ TapActionRequest
105
+ | LongTapActionRequest
106
+ | TypeActionRequest
107
+ | NodeActionRequest
108
+ | ScrollActionRequest
109
+ | SwipeActionRequest
110
+ | GlobalActionRequest
111
+ | LaunchAppActionRequest
112
+ | OpenUrlActionRequest
113
+ ),
114
+ ) -> None:
115
+ capabilities = session.device_capabilities
116
+ if capabilities is None:
117
+ return
118
+ required_action_kind = required_action_kind_for(request)
119
+ if capabilities.supports_action(required_action_kind):
120
+ return
121
+ raise unsupported_command_capability(
122
+ command=command,
123
+ missing_action_kinds=_public_missing_action_kinds(
124
+ command=command,
125
+ missing_action_kinds=[required_action_kind],
126
+ ),
127
+ )
128
+
129
+
130
+ def validate_ref_action(
131
+ session: WorkspaceRuntime,
132
+ command: RefBoundActionCommand,
133
+ ) -> BoundRefNode | None:
134
+ if isinstance(command, SubmitCommand):
135
+ return None
136
+ screen = current_public_screen(session)
137
+ if screen is None:
138
+ raise DaemonError(
139
+ code=DaemonErrorCode.SCREEN_NOT_READY,
140
+ message="screen is not ready yet",
141
+ retryable=False,
142
+ details={"workspaceRoot": session.workspace_root.as_posix()},
143
+ http_status=200,
144
+ )
145
+ if command.source_screen_id != screen.screen_id:
146
+ return None
147
+ bound = _find_bound_ref_node(screen, command.ref)
148
+ if bound is None:
149
+ raise DaemonError(
150
+ code=DaemonErrorCode.REF_RESOLUTION_FAILED,
151
+ message="ref does not exist on the current screen",
152
+ retryable=False,
153
+ details={"ref": command.ref},
154
+ http_status=200,
155
+ )
156
+ _ensure_not_blocked(screen, bound, action=command.kind.value)
157
+ _ensure_action_exposed(bound.node, command.kind.value)
158
+ if isinstance(command, ScrollCommand):
159
+ _ensure_scroll_direction_exposed(bound.node, command.direction)
160
+ if isinstance(command, (FocusCommand, TypeCommand)):
161
+ _ensure_input_capable(bound.node, action=command.kind.value)
162
+ if isinstance(command, TypeCommand):
163
+ _ensure_matching_focused_input(screen, node=bound.node)
164
+ return bound
165
+
166
+
167
+ def validate_action_semantics(
168
+ session: WorkspaceRuntime,
169
+ command: ActionCommand,
170
+ ) -> None:
171
+ if not is_ref_bound_action_command(command):
172
+ return
173
+ validate_ref_action(session, command)
174
+
175
+
176
+ def validate_resolved_ref_action(
177
+ session: WorkspaceRuntime,
178
+ command: RefBoundActionCommand,
179
+ request_handle: NodeHandle | None,
180
+ ) -> None:
181
+ if isinstance(command, SubmitCommand):
182
+ return
183
+ screen = current_public_screen(session)
184
+ if screen is None or request_handle is None:
185
+ return
186
+ if command.source_screen_id == screen.screen_id:
187
+ return
188
+ compiled_screen = current_compiled_screen(session)
189
+ if compiled_screen is None:
190
+ raise DaemonError(
191
+ code=DaemonErrorCode.SCREEN_NOT_READY,
192
+ message="screen is not ready yet",
193
+ retryable=False,
194
+ details={"workspaceRoot": session.workspace_root.as_posix()},
195
+ http_status=200,
196
+ )
197
+ bound = _find_repaired_target(
198
+ session,
199
+ compiled_screen,
200
+ request_handle,
201
+ command.ref,
202
+ source_screen_id=command.source_screen_id,
203
+ )
204
+ _ensure_not_blocked_semantic(
205
+ bound,
206
+ action=command.kind.value,
207
+ compiled_screen=compiled_screen,
208
+ )
209
+ _ensure_action_exposed_semantic(bound.node, action=command.kind.value)
210
+ if isinstance(command, ScrollCommand):
211
+ _ensure_scroll_direction_exposed_semantic(bound.node, command.direction)
212
+ if isinstance(command, (FocusCommand, TypeCommand)):
213
+ _ensure_input_capable_semantic(bound.node, action=command.kind.value)
214
+ if isinstance(command, TypeCommand):
215
+ _ensure_matching_focused_input_semantic(
216
+ compiled_screen,
217
+ target=bound.node,
218
+ ref=command.ref,
219
+ )
220
+
221
+
222
+ def required_action_kind_for(
223
+ command: (
224
+ ActionCommand
225
+ | TapActionRequest
226
+ | LongTapActionRequest
227
+ | TypeActionRequest
228
+ | NodeActionRequest
229
+ | ScrollActionRequest
230
+ | SwipeActionRequest
231
+ | GlobalActionRequest
232
+ | LaunchAppActionRequest
233
+ | OpenUrlActionRequest
234
+ | OpenAppTarget
235
+ | OpenUrlTarget
236
+ ),
237
+ ) -> str:
238
+ if isinstance(command, (OpenAppTarget, OpenUrlTarget)):
239
+ return validate_open_target(command).required_action_kind
240
+ if isinstance(
241
+ command,
242
+ (
243
+ TapActionRequest,
244
+ LongTapActionRequest,
245
+ TypeActionRequest,
246
+ NodeActionRequest,
247
+ ScrollActionRequest,
248
+ SwipeActionRequest,
249
+ GlobalActionRequest,
250
+ LaunchAppActionRequest,
251
+ OpenUrlActionRequest,
252
+ ),
253
+ ):
254
+ return required_action_kind_for_request(command)
255
+ if isinstance(command, OpenCommand):
256
+ return required_action_kind_for(command.target)
257
+ if isinstance(command, TapCommand):
258
+ return "tap"
259
+ if isinstance(command, LongTapCommand):
260
+ return "longTap"
261
+ if isinstance(command, TypeCommand):
262
+ return "type"
263
+ if isinstance(command, (FocusCommand, SubmitCommand)):
264
+ return "node"
265
+ if isinstance(command, ScrollCommand):
266
+ return "scroll"
267
+ if isinstance(command, GlobalCommand):
268
+ return "global"
269
+ raise TypeError(f"unsupported action capability input: {type(command)!r}")
270
+
271
+
272
+ def unsupported_command_capability(
273
+ *,
274
+ command: CommandKind,
275
+ missing_capabilities: list[str] | None = None,
276
+ missing_action_kinds: list[str] | None = None,
277
+ ) -> DaemonError:
278
+ missing_capabilities = missing_capabilities or []
279
+ missing_action_kinds = missing_action_kinds or []
280
+ return DaemonError(
281
+ code=DaemonErrorCode.DEVICE_AGENT_CAPABILITY_MISMATCH,
282
+ message=f"{command.value} is not supported by the connected device agent",
283
+ retryable=False,
284
+ details={
285
+ "command": command.value,
286
+ "missingCapabilities": missing_capabilities,
287
+ "missingActionKinds": missing_action_kinds,
288
+ },
289
+ http_status=200,
290
+ )
291
+
292
+
293
+ def _public_missing_action_kinds(
294
+ *, command: CommandKind, missing_action_kinds: list[str]
295
+ ) -> list[str]:
296
+ if command is CommandKind.FOCUS:
297
+ return [
298
+ "focus" if action_kind == "node" else action_kind
299
+ for action_kind in missing_action_kinds
300
+ ]
301
+ if command is CommandKind.SUBMIT:
302
+ return [
303
+ "submit" if action_kind == "node" else action_kind
304
+ for action_kind in missing_action_kinds
305
+ ]
306
+ return missing_action_kinds
307
+
308
+
309
+ def _find_bound_ref_node(screen: PublicScreen, ref: str) -> BoundRefNode | None:
310
+ for group in screen.groups:
311
+ for node in group.nodes:
312
+ if node.ref == ref:
313
+ return BoundRefNode(node=node, group_name=group.name)
314
+ return None
315
+
316
+
317
+ def _ensure_not_blocked(
318
+ screen: PublicScreen,
319
+ bound: BoundRefNode,
320
+ *,
321
+ action: str,
322
+ ) -> None:
323
+ blocking_group = screen.surface.blocking_group
324
+ if blocking_group is None or bound.group_name == blocking_group:
325
+ return
326
+ if keyboard_blocker_allows_public_type(
327
+ blocking_group=blocking_group,
328
+ action=action,
329
+ screen=screen,
330
+ node=bound.node,
331
+ ):
332
+ return
333
+ raise DaemonError(
334
+ code=DaemonErrorCode.TARGET_BLOCKED,
335
+ message="target is blocked on the current screen",
336
+ retryable=False,
337
+ details=blocked_by_group_fields(
338
+ blocking_group=blocking_group,
339
+ ref=bound.node.ref,
340
+ ),
341
+ http_status=200,
342
+ )
343
+
344
+
345
+ def _ensure_not_blocked_semantic(
346
+ bound: BoundSemanticNode,
347
+ *,
348
+ action: str,
349
+ compiled_screen: CompiledScreen,
350
+ ) -> None:
351
+ blocking_group = bound.blocking_group
352
+ if blocking_group is None or bound.node.group == blocking_group:
353
+ return
354
+ if keyboard_blocker_allows_semantic_type(
355
+ blocking_group=blocking_group,
356
+ action=action,
357
+ screen=compiled_screen,
358
+ node=bound.node,
359
+ ):
360
+ return
361
+ raise DaemonError(
362
+ code=DaemonErrorCode.TARGET_BLOCKED,
363
+ message="target is blocked on the current screen",
364
+ retryable=False,
365
+ details=blocked_by_group_fields(
366
+ blocking_group=blocking_group,
367
+ ref=bound.node.ref or None,
368
+ ),
369
+ http_status=200,
370
+ )
371
+
372
+
373
+ def _ensure_action_exposed(node: PublicNode, action: str) -> None:
374
+ if action in node.actions:
375
+ return
376
+ raise DaemonError(
377
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
378
+ message=f"{action} is not available for the requested target",
379
+ retryable=False,
380
+ details={
381
+ "reason": "action_not_exposed",
382
+ "ref": node.ref,
383
+ "action": action,
384
+ },
385
+ http_status=200,
386
+ )
387
+
388
+
389
+ def _ensure_action_exposed_semantic(node: SemanticNode, *, action: str) -> None:
390
+ if action in node.actions:
391
+ return
392
+ raise DaemonError(
393
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
394
+ message=f"{action} is not available for the requested target",
395
+ retryable=False,
396
+ details={
397
+ "reason": "action_not_exposed",
398
+ "ref": node.ref or None,
399
+ "action": action,
400
+ },
401
+ http_status=200,
402
+ )
403
+
404
+
405
+ def _ensure_scroll_direction_exposed(node: PublicNode, direction: str) -> None:
406
+ if direction in node.scroll_directions:
407
+ return
408
+ raise DaemonError(
409
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
410
+ message=(
411
+ f"scroll direction '{direction}' is not available for the requested target"
412
+ ),
413
+ retryable=False,
414
+ details={
415
+ "reason": "scroll_direction_not_exposed",
416
+ "ref": node.ref,
417
+ "direction": direction,
418
+ "scrollDirections": list(node.scroll_directions),
419
+ },
420
+ http_status=200,
421
+ )
422
+
423
+
424
+ def _ensure_scroll_direction_exposed_semantic(
425
+ node: SemanticNode, direction: str
426
+ ) -> None:
427
+ if direction in node.scroll_directions:
428
+ return
429
+ raise DaemonError(
430
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
431
+ message=(
432
+ f"scroll direction '{direction}' is not available for the requested target"
433
+ ),
434
+ retryable=False,
435
+ details={
436
+ "reason": "scroll_direction_not_exposed",
437
+ "ref": node.ref or None,
438
+ "direction": direction,
439
+ "scrollDirections": list(node.scroll_directions),
440
+ },
441
+ http_status=200,
442
+ )
443
+
444
+
445
+ def _ensure_input_capable(node: PublicNode, *, action: str) -> None:
446
+ if node.role == "input":
447
+ return
448
+ raise DaemonError(
449
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
450
+ message=f"{action} requires an input-capable target",
451
+ retryable=False,
452
+ details={
453
+ "reason": "not_input_capable",
454
+ "ref": node.ref,
455
+ "action": action,
456
+ "role": node.role,
457
+ },
458
+ http_status=200,
459
+ )
460
+
461
+
462
+ def _ensure_input_capable_semantic(node: SemanticNode, *, action: str) -> None:
463
+ if node.role == "input":
464
+ return
465
+ raise DaemonError(
466
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
467
+ message=f"{action} requires an input-capable target",
468
+ retryable=False,
469
+ details={
470
+ "reason": "not_input_capable",
471
+ "ref": node.ref or None,
472
+ "action": action,
473
+ "role": node.role,
474
+ },
475
+ http_status=200,
476
+ )
477
+
478
+
479
+ def _ensure_matching_focused_input(screen: PublicScreen, *, node: PublicNode) -> None:
480
+ if public_node_is_focused_input(screen, node):
481
+ return
482
+ raise DaemonError(
483
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
484
+ message="target is not the current focused input",
485
+ retryable=False,
486
+ details=focus_mismatch_fields(
487
+ ref=node.ref,
488
+ focused_input_ref=public_focused_input_ref(screen),
489
+ ),
490
+ http_status=200,
491
+ )
492
+
493
+
494
+ def _ensure_matching_focused_input_semantic(
495
+ screen: CompiledScreen,
496
+ *,
497
+ target: SemanticNode,
498
+ ref: str,
499
+ ) -> None:
500
+ if semantic_node_is_focused_input(screen, target):
501
+ return
502
+ raise DaemonError(
503
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
504
+ message="target is not the current focused input",
505
+ retryable=False,
506
+ details=focus_mismatch_fields(
507
+ ref=ref,
508
+ focused_input_ref=semantic_focused_input_ref(screen),
509
+ ),
510
+ http_status=200,
511
+ )
512
+
513
+
514
+ def _find_repaired_target(
515
+ session: WorkspaceRuntime,
516
+ screen: CompiledScreen,
517
+ handle: NodeHandle,
518
+ ref: str,
519
+ *,
520
+ source_screen_id: str,
521
+ ) -> BoundSemanticNode:
522
+ for node in _compiled_screen_nodes(screen):
523
+ if node.raw_rid == handle.rid:
524
+ return BoundSemanticNode(node=node, blocking_group=screen.blocking_group)
525
+ raise ref_repair_error(
526
+ failed_repair_decision(ref=ref, source_screen_id=source_screen_id),
527
+ public_screen=current_public_screen(session),
528
+ artifacts=current_artifacts(session),
529
+ )
530
+
531
+
532
+ def _compiled_screen_nodes(screen: CompiledScreen) -> tuple[SemanticNode, ...]:
533
+ return (
534
+ tuple(screen.targets)
535
+ + tuple(screen.dialog)
536
+ + tuple(screen.keyboard)
537
+ + tuple(screen.system)
538
+ + tuple(screen.context)
539
+ )