feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
# Artemis Thin Client Design
## Goals
Developer machines install only a small remote SDK, while the host connected to
Android devices runs the full Artemis stack. The SDK must:
- have no dependency on ADB, device drivers, agents, LLMs, web services, or image libraries;
- never attempt to start a local Artemis daemon;
- remain compatible with the existing server API;
- allow the server to add agent and device capabilities independently; and
- provide explicit capability negotiation and errors as the protocol evolves.
## Boundary
The client owns request modeling, HTTP communication, status polling, error
normalization, and result models. Device discovery, task scheduling, concurrency
control, model selection, execution, logs, videos, and artifact generation all
belong to the server.
The distribution is named `artemis-client`, while the Python import is
`artemis_client`. This prevents it from shadowing the full runtime's top-level
`artemis` package. The full runtime can depend on this package later and
re-export the client from `artemis` for compatibility.
## Phase 1: Compatibility Adapter
The first release calls the current `/api/*` administration endpoints directly.
The client generates a task ID and maps it to the server's existing `session_id`
to make retries idempotent. While a task is queued but not yet visible in the
session database, the client reconstructs its state from `queue` and
`active_tasks` in `/api/status`.
Experimental parameters live in `options`. Older servers ignore unknown fields,
while newer servers can consume them without requiring an immediate SDK release.
## Phase 2: Stable Remote API
The server should expose versioned endpoints dedicated to remote clients:
```text
POST /api/v1/tasks
GET /api/v1/tasks/{task_id}
DELETE /api/v1/tasks/{task_id}
GET /api/v1/tasks/{task_id}/events
GET /api/v1/tasks/{task_id}/artifacts
GET /api/v1/devices
GET /api/v1/capabilities
```
The administration API and remote SDK API should remain separate. The remote API
uses a consistent error envelope:
```json
{
"error": {
"code": "DEVICE_OFFLINE",
"message": "The selected device is offline",
"retryable": false
}
}
```
## Compatibility Rules
1. `/api/v1` may add optional request and response fields, but must not change existing field semantics.
2. The client ignores unknown response fields and preserves the original response in each model's `raw` attribute.
3. Every new request field must have a server-side default.
4. The SDK checks `/api/v1/capabilities` before using a new feature.
5. Breaking changes ship under `/api/v2`; they are never applied in place.
6. The SDK and server follow semantic versioning independently.
## Security
The current daemon exposes administration and credential-related endpoints and
must not listen on a public interface without protection. Use an SSH or VPN tunnel
in the short term. In production, place a reverse proxy in front of the daemon,
expose only the remote API, and enforce HTTPS, bearer-token authentication,
request-size limits, rate limits, and audit logging. Tokens are accepted only
through arguments or environment variables and are never persisted by the SDK.
## Future Work
- SSE event subscriptions with reconnection support;
- artifact manifests and streaming downloads;
- batch task submission;
- server and API version constraints;
- retries limited to network failures and idempotent requests; and
- OpenAPI contract tests to prevent server and SDK drift.
+112
View File
@@ -0,0 +1,112 @@
# Artemis Client
`artemis-client` is the remote Python SDK that runs on a developer machine. It
communicates with the Artemis daemon on a device host over HTTP and contains no
ADB, device-driver, agent, LLM, or image-processing code.
Runtime dependencies: **zero**. HTTP, JSON, and asynchronous adapters use only
the Python standard library.
## Installation
Install from a local checkout:
```powershell
uv add --editable C:\path\to\artemis\packages\artemis-client
```
Install from the Git repository subdirectory:
```powershell
uv add "artemis-client @ git+https://github.com/google/artemis.git#subdirectory=packages/artemis-client"
```
## Connect to a Device Host
The current Artemis daemon administration API does not include built-in remote
authentication. Do not expose its port directly to the public internet. During
the initial rollout, connect through an SSH tunnel:
```powershell
ssh -L 8000:127.0.0.1:8000 user@artemis-host
```
Then connect the SDK to the local tunnel endpoint:
```python
from artemis_client import ArtemisClient
client = ArtemisClient("http://127.0.0.1:8000")
```
For production, place a reverse proxy in front of the daemon that enforces HTTPS
and bearer-token authentication and exposes only task-related APIs.
## Usage
```python
import asyncio
import os
from artemis_client import ArtemisClient
async def main() -> None:
client = ArtemisClient(
os.environ["ARTEMIS_BASE_URL"],
token=os.environ.get("ARTEMIS_TOKEN"),
)
handle = await client.submit(
"Open the Battery page in Settings and verify the battery percentage",
profile="flash",
device_serial="emulator-5554",
)
print("queued:", handle.task_id)
result = await client.wait_for_task(handle.task_id, timeout=600)
if not result.succeeded:
raise RuntimeError(result.error or result.status)
asyncio.run(main())
```
You can also submit a task and wait for it in one call:
```python
result = await client.run("Open Android Settings and check the Wi-Fi status")
```
Main methods:
- `health()` quickly checks whether the server and scheduler are alive.
- `readiness()` runs full device and toolchain diagnostics and may take longer.
- `capabilities()` negotiates API features and falls back for older servers.
- `list_devices()` lists devices available on the host.
- `submit()` submits an idempotent task and returns immediately.
- `get_task()` retrieves a task.
- `wait_for_task()` waits for a terminal state.
- `run()` submits a task and waits for completion.
- `stop()` stops one task.
By default, `submit()` generates a UUID on the client and sends it as the current
server's `session_id`. Network retries reuse the same `task_id`, preventing the
server from creating duplicate tasks.
## Compatibility
Version 0.1 supports these current Artemis daemon endpoints:
- `POST /api/run`
- `GET /api/sessions/{session_id}`
- `GET /api/status`
- `POST /api/stop`
- `GET /api/devices`
- `GET /api/system/readiness`
Capability discovery first attempts the future `GET /api/v1/capabilities`
endpoint. If an older server returns 404, the SDK uses its known baseline
capability set.
See [DESIGN.md](DESIGN.md) for the architecture and protocol evolution plan.
+30
View File
@@ -0,0 +1,30 @@
"""Run one task against an Artemis host."""
import asyncio
import os
from artemis_client import ArtemisClient
async def main() -> None:
client = ArtemisClient(
os.environ.get("ARTEMIS_BASE_URL", "http://127.0.0.1:8000"),
token=os.environ.get("ARTEMIS_TOKEN"),
)
devices = await client.list_devices()
if not devices:
raise RuntimeError("The Artemis host did not report an Android device")
result = await client.run(
"Open the Battery page in Android Settings and verify the battery percentage.",
profile="flash",
device_serial=devices[0].serial,
)
if not result.succeeded:
raise RuntimeError(result.error or f"Task ended with status {result.status}")
print(f"Task {result.task_id} completed on {result.device_serial}")
if __name__ == "__main__":
asyncio.run(main())
+36
View File
@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "artemis-client"
version = "0.1.0"
description = "Zero-runtime-dependency Python client for a remote Artemis host"
readme = "README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
authors = [
{ name = "Artemis contributors" },
]
dependencies = []
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
artemis_client = ["py.typed"]
[tool.pyright]
include = ["src", "tests", "examples"]
extraPaths = ["src"]
pythonVersion = "3.10"
typeCheckingMode = "standard"
@@ -0,0 +1,45 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Public API for the lightweight Artemis remote client."""
from artemis_client.client import ArtemisClient
from artemis_client.errors import (
ApiError,
ArtemisClientError,
AuthenticationError,
ConflictError,
NetworkError,
NotFoundError,
ProtocolError,
TaskRejectedError,
TaskTimeoutError,
)
from artemis_client.models import Capabilities, Device, TaskHandle, TaskResult
from artemis_client.transport import JsonTransport
__version__ = "0.1.0"
__all__ = [
"ApiError",
"ArtemisClient",
"ArtemisClientError",
"AuthenticationError",
"Capabilities",
"ConflictError",
"Device",
"JsonTransport",
"NetworkError",
"NotFoundError",
"ProtocolError",
"TaskHandle",
"TaskRejectedError",
"TaskResult",
"TaskTimeoutError",
"__version__",
]
@@ -0,0 +1,365 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Asynchronous, remote-only Artemis client."""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from collections.abc import Mapping
from typing import Any, Literal, Protocol
from artemis_client.errors import NotFoundError, ProtocolError, TaskRejectedError, TaskTimeoutError
from artemis_client.models import Capabilities, Device, TaskHandle, TaskResult
from artemis_client.transport import JsonTransport
class _Transport(Protocol):
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any: ...
class ArtemisClient:
"""Thin client for an Artemis daemon running on another host.
This class never imports ADB, an Artemis agent, an LLM provider, or a
device driver. All execution is delegated to ``base_url``.
"""
_LEGACY_FEATURES = frozenset(
{
"tasks.submit",
"tasks.get",
"tasks.stop",
"devices.list",
"system.readiness",
}
)
def __init__(
self,
base_url: str | None = None,
*,
token: str | None = None,
request_timeout: float = 30.0,
poll_interval: float = 1.0,
headers: Mapping[str, str] | None = None,
transport: _Transport | None = None,
device_id: str | None = None,
device_serial: str | None = None,
default_profile: Literal["flash", "pro"] = "flash",
concurrency_mode: str = "per_device",
max_concurrency: int | None = None,
standalone: bool = False,
) -> None:
if poll_interval <= 0:
raise ValueError("poll_interval must be greater than zero")
resolved_base_url = base_url or os.environ.get("ARTEMIS_BASE_URL")
if not resolved_base_url:
daemon_host = os.environ.get("ARTEMIS_DAEMON_HOST", "127.0.0.1")
daemon_port = os.environ.get("ARTEMIS_DAEMON_PORT", "8000")
resolved_base_url = f"http://{daemon_host}:{daemon_port}"
self.base_url = resolved_base_url.rstrip("/")
self.poll_interval = float(poll_interval)
self._device_serial = device_serial or device_id
self.default_profile = default_profile
self.concurrency_mode = str(concurrency_mode).strip().lower()
self.max_concurrency = max_concurrency
self.standalone = standalone
self._transport = transport or JsonTransport(
resolved_base_url,
token=token,
timeout=request_timeout,
headers=headers,
)
@property
def device_id(self) -> str | None:
"""Default remote device serial (legacy-compatible alias)."""
return self._device_serial
@device_id.setter
def device_id(self, value: str | None) -> None:
self._device_serial = value
@property
def device_serial(self) -> str | None:
return self._device_serial
@device_serial.setter
def device_serial(self, value: str | None) -> None:
self._device_serial = value
def set_device(self, device_serial: str) -> ArtemisClient:
"""Set the default remote device and return this client."""
self._device_serial = device_serial
return self
def set_concurrency_mode(self, mode: str) -> ArtemisClient:
"""Retain the legacy setting for callers migrating to server-side queues."""
self.concurrency_mode = str(mode).strip().lower()
return self
async def health(self) -> Mapping[str, Any]:
"""Perform a fast liveness check against the scheduler status API."""
payload = await self._request("GET", "/api/status")
return self._mapping(payload, endpoint="/api/status")
async def readiness(self) -> Mapping[str, Any]:
"""Run the host's full device and toolchain readiness diagnostics.
This endpoint can take substantially longer than :meth:`health`; set
a larger ``request_timeout`` when constructing the client if needed.
"""
payload = await self._request("GET", "/api/system/readiness")
return self._mapping(payload, endpoint="/api/system/readiness")
async def capabilities(self) -> Capabilities:
"""Discover server features, with a baseline for today's legacy API."""
try:
payload = await self._request("GET", "/api/v1/capabilities")
except NotFoundError:
return Capabilities(api_version="legacy", features=self._LEGACY_FEATURES)
return Capabilities.from_payload(self._mapping(payload, endpoint="/api/v1/capabilities"))
async def list_devices(self) -> tuple[Device, ...]:
"""List Android devices visible to the remote Artemis host."""
payload = await self._request("GET", "/api/devices")
if isinstance(payload, Mapping):
raw_devices = payload.get("devices", [])
else:
raw_devices = payload
if not isinstance(raw_devices, list):
raise ProtocolError("/api/devices response must contain a device list")
devices: list[Device] = []
for item in raw_devices:
if not isinstance(item, Mapping):
raise ProtocolError("/api/devices contained a non-object device entry")
devices.append(Device.from_payload(item))
return tuple(devices)
async def submit(
self,
goal: str,
*,
profile: Literal["flash", "pro"] | None = None,
device_serial: str | None = None,
expected_output: str | None = None,
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
conversation_id: str | None = None,
task_id: str | None = None,
options: Mapping[str, Any] | None = None,
) -> TaskHandle:
"""Submit one task and return immediately after scheduler admission.
``task_id`` is generated client-side and sent as the legacy
``session_id``. Reusing it makes submission retries idempotent.
Experimental, forward-compatible fields belong in ``options``.
"""
normalized_goal = goal.strip()
if not normalized_goal:
raise ValueError("goal must not be empty")
if task_id is None:
resolved_task_id = str(uuid.uuid4())
else:
try:
resolved_task_id = str(uuid.UUID(task_id))
except (AttributeError, TypeError, ValueError) as exc:
raise ValueError("task_id must be a valid UUID string") from exc
resolved_profile = profile or self.default_profile
resolved_device = device_serial or self.device_serial
payload: dict[str, Any] = {
"goal": normalized_goal,
"profile": resolved_profile,
"session_id": resolved_task_id,
"ingress": "python_sdk",
}
optional_values = {
"device_serial": resolved_device,
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"locked_app_package": locked_app_package,
"app_path": app_path,
"conversation_id": conversation_id,
"options": dict(options) if options is not None else None,
}
payload.update({key: value for key, value in optional_values.items() if value is not None})
response = self._mapping(
await self._request("POST", "/api/run", json_body=payload),
endpoint="/api/run",
)
status = str(response.get("status") or "unknown").lower()
tasks = response.get("tasks")
if status == "rejected" or (isinstance(tasks, list) and not tasks):
detail = str(response.get("error") or "Artemis host rejected the task")
raise TaskRejectedError(detail)
if not isinstance(tasks, list) or not tasks or not isinstance(tasks[0], Mapping):
raise ProtocolError("/api/run response did not contain an admitted task")
task_payload = dict(tasks[0])
task_payload.setdefault("session_id", resolved_task_id)
task_payload.setdefault("status", status)
return TaskHandle.from_payload(task_payload)
async def get_task(self, task_id: str) -> TaskResult:
"""Get a task from session storage or the live scheduler queue."""
try:
payload = await self._request("GET", f"/api/sessions/{task_id}")
return TaskResult.from_payload(
self._mapping(payload, endpoint=f"/api/sessions/{task_id}"),
task_id=task_id,
)
except NotFoundError:
status_payload = self._mapping(
await self._request("GET", "/api/status"),
endpoint="/api/status",
)
live_task = self._find_live_task(status_payload, task_id)
if live_task is None:
return TaskResult(task_id=task_id, status="launching")
return TaskResult.from_payload(live_task, task_id=task_id)
async def wait_for_task(
self,
task_id: str,
*,
timeout: float = 1800.0,
poll_interval: float | None = None,
) -> TaskResult:
"""Wait until a task reaches a terminal state."""
if timeout <= 0:
raise ValueError("timeout must be greater than zero")
interval = self.poll_interval if poll_interval is None else float(poll_interval)
if interval <= 0:
raise ValueError("poll_interval must be greater than zero")
started = time.monotonic()
while True:
result = await self.get_task(task_id)
if result.done:
return result
elapsed = time.monotonic() - started
if elapsed >= timeout:
raise TaskTimeoutError(task_id, timeout)
await asyncio.sleep(min(interval, max(0.0, timeout - elapsed)))
async def run(
self,
goal: str,
*,
profile: Literal["flash", "pro"] | None = None,
device_serial: str | None = None,
expected_output: str | None = None,
enable_outputter: bool | None = None,
locked_app_package: str | None = None,
app_path: str | None = None,
conversation_id: str | None = None,
task_id: str | None = None,
options: Mapping[str, Any] | None = None,
timeout: float = 1800.0,
poll_interval: float | None = None,
) -> TaskResult:
"""Submit a task and wait for its terminal result."""
handle = await self.submit(
goal,
profile=profile,
device_serial=device_serial,
expected_output=expected_output,
enable_outputter=enable_outputter,
locked_app_package=locked_app_package,
app_path=app_path,
conversation_id=conversation_id,
task_id=task_id,
options=options,
)
return await self.wait_for_task(
handle.task_id,
timeout=timeout,
poll_interval=poll_interval,
)
async def run_task(self, task: Any, **overrides: Any) -> TaskResult:
"""Run a task-like object while delegating all execution remotely."""
goal = getattr(task, "goal", None)
if not isinstance(goal, str) or not goal.strip():
raise ValueError("task must expose a non-empty goal attribute")
values: dict[str, Any] = {
"profile": getattr(task, "profile", None),
"device_serial": getattr(task, "device_serial", None)
or getattr(task, "device_id", None),
"locked_app_package": getattr(task, "locked_package", None),
}
values.update(overrides)
return await self.run(goal, **values)
async def stop(self, task_id: str) -> bool:
"""Request cancellation of one remote task."""
payload = self._mapping(
await self._request(
"POST",
"/api/stop",
json_body={"session_id": task_id},
),
endpoint="/api/stop",
)
return str(payload.get("status") or "").lower() == "stopped"
async def _request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
return await asyncio.to_thread(
self._transport.request,
method,
path,
json_body=json_body,
)
@staticmethod
def _mapping(payload: Any, *, endpoint: str) -> Mapping[str, Any]:
if not isinstance(payload, Mapping):
raise ProtocolError(f"{endpoint} response must be a JSON object")
return payload
@staticmethod
def _find_live_task(
scheduler: Mapping[str, Any],
task_id: str,
) -> Mapping[str, Any] | None:
for collection_name in ("queue", "active_tasks"):
collection = scheduler.get(collection_name) or []
if not isinstance(collection, list):
continue
for item in collection:
if not isinstance(item, Mapping):
continue
item_id = item.get("task_id") or item.get("session_id")
if str(item_id) == task_id:
return item
active_id = scheduler.get("task_id") or scheduler.get("session_id")
if active_id is not None and str(active_id) == task_id:
synthesized = dict(scheduler)
synthesized.setdefault("session_id", task_id)
return synthesized
return None
@@ -0,0 +1,66 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Exceptions raised by the Artemis remote client."""
from __future__ import annotations
from typing import Any
class ArtemisClientError(Exception):
"""Base class for all errors raised by this package."""
class NetworkError(ArtemisClientError):
"""The Artemis host could not be reached or the connection failed."""
class ProtocolError(ArtemisClientError):
"""The server returned a response that does not satisfy the API contract."""
class ApiError(ArtemisClientError):
"""The Artemis host returned an unsuccessful HTTP response."""
def __init__(
self,
status_code: int,
detail: str,
*,
payload: Any = None,
) -> None:
self.status_code = status_code
self.detail = detail
self.payload = payload
super().__init__(f"Artemis API returned HTTP {status_code}: {detail}")
class AuthenticationError(ApiError):
"""Authentication or authorization failed."""
class NotFoundError(ApiError):
"""The requested Artemis resource does not exist."""
class ConflictError(ApiError):
"""The request conflicts with the current server or device state."""
class TaskRejectedError(ArtemisClientError):
"""The server accepted the request but rejected task admission."""
class TaskTimeoutError(ArtemisClientError):
"""Waiting for an Artemis task exceeded the caller's deadline."""
def __init__(self, task_id: str, timeout: float) -> None:
self.task_id = task_id
self.timeout = timeout
super().__init__(f"Task {task_id} did not finish within {timeout:g} seconds")
@@ -0,0 +1,212 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Dependency-free data models for the Artemis remote API."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Mapping
TERMINAL_TASK_STATUSES = frozenset(
{"completed", "success", "failed", "cancelled", "canceled", "rejected"}
)
SUCCESS_TASK_STATUSES = frozenset({"completed", "success"})
def _string(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _integer(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _device_from_payload(payload: Mapping[str, Any]) -> str | None:
direct = _string(payload.get("device_serial") or payload.get("device_id"))
if direct:
return direct
device_info = payload.get("device_info")
if isinstance(device_info, str):
try:
device_info = json.loads(device_info)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if isinstance(device_info, Mapping):
return _string(device_info.get("device_serial") or device_info.get("device_id"))
return None
@dataclass(frozen=True, slots=True)
class TaskHandle:
"""A task accepted by the remote Artemis scheduler."""
task_id: str
status: str
device_serial: str | None = None
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
@property
def session_id(self) -> str:
"""Compatibility alias for servers that call a task a session."""
return self.task_id
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> TaskHandle:
task_id = _string(payload.get("task_id") or payload.get("session_id") or payload.get("id"))
if not task_id:
from artemis_client.errors import ProtocolError
raise ProtocolError("Task admission response did not contain a task/session ID")
return cls(
task_id=task_id,
status=(_string(payload.get("status")) or "queued").lower(),
device_serial=_device_from_payload(payload),
raw=dict(payload),
)
@dataclass(frozen=True, slots=True)
class TaskResult:
"""Current or terminal state of a remote Artemis task."""
task_id: str
status: str
goal: str | None = None
profile: str | None = None
device_serial: str | None = None
output: Any = None
error: str | None = None
turns: int | None = None
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
@property
def session_id(self) -> str:
return self.task_id
@property
def trace_id(self) -> str:
return self.task_id
@property
def done(self) -> bool:
return self.status in TERMINAL_TASK_STATUSES
@property
def succeeded(self) -> bool:
return self.status in SUCCESS_TASK_STATUSES
@classmethod
def from_payload(
cls,
payload: Mapping[str, Any],
*,
task_id: str | None = None,
) -> TaskResult:
resolved_id = _string(
payload.get("task_id")
or payload.get("session_id")
or payload.get("trace_id")
or payload.get("id")
or task_id
)
if not resolved_id:
from artemis_client.errors import ProtocolError
raise ProtocolError("Task response did not contain a task/session ID")
output = payload.get("output")
if output is None:
output = payload.get("result")
if output is None:
output = payload.get("summary")
return cls(
task_id=resolved_id,
status=(_string(payload.get("status")) or "unknown").lower(),
goal=_string(payload.get("goal") or payload.get("initial_goal")),
profile=_string(payload.get("profile")),
device_serial=_device_from_payload(payload),
output=output,
error=_string(payload.get("error") or payload.get("error_message")),
turns=_integer(payload.get("turns") or payload.get("current_turn")),
raw=dict(payload),
)
@dataclass(frozen=True, slots=True)
class Device:
"""A device reported by the remote Artemis host."""
serial: str
state: str
model: str | None = None
product: str | None = None
busy: bool = False
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> Device:
serial = _string(
payload.get("serial") or payload.get("device_serial") or payload.get("device_id")
)
if not serial:
from artemis_client.errors import ProtocolError
raise ProtocolError("Device response did not contain a serial number")
state = (_string(payload.get("state") or payload.get("status")) or "unknown").lower()
busy_value = payload.get("busy")
if busy_value is None:
busy_value = payload.get("is_busy")
return cls(
serial=serial,
state=state,
model=_string(payload.get("model")),
product=_string(payload.get("product")),
busy=bool(busy_value) or state in {"busy", "running", "locked"},
raw=dict(payload),
)
@dataclass(frozen=True, slots=True)
class Capabilities:
"""Features advertised by an Artemis host."""
api_version: str
features: frozenset[str]
server_version: str | None = None
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
def supports(self, feature: str) -> bool:
return feature in self.features
@classmethod
def from_payload(cls, payload: Mapping[str, Any]) -> Capabilities:
raw_features = payload.get("features") or []
if isinstance(raw_features, Mapping):
features = frozenset(str(key) for key, enabled in raw_features.items() if enabled)
elif isinstance(raw_features, (list, tuple, set, frozenset)):
features = frozenset(str(item) for item in raw_features)
else:
features = frozenset()
return cls(
api_version=_string(payload.get("api_version")) or "unknown",
server_version=_string(payload.get("server_version") or payload.get("version")),
features=features,
raw=dict(payload),
)
@@ -0,0 +1 @@
@@ -0,0 +1,134 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
"""Small JSON-over-HTTP transport implemented with the Python standard library."""
from __future__ import annotations
import json
import ssl
import urllib.error
import urllib.request
from typing import Any, Mapping
from urllib.parse import urlsplit
from artemis_client.errors import (
ApiError,
AuthenticationError,
ConflictError,
NetworkError,
NotFoundError,
ProtocolError,
)
class JsonTransport:
"""Synchronous transport used internally by the async client.
Calls are moved to worker threads by :class:`ArtemisClient`, keeping the
public API asynchronous without adding an HTTP dependency.
"""
def __init__(
self,
base_url: str,
*,
token: str | None = None,
timeout: float = 30.0,
headers: Mapping[str, str] | None = None,
ssl_context: ssl.SSLContext | None = None,
) -> None:
normalized = base_url.rstrip("/")
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("base_url must be an absolute http:// or https:// URL")
if timeout <= 0:
raise ValueError("timeout must be greater than zero")
self.base_url = normalized
self.timeout = float(timeout)
self.ssl_context = ssl_context
self.headers = {
"Accept": "application/json",
"User-Agent": "artemis-client/0.1",
**dict(headers or {}),
}
if token:
self.headers["Authorization"] = f"Bearer {token}"
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
if not path.startswith("/"):
path = f"/{path}"
url = f"{self.base_url}{path}"
body = None
headers = dict(self.headers)
if json_body is not None:
body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
url,
data=body,
headers=headers,
method=method.upper(),
)
try:
with urllib.request.urlopen(
request,
timeout=self.timeout,
context=self.ssl_context,
) as response:
data = response.read()
if not data:
return None
try:
return json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ProtocolError(f"Artemis host returned invalid JSON from {path}") from exc
except urllib.error.HTTPError as exc:
payload = self._decode_error_payload(exc)
detail = self._error_detail(payload, exc.reason)
error_type = {
401: AuthenticationError,
403: AuthenticationError,
404: NotFoundError,
409: ConflictError,
}.get(exc.code, ApiError)
raise error_type(exc.code, detail, payload=payload) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
reason = getattr(exc, "reason", exc)
raise NetworkError(f"Could not reach Artemis host at {url}: {reason}") from exc
@staticmethod
def _decode_error_payload(error: urllib.error.HTTPError) -> Any:
try:
data = error.read()
except OSError:
return None
if not data:
return None
try:
return json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return data.decode("utf-8", errors="replace")
@staticmethod
def _error_detail(payload: Any, fallback: Any) -> str:
if isinstance(payload, Mapping):
detail = payload.get("detail") or payload.get("error") or payload.get("message")
if detail is not None:
return str(detail)
if isinstance(payload, str) and payload.strip():
return payload.strip()
return str(fallback or "request failed")
@@ -0,0 +1,246 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
from __future__ import annotations
import unittest
from collections import defaultdict
from collections.abc import Mapping
from typing import Any
from artemis_client import (
ArtemisClient,
NotFoundError,
ProtocolError,
TaskRejectedError,
TaskTimeoutError,
)
class FakeTransport:
def __init__(self) -> None:
self.responses: dict[tuple[str, str], list[Any]] = defaultdict(list)
self.calls: list[tuple[str, str, Mapping[str, Any] | None]] = []
def add(self, method: str, path: str, *responses: Any) -> None:
self.responses[(method, path)].extend(responses)
def request(
self,
method: str,
path: str,
*,
json_body: Mapping[str, Any] | None = None,
) -> Any:
self.calls.append((method, path, json_body))
queued = self.responses[(method, path)]
if not queued:
raise AssertionError(f"Unexpected request: {method} {path}")
response = queued.pop(0)
if isinstance(response, BaseException):
raise response
return response
class ArtemisClientTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.transport = FakeTransport()
self.client = ArtemisClient(
"https://artemis.example.test",
poll_interval=0.001,
transport=self.transport,
)
async def test_submit_sends_idempotent_session_id(self) -> None:
task_id = "00000000-0000-4000-8000-000000000123"
self.transport.add(
"POST",
"/api/run",
{
"status": "started",
"tasks": [
{
"session_id": task_id,
"status": "pending",
"device_serial": "pixel-8",
}
],
},
)
handle = await self.client.submit(
"Open Settings",
task_id=task_id,
device_serial="pixel-8",
options={"record_video": True},
)
self.assertEqual(handle.task_id, task_id)
self.assertEqual(handle.device_serial, "pixel-8")
body = self.transport.calls[0][2]
assert body is not None
self.assertEqual(body["session_id"], task_id)
self.assertEqual(body["ingress"], "python_sdk")
self.assertEqual(body["options"], {"record_video": True})
async def test_submit_rejected_task_raises_specific_error(self) -> None:
self.transport.add(
"POST",
"/api/run",
{
"status": "rejected",
"error": "Device is offline",
"tasks": [],
},
)
with self.assertRaisesRegex(TaskRejectedError, "Device is offline"):
await self.client.submit("Open Settings")
async def test_submit_rejects_non_uuid_task_id(self) -> None:
with self.assertRaisesRegex(ValueError, "valid UUID"):
await self.client.submit("Open Settings", task_id="not-a-uuid")
async def test_run_finds_queued_task_then_reads_terminal_session(self) -> None:
task_id = "00000000-0000-4000-8000-000000000124"
self.transport.add(
"POST",
"/api/run",
{
"status": "started",
"tasks": [{"session_id": task_id, "status": "pending"}],
},
)
self.transport.add(
"GET",
f"/api/sessions/{task_id}",
NotFoundError(404, "not created yet"),
{
"session_id": task_id,
"status": "completed",
"goal": "Open Settings",
"current_turn": 4,
"summary": "Battery page opened",
},
)
self.transport.add(
"GET",
"/api/status",
{
"status": "running",
"queue": [{"session_id": task_id, "status": "pending"}],
},
)
result = await self.client.run("Open Settings", task_id=task_id, timeout=1)
self.assertTrue(result.done)
self.assertTrue(result.succeeded)
self.assertEqual(result.turns, 4)
self.assertEqual(result.output, "Battery page opened")
async def test_get_task_returns_launching_when_not_visible_yet(self) -> None:
self.transport.add(
"GET",
"/api/sessions/new-task",
NotFoundError(404, "missing"),
)
self.transport.add("GET", "/api/status", {"status": "idle", "queue": []})
result = await self.client.get_task("new-task")
self.assertEqual(result.status, "launching")
self.assertFalse(result.done)
async def test_wait_for_task_times_out(self) -> None:
class LaunchingClient(ArtemisClient):
async def get_task(self, task_id: str): # type: ignore[override]
from artemis_client import TaskResult
return TaskResult(task_id=task_id, status="running")
client = LaunchingClient(
"https://artemis.example.test",
poll_interval=0.001,
transport=self.transport,
)
with self.assertRaises(TaskTimeoutError):
await client.wait_for_task("slow-task", timeout=0.003)
async def test_list_devices_accepts_legacy_shape(self) -> None:
self.transport.add(
"GET",
"/api/devices",
{
"devices": [
{
"serial": "emulator-5554",
"state": "device",
"model": "Pixel_8",
"busy": True,
}
]
},
)
devices = await self.client.list_devices()
self.assertEqual(len(devices), 1)
self.assertEqual(devices[0].serial, "emulator-5554")
self.assertTrue(devices[0].busy)
async def test_capabilities_falls_back_for_legacy_server(self) -> None:
self.transport.add(
"GET",
"/api/v1/capabilities",
NotFoundError(404, "not implemented"),
)
capabilities = await self.client.capabilities()
self.assertEqual(capabilities.api_version, "legacy")
self.assertTrue(capabilities.supports("tasks.submit"))
async def test_health_uses_fast_scheduler_endpoint(self) -> None:
self.transport.add("GET", "/api/status", {"status": "idle"})
health = await self.client.health()
self.assertEqual(health["status"], "idle")
async def test_readiness_uses_full_diagnostics_endpoint(self) -> None:
self.transport.add(
"GET",
"/api/system/readiness",
{"overall_status": "ready"},
)
readiness = await self.client.readiness()
self.assertEqual(readiness["overall_status"], "ready")
async def test_invalid_devices_payload_is_rejected(self) -> None:
self.transport.add("GET", "/api/devices", {"devices": "not-a-list"})
with self.assertRaises(ProtocolError):
await self.client.list_devices()
async def test_stop_targets_one_session(self) -> None:
self.transport.add("POST", "/api/stop", {"status": "stopped"})
stopped = await self.client.stop("task-stop")
self.assertTrue(stopped)
self.assertEqual(
self.transport.calls[0],
("POST", "/api/stop", {"session_id": "task-stop"}),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,85 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
from __future__ import annotations
import io
import json
import unittest
import urllib.error
from email.message import Message
from unittest.mock import patch
from artemis_client import AuthenticationError, JsonTransport, NetworkError, ProtocolError
class FakeResponse:
def __init__(self, payload: bytes) -> None:
self.payload = payload
def __enter__(self) -> FakeResponse:
return self
def __exit__(self, *args: object) -> None:
return None
def read(self) -> bytes:
return self.payload
class JsonTransportTests(unittest.TestCase):
def test_rejects_non_http_base_url(self) -> None:
with self.assertRaises(ValueError):
JsonTransport("file:///tmp/artemis")
@patch("urllib.request.urlopen")
def test_sends_json_and_bearer_token(self, urlopen) -> None:
urlopen.return_value = FakeResponse(json.dumps({"ok": True}).encode())
transport = JsonTransport("https://host.example/", token="secret")
result = transport.request("POST", "/api/run", json_body={"goal": "test"})
self.assertEqual(result, {"ok": True})
request = urlopen.call_args.args[0]
self.assertEqual(request.full_url, "https://host.example/api/run")
self.assertEqual(request.headers["Authorization"], "Bearer secret")
self.assertEqual(json.loads(request.data), {"goal": "test"})
@patch("urllib.request.urlopen")
def test_maps_unauthorized_response(self, urlopen) -> None:
urlopen.side_effect = urllib.error.HTTPError(
"https://host.example/api/run",
401,
"Unauthorized",
Message(),
io.BytesIO(b'{"detail":"bad token"}'),
)
transport = JsonTransport("https://host.example")
with self.assertRaisesRegex(AuthenticationError, "bad token"):
transport.request("GET", "/api/run")
@patch("urllib.request.urlopen")
def test_invalid_json_raises_protocol_error(self, urlopen) -> None:
urlopen.return_value = FakeResponse(b"not-json")
transport = JsonTransport("https://host.example")
with self.assertRaises(ProtocolError):
transport.request("GET", "/api/status")
@patch("urllib.request.urlopen")
def test_network_failure_is_wrapped(self, urlopen) -> None:
urlopen.side_effect = urllib.error.URLError("connection refused")
transport = JsonTransport("https://host.example")
with self.assertRaisesRegex(NetworkError, "connection refused"):
transport.request("GET", "/api/status")
if __name__ == "__main__":
unittest.main()