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,30 @@
1
+ """JSON envelope helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from androidctl_contracts.daemon_api import DaemonErrorEnvelope
8
+ from androidctl_contracts.errors import DaemonError as ContractDaemonError
9
+ from androidctl_contracts.errors import DaemonErrorCode as ContractDaemonErrorCode
10
+ from androidctld.errors import DaemonError
11
+
12
+
13
+ def success_envelope(result: dict[str, Any]) -> dict[str, Any]:
14
+ return {
15
+ "ok": True,
16
+ "result": result,
17
+ }
18
+
19
+
20
+ def error_envelope(error: DaemonError) -> dict[str, Any]:
21
+ try:
22
+ contract_error = error.to_contract_error()
23
+ except ValueError:
24
+ contract_error = ContractDaemonError(
25
+ code=ContractDaemonErrorCode.INTERNAL_COMMAND_FAILURE,
26
+ message="unexpected daemon failure",
27
+ retryable=False,
28
+ details={},
29
+ )
30
+ return DaemonErrorEnvelope(error=contract_error).model_dump()
@@ -0,0 +1,123 @@
1
+ """HTTP host lifecycle and readiness probing for androidctld."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from typing import Any, Protocol
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.request import OpenerDirector, ProxyHandler, Request, build_opener
14
+
15
+ from androidctl_contracts.daemon_api import (
16
+ OWNER_HEADER_NAME,
17
+ DaemonSuccessEnvelope,
18
+ HealthResult,
19
+ )
20
+ from androidctld import SERVICE_NAME
21
+ from androidctld.auth.active_registry import ActiveDaemonRecord
22
+ from androidctld.config import TOKEN_HEADER_NAME, DaemonConfig
23
+
24
+
25
+ class _Opener(Protocol):
26
+ def open(self, request: Request, timeout: float) -> Any: ...
27
+
28
+
29
+ class DaemonHttpHost:
30
+ _READY_TIMEOUT_SECONDS = 2.0
31
+ _READY_POLL_INTERVAL_SECONDS = 0.05
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ config: DaemonConfig,
37
+ logger: logging.Logger,
38
+ opener_factory: Callable[[], _Opener | OpenerDirector] | None = None,
39
+ ) -> None:
40
+ self._config = config
41
+ self._logger = logger
42
+ self._opener_factory: Callable[[], _Opener | OpenerDirector]
43
+ self._opener_factory = opener_factory or self._build_proxy_bypassing_opener
44
+ self._server: ThreadingHTTPServer | None = None
45
+ self._thread: threading.Thread | None = None
46
+
47
+ @property
48
+ def is_running(self) -> bool:
49
+ return self._server is not None
50
+
51
+ @property
52
+ def ready_poll_interval_seconds(self) -> float:
53
+ return self._READY_POLL_INTERVAL_SECONDS
54
+
55
+ def start(self, handler_class: type[BaseHTTPRequestHandler]) -> tuple[str, int]:
56
+ if self._server is not None:
57
+ raise RuntimeError("androidctld server is already running")
58
+ host = self._config.host
59
+ httpd = ThreadingHTTPServer((host, self._config.port), handler_class)
60
+ httpd.daemon_threads = True
61
+ try:
62
+ thread = threading.Thread(
63
+ target=httpd.serve_forever,
64
+ name="androidctld-http",
65
+ daemon=True,
66
+ )
67
+ thread.start()
68
+ except Exception:
69
+ httpd.server_close()
70
+ raise
71
+ self._server = httpd
72
+ self._thread = thread
73
+ _, port = httpd.server_address[:2]
74
+ return host, port
75
+
76
+ def wait_until_ready(
77
+ self,
78
+ *,
79
+ record: ActiveDaemonRecord,
80
+ owner_id: str | None = None,
81
+ ) -> None:
82
+ deadline = time.monotonic() + self._READY_TIMEOUT_SECONDS
83
+ health_url = f"http://{record.host}:{record.port}/health"
84
+ headers = {
85
+ TOKEN_HEADER_NAME: record.token,
86
+ OWNER_HEADER_NAME: owner_id or self._config.owner_id,
87
+ }
88
+ opener = self._opener_factory()
89
+ while True:
90
+ request = Request(health_url, method="POST", data=b"{}", headers=headers)
91
+ try:
92
+ with opener.open(
93
+ request, timeout=self._READY_POLL_INTERVAL_SECONDS
94
+ ) as response:
95
+ payload = json.loads(response.read().decode("utf-8"))
96
+ envelope = DaemonSuccessEnvelope[HealthResult].model_validate(payload)
97
+ if envelope.result.service == SERVICE_NAME:
98
+ return
99
+ except HTTPError as error:
100
+ error.close()
101
+ except (OSError, URLError, ValueError, json.JSONDecodeError):
102
+ pass
103
+ if time.monotonic() >= deadline:
104
+ raise RuntimeError(
105
+ "timed out waiting for androidctld readiness health check"
106
+ )
107
+ time.sleep(self._READY_POLL_INTERVAL_SECONDS)
108
+
109
+ def stop(self) -> None:
110
+ server = self._server
111
+ thread = self._thread
112
+ self._server = None
113
+ self._thread = None
114
+ if server is None:
115
+ return
116
+ server.shutdown()
117
+ server.server_close()
118
+ if thread is not None:
119
+ thread.join(timeout=2.0)
120
+
121
+ @staticmethod
122
+ def _build_proxy_bypassing_opener() -> OpenerDirector:
123
+ return build_opener(ProxyHandler({}))
@@ -0,0 +1,112 @@
1
+ """Daemon ingress boundary for authenticated request dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol
8
+
9
+ from androidctl_contracts.command_results import RetainedResultEnvelope
10
+ from androidctl_contracts.daemon_api import OWNER_HEADER_NAME
11
+ from androidctld.config import TOKEN_HEADER_NAME
12
+ from androidctld.errors import DaemonError, DaemonErrorCode, unauthorized
13
+
14
+
15
+ class DaemonDispatcher(Protocol):
16
+ def handle(
17
+ self,
18
+ method: str,
19
+ path: str,
20
+ headers: dict[str, str],
21
+ body: bytes,
22
+ ) -> tuple[int, dict[str, Any]]: ...
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class IngressResult:
27
+ status_code: int
28
+ payload: dict[str, Any]
29
+ shutdown_after_write: bool = False
30
+
31
+
32
+ class DaemonIngress:
33
+ def __init__(
34
+ self,
35
+ *,
36
+ token_provider: Callable[[], str | None],
37
+ owner_id_provider: Callable[[], str | None],
38
+ dispatcher: DaemonDispatcher,
39
+ ) -> None:
40
+ self._token_provider = token_provider
41
+ self._owner_id_provider = owner_id_provider
42
+ self._dispatcher = dispatcher
43
+
44
+ def handle(
45
+ self,
46
+ *,
47
+ method: str,
48
+ path: str,
49
+ headers: dict[str, str],
50
+ body: bytes,
51
+ ) -> IngressResult:
52
+ self._require_token(headers)
53
+ self._require_owner(headers)
54
+ status_code, payload = self._dispatcher.handle(
55
+ method=method,
56
+ path=path,
57
+ headers=headers,
58
+ body=body,
59
+ )
60
+ return IngressResult(
61
+ status_code=status_code,
62
+ payload=payload,
63
+ shutdown_after_write=_should_shutdown_after_write(
64
+ method=method,
65
+ path=path,
66
+ status_code=status_code,
67
+ payload=payload,
68
+ ),
69
+ )
70
+
71
+ def _require_token(self, headers: dict[str, str]) -> None:
72
+ expected = self._token_provider()
73
+ supplied = self._header_value(headers, TOKEN_HEADER_NAME)
74
+ if not expected or supplied != expected:
75
+ raise unauthorized()
76
+
77
+ def _require_owner(self, headers: dict[str, str]) -> None:
78
+ owner_id = self._owner_id_provider()
79
+ if not owner_id:
80
+ return
81
+ supplied = self._header_value(headers, OWNER_HEADER_NAME)
82
+ if supplied == owner_id:
83
+ return
84
+ raise DaemonError(
85
+ code=DaemonErrorCode.WORKSPACE_BUSY,
86
+ message="workspace daemon is owned by a different shell or agent",
87
+ retryable=False,
88
+ details={"ownerId": owner_id},
89
+ http_status=200,
90
+ )
91
+
92
+ def _header_value(self, headers: dict[str, str], header_name: str) -> str | None:
93
+ for key, value in headers.items():
94
+ if key.lower() == header_name.lower():
95
+ return value.strip()
96
+ return None
97
+
98
+
99
+ def _should_shutdown_after_write(
100
+ *,
101
+ method: str,
102
+ path: str,
103
+ status_code: int,
104
+ payload: dict[str, Any],
105
+ ) -> bool:
106
+ if method != "POST" or path != "/runtime/close" or status_code != 200:
107
+ return False
108
+ try:
109
+ result = RetainedResultEnvelope.model_validate(payload)
110
+ except ValueError:
111
+ return False
112
+ return result.ok is True and result.command == "close"
@@ -0,0 +1,204 @@
1
+ """Listener health evidence for daemon ownership records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import Any, Protocol
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.request import OpenerDirector, ProxyHandler, Request, build_opener
12
+
13
+ from androidctl_contracts.daemon_api import (
14
+ OWNER_HEADER_NAME,
15
+ DaemonErrorEnvelope,
16
+ DaemonSuccessEnvelope,
17
+ HealthResult,
18
+ )
19
+ from androidctl_contracts.user_state import ActiveDaemonRecord
20
+ from androidctld import SERVICE_NAME
21
+ from androidctld.config import TOKEN_HEADER_NAME, normalize_loopback_host
22
+
23
+
24
+ class _Opener(Protocol):
25
+ def open(self, request: Request, timeout: float) -> Any: ...
26
+
27
+
28
+ class OwnershipHealthStatus(str, Enum):
29
+ LIVE_MATCH = "live_match"
30
+ LIVE_MISMATCH = "live_mismatch"
31
+ UNREACHABLE = "unreachable"
32
+ UNPROBEABLE = "unprobeable"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class OwnershipHealthProbeResult:
37
+ status: OwnershipHealthStatus
38
+ token: str | None = None
39
+
40
+ @property
41
+ def is_live(self) -> bool:
42
+ return self.status in {
43
+ OwnershipHealthStatus.LIVE_MATCH,
44
+ OwnershipHealthStatus.LIVE_MISMATCH,
45
+ }
46
+
47
+
48
+ class OwnershipHealthProbe:
49
+ _TIMEOUT_SECONDS = 0.15
50
+
51
+ def __init__(
52
+ self,
53
+ *,
54
+ opener_factory: Callable[[], _Opener | OpenerDirector] | None = None,
55
+ timeout_seconds: float | None = None,
56
+ ) -> None:
57
+ self._opener_factory = opener_factory or self._build_proxy_bypassing_opener
58
+ self._timeout_seconds = timeout_seconds or self._TIMEOUT_SECONDS
59
+
60
+ def probe(
61
+ self,
62
+ *,
63
+ host: str,
64
+ port: int,
65
+ owner_id: str,
66
+ workspace_root: str,
67
+ expected_workspace_root: str,
68
+ expected_owner_id: str,
69
+ tokens: Iterable[str],
70
+ ) -> OwnershipHealthProbeResult:
71
+ if port <= 0:
72
+ return OwnershipHealthProbeResult(OwnershipHealthStatus.UNPROBEABLE)
73
+ try:
74
+ normalized_host = normalize_loopback_host(host)
75
+ except ValueError:
76
+ return OwnershipHealthProbeResult(OwnershipHealthStatus.UNPROBEABLE)
77
+
78
+ opener = self._opener_factory()
79
+ url = f"http://{self._url_host(normalized_host)}:{port}/health"
80
+ mismatch: OwnershipHealthProbeResult | None = None
81
+ for token in self._probe_tokens(tokens):
82
+ headers = {
83
+ TOKEN_HEADER_NAME: token,
84
+ OWNER_HEADER_NAME: owner_id,
85
+ }
86
+ request = Request(url, method="POST", data=b"{}", headers=headers)
87
+ try:
88
+ with opener.open(request, timeout=self._timeout_seconds) as response:
89
+ payload = json.loads(response.read().decode("utf-8"))
90
+ except HTTPError as error:
91
+ try:
92
+ payload = json.loads(error.read().decode("utf-8"))
93
+ except (OSError, ValueError, json.JSONDecodeError):
94
+ error.close()
95
+ continue
96
+ error.close()
97
+ except (OSError, URLError, ValueError, json.JSONDecodeError):
98
+ continue
99
+
100
+ status = self._classify_health_payload(
101
+ payload,
102
+ token=token,
103
+ owner_id=owner_id,
104
+ workspace_root=workspace_root,
105
+ expected_workspace_root=expected_workspace_root,
106
+ expected_owner_id=expected_owner_id,
107
+ )
108
+ if status is None:
109
+ continue
110
+ if status.status == OwnershipHealthStatus.LIVE_MATCH:
111
+ return status
112
+ if status.status == OwnershipHealthStatus.LIVE_MISMATCH:
113
+ mismatch = status
114
+ continue
115
+ return status
116
+ if mismatch is not None:
117
+ return mismatch
118
+ return OwnershipHealthProbeResult(OwnershipHealthStatus.UNREACHABLE)
119
+
120
+ def _classify_health_payload(
121
+ self,
122
+ payload: object,
123
+ *,
124
+ token: str,
125
+ owner_id: str,
126
+ workspace_root: str,
127
+ expected_workspace_root: str,
128
+ expected_owner_id: str,
129
+ ) -> OwnershipHealthProbeResult | None:
130
+ try:
131
+ envelope = DaemonSuccessEnvelope[HealthResult].model_validate(payload)
132
+ except ValueError:
133
+ return self._classify_error_payload(payload)
134
+
135
+ result = envelope.result
136
+ if result.service != SERVICE_NAME:
137
+ return None
138
+ if (
139
+ result.workspace_root == expected_workspace_root
140
+ and result.owner_id == expected_owner_id
141
+ and workspace_root == expected_workspace_root
142
+ and owner_id == expected_owner_id
143
+ ):
144
+ return OwnershipHealthProbeResult(
145
+ OwnershipHealthStatus.LIVE_MATCH,
146
+ token=token,
147
+ )
148
+ return OwnershipHealthProbeResult(OwnershipHealthStatus.LIVE_MISMATCH)
149
+
150
+ @staticmethod
151
+ def _classify_error_payload(
152
+ payload: object,
153
+ ) -> OwnershipHealthProbeResult | None:
154
+ try:
155
+ envelope = DaemonErrorEnvelope.model_validate(payload)
156
+ except ValueError:
157
+ return None
158
+ if envelope.error.code.value in {"DAEMON_UNAUTHORIZED", "WORKSPACE_BUSY"}:
159
+ return OwnershipHealthProbeResult(OwnershipHealthStatus.LIVE_MISMATCH)
160
+ return None
161
+
162
+ def probe_active_record(
163
+ self,
164
+ record: ActiveDaemonRecord,
165
+ *,
166
+ expected_workspace_root: str,
167
+ expected_owner_id: str,
168
+ ) -> OwnershipHealthProbeResult:
169
+ return self.probe(
170
+ host=record.host,
171
+ port=record.port,
172
+ owner_id=record.owner_id,
173
+ workspace_root=record.workspace_root,
174
+ expected_workspace_root=expected_workspace_root,
175
+ expected_owner_id=expected_owner_id,
176
+ tokens=(record.token,),
177
+ )
178
+
179
+ @staticmethod
180
+ def _unique_tokens(tokens: Iterable[str]) -> list[str]:
181
+ unique: list[str] = []
182
+ seen: set[str] = set()
183
+ for token in tokens:
184
+ token = token.strip()
185
+ if not token or token in seen:
186
+ continue
187
+ unique.append(token)
188
+ seen.add(token)
189
+ return unique
190
+
191
+ @classmethod
192
+ def _probe_tokens(cls, tokens: Iterable[str]) -> list[str]:
193
+ unique = cls._unique_tokens(tokens)
194
+ return unique or [""]
195
+
196
+ @staticmethod
197
+ def _url_host(host: str) -> str:
198
+ if ":" in host and not host.startswith("["):
199
+ return f"[{host}]"
200
+ return host
201
+
202
+ @staticmethod
203
+ def _build_proxy_bypassing_opener() -> OpenerDirector:
204
+ return build_opener(ProxyHandler({}))