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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- 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
|
+
]
|