Implement Apex Agent MVP scaffold
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Core device and domain abstractions for Apex Agent."""
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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."""
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ApexAgentError(Exception):
|
||||
"""Base error for semantic errors surfaced above the driver layer."""
|
||||
|
||||
|
||||
class DriverError(ApexAgentError):
|
||||
"""A driver operation failed."""
|
||||
|
||||
|
||||
class DeviceNotFoundError(ApexAgentError):
|
||||
"""A requested device id is unknown."""
|
||||
|
||||
|
||||
class DeviceOfflineError(ApexAgentError):
|
||||
"""A device is unavailable or lost its connection."""
|
||||
|
||||
|
||||
class DeviceBusyError(ApexAgentError):
|
||||
"""A device is already in use."""
|
||||
|
||||
|
||||
class ElementNotFoundError(ApexAgentError):
|
||||
"""A requested screen element was not found."""
|
||||
|
||||
|
||||
class TaskFailedError(ApexAgentError):
|
||||
"""A task failed before reaching its goal."""
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
DeviceStatus = Literal["idle", "busy", "offline", "error"]
|
||||
TaskStatus = Literal["created", "running", "completed", "failed", "cancelled"]
|
||||
StepStatus = Literal["pending", "running", "completed", "failed"]
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bounds:
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
@property
|
||||
def right(self) -> float:
|
||||
return self.x + self.width
|
||||
|
||||
@property
|
||||
def bottom(self) -> float:
|
||||
return self.y + self.height
|
||||
|
||||
@property
|
||||
def center(self) -> tuple[float, float]:
|
||||
return (self.x + self.width / 2, self.y + self.height / 2)
|
||||
|
||||
def to_dict(self) -> dict[str, float]:
|
||||
return {
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Bounds":
|
||||
return cls(
|
||||
x=float(data["x"]),
|
||||
y=float(data["y"]),
|
||||
width=float(data["width"]),
|
||||
height=float(data["height"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
id: str
|
||||
status: DeviceStatus = "idle"
|
||||
name: str | None = None
|
||||
driver_type: str = "wda"
|
||||
connection_info: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"status": self.status,
|
||||
"driver_type": self.driver_type,
|
||||
"connection_info": dict(self.connection_info),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneElement:
|
||||
id: str
|
||||
type: str
|
||||
bounds: Bounds
|
||||
text: str | None = None
|
||||
confidence: float | None = None
|
||||
source: str | None = None
|
||||
|
||||
@property
|
||||
def center(self) -> tuple[float, float]:
|
||||
return self.bounds.center
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"text": self.text,
|
||||
"bounds": self.bounds.to_dict(),
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
if self.source:
|
||||
data["source"] = self.source
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SceneElement":
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
type=str(data.get("type") or "unknown"),
|
||||
text=data.get("text"),
|
||||
bounds=Bounds.from_dict(data["bounds"]),
|
||||
confidence=data.get("confidence"),
|
||||
source=data.get("source"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scene:
|
||||
width: int
|
||||
height: int
|
||||
elements: list[SceneElement] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"screen": {"width": self.width, "height": self.height},
|
||||
"elements": [element.to_dict() for element in self.elements],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "Scene":
|
||||
screen = data.get("screen") or {}
|
||||
return cls(
|
||||
width=int(screen.get("width") or data.get("width") or 0),
|
||||
height=int(screen.get("height") or data.get("height") or 0),
|
||||
elements=[
|
||||
SceneElement.from_dict(element)
|
||||
for element in data.get("elements", [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
goal: str
|
||||
device_id: str
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
status: TaskStatus = "created"
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
completed_at: datetime | None = None
|
||||
failure_reason: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"goal": self.goal,
|
||||
"device_id": self.device_id,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"completed_at": self.completed_at.isoformat()
|
||||
if self.completed_at
|
||||
else None,
|
||||
"failure_reason": self.failure_reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
description: str
|
||||
action: str
|
||||
args: dict[str, Any] = field(default_factory=dict)
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
status: StepStatus = "pending"
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"description": self.description,
|
||||
"action": self.action,
|
||||
"args": dict(self.args),
|
||||
"status": self.status,
|
||||
"result": self.result,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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
|
||||
Reference in New Issue
Block a user