feat: checkpoint device agent runtime milestones

This commit is contained in:
2026-07-06 17:24:03 +08:00
parent 2d4251e98e
commit 5658735bca
153 changed files with 8060 additions and 65 deletions
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from threading import RLock
from time import sleep
from core.driver import Driver
from core.errors import (
DeviceBusyError,
DeviceNotFoundError,
DeviceOfflineError,
DriverError,
)
from core.models import Device, DeviceStatus
DriverFactory = Callable[[], Driver]
class DeviceManager:
def __init__(self) -> None:
self._devices: dict[str, Device] = {}
self._factories: dict[str, DriverFactory] = {}
self._drivers: dict[str, Driver] = {}
self._lock = RLock()
def register_device(
self,
device_id: str,
driver_factory: DriverFactory,
*,
name: str | None = None,
driver_type: str = "wda",
connection_info: dict[str, object] | None = None,
status: DeviceStatus = "idle",
) -> Device:
with self._lock:
device = Device(
id=device_id,
name=name,
status=status,
driver_type=driver_type,
connection_info=dict(connection_info or {}),
)
self._devices[device_id] = device
self._factories[device_id] = driver_factory
self._drivers.pop(device_id, None)
return replace(device)
def unregister_device(self, device_id: str) -> None:
with self._lock:
driver = self._drivers.pop(device_id, None)
if driver:
driver.disconnect()
self._devices.pop(device_id, None)
self._factories.pop(device_id, None)
def list_devices(self) -> list[Device]:
with self._lock:
return [replace(device) for device in self._devices.values()]
def status(self, device_id: str) -> DeviceStatus:
with self._lock:
return self._device(device_id).status
def connect(
self,
device_id: str,
*,
max_retries: int = 2,
retry_backoff_seconds: float = 0.25,
) -> Driver:
if max_retries < 1:
raise ValueError("max_retries must be at least 1")
last_error: Exception | None = None
for attempt in range(1, max_retries + 1):
with self._lock:
device = self._device(device_id)
if device.status == "busy" and device_id in self._drivers:
return self._drivers[device_id]
if device.status == "busy":
raise DeviceBusyError(f"device {device_id} is busy")
driver = self._factories[device_id]()
try:
driver.connect()
except Exception as exc:
last_error = exc
with self._lock:
status: DeviceStatus = (
"offline" if attempt == max_retries else "error"
)
self._set_status(device_id, status)
if attempt < max_retries:
sleep(retry_backoff_seconds)
continue
with self._lock:
self._drivers[device_id] = driver
self._set_status(device_id, "busy")
return driver
raise DeviceOfflineError(
f"device {device_id} is offline"
) from last_error
def disconnect(self, device_id: str) -> None:
with self._lock:
self._device(device_id)
driver = self._drivers.pop(device_id, None)
if driver:
driver.disconnect()
with self._lock:
self._set_status(device_id, "idle")
def mark_error(self, device_id: str, *, offline: bool = False) -> None:
with self._lock:
self._device(device_id)
self._drivers.pop(device_id, None)
self._set_status(device_id, "offline" if offline else "error")
def active_driver(self, device_id: str | None = None) -> Driver:
with self._lock:
if device_id is None:
busy_ids = [
known_id
for known_id, device in self._devices.items()
if device.status == "busy" and known_id in self._drivers
]
if len(busy_ids) != 1:
raise DriverError(
"exactly one connected device is required when no device_id is given"
)
device_id = busy_ids[0]
self._device(device_id)
driver = self._drivers.get(device_id)
if not driver:
raise DeviceOfflineError(f"device {device_id} is not connected")
return driver
def _device(self, device_id: str) -> Device:
try:
return self._devices[device_id]
except KeyError as exc:
raise DeviceNotFoundError(f"unknown device {device_id}") from exc
def _set_status(self, device_id: str, status: DeviceStatus) -> None:
device = self._devices[device_id]
self._devices[device_id] = replace(device, status=status)
DEFAULT_MANAGER = DeviceManager()
-68
View File
@@ -1,68 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class Driver(ABC):
"""Driver-independent device capability interface.
Implementations may hold a live connection handle, but no task or business
state belongs here.
"""
@abstractmethod
def connect(self) -> None:
"""Open the device connection."""
@abstractmethod
def disconnect(self) -> None:
"""Close the device connection."""
@abstractmethod
def screenshot(self) -> bytes:
"""Return the current screen as image bytes."""
@abstractmethod
def tap(self, x: float, y: float) -> None:
"""Tap the screen at the given coordinates."""
@abstractmethod
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
"""Swipe between two screen coordinates."""
@abstractmethod
def input(self, text: str) -> None:
"""Input text into the current focused field."""
@abstractmethod
def launch(self, app_id: str) -> None:
"""Launch an app by bundle id or driver-supported app identifier."""
@abstractmethod
def terminate(self, app_id: str) -> None:
"""Terminate an app by bundle id or driver-supported app identifier."""
@abstractmethod
def tree(self) -> Any:
"""Return the raw UI tree from the device driver."""
@abstractmethod
def home(self) -> None:
"""Press the device home button."""
@abstractmethod
def lock(self) -> None:
"""Lock the device."""
@abstractmethod
def unlock(self) -> None:
"""Unlock the device."""
+7 -8
View File
@@ -1,30 +1,29 @@
from __future__ import annotations
class ApexAgentError(Exception):
class DeviceRuntimeError(Exception):
"""Base error for semantic errors surfaced above the driver layer."""
class DriverError(ApexAgentError):
class DriverError(DeviceRuntimeError):
"""A driver operation failed."""
class DeviceNotFoundError(ApexAgentError):
class DeviceNotFoundError(DeviceRuntimeError):
"""A requested device id is unknown."""
class DeviceOfflineError(ApexAgentError):
class DeviceOfflineError(DeviceRuntimeError):
"""A device is unavailable or lost its connection."""
class DeviceBusyError(ApexAgentError):
class DeviceBusyError(DeviceRuntimeError):
"""A device is already in use."""
class ElementNotFoundError(ApexAgentError):
class ElementNotFoundError(DeviceRuntimeError):
"""A requested screen element was not found."""
class TaskFailedError(ApexAgentError):
class TaskFailedError(DeviceRuntimeError):
"""A task failed before reaching its goal."""
-155
View File
@@ -1,155 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from core.driver import Driver
from core.errors import DeviceOfflineError, DriverError
@dataclass(frozen=True)
class WDADriverConfig:
server_url: str = "http://127.0.0.1:4723"
platform_name: str = "iOS"
automation_name: str = "XCUITest"
device_name: str | None = None
udid: str | None = None
wda_local_port: int | None = None
no_reset: bool = True
extra_capabilities: dict[str, Any] = field(default_factory=dict)
class WDADriver(Driver):
def __init__(self, config: WDADriverConfig | None = None) -> None:
self.config = config or WDADriverConfig()
self._client: Any | None = None
def connect(self) -> None:
try:
from appium import webdriver
from appium.options.ios import XCUITestOptions
from appium.webdriver.client_config import AppiumClientConfig
except ImportError as exc:
raise DriverError("Appium Python client is not installed") from exc
capabilities: dict[str, Any] = {
"platformName": self.config.platform_name,
"automationName": self.config.automation_name,
"noReset": self.config.no_reset,
**self.config.extra_capabilities,
}
if self.config.device_name:
capabilities["deviceName"] = self.config.device_name
if self.config.udid:
capabilities["udid"] = self.config.udid
if self.config.wda_local_port:
capabilities["wdaLocalPort"] = self.config.wda_local_port
options = XCUITestOptions().load_capabilities(capabilities)
client_config = AppiumClientConfig(remote_server_addr=self.config.server_url)
try:
self._client = webdriver.Remote(
options=options,
client_config=client_config,
)
except Exception as exc:
self._client = None
raise DeviceOfflineError("device offline") from exc
def disconnect(self) -> None:
client = self._require_client()
try:
client.quit()
finally:
self._client = None
def screenshot(self) -> bytes:
client = self._require_client()
try:
return client.get_screenshot_as_png()
except Exception as exc:
raise DriverError("screenshot failed") from exc
def tap(self, x: float, y: float) -> None:
client = self._require_client()
try:
client.execute_script("mobile: tap", {"x": x, "y": y})
except Exception as exc:
raise DriverError("tap failed") from exc
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
client = self._require_client()
try:
client.execute_script(
"mobile: dragFromToForDuration",
{
"fromX": start_x,
"fromY": start_y,
"toX": end_x,
"toY": end_y,
"duration": duration_ms / 1000,
},
)
except Exception as exc:
raise DriverError("swipe failed") from exc
def input(self, text: str) -> None:
client = self._require_client()
try:
client.switch_to.active_element.send_keys(text)
except Exception as exc:
raise DriverError("text input failed") from exc
def launch(self, app_id: str) -> None:
client = self._require_client()
try:
client.activate_app(app_id)
except Exception as exc:
raise DriverError("app launch failed") from exc
def terminate(self, app_id: str) -> None:
client = self._require_client()
try:
client.terminate_app(app_id)
except Exception as exc:
raise DriverError("app terminate failed") from exc
def tree(self) -> str:
client = self._require_client()
try:
return client.page_source
except Exception as exc:
raise DriverError("ui tree retrieval failed") from exc
def home(self) -> None:
client = self._require_client()
try:
client.execute_script("mobile: pressButton", {"name": "home"})
except Exception as exc:
raise DriverError("home failed") from exc
def lock(self) -> None:
client = self._require_client()
try:
client.lock()
except Exception as exc:
raise DriverError("lock failed") from exc
def unlock(self) -> None:
client = self._require_client()
try:
client.unlock()
except Exception as exc:
raise DriverError("unlock failed") from exc
def _require_client(self) -> Any:
if self._client is None:
raise DeviceOfflineError("device offline")
return self._client