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,630 @@
1
+ """Shared semantic command result payload models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Annotated, Any, Literal, TypeAlias
7
+
8
+ from pydantic import (
9
+ BeforeValidator,
10
+ Field,
11
+ StringConstraints,
12
+ field_validator,
13
+ model_serializer,
14
+ model_validator,
15
+ )
16
+
17
+ from ._wire_helpers import _drop_unset_keys, _validate_absolute_path
18
+ from .base import DaemonWireModel
19
+ from .command_catalog import (
20
+ RETAINED_RESULT_COMMAND_NAMES,
21
+ SEMANTIC_RESULT_COMMAND_NAMES,
22
+ entry_for_retained_result_command,
23
+ entry_for_semantic_result_command,
24
+ result_family_for_command,
25
+ )
26
+ from .public_screen import PUBLIC_REF_RE, PublicScreen
27
+ from .vocabulary import (
28
+ ContinuityStatus,
29
+ ExecutionOutcome,
30
+ ObservationQuality,
31
+ PayloadMode,
32
+ PublicResultCategory,
33
+ PublicResultFamily,
34
+ RetainedEnvelopeKind,
35
+ SemanticResultCode,
36
+ )
37
+
38
+
39
+ def _validate_json_true(value: Any) -> Any:
40
+ if value is not True:
41
+ raise ValueError("ok must be JSON boolean true")
42
+ return value
43
+
44
+
45
+ _TrimmedString = Annotated[
46
+ str,
47
+ StringConstraints(strip_whitespace=True, min_length=1, strict=True),
48
+ ]
49
+ _StrictTrue: TypeAlias = Annotated[Literal[True], BeforeValidator(_validate_json_true)]
50
+
51
+ _ARTIFACT_WARNING_TOKENS = {
52
+ "ARTIFACT_SCREEN_XML_GARBAGE_COLLECTED",
53
+ "ARTIFACT_SCREEN_XML_MISSING",
54
+ "artifactGarbageCollected",
55
+ "artifactMissing",
56
+ }
57
+ ActionTargetIdentityStatus: TypeAlias = Literal[
58
+ "sameRef",
59
+ "successor",
60
+ "gone",
61
+ "unconfirmed",
62
+ ]
63
+ ActionTargetEvidence: TypeAlias = Literal[
64
+ "liveRef",
65
+ "refRepair",
66
+ "requestTarget",
67
+ "resolvedTarget",
68
+ "reusedRef",
69
+ "fingerprintRematch",
70
+ "focusConfirmation",
71
+ "typeConfirmation",
72
+ "submitConfirmation",
73
+ "attributedRoute",
74
+ "targetGone",
75
+ "publicChange",
76
+ "ambiguousSuccessor",
77
+ ]
78
+
79
+ _ACTION_TARGET_COMMANDS = {"focus", "type", "submit"}
80
+ _PAYLOAD_LIGHT_LOST_TRUTH_CODES = {
81
+ SemanticResultCode.DEVICE_UNAVAILABLE,
82
+ SemanticResultCode.POST_ACTION_OBSERVATION_LOST,
83
+ }
84
+
85
+
86
+ def _drop_none_alias_keys(
87
+ data: dict[str, Any],
88
+ *,
89
+ aliases: set[str],
90
+ ) -> dict[str, Any]:
91
+ for alias in aliases:
92
+ if data.get(alias) is None:
93
+ data.pop(alias, None)
94
+ return data
95
+
96
+
97
+ def _validate_frozen_value(
98
+ field_name: str,
99
+ value: str,
100
+ *,
101
+ allowed: set[str],
102
+ ) -> str:
103
+ if value not in allowed:
104
+ allowed_values = ", ".join(sorted(allowed))
105
+ raise ValueError(f"{field_name} must be one of: {allowed_values}")
106
+ return value
107
+
108
+
109
+ def _validate_screenshot_png(value: str | None) -> str | None:
110
+ path = _validate_absolute_path(value)
111
+ if path is None:
112
+ return None
113
+
114
+ normalized = path.replace("\\", "/")
115
+ if not _path_is_in_namespace(normalized, ".androidctl/screenshots"):
116
+ raise ValueError("screenshotPng must point into .androidctl/screenshots")
117
+
118
+ return path
119
+
120
+
121
+ def _validate_screen_xml(value: str | None) -> str | None:
122
+ path = _validate_absolute_path(value)
123
+ if path is None:
124
+ return None
125
+
126
+ normalized = path.replace("\\", "/")
127
+ if not _path_is_in_namespace(normalized, ".androidctl/artifacts/screens"):
128
+ raise ValueError("screenXml must point into .androidctl/artifacts/screens")
129
+
130
+ return path
131
+
132
+
133
+ def _path_is_in_namespace(normalized_path: str, namespace: str) -> bool:
134
+ segments = [segment for segment in normalized_path.split("/") if segment]
135
+ namespace_segments = namespace.split("/")
136
+ if ".." in segments:
137
+ return False
138
+ return any(
139
+ segments[index : index + len(namespace_segments)] == namespace_segments
140
+ for index in range(len(segments) - len(namespace_segments) + 1)
141
+ )
142
+
143
+
144
+ def _find_snake_case_key_path(value: object, *, path: str) -> str | None:
145
+ if isinstance(value, Mapping):
146
+ for key, item in value.items():
147
+ if not isinstance(key, str):
148
+ continue
149
+ key_path = f"{path}.{key}"
150
+ if "_" in key:
151
+ return key_path
152
+ nested_path = _find_snake_case_key_path(item, path=key_path)
153
+ if nested_path is not None:
154
+ return nested_path
155
+ return None
156
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
157
+ for index, item in enumerate(value):
158
+ nested_path = _find_snake_case_key_path(item, path=f"{path}[{index}]")
159
+ if nested_path is not None:
160
+ return nested_path
161
+ return None
162
+
163
+
164
+ def _artifact_payload_is_empty(artifacts: ArtifactPayload) -> bool:
165
+ return artifacts.screenshot_png is None and artifacts.screen_xml is None
166
+
167
+
168
+ class ArtifactPayload(DaemonWireModel):
169
+ """Published artifact pointers for a semantic command result."""
170
+
171
+ screenshot_png: str | None = None
172
+ screen_xml: str | None = None
173
+
174
+ @field_validator("screenshot_png")
175
+ @classmethod
176
+ def validate_screenshot_png(cls, value: str | None) -> str | None:
177
+ return _validate_screenshot_png(value)
178
+
179
+ @field_validator("screen_xml")
180
+ @classmethod
181
+ def validate_screen_xml(cls, value: str | None) -> str | None:
182
+ return _validate_screen_xml(value)
183
+
184
+ @model_serializer(mode="wrap")
185
+ def serialize_model(self, handler: Any) -> dict[str, Any]:
186
+ return _drop_unset_keys(
187
+ handler(self),
188
+ fields_set=self.model_fields_set,
189
+ optional_fields={"screen_xml", "screenshot_png"},
190
+ )
191
+
192
+
193
+ class RetainedResultEnvelope(DaemonWireModel):
194
+ """Stable retained envelope shape for non-semantic public command results."""
195
+
196
+ ok: bool
197
+ command: str
198
+ envelope: RetainedEnvelopeKind
199
+ code: str | None = None
200
+ message: str | None = None
201
+ artifacts: dict[str, Any] = Field(default_factory=dict)
202
+ details: dict[str, Any] = Field(default_factory=dict)
203
+
204
+ @field_validator("command")
205
+ @classmethod
206
+ def validate_command(cls, value: str) -> str:
207
+ family = result_family_for_command(value)
208
+ if family is PublicResultFamily.SEMANTIC:
209
+ raise ValueError("semantic commands are not retained envelopes")
210
+ if family is PublicResultFamily.LIST_APPS:
211
+ raise ValueError(
212
+ "list-apps command is not a retained result family; "
213
+ "use ListAppsResult"
214
+ )
215
+ return _validate_frozen_value(
216
+ "command",
217
+ value,
218
+ allowed=RETAINED_RESULT_COMMAND_NAMES,
219
+ )
220
+
221
+ @model_validator(mode="after")
222
+ def validate_envelope_kind(self) -> RetainedResultEnvelope:
223
+ catalog_entry = entry_for_retained_result_command(self.command)
224
+ if catalog_entry is None or catalog_entry.retained_envelope_kind is None:
225
+ raise ValueError(f"command={self.command!r} is not a retained command")
226
+ if self.envelope != catalog_entry.retained_envelope_kind:
227
+ raise ValueError(
228
+ "envelope must match retained command catalog mapping for "
229
+ "command="
230
+ f"{self.command!r}: expected "
231
+ f"{catalog_entry.retained_envelope_kind.value!r}"
232
+ )
233
+ return self
234
+
235
+ @model_serializer(mode="wrap")
236
+ def serialize_model(self, handler: Any) -> dict[str, Any]:
237
+ return _drop_unset_keys(
238
+ handler(self),
239
+ fields_set=self.model_fields_set,
240
+ optional_fields={"code", "message"},
241
+ )
242
+
243
+
244
+ class TruthPayload(DaemonWireModel):
245
+ execution_outcome: ExecutionOutcome
246
+ continuity_status: ContinuityStatus
247
+ observation_quality: ObservationQuality
248
+ changed: bool | None = None
249
+
250
+ @model_serializer(mode="wrap")
251
+ def serialize_model(self, handler: Any) -> dict[str, Any]:
252
+ return _drop_unset_keys(
253
+ handler(self),
254
+ fields_set=self.model_fields_set,
255
+ optional_fields={"changed"},
256
+ )
257
+
258
+
259
+ class ActionTargetPayload(DaemonWireModel):
260
+ """Public-safe identity outcome for semantic focus/type/submit actions."""
261
+
262
+ source_ref: str
263
+ source_screen_id: str = Field(min_length=1, strict=True)
264
+ subject_ref: str
265
+ next_screen_id: str = Field(min_length=1, strict=True)
266
+ identity_status: ActionTargetIdentityStatus
267
+ evidence: tuple[ActionTargetEvidence, ...]
268
+ dispatched_ref: str | None = None
269
+ next_ref: str | None = None
270
+
271
+ @field_validator("source_ref", "subject_ref", "dispatched_ref", "next_ref")
272
+ @classmethod
273
+ def validate_public_ref(cls, value: str | None) -> str | None:
274
+ if value is None:
275
+ return None
276
+ if not PUBLIC_REF_RE.fullmatch(value):
277
+ raise ValueError("actionTarget refs must be public refs like n1")
278
+ return value
279
+
280
+ @field_validator("evidence", mode="before")
281
+ @classmethod
282
+ def coerce_evidence(cls, value: object) -> object:
283
+ if isinstance(value, list):
284
+ return tuple(value)
285
+ return value
286
+
287
+ @field_validator("evidence")
288
+ @classmethod
289
+ def validate_evidence(
290
+ cls, value: tuple[ActionTargetEvidence, ...]
291
+ ) -> tuple[ActionTargetEvidence, ...]:
292
+ if not value:
293
+ raise ValueError("actionTarget evidence must be non-empty")
294
+ if len(set(value)) != len(value):
295
+ raise ValueError("actionTarget evidence entries must be unique")
296
+ return value
297
+
298
+ @model_validator(mode="after")
299
+ def validate_identity_invariants(self) -> ActionTargetPayload:
300
+ if self.identity_status == "sameRef":
301
+ if self.next_ref != self.subject_ref:
302
+ raise ValueError("sameRef requires nextRef to equal subjectRef")
303
+ elif self.identity_status == "successor":
304
+ if self.next_ref is None:
305
+ raise ValueError("successor requires nextRef")
306
+ if self.next_ref == self.subject_ref:
307
+ raise ValueError("successor requires nextRef to differ from subjectRef")
308
+ elif (
309
+ self.identity_status in {"gone", "unconfirmed"}
310
+ and self.next_ref is not None
311
+ ):
312
+ raise ValueError("gone/unconfirmed require nextRef to be absent")
313
+ return self
314
+
315
+ @model_serializer(mode="wrap")
316
+ def serialize_model(self, handler: Any) -> dict[str, Any]:
317
+ dumped = _drop_unset_keys(
318
+ handler(self),
319
+ fields_set=self.model_fields_set,
320
+ optional_fields={"dispatched_ref", "next_ref"},
321
+ )
322
+ _drop_none_alias_keys(dumped, aliases={"dispatchedRef", "nextRef"})
323
+ return dumped
324
+
325
+
326
+ class CommandResultCore(DaemonWireModel):
327
+ """Stable semantic result shape shared across CLI and daemon."""
328
+
329
+ ok: bool
330
+ command: str
331
+ category: PublicResultCategory
332
+ payload_mode: PayloadMode
333
+ source_screen_id: str | None = None
334
+ next_screen_id: str | None = None
335
+ code: SemanticResultCode | None = None
336
+ message: str | None = None
337
+ truth: TruthPayload
338
+ action_target: ActionTargetPayload | None = None
339
+ screen: PublicScreen | None = None
340
+ uncertainty: list[str] = Field(default_factory=list)
341
+ warnings: list[str] = Field(default_factory=list)
342
+ artifacts: ArtifactPayload = Field(default_factory=ArtifactPayload)
343
+
344
+ @field_validator("screen", mode="before")
345
+ @classmethod
346
+ def validate_screen_alias_form_nested_payload(cls, value: object) -> object:
347
+ snake_case_path = _find_snake_case_key_path(value, path="screen")
348
+ if snake_case_path is not None:
349
+ raise ValueError(
350
+ f"screen payload must use alias-form keys; found {snake_case_path}"
351
+ )
352
+ return value
353
+
354
+ @field_validator("action_target", mode="before")
355
+ @classmethod
356
+ def validate_action_target_alias_form_nested_payload(cls, value: object) -> object:
357
+ snake_case_path = _find_snake_case_key_path(value, path="actionTarget")
358
+ if snake_case_path is not None:
359
+ raise ValueError(
360
+ "actionTarget payload must use alias-form keys; "
361
+ f"found {snake_case_path}"
362
+ )
363
+ return value
364
+
365
+ @field_validator("command")
366
+ @classmethod
367
+ def validate_command(cls, value: str) -> str:
368
+ family = result_family_for_command(value)
369
+ if family is PublicResultFamily.RETAINED:
370
+ raise ValueError("retained commands must use RetainedResultEnvelope")
371
+ if family is PublicResultFamily.LIST_APPS:
372
+ raise ValueError(
373
+ "list-apps command is not a semantic result family; "
374
+ "use ListAppsResult"
375
+ )
376
+ return _validate_frozen_value(
377
+ "command",
378
+ value,
379
+ allowed=SEMANTIC_RESULT_COMMAND_NAMES,
380
+ )
381
+
382
+ @field_validator("warnings")
383
+ @classmethod
384
+ def validate_warnings(cls, value: list[str]) -> list[str]:
385
+ rejected = sorted(set(value) & _ARTIFACT_WARNING_TOKENS)
386
+ if rejected:
387
+ rejected_values = ", ".join(rejected)
388
+ raise ValueError(
389
+ f"warnings must not use artifact lifecycle tokens: {rejected_values}"
390
+ )
391
+ return value
392
+
393
+ @model_validator(mode="after")
394
+ def validate_payload_mode(self) -> CommandResultCore:
395
+ catalog_entry = entry_for_semantic_result_command(self.command)
396
+ if catalog_entry is None:
397
+ raise ValueError(
398
+ f"unknown semantic command catalog entry for command={self.command!r}"
399
+ )
400
+ if catalog_entry.result_category is None:
401
+ raise ValueError(
402
+ f"semantic command={self.command!r} must have a result category"
403
+ )
404
+ if self.category != catalog_entry.result_category:
405
+ raise ValueError(
406
+ "category must match command catalog mapping for "
407
+ "command="
408
+ f"{self.command!r}: expected "
409
+ f"{catalog_entry.result_category.value!r}"
410
+ )
411
+
412
+ if self.ok:
413
+ if self.code is not None or self.message is not None:
414
+ raise ValueError("code/message are failure-only fields")
415
+ else:
416
+ if self.code is None or not self.code.strip():
417
+ raise ValueError("failure results require a non-empty code")
418
+ if self.message is None or not self.message.strip():
419
+ raise ValueError("failure results require a non-empty message")
420
+ if self.code is SemanticResultCode.ACTION_NOT_CONFIRMED:
421
+ self._validate_action_not_confirmed_shape()
422
+ if self.code in _PAYLOAD_LIGHT_LOST_TRUTH_CODES:
423
+ self._validate_payload_light_lost_truth_shape()
424
+
425
+ if self.payload_mode == PayloadMode.NONE:
426
+ if self.ok:
427
+ raise ValueError("semantic success results must use payloadMode='full'")
428
+ if self.next_screen_id is not None or self.screen is not None:
429
+ raise ValueError(
430
+ "payloadMode='none' requires nextScreenId and screen to be absent"
431
+ )
432
+ elif self.payload_mode == PayloadMode.FULL and self.screen is None:
433
+ raise ValueError("payloadMode='full' requires screen")
434
+
435
+ if self.screen is not None:
436
+ screen_id = self.screen.screen_id
437
+ if self.next_screen_id is None:
438
+ raise ValueError("screen requires nextScreenId")
439
+ if self.next_screen_id != screen_id:
440
+ raise ValueError("nextScreenId must match screen.screenId")
441
+
442
+ if self.action_target is not None:
443
+ if self.command not in _ACTION_TARGET_COMMANDS:
444
+ raise ValueError(
445
+ "actionTarget is only allowed for focus/type/submit results"
446
+ )
447
+ if self.payload_mode != PayloadMode.FULL:
448
+ raise ValueError("actionTarget requires payloadMode='full'")
449
+ if not self.ok:
450
+ raise ValueError(
451
+ "actionTarget is only allowed for semantic success results"
452
+ )
453
+ if self.next_screen_id is None:
454
+ raise ValueError("actionTarget requires nextScreenId")
455
+ if self.source_screen_id is None:
456
+ raise ValueError("actionTarget requires sourceScreenId")
457
+ if self.action_target.source_screen_id != self.source_screen_id:
458
+ raise ValueError(
459
+ "actionTarget.sourceScreenId must match root sourceScreenId"
460
+ )
461
+ if self.action_target.next_screen_id != self.next_screen_id:
462
+ raise ValueError(
463
+ "actionTarget.nextScreenId must match root nextScreenId"
464
+ )
465
+
466
+ if self.source_screen_id is None:
467
+ if self.truth.continuity_status != ContinuityStatus.NONE:
468
+ raise ValueError(
469
+ "sourceScreenId is required when continuityStatus is not 'none'"
470
+ )
471
+ if self.truth.changed is not None:
472
+ raise ValueError(
473
+ "sourceScreenId is required when truth.changed is present"
474
+ )
475
+
476
+ return self
477
+
478
+ def _validate_action_not_confirmed_shape(self) -> None:
479
+ if self.truth.execution_outcome is not ExecutionOutcome.DISPATCHED:
480
+ raise ValueError(
481
+ "ACTION_NOT_CONFIRMED requires truth.executionOutcome='dispatched'"
482
+ )
483
+ if self.payload_mode is not PayloadMode.FULL:
484
+ raise ValueError("ACTION_NOT_CONFIRMED requires payloadMode='full'")
485
+ if self.screen is None:
486
+ raise ValueError("ACTION_NOT_CONFIRMED requires screen")
487
+ if self.next_screen_id is None:
488
+ raise ValueError("ACTION_NOT_CONFIRMED requires nextScreenId")
489
+ if self.truth.observation_quality is not ObservationQuality.AUTHORITATIVE:
490
+ raise ValueError(
491
+ "ACTION_NOT_CONFIRMED requires "
492
+ "truth.observationQuality='authoritative'"
493
+ )
494
+
495
+ def _validate_payload_light_lost_truth_shape(self) -> None:
496
+ if self.payload_mode is not PayloadMode.NONE:
497
+ raise ValueError(
498
+ f"{self.code.value} requires payloadMode='none'"
499
+ if self.code is not None
500
+ else "lost-truth failures require payloadMode='none'"
501
+ )
502
+ if self.next_screen_id is not None or self.screen is not None:
503
+ raise ValueError(
504
+ f"{self.code.value} requires nextScreenId and screen to be absent"
505
+ if self.code is not None
506
+ else "lost-truth failures require nextScreenId and screen to be absent"
507
+ )
508
+ if self.action_target is not None:
509
+ raise ValueError(
510
+ f"{self.code.value} requires actionTarget to be absent"
511
+ if self.code is not None
512
+ else "lost-truth failures require actionTarget to be absent"
513
+ )
514
+ if self.truth.continuity_status is not ContinuityStatus.NONE:
515
+ raise ValueError(
516
+ f"{self.code.value} requires truth.continuityStatus='none'"
517
+ if self.code is not None
518
+ else "lost-truth failures require truth.continuityStatus='none'"
519
+ )
520
+ if self.truth.observation_quality is not ObservationQuality.NONE:
521
+ raise ValueError(
522
+ f"{self.code.value} requires truth.observationQuality='none'"
523
+ if self.code is not None
524
+ else "lost-truth failures require truth.observationQuality='none'"
525
+ )
526
+ if self.truth.changed is not None:
527
+ raise ValueError(
528
+ f"{self.code.value} requires truth.changed to be absent"
529
+ if self.code is not None
530
+ else "lost-truth failures require truth.changed to be absent"
531
+ )
532
+ if not _artifact_payload_is_empty(self.artifacts):
533
+ raise ValueError(
534
+ f"{self.code.value} requires semantic artifact pointers to be absent"
535
+ if self.code is not None
536
+ else (
537
+ "lost-truth failures require semantic artifact pointers "
538
+ "to be absent"
539
+ )
540
+ )
541
+ if (
542
+ self.code is SemanticResultCode.POST_ACTION_OBSERVATION_LOST
543
+ and self.truth.execution_outcome is not ExecutionOutcome.DISPATCHED
544
+ ):
545
+ raise ValueError(
546
+ "POST_ACTION_OBSERVATION_LOST requires "
547
+ "truth.executionOutcome='dispatched'"
548
+ )
549
+
550
+ @model_serializer(mode="wrap")
551
+ def serialize_model(self, handler: Any) -> dict[str, Any]:
552
+ return _drop_unset_keys(
553
+ handler(self),
554
+ fields_set=self.model_fields_set,
555
+ optional_fields={
556
+ "source_screen_id",
557
+ "next_screen_id",
558
+ "code",
559
+ "message",
560
+ "action_target",
561
+ "screen",
562
+ },
563
+ )
564
+
565
+
566
+ class ListAppEntry(DaemonWireModel):
567
+ """Public app list entry exposed by the list-apps result family."""
568
+
569
+ package_name: _TrimmedString
570
+ app_label: _TrimmedString
571
+
572
+
573
+ class ListAppsResult(DaemonWireModel):
574
+ """Success-only public result for the list-apps command."""
575
+
576
+ ok: _StrictTrue
577
+ command: Literal["list-apps"]
578
+ apps: list[ListAppEntry]
579
+
580
+ @model_validator(mode="after")
581
+ def validate_catalog_family(self) -> ListAppsResult:
582
+ if result_family_for_command(self.command) is not PublicResultFamily.LIST_APPS:
583
+ raise ValueError("command='list-apps' must map to listApps result family")
584
+ return self
585
+
586
+
587
+ def dump_canonical_command_result(
588
+ payload: CommandResultCore | Mapping[str, Any],
589
+ ) -> dict[str, Any]:
590
+ """Dump command-result output with semantic absence represented by omission."""
591
+
592
+ result = (
593
+ payload
594
+ if isinstance(payload, CommandResultCore)
595
+ else CommandResultCore.model_validate(payload)
596
+ )
597
+ dumped = result.model_dump(by_alias=True, mode="json")
598
+ _drop_none_alias_keys(
599
+ dumped,
600
+ aliases={
601
+ "sourceScreenId",
602
+ "nextScreenId",
603
+ "code",
604
+ "message",
605
+ "actionTarget",
606
+ "screen",
607
+ },
608
+ )
609
+
610
+ truth = dumped.get("truth")
611
+ if isinstance(truth, dict):
612
+ _drop_none_alias_keys(truth, aliases={"changed"})
613
+
614
+ artifacts = dumped.get("artifacts")
615
+ if isinstance(artifacts, dict):
616
+ _drop_none_alias_keys(artifacts, aliases={"screenXml", "screenshotPng"})
617
+
618
+ return dumped
619
+
620
+
621
+ __all__ = [
622
+ "ActionTargetPayload",
623
+ "ArtifactPayload",
624
+ "CommandResultCore",
625
+ "ListAppEntry",
626
+ "ListAppsResult",
627
+ "RetainedResultEnvelope",
628
+ "TruthPayload",
629
+ "dump_canonical_command_result",
630
+ ]