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,93 @@
1
+ """Central semantic error mapping for semantic command results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from androidctl_contracts.vocabulary import SemanticResultCode
8
+ from androidctld.errors import DaemonError, DaemonErrorCode
9
+
10
+ _DIRECT_SEMANTIC_CODES: dict[DaemonErrorCode, SemanticResultCode] = {
11
+ DaemonErrorCode.REF_STALE: SemanticResultCode.REF_STALE,
12
+ DaemonErrorCode.WAIT_TIMEOUT: SemanticResultCode.WAIT_TIMEOUT,
13
+ DaemonErrorCode.TARGET_BLOCKED: SemanticResultCode.TARGET_BLOCKED,
14
+ DaemonErrorCode.TARGET_NOT_ACTIONABLE: SemanticResultCode.TARGET_NOT_ACTIONABLE,
15
+ DaemonErrorCode.OPEN_FAILED: SemanticResultCode.OPEN_FAILED,
16
+ DaemonErrorCode.ACTION_NOT_CONFIRMED: SemanticResultCode.ACTION_NOT_CONFIRMED,
17
+ DaemonErrorCode.TYPE_NOT_CONFIRMED: SemanticResultCode.TYPE_NOT_CONFIRMED,
18
+ DaemonErrorCode.SUBMIT_NOT_CONFIRMED: SemanticResultCode.SUBMIT_NOT_CONFIRMED,
19
+ }
20
+
21
+ _DEVICE_UNAVAILABLE_CODES = {
22
+ DaemonErrorCode.RUNTIME_NOT_CONNECTED,
23
+ DaemonErrorCode.SCREEN_NOT_READY,
24
+ DaemonErrorCode.DEVICE_DISCONNECTED,
25
+ DaemonErrorCode.DEVICE_AGENT_UNAVAILABLE,
26
+ DaemonErrorCode.DEVICE_AGENT_UNAUTHORIZED,
27
+ DaemonErrorCode.DEVICE_RPC_FAILED,
28
+ DaemonErrorCode.DEVICE_RPC_TRANSPORT_RESET,
29
+ }
30
+
31
+ _MUTATING_COMMANDS = {
32
+ "open",
33
+ "tap",
34
+ "longTap",
35
+ "long-tap",
36
+ "focus",
37
+ "type",
38
+ "submit",
39
+ "scroll",
40
+ "back",
41
+ "home",
42
+ "recents",
43
+ "notifications",
44
+ }
45
+
46
+ _REF_STALE_PUBLIC_MESSAGE = (
47
+ "The referenced element is no longer available on the current screen."
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SemanticFailure:
53
+ code: SemanticResultCode
54
+ message: str
55
+ continuity_status_override: str | None = None
56
+
57
+
58
+ def map_daemon_error_to_semantic_failure(
59
+ *,
60
+ command_name: str,
61
+ error: DaemonError,
62
+ truth_lost_after_dispatch: bool = False,
63
+ ) -> SemanticFailure | None:
64
+ if error.code == DaemonErrorCode.REF_STALE:
65
+ return SemanticFailure(
66
+ code=SemanticResultCode.REF_STALE,
67
+ message=_REF_STALE_PUBLIC_MESSAGE,
68
+ continuity_status_override="stale",
69
+ )
70
+
71
+ direct_code = _DIRECT_SEMANTIC_CODES.get(error.code)
72
+ if direct_code is not None:
73
+ return SemanticFailure(code=direct_code, message=error.message)
74
+
75
+ if error.code not in _DEVICE_UNAVAILABLE_CODES:
76
+ return None
77
+
78
+ if command_name in _MUTATING_COMMANDS and truth_lost_after_dispatch:
79
+ return SemanticFailure(
80
+ code=SemanticResultCode.POST_ACTION_OBSERVATION_LOST,
81
+ message=(
82
+ "Action may have been dispatched, but no current screen truth is "
83
+ "available."
84
+ ),
85
+ )
86
+
87
+ return SemanticFailure(
88
+ code=SemanticResultCode.DEVICE_UNAVAILABLE,
89
+ message="No current device observation is available.",
90
+ )
91
+
92
+
93
+ __all__ = ["SemanticFailure", "map_daemon_error_to_semantic_failure"]
@@ -0,0 +1,135 @@
1
+ """Shared semantic continuity truth helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from androidctld.runtime.models import WorkspaceRuntime
8
+ from androidctld.runtime.screen_state import (
9
+ get_authoritative_current_basis,
10
+ )
11
+ from androidctld.semantics.compiler import CompiledScreen
12
+ from androidctld.semantics.continuity import evaluate_continuity
13
+ from androidctld.semantics.public_models import PublicScreen
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SemanticContinuityTruth:
18
+ continuity_status: str
19
+ changed: bool | None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class SemanticSourceBasis:
24
+ source_screen_id: str | None
25
+ source_compiled_screen: CompiledScreen | None
26
+
27
+
28
+ def capture_runtime_source_basis(
29
+ *,
30
+ runtime: WorkspaceRuntime,
31
+ ) -> SemanticSourceBasis:
32
+ basis = get_authoritative_current_basis(runtime)
33
+ return SemanticSourceBasis(
34
+ source_screen_id=(None if basis is None else basis.screen_id),
35
+ source_compiled_screen=(None if basis is None else basis.compiled_screen),
36
+ )
37
+
38
+
39
+ def resolve_global_action_source_basis(
40
+ *,
41
+ runtime: WorkspaceRuntime,
42
+ source_screen_id: str | None,
43
+ ) -> SemanticSourceBasis:
44
+ basis = get_authoritative_current_basis(runtime)
45
+ if source_screen_id is None:
46
+ return SemanticSourceBasis(
47
+ source_screen_id=(None if basis is None else basis.screen_id),
48
+ source_compiled_screen=(None if basis is None else basis.compiled_screen),
49
+ )
50
+ if basis is not None and basis.screen_id == source_screen_id:
51
+ return SemanticSourceBasis(
52
+ source_screen_id=source_screen_id,
53
+ source_compiled_screen=basis.compiled_screen,
54
+ )
55
+ return SemanticSourceBasis(
56
+ source_screen_id=source_screen_id,
57
+ source_compiled_screen=None,
58
+ )
59
+
60
+
61
+ def resolve_screen_continuity(
62
+ *,
63
+ source_screen_id: str | None,
64
+ source_compiled_screen: CompiledScreen | None,
65
+ current_screen: PublicScreen | None,
66
+ candidate_compiled_screen: CompiledScreen | None,
67
+ ) -> SemanticContinuityTruth:
68
+ if source_screen_id is None or current_screen is None:
69
+ return SemanticContinuityTruth("none", None)
70
+ if (
71
+ source_compiled_screen is not None
72
+ and source_compiled_screen.screen_id == source_screen_id
73
+ and candidate_compiled_screen is not None
74
+ ):
75
+ decision = evaluate_continuity(
76
+ source_screen=source_compiled_screen,
77
+ candidate_screen=candidate_compiled_screen,
78
+ )
79
+ return SemanticContinuityTruth(
80
+ continuity_status=decision.continuity_status,
81
+ changed=decision.changed,
82
+ )
83
+ continuity_status = (
84
+ "stable" if source_screen_id == current_screen.screen_id else "stale"
85
+ )
86
+ return SemanticContinuityTruth(
87
+ continuity_status=continuity_status,
88
+ changed=continuity_status == "stale",
89
+ )
90
+
91
+
92
+ def resolve_runtime_continuity(
93
+ *,
94
+ runtime: WorkspaceRuntime,
95
+ source_screen_id: str | None,
96
+ source_compiled_screen: CompiledScreen | None,
97
+ ) -> SemanticContinuityTruth:
98
+ basis = get_authoritative_current_basis(runtime)
99
+ return resolve_screen_continuity(
100
+ source_screen_id=source_screen_id,
101
+ source_compiled_screen=source_compiled_screen,
102
+ current_screen=(None if basis is None else basis.public_screen),
103
+ candidate_compiled_screen=(None if basis is None else basis.compiled_screen),
104
+ )
105
+
106
+
107
+ def resolve_open_changed(
108
+ *,
109
+ runtime: WorkspaceRuntime,
110
+ source_screen_id: str | None,
111
+ source_compiled_screen: CompiledScreen | None,
112
+ ) -> bool | None:
113
+ if source_screen_id is None or source_compiled_screen is None:
114
+ return None
115
+ if source_compiled_screen.screen_id != source_screen_id:
116
+ return None
117
+ basis = get_authoritative_current_basis(runtime)
118
+ if basis is None:
119
+ return None
120
+ decision = evaluate_continuity(
121
+ source_screen=source_compiled_screen,
122
+ candidate_screen=basis.compiled_screen,
123
+ )
124
+ return decision.changed
125
+
126
+
127
+ __all__ = [
128
+ "SemanticContinuityTruth",
129
+ "SemanticSourceBasis",
130
+ "capture_runtime_source_basis",
131
+ "resolve_global_action_source_basis",
132
+ "resolve_open_changed",
133
+ "resolve_runtime_continuity",
134
+ "resolve_screen_continuity",
135
+ ]
@@ -0,0 +1,67 @@
1
+ """Semantic daemon-boundary command service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from androidctld.artifacts.writer import ArtifactWriter
9
+ from androidctld.commands.assembly import SleepFn, assemble_command_service
10
+ from androidctld.commands.command_models import InternalCommand
11
+ from androidctld.commands.executor import CommandExecutor
12
+ from androidctld.device.bootstrap import DeviceBootstrapper
13
+ from androidctld.device.interfaces import DeviceClientFactory
14
+ from androidctld.runtime import RuntimeStore
15
+ from androidctld.semantics.compiler import SemanticCompiler
16
+ from androidctld.snapshots.service import SnapshotService
17
+
18
+ __all__ = ["CommandService"]
19
+
20
+
21
+ class CommandService:
22
+ def __init__(
23
+ self,
24
+ runtime_store: RuntimeStore,
25
+ bootstrapper: DeviceBootstrapper | None = None,
26
+ snapshot_service: SnapshotService | None = None,
27
+ semantic_compiler: SemanticCompiler | None = None,
28
+ artifact_writer: ArtifactWriter | None = None,
29
+ device_client_factory: DeviceClientFactory | None = None,
30
+ sleep_fn: SleepFn | None = None,
31
+ time_fn: Callable[[], float] | None = None,
32
+ executor: CommandExecutor | None = None,
33
+ ) -> None:
34
+ assembly = assemble_command_service(
35
+ runtime_store=runtime_store,
36
+ bootstrapper=bootstrapper,
37
+ snapshot_service=snapshot_service,
38
+ semantic_compiler=semantic_compiler,
39
+ artifact_writer=artifact_writer,
40
+ device_client_factory=device_client_factory,
41
+ sleep_fn=sleep_fn,
42
+ time_fn=time_fn,
43
+ )
44
+ self._runtime_kernel = assembly.runtime_kernel
45
+ self._runtime_store = assembly.runtime_store
46
+ self._orchestrator = assembly.orchestrator
47
+ self._executor = assembly.executor if executor is None else executor
48
+
49
+ def run(
50
+ self,
51
+ command: InternalCommand,
52
+ ) -> dict[str, Any]:
53
+ runtime = self._runtime_store.ensure_runtime()
54
+ return self._orchestrator.run(
55
+ runtime=runtime,
56
+ command=command,
57
+ execute=lambda: self._executor.run(
58
+ command=command,
59
+ ),
60
+ )
61
+
62
+ def close_runtime(self) -> dict[str, Any]:
63
+ runtime = self._runtime_store.ensure_runtime()
64
+ return self._orchestrator.close_runtime(
65
+ runtime=runtime,
66
+ close=lambda: self._runtime_kernel.close_runtime(runtime),
67
+ )
androidctld/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Shared configuration helpers for androidctld."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from androidctl_contracts.paths import daemon_state_root
10
+
11
+ DEFAULT_HOST = "127.0.0.1"
12
+ ACTIVE_FILE_NAME = "active.json"
13
+ ACTIVE_LOCK_FILE_NAME = "active.lock"
14
+ OWNER_LOCK_FILE_NAME = "owner.lock"
15
+ TOKEN_FILE_NAME = "token.json"
16
+ TOKEN_HEADER_NAME = "X-Androidctld-Token"
17
+
18
+
19
+ def normalize_loopback_host(host: str) -> str:
20
+ normalized = host.strip().lower()
21
+ if not normalized:
22
+ raise ValueError("daemon host must not be empty")
23
+ if normalized == "localhost":
24
+ return DEFAULT_HOST
25
+ try:
26
+ parsed = ipaddress.ip_address(normalized)
27
+ except ValueError as error:
28
+ raise ValueError("daemon host must be a loopback address") from error
29
+ if not parsed.is_loopback:
30
+ raise ValueError("daemon host must be a loopback address")
31
+ if parsed.version == 6:
32
+ return "::1"
33
+ return str(parsed)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class DaemonConfig:
38
+ workspace_root: Path
39
+ owner_id: str
40
+ host: str = DEFAULT_HOST
41
+ port: int = 0
42
+ state_dir: Path = field(init=False)
43
+
44
+ def __post_init__(self) -> None:
45
+ raw_workspace_root = str(self.workspace_root).strip()
46
+ if not raw_workspace_root:
47
+ raise ValueError("daemon workspace_root must not be empty")
48
+ resolved_root = Path(raw_workspace_root).expanduser().resolve()
49
+ normalized_owner = self.owner_id.strip()
50
+ if not normalized_owner:
51
+ raise ValueError("daemon owner_id must not be empty")
52
+ object.__setattr__(self, "owner_id", normalized_owner)
53
+ object.__setattr__(self, "workspace_root", resolved_root)
54
+ object.__setattr__(self, "host", normalize_loopback_host(self.host))
55
+ object.__setattr__(
56
+ self,
57
+ "state_dir",
58
+ daemon_state_root(resolved_root),
59
+ )
60
+
61
+ @property
62
+ def active_file_path(self) -> Path:
63
+ return self.state_dir / ACTIVE_FILE_NAME
64
+
65
+ @property
66
+ def active_lock_path(self) -> Path:
67
+ return self.state_dir / ACTIVE_LOCK_FILE_NAME
68
+
69
+ @property
70
+ def owner_lock_path(self) -> Path:
71
+ return self.state_dir / OWNER_LOCK_FILE_NAME
72
+
73
+ @property
74
+ def token_file_path(self) -> Path:
75
+ return self.state_dir / TOKEN_FILE_NAME
@@ -0,0 +1 @@
1
+ """Daemon HTTP server package."""
@@ -0,0 +1,326 @@
1
+ """Active-slot ownership and publication for androidctld."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from androidctld.auth.active_registry import ActiveDaemonRecord, ActiveDaemonRegistry
13
+ from androidctld.auth.secret_files import write_secret_json_file_atomically
14
+ from androidctld.auth.token_store import DaemonTokenStore
15
+ from androidctld.config import DaemonConfig, normalize_loopback_host
16
+ from androidctld.daemon.ownership_probe import (
17
+ OwnershipHealthProbe,
18
+ OwnershipHealthProbeResult,
19
+ OwnershipHealthStatus,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class _OwnerLockRecord:
25
+ pid: int
26
+ host: str
27
+ port: int
28
+ started_at: str
29
+ workspace_root: str
30
+ owner_id: str
31
+
32
+ @property
33
+ def identity(self) -> tuple[int, str]:
34
+ return (self.pid, self.started_at)
35
+
36
+
37
+ class ActiveSlotCoordinator:
38
+ _OWNER_LOCK_STALE_SECONDS = 2.0
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ config: DaemonConfig,
44
+ active_registry: ActiveDaemonRegistry,
45
+ existing_token_reader: Callable[[], str | None] | None = None,
46
+ ownership_probe: OwnershipHealthProbe | None = None,
47
+ ) -> None:
48
+ self._config = config
49
+ self._active_registry = active_registry
50
+ self._existing_token_reader = existing_token_reader or (
51
+ lambda: DaemonTokenStore.load_existing_token(self._config.token_file_path)
52
+ )
53
+ self._ownership_probe = ownership_probe or OwnershipHealthProbe()
54
+ self._active_record: ActiveDaemonRecord | None = None
55
+ self._owns_owner_lock = False
56
+
57
+ @property
58
+ def active_record(self) -> ActiveDaemonRecord | None:
59
+ return self._active_record
60
+
61
+ def acquire(self) -> None:
62
+ lock_path = self._config.owner_lock_path
63
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
64
+ while True:
65
+ fd: int | None = None
66
+ try:
67
+ fd = os.open(
68
+ lock_path,
69
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
70
+ 0o600,
71
+ )
72
+ os.write(fd, str(os.getpid()).encode("utf-8"))
73
+ os.close(fd)
74
+ fd = None
75
+ self._owns_owner_lock = True
76
+ return
77
+ except FileExistsError:
78
+ if self._recover_stale_owner_lock(lock_path):
79
+ continue
80
+ self._restore_active_record_from_owner_lock()
81
+ raise RuntimeError("live daemon already owns active slot") from None
82
+ finally:
83
+ if fd is not None:
84
+ os.close(fd)
85
+
86
+ def prepare(self, *, host: str, port: int, token: str) -> ActiveDaemonRecord:
87
+ record = self._active_registry.build_record(host=host, port=port, token=token)
88
+ self._active_record = record
89
+ return record
90
+
91
+ def publish(self, record: ActiveDaemonRecord | None = None) -> ActiveDaemonRecord:
92
+ active_record = record or self._active_record
93
+ if active_record is None:
94
+ raise RuntimeError("active record is not prepared")
95
+ self._active_registry.publish(active_record)
96
+ self._publish_owner_lock_record(active_record)
97
+ self._active_record = active_record
98
+ return active_record
99
+
100
+ def clear_record(self) -> None:
101
+ active_record = self._active_record
102
+ if active_record is not None:
103
+ self._active_registry.clear(record=active_record)
104
+ self._active_record = None
105
+
106
+ def release_owner(self) -> None:
107
+ if not self._owns_owner_lock:
108
+ return
109
+ self._owns_owner_lock = False
110
+ try:
111
+ self._config.owner_lock_path.unlink()
112
+ except FileNotFoundError:
113
+ return
114
+
115
+ def release(self) -> None:
116
+ self.clear_record()
117
+ self.release_owner()
118
+
119
+ def _recover_stale_owner_lock(self, lock_path: Path) -> bool:
120
+ try:
121
+ lock_age_seconds = time.time() - lock_path.stat().st_mtime
122
+ except OSError:
123
+ return False
124
+ if self._owner_lock_has_live_evidence(lock_path):
125
+ return False
126
+ if lock_age_seconds < self._OWNER_LOCK_STALE_SECONDS:
127
+ pid = self._owner_lock_pid(lock_path)
128
+ if pid <= 0 or ActiveDaemonRegistry._is_pid_live(pid):
129
+ return False
130
+ try:
131
+ lock_path.unlink()
132
+ except FileNotFoundError:
133
+ return True
134
+ except OSError:
135
+ return False
136
+ return True
137
+
138
+ def _owner_lock_has_live_evidence(self, lock_path: Path) -> bool:
139
+ owner_lock = self._read_owner_lock_record(lock_path)
140
+ if owner_lock is None:
141
+ return False
142
+ token_probe = self._probe_owner_lock(owner_lock)
143
+ return token_probe.is_live
144
+
145
+ def _publish_owner_lock_record(self, record: ActiveDaemonRecord) -> None:
146
+ payload = {
147
+ "host": normalize_loopback_host(record.host),
148
+ "ownerId": record.owner_id,
149
+ "pid": record.pid,
150
+ "port": record.port,
151
+ "startedAt": record.started_at,
152
+ "workspaceRoot": record.workspace_root,
153
+ }
154
+ write_secret_json_file_atomically(self._config.owner_lock_path, payload)
155
+
156
+ def _owner_lock_pid(self, lock_path: Path) -> int:
157
+ try:
158
+ raw = lock_path.read_text(encoding="utf-8").strip()
159
+ except OSError:
160
+ return -1
161
+ if not raw:
162
+ return -1
163
+ try:
164
+ return int(raw)
165
+ except ValueError:
166
+ pass
167
+ try:
168
+ payload = json.loads(raw)
169
+ except (ValueError, json.JSONDecodeError):
170
+ return -1
171
+ if not isinstance(payload, dict):
172
+ return -1
173
+ pid = payload.get("pid")
174
+ if isinstance(pid, int):
175
+ return pid
176
+ return -1
177
+
178
+ def _restore_active_record_from_owner_lock(self) -> None:
179
+ owner_lock = self._read_owner_lock_record(self._config.owner_lock_path)
180
+ if owner_lock is None:
181
+ return
182
+ if not self._owner_lock_matches_current_config(owner_lock):
183
+ return
184
+ probe_result = self._probe_owner_lock(owner_lock)
185
+ if probe_result.status != OwnershipHealthStatus.LIVE_MATCH:
186
+ return
187
+ token = probe_result.token
188
+ if token is None:
189
+ token = self._first_token_for_owner_lock(owner_lock)
190
+ if token is None:
191
+ return
192
+ record = ActiveDaemonRecord(
193
+ pid=owner_lock.pid,
194
+ host=owner_lock.host,
195
+ port=owner_lock.port,
196
+ token=token,
197
+ started_at=owner_lock.started_at,
198
+ workspace_root=owner_lock.workspace_root,
199
+ owner_id=owner_lock.owner_id,
200
+ )
201
+ try:
202
+ self._active_registry.restore(record)
203
+ except ValueError:
204
+ return
205
+
206
+ def _read_owner_lock_record(self, lock_path: Path) -> _OwnerLockRecord | None:
207
+ try:
208
+ raw = lock_path.read_text(encoding="utf-8").strip()
209
+ except OSError:
210
+ return None
211
+ if not raw:
212
+ return None
213
+ try:
214
+ payload = json.loads(raw)
215
+ except (ValueError, json.JSONDecodeError):
216
+ return None
217
+ if not isinstance(payload, dict):
218
+ return None
219
+ try:
220
+ pid = self._read_int(payload.get("pid"))
221
+ host = self._read_str(payload.get("host"))
222
+ port = self._read_int(payload.get("port"))
223
+ started_at = self._read_str(payload.get("startedAt"))
224
+ workspace_root = self._read_str(payload.get("workspaceRoot"))
225
+ owner_id = self._read_str(payload.get("ownerId"))
226
+ except ValueError:
227
+ return None
228
+ return _OwnerLockRecord(
229
+ pid=pid,
230
+ host=host,
231
+ port=port,
232
+ started_at=started_at,
233
+ workspace_root=workspace_root,
234
+ owner_id=owner_id,
235
+ )
236
+
237
+ @staticmethod
238
+ def _read_int(value: object) -> int:
239
+ if isinstance(value, bool) or not isinstance(value, int):
240
+ raise ValueError("expected integer")
241
+ return value
242
+
243
+ @staticmethod
244
+ def _read_str(value: object) -> str:
245
+ if not isinstance(value, str):
246
+ raise ValueError("expected string")
247
+ value = value.strip()
248
+ if not value:
249
+ raise ValueError("expected non-empty string")
250
+ return value
251
+
252
+ def _owner_lock_matches_current_config(self, record: _OwnerLockRecord) -> bool:
253
+ if record.workspace_root != self._config.workspace_root.as_posix():
254
+ return False
255
+ if record.owner_id != self._config.owner_id:
256
+ return False
257
+ if self._normalize_owner_host(record.host) != self._config.host:
258
+ return False
259
+ return self._config.port == 0 or record.port == self._config.port
260
+
261
+ def _probe_owner_lock(
262
+ self,
263
+ record: _OwnerLockRecord,
264
+ ) -> OwnershipHealthProbeResult:
265
+ tokens = self._tokens_for_owner_lock(record)
266
+ return self._ownership_probe.probe(
267
+ host=record.host,
268
+ port=record.port,
269
+ owner_id=record.owner_id,
270
+ workspace_root=record.workspace_root,
271
+ expected_workspace_root=self._config.workspace_root.as_posix(),
272
+ expected_owner_id=self._config.owner_id,
273
+ tokens=tokens,
274
+ )
275
+
276
+ def _tokens_for_owner_lock(self, record: _OwnerLockRecord) -> list[str]:
277
+ tokens: list[str] = []
278
+ token = self._existing_token_reader()
279
+ if token is not None:
280
+ tokens.append(token)
281
+ active_record = self._active_registry.read()
282
+ if active_record is not None and self._active_record_matches_owner_lock(
283
+ active_record,
284
+ record,
285
+ ):
286
+ tokens.append(active_record.token)
287
+ return self._unique_tokens(tokens)
288
+
289
+ def _first_token_for_owner_lock(self, record: _OwnerLockRecord) -> str | None:
290
+ tokens = self._tokens_for_owner_lock(record)
291
+ if not tokens:
292
+ return None
293
+ return tokens[0]
294
+
295
+ @staticmethod
296
+ def _unique_tokens(tokens: list[str]) -> list[str]:
297
+ unique: list[str] = []
298
+ seen: set[str] = set()
299
+ for token in tokens:
300
+ token = token.strip()
301
+ if not token or token in seen:
302
+ continue
303
+ unique.append(token)
304
+ seen.add(token)
305
+ return unique
306
+
307
+ @staticmethod
308
+ def _normalize_owner_host(host: str) -> str:
309
+ try:
310
+ return normalize_loopback_host(host)
311
+ except ValueError:
312
+ return host
313
+
314
+ @staticmethod
315
+ def _active_record_matches_owner_lock(
316
+ active_record: ActiveDaemonRecord,
317
+ owner_lock: _OwnerLockRecord,
318
+ ) -> bool:
319
+ return (
320
+ active_record.pid == owner_lock.pid
321
+ and active_record.host == owner_lock.host
322
+ and active_record.port == owner_lock.port
323
+ and active_record.started_at == owner_lock.started_at
324
+ and active_record.workspace_root == owner_lock.workspace_root
325
+ and active_record.owner_id == owner_lock.owner_id
326
+ )