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,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
|
+
)
|