This commit is contained in:
2026-07-06 23:44:18 +08:00
parent a453d9e6ba
commit d899b875ce
23 changed files with 3274 additions and 51 deletions
+25
View File
@@ -0,0 +1,25 @@
"""Cloud runtime: multi-host device pool, scheduler, plugins, and public SDK.
Sibling to ``agents/``, ``workflow/``, etc. Composes the existing single-process
capabilities (``device-management``, ``agent-runtime``, ``workflow-orchestration``,
``driver-registry``) without editing them.
"""
__all__ = [
"CloudConfig",
"CloudStore",
"DevicePool",
"HostRegistration",
"PooledDevice",
"TaskScheduler",
"ScheduledTask",
"TaskConstraints",
"AssignmentStrategy",
"fifo_match_strategy",
"TaskDispatcher",
"Assignment",
"RemoteDispatchNotSupportedError",
"PluginManifest",
"PluginRegistry",
"DiscoveryResult",
]
+35
View File
@@ -0,0 +1,35 @@
"""Configuration for the cloud runtime with conservative inert defaults."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class CloudConfig:
"""Tunable cloud-runtime knobs.
Defaults are deliberately conservative so importing ``cloud`` is inert
until a caller actually registers a second host or submits through the SDK.
"""
# How frequently a host is expected to push its device snapshot. Informational
# only at runtime (the pool does not run a timer); drives operator expectations.
sync_interval_seconds: int = 30
# A host whose ``last_seen_at`` is older than this threshold reports all its
# devices as ``unreachable`` on the next pool read.
stale_after_seconds: int = 90
# Bound on the number of ``queued`` ScheduledTasks. Submissions beyond this
# are rejected rather than letting the backlog grow without limit.
max_queue_depth: int = 100
# Strategy name looked up in the AssignmentStrategy registry (default fifo_match).
default_assignment_strategy: str = "fifo_match"
# Public SDK URL prefix. Mounted as the FastAPI APIRouter ``prefix``.
api_version_prefix: str = "/v1"
# SQLite file path (relative to CWD or absolute) owned by CloudStore.
db_path: str = "cloud/cloud.sqlite3"
+103
View File
@@ -0,0 +1,103 @@
"""Task dispatcher: composes TaskRunner / WorkflowRunner to execute assignments.
Capability: ``task-scheduler`` (closing the loop from ``assigned`` to ``executed``).
Composes the existing single-process execution entry points (``runtime.task.TaskRunner``,
``workflow.runner.WorkflowRunner``) without editing them, strictly through their
public ``run(task) -> Task`` / ``run(definition, device_id) -> WorkflowRun`` contracts.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from core.models import Task
if TYPE_CHECKING:
from cloud.store import CloudStore
@dataclass(frozen=True)
class Assignment:
"""A scheduler-produced binding of a queued task to a specific device+host."""
task_id: str
device_id: str
host_id: str
goal: str | None
workflow_definition_id: str | None
class RemoteDispatchNotSupportedError(RuntimeError):
"""Raised when an assignment targets a host other than the local process."""
class UnknownWorkflowDefinitionError(RuntimeError):
"""Raised when a workflow-based assignment references an unknown definition id."""
# Callable type aliases; kept lazy so importing this module never imports runtime/workflow.
TaskRunnerFactory = Callable[[], "object"]
WorkflowRunnerFactory = Callable[[], "object"]
class TaskDispatcher:
"""Executes assignments via the existing task / workflow runners.
Local-only in this change: an assignment whose ``host_id`` does not match
the dispatcher's own ``local_host_id`` raises ``RemoteDispatchNotSupportedError``
rather than attempting any execution over the network.
"""
def __init__(
self,
local_host_id: str,
task_runner_factory: TaskRunnerFactory,
workflow_runner_factory: WorkflowRunnerFactory,
store: "CloudStore",
) -> None:
self.local_host_id = local_host_id
self.task_runner_factory = task_runner_factory
self.workflow_runner_factory = workflow_runner_factory
self.store = store
def dispatch(self, assignment: Assignment) -> None:
if assignment.host_id != self.local_host_id:
raise RemoteDispatchNotSupportedError(
f"assignment {assignment.task_id} targets host "
f"{assignment.host_id!r}, but this dispatcher owns {self.local_host_id!r}"
)
if assignment.workflow_definition_id:
status = self._dispatch_workflow(assignment)
else:
status = self._dispatch_goal(assignment)
self.store.update_task(assignment.task_id, status=status)
def _dispatch_goal(self, assignment: Assignment) -> str:
task = Task(
goal=assignment.goal or "",
device_id=assignment.device_id,
)
result = self.task_runner_factory().run(task) # type: ignore[attr-defined]
result_status = getattr(result, "status", None)
return "done" if result_status == "completed" else "failed"
def _dispatch_workflow(self, assignment: Assignment) -> str:
runner = self.workflow_runner_factory()
store = getattr(runner, "store", None)
if store is None:
raise UnknownWorkflowDefinitionError(
"workflow runner has no store to load definitions from"
)
definition = store.get_definition(assignment.workflow_definition_id)
if definition is None:
raise UnknownWorkflowDefinitionError(
f"unknown workflow definition {assignment.workflow_definition_id!r}"
)
run = runner.run(definition, device_id=assignment.device_id) # type: ignore[attr-defined]
run_status = getattr(run, "status", None)
return "done" if run_status == "completed" else "failed"
+245
View File
@@ -0,0 +1,245 @@
"""Plugin manifest and registry: discover and wire drivers/tools/skills.
Capability: ``plugin-system``.
Two discovery paths (Python ``importlib.metadata`` entry points in group
``device_agent_runtime.plugins``, and a local ``plugin.json`` file scan) feed
into one validation + registration path. Only ``driver``-kind plugins are
concretely wired (into ``driver/registry.py``'s extension point); ``tool``- and
``skill``-kind manifests are accepted and stored as ``wired=False``.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from cloud.store import CloudStore
logger = logging.getLogger(__name__)
ENTRY_POINT_GROUP = "device_agent_runtime.plugins"
PLUGIN_MANIFEST_FILENAME = "plugin.json"
EntryPointKind = Literal["driver", "tool", "skill"]
_VALID_ENTRY_POINT_KINDS = ("driver", "tool", "skill")
@dataclass(frozen=True)
class PluginManifest:
"""A plugin's declaration: who it is, what kind it is, where it lives."""
name: str
version: str
entry_point_kind: EntryPointKind
target: str
def __post_init__(self) -> None:
if not self.name:
raise PluginValidationError("plugin manifest 'name' is required")
if not self.version:
raise PluginValidationError("plugin manifest 'version' is required")
if not self.target:
raise PluginValidationError("plugin manifest 'target' is required")
if self.entry_point_kind not in _VALID_ENTRY_POINT_KINDS:
raise PluginValidationError(
f"plugin manifest 'entry_point_kind' must be one of "
f"{_VALID_ENTRY_POINT_KINDS}, got {self.entry_point_kind!r}"
)
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"version": self.version,
"entry_point_kind": self.entry_point_kind,
"target": self.target,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PluginManifest":
return cls(
name=str(data["name"]),
version=str(data["version"]),
entry_point_kind=str(data["entry_point_kind"]), # type: ignore[arg-type]
target=str(data["target"]),
)
@dataclass
class DiscoveryResult:
"""The outcome of running both discovery scans."""
registered: list[PluginManifest] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
class PluginValidationError(ValueError):
"""Raised when a plugin manifest fails schema validation."""
class DuplicatePluginError(ValueError):
"""Raised when a plugin name is already registered."""
class DriverRegistryUnavailableError(RuntimeError):
"""Raised when ``driver.registry.register_driver_type`` is not importable."""
class PluginTargetResolutionError(RuntimeError):
"""Raised when a plugin target cannot be resolved to a callable."""
class PluginRegistry:
"""Validates, persists, and wires plugins discovered or submitted directly."""
def __init__(self, store: "CloudStore") -> None:
self.store = store
def register(self, manifest: PluginManifest) -> PluginManifest:
"""Validate, persist, and (for drivers) wire a plugin manifest."""
if self.store.get_plugin(manifest.name) is not None:
raise DuplicatePluginError(
f"a plugin named {manifest.name!r} is already registered"
)
wired = False
if manifest.entry_point_kind == "driver":
wired = self._wire_driver(manifest)
# tool/skill-kind: stored with wired=False, no other resolution attempted.
self.store.save_plugin(manifest, wired=wired)
if not wired:
logger.info(
"plugin %r registered but not wired to any execution path",
manifest.name,
)
return manifest
def list(self) -> list[tuple[PluginManifest, bool]]:
return self.store.list_plugins()
def discover(self, scan_path: str | Path | None = None) -> DiscoveryResult:
result = DiscoveryResult()
for manifest in self.discover_entry_points():
try:
self.register(manifest)
result.registered.append(manifest)
except (PluginValidationError, DuplicatePluginError) as exc:
result.errors.append(
f"entry-point plugin {manifest.name!r}: {exc}"
)
if scan_path is not None:
for manifest in self.discover_manifest_files(scan_path):
try:
self.register(manifest)
result.registered.append(manifest)
except (PluginValidationError, DuplicatePluginError) as exc:
result.errors.append(
f"manifest-file plugin {manifest.name!r}: {exc}"
)
return result
def discover_entry_points(self) -> list[PluginManifest]:
manifests: list[PluginManifest] = []
try:
entry_points = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
except TypeError:
# Older Pythons: entry_points() returns a dict-like view.
all_eps = importlib.metadata.entry_points()
entry_points = all_eps.get(ENTRY_POINT_GROUP, []) # type: ignore[union-attr]
for entry_point in entry_points:
try:
loaded = entry_point.load()
except Exception as exc: # pragma: no cover - exercised via fake eps in tests
logger.warning("entry point %r failed to load: %s", entry_point.name, exc)
continue
manifest = _coerce_to_manifest(loaded)
if manifest is not None:
manifests.append(manifest)
return manifests
def discover_manifest_files(self, scan_path: str | Path) -> list[PluginManifest]:
root = Path(scan_path)
manifests: list[PluginManifest] = []
if not root.exists():
return manifests
for path in sorted(root.rglob(PLUGIN_MANIFEST_FILENAME)):
try:
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
manifest = PluginManifest.from_dict(data)
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
logger.warning("malformed plugin manifest %s: %s", path, exc)
continue
manifests.append(manifest)
return manifests
def _wire_driver(self, manifest: PluginManifest) -> bool:
register_fn = _resolve_driver_register_function()
if register_fn is None:
raise DriverRegistryUnavailableError(
"driver.registry.register_driver_type is not importable; "
"the driver-registry extension point must be available to "
"register driver-kind plugins"
)
builder = _resolve_target(manifest.target)
register_fn(manifest.name, builder)
return True
def _resolve_driver_register_function():
"""Return ``driver.registry.register_driver_type`` if importable, else None."""
try:
module = importlib.import_module("driver.registry")
except ImportError:
return None
fn = getattr(module, "register_driver_type", None)
if not callable(fn):
return None
return fn
def _resolve_target(target: str):
"""Resolve ``module.sub:attribute`` to a callable."""
if ":" not in target:
raise PluginTargetResolutionError(
f"plugin target {target!r} must be 'module.path:attribute'"
)
module_path, _, attribute = target.partition(":")
if not module_path or not attribute:
raise PluginTargetResolutionError(
f"plugin target {target!r} must be 'module.path:attribute'"
)
try:
module = importlib.import_module(module_path)
except ImportError as exc:
raise PluginTargetResolutionError(
f"could not import plugin target module {module_path!r}: {exc}"
) from exc
obj = getattr(module, attribute, None)
if obj is None or not callable(obj):
raise PluginTargetResolutionError(
f"plugin target {target!r} did not resolve to a callable"
)
return obj
def _coerce_to_manifest(loaded: Any) -> PluginManifest | None:
if isinstance(loaded, PluginManifest):
return loaded
if isinstance(loaded, dict):
try:
return PluginManifest.from_dict(loaded)
except (KeyError, ValueError):
return None
if callable(loaded):
try:
result = loaded()
except Exception: # pragma: no cover - defensive
return None
return _coerce_to_manifest(result)
return None
+126
View File
@@ -0,0 +1,126 @@
"""Device pool: aggregating device state across hosts with staleness tracking.
Capability: ``device-pool``.
The pool never reaches out over the network. Each host's own agent calls
``DevicePool.sync_host_devices(host_id, snapshot)`` to push its current
``DeviceManager.list_devices()`` result. A host whose heartbeat goes stale
has its devices lazily reported as ``unreachable`` on the next read.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import TYPE_CHECKING, Literal
from core.models import Device, utc_now
if TYPE_CHECKING:
from cloud.config import CloudConfig
from cloud.store import CloudStore
PooledDeviceStatus = Literal["idle", "busy", "offline", "error", "unreachable"]
# Statuses that map 1:1 from a host's snapshot. ``unreachable`` is pool-only.
_HOST_REPORTED_STATUSES = ("idle", "busy", "offline", "error")
@dataclass(frozen=True)
class HostRegistration:
"""A host process that has registered itself with the pool."""
host_id: str
address: str | None
last_seen_at: datetime
@dataclass(frozen=True)
class PooledDevice:
"""A device owned by a registered host, as seen by the pool."""
device_id: str
host_id: str
driver_type: str
status: PooledDeviceStatus
capability_tags: list[str] = field(default_factory=list)
synced_at: datetime | None = None
class DevicePool:
"""Aggregates ``Device`` snapshots pushed by many host processes."""
def __init__(self, store: "CloudStore", config: "CloudConfig") -> None:
self.store = store
self.config = config
def sync_host_devices(
self,
host_id: str,
snapshot: list[Device],
*,
address: str | None = None,
) -> None:
"""Push a host's current device snapshot into the pool.
Updates the host's ``last_seen_at`` and atomically replaces its
previously-stored device rows with the new snapshot. Devices from
other hosts are untouched.
"""
now = utc_now()
self.store.upsert_host(host_id, address=address, last_seen_at=now)
devices = [self._to_pooled(device, host_id, now) for device in snapshot]
self.store.replace_host_devices(host_id, devices)
def list_devices(self) -> list[PooledDevice]:
devices = self.store.list_devices()
if not devices:
return []
hosts = {h.host_id: h for h in self.store.list_hosts()}
now = utc_now()
result: list[PooledDevice] = []
for device in devices:
host = hosts.get(device.host_id)
if host is not None and self._is_stale(host, now):
device = self._as_unreachable(device)
result.append(device)
return result
def get_device(self, device_id: str) -> PooledDevice | None:
device = self.store.get_device(device_id)
if device is None:
return None
host = self.store.get_host(device.host_id)
if host is not None and self._is_stale(host, utc_now()):
return self._as_unreachable(device)
return device
def list_hosts(self) -> list[HostRegistration]:
return self.store.list_hosts()
def _to_pooled(
self,
device: Device,
host_id: str,
synced_at: datetime,
) -> PooledDevice:
raw_status = device.status if device.status in _HOST_REPORTED_STATUSES else "idle"
tags = list(getattr(device, "capability_tags", []) or [])
return PooledDevice(
device_id=device.id,
host_id=host_id,
driver_type=device.driver_type,
status=raw_status, # type: ignore[arg-type]
capability_tags=tags,
synced_at=synced_at,
)
def _is_stale(self, host: HostRegistration, now: datetime) -> bool:
age = (now - host.last_seen_at).total_seconds()
return age > self.config.stale_after_seconds
def _as_unreachable(self, device: PooledDevice) -> PooledDevice:
if device.status == "unreachable":
return device
return replace(device, status="unreachable")
+190
View File
@@ -0,0 +1,190 @@
"""Task scheduler: bounded queue + pluggable assignment strategy.
Capability: ``task-scheduler``.
Mirrors the "string key -> swappable implementation" registry shape already used
by ``driver/registry.py``, ``perception/provider.py``, and ``workflow/conditions.py``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
from uuid import uuid4
from core.models import utc_now
if TYPE_CHECKING:
from cloud.config import CloudConfig
from cloud.dispatch import Assignment
from cloud.pool import DevicePool, PooledDevice
from cloud.store import CloudStore
ScheduledTaskStatus = Literal["queued", "assigned", "dispatched", "done", "failed"]
@dataclass(frozen=True)
class TaskConstraints:
"""Optional device constraints attached to a task submission."""
driver_type: str | None = None
capability_tags: list[str] = field(default_factory=list)
@dataclass
class ScheduledTask:
"""A task submitted to the cloud scheduler, awaiting or undergoing assignment."""
id: str
goal: str | None
workflow_definition_id: str | None
constraints: TaskConstraints
status: ScheduledTaskStatus = "queued"
assigned_device_id: str | None = None
assigned_host_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
@runtime_checkable
class AssignmentStrategy(Protocol):
"""Selects one device from a pre-filterd list of matching candidates."""
def select(
self,
task: ScheduledTask,
candidates: "list[PooledDevice]",
) -> "PooledDevice | None": ...
class FifoMatchStrategy:
"""Default strategy: return the first candidate, or None if empty.
The caller is responsible for pre-filtering to idle, constraint-matching
devices in submission order. This keeps the strategy trivially replaceable.
"""
def select(
self,
task: ScheduledTask,
candidates: "list[PooledDevice]",
) -> "PooledDevice | None":
if not candidates:
return None
return candidates[0]
FIFO_MATCH_STRATEGY_NAME = "fifo_match"
DEFAULT_STRATEGIES: dict[str, AssignmentStrategy] = {
FIFO_MATCH_STRATEGY_NAME: FifoMatchStrategy(),
}
class UnknownAssignmentStrategyError(ValueError):
"""Raised when the configured default strategy is not in the registry."""
class QueueFullError(RuntimeError):
"""Raised when a submission would exceed ``config.max_queue_depth``."""
class TaskSubmissionValidationError(ValueError):
"""Raised when a submission has neither a goal nor a workflow_definition_id."""
class TaskScheduler:
"""Accepts task submissions and assigns queued tasks to idle devices."""
def __init__(
self,
pool: "DevicePool",
store: "CloudStore",
config: "CloudConfig",
*,
strategies: dict[str, AssignmentStrategy] | None = None,
) -> None:
self.pool = pool
self.store = store
self.config = config
self._strategies = dict(strategies) if strategies is not None else dict(DEFAULT_STRATEGIES)
if config.default_assignment_strategy not in self._strategies:
raise UnknownAssignmentStrategyError(
f"unknown assignment strategy {config.default_assignment_strategy!r}; "
f"registered strategies: {sorted(self._strategies)}"
)
def submit(
self,
goal: str | None = None,
workflow_definition_id: str | None = None,
constraints: TaskConstraints | None = None,
) -> str:
if not goal and not workflow_definition_id:
raise TaskSubmissionValidationError(
"a submission must specify either a goal or a workflow_definition_id"
)
if self.store.count_queued_tasks() >= self.config.max_queue_depth:
raise QueueFullError(
f"task queue is full ({self.config.max_queue_depth} queued)"
)
task = ScheduledTask(
id=uuid4().hex,
goal=goal,
workflow_definition_id=workflow_definition_id,
constraints=constraints or TaskConstraints(),
status="queued",
created_at=utc_now(),
)
self.store.enqueue_task(task)
return task.id
def assign(self) -> "list[Assignment]":
from cloud.dispatch import Assignment
strategy = self._strategies[self.config.default_assignment_strategy]
assignments: list[Assignment] = []
queued = self.store.list_queued_tasks() # ordered oldest-first
if not queued:
return assignments
devices = self.pool.list_devices()
assigned_device_ids: set[str] = set()
for task in queued:
candidates = [
device
for device in devices
if device.device_id not in assigned_device_ids
and device.status == "idle"
and _matches(device, task.constraints)
]
selected = strategy.select(task, candidates)
if selected is None:
continue
assigned_device_ids.add(selected.device_id)
self.store.update_task(
task.id,
status="assigned",
assigned_device_id=selected.device_id,
assigned_host_id=selected.host_id,
)
assignments.append(
Assignment(
task_id=task.id,
device_id=selected.device_id,
host_id=selected.host_id,
goal=task.goal,
workflow_definition_id=task.workflow_definition_id,
)
)
return assignments
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
if constraints.driver_type and device.driver_type != constraints.driver_type:
return False
if constraints.capability_tags:
device_tags = set(device.capability_tags)
if not all(tag in device_tags for tag in constraints.capability_tags):
return False
return True
+9
View File
@@ -0,0 +1,9 @@
"""Public platform SDK: versioned REST API and Python client for external integrators."""
__all__ = [
"CloudClient",
"create_cloud_router",
"AuthProvider",
"NullAuthProvider",
"Principal",
]
+222
View File
@@ -0,0 +1,222 @@
"""Versioned REST API for external integrators.
Capability: ``platform-sdk``.
Mirrors ``api/console.py``'s ``create_console_router`` shape: a factory that
returns a ``fastapi.APIRouter`` mounted under a versioned ``/v1`` prefix.
Every route flows through an ``AuthProvider`` hook (no-op default) so real
authentication can be added later without changing route signatures.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from cloud.sdk.models import (
DeviceResponse,
ErrorResponse,
HostResponse,
PluginRegistrationRequest,
PluginResponse,
TaskStatusResponse,
TaskSubmissionRequest,
TaskSubmissionResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
if TYPE_CHECKING:
from cloud.plugins import PluginRegistry
from cloud.pool import DevicePool
from cloud.scheduler import TaskConstraints, TaskScheduler
@dataclass(frozen=True)
class Principal:
"""An authenticated principal. ``anonymous`` for the NullAuthProvider."""
id: str = "anonymous"
@runtime_checkable
class AuthProvider(Protocol):
"""Returns a Principal if the request is allowed, None to reject."""
def authenticate(self, request: object) -> Principal | None: ...
class NullAuthProvider:
"""Default auth provider: every caller is anonymous-and-allowed."""
def authenticate(self, request: object) -> Principal | None:
return Principal()
def create_cloud_router(
*,
pool: "DevicePool",
scheduler: "TaskScheduler",
plugin_registry: "PluginRegistry",
auth_provider: AuthProvider | None = None,
version_prefix: str = "/v1",
) -> APIRouter:
"""Build the ``/v1`` APIRouter exposing the platform SDK surface."""
auth = auth_provider or NullAuthProvider()
router = APIRouter(prefix=version_prefix, tags=["cloud-platform"])
def _authorize(request: Request) -> Principal:
principal = auth.authenticate(request)
if principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
)
return principal
@router.post(
"/tasks",
response_model=TaskSubmissionResponse,
status_code=status.HTTP_201_CREATED,
)
def submit_task(
payload: TaskSubmissionRequest,
request: Request,
) -> TaskSubmissionResponse:
_authorize(request)
task_constraints = _build_constraints(payload.constraints)
try:
task_id = scheduler.submit(
goal=payload.goal,
workflow_definition_id=payload.workflow_definition_id,
constraints=task_constraints,
)
except (ValueError, RuntimeError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
return TaskSubmissionResponse(task_id=task_id)
@router.get("/tasks/{task_id}", response_model=TaskStatusResponse)
def get_task_status(task_id: str, request: Request) -> TaskStatusResponse:
_authorize(request)
task = scheduler.store.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task {task_id!r} not found",
)
return TaskStatusResponse(
id=task.id,
status=task.status,
goal=task.goal,
workflow_definition_id=task.workflow_definition_id,
assigned_device_id=task.assigned_device_id,
assigned_host_id=task.assigned_host_id,
)
@router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request)
return [
DeviceResponse(
device_id=d.device_id,
host_id=d.host_id,
driver_type=d.driver_type,
status=d.status,
capability_tags=list(d.capability_tags),
)
for d in pool.list_devices()
]
@router.get("/hosts", response_model=list[HostResponse])
def list_hosts(request: Request) -> list[HostResponse]:
_authorize(request)
return [
HostResponse(
host_id=h.host_id,
address=h.address,
last_seen_at=h.last_seen_at.isoformat() if h.last_seen_at else "",
)
for h in pool.list_hosts()
]
@router.get("/plugins", response_model=list[PluginResponse])
def list_plugins(request: Request) -> list[PluginResponse]:
_authorize(request)
return [
PluginResponse(
name=manifest.name,
version=manifest.version,
entry_point_kind=manifest.entry_point_kind,
target=manifest.target,
wired=wired,
)
for manifest, wired in plugin_registry.list()
]
@router.post(
"/plugins",
response_model=PluginResponse,
status_code=status.HTTP_201_CREATED,
responses={
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse},
status.HTTP_409_CONFLICT: {"model": ErrorResponse},
},
)
def register_plugin(
payload: PluginRegistrationRequest,
request: Request,
) -> PluginResponse:
_authorize(request)
from cloud.plugins import (
DriverRegistryUnavailableError,
DuplicatePluginError,
PluginManifest,
PluginValidationError,
)
manifest = PluginManifest(
name=payload.name,
version=payload.version,
entry_point_kind=payload.entry_point_kind,
target=payload.target,
)
try:
plugin_registry.register(manifest)
except DuplicatePluginError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except DriverRegistryUnavailableError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except (PluginValidationError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
stored = plugin_registry.store.get_plugin(manifest.name)
wired = stored[1] if stored is not None else False
return PluginResponse(
name=manifest.name,
version=manifest.version,
entry_point_kind=manifest.entry_point_kind,
target=manifest.target,
wired=wired,
)
return router
def _build_constraints(model):
from cloud.scheduler import TaskConstraints
return TaskConstraints(
driver_type=model.driver_type,
capability_tags=list(model.capability_tags),
)
+113
View File
@@ -0,0 +1,113 @@
"""Thin Python SDK client for the ``/v1`` REST API.
Capability: ``platform-sdk``.
Uses ``httpx`` directly so the client can talk to any deployed cloud-runtime
process over HTTP, or to a FastAPI ``TestClient`` instance in tests.
"""
from __future__ import annotations
from typing import Any
import httpx
class CloudClient:
"""A minimal Python wrapper for the platform SDK's ``/v1`` routes."""
def __init__(
self,
base_url: str,
*,
http_client: httpx.Client | Any = None,
api_prefix: str = "/v1",
) -> None:
self._base_url = base_url.rstrip("/")
self._api_prefix = api_prefix.rstrip("/")
if http_client is None:
self._http = httpx.Client(base_url=self._base_url)
self._owns_client = True
else:
self._http = http_client
self._owns_client = False
def close(self) -> None:
if self._owns_client:
self._http.close()
def __enter__(self) -> "CloudClient":
return self
def __exit__(self, *exc: object) -> None:
self.close()
# ------------------------------------------------------------------- tasks
def submit_task(
self,
*,
goal: str | None = None,
workflow_definition_id: str | None = None,
driver_type: str | None = None,
capability_tags: list[str] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"goal": goal,
"workflow_definition_id": workflow_definition_id,
}
if driver_type is not None or capability_tags is not None:
payload["constraints"] = {
"driver_type": driver_type,
"capability_tags": list(capability_tags or []),
}
resp = self._http.post(self._url("/tasks"), json=payload)
resp.raise_for_status()
return resp.json()
def get_task_status(self, task_id: str) -> dict[str, Any]:
resp = self._http.get(self._url(f"/tasks/{task_id}"))
resp.raise_for_status()
return resp.json()
# ----------------------------------------------------------------- devices
def list_devices(self) -> list[dict[str, Any]]:
resp = self._http.get(self._url("/devices"))
resp.raise_for_status()
return resp.json()
def list_hosts(self) -> list[dict[str, Any]]:
resp = self._http.get(self._url("/hosts"))
resp.raise_for_status()
return resp.json()
# ----------------------------------------------------------------- plugins
def list_plugins(self) -> list[dict[str, Any]]:
resp = self._http.get(self._url("/plugins"))
resp.raise_for_status()
return resp.json()
def register_plugin(
self,
*,
name: str,
version: str,
entry_point_kind: str,
target: str,
) -> dict[str, Any]:
payload = {
"name": name,
"version": version,
"entry_point_kind": entry_point_kind,
"target": target,
}
resp = self._http.post(self._url("/plugins"), json=payload)
resp.raise_for_status()
return resp.json()
# ------------------------------------------------------------------ helpers
def _url(self, path: str) -> str:
return f"{self._base_url}{self._api_prefix}{path}"
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic request/response models for the platform SDK REST API."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class TaskConstraintsModel(BaseModel):
driver_type: str | None = None
capability_tags: list[str] = Field(default_factory=list)
class TaskSubmissionRequest(BaseModel):
goal: str | None = None
workflow_definition_id: str | None = None
constraints: TaskConstraintsModel = Field(default_factory=TaskConstraintsModel)
class TaskSubmissionResponse(BaseModel):
task_id: str
class TaskStatusResponse(BaseModel):
id: str
status: str
goal: str | None = None
workflow_definition_id: str | None = None
assigned_device_id: str | None = None
assigned_host_id: str | None = None
class DeviceResponse(BaseModel):
device_id: str
host_id: str
driver_type: str
status: str
capability_tags: list[str] = Field(default_factory=list)
class HostResponse(BaseModel):
host_id: str
address: str | None = None
last_seen_at: str
class PluginRegistrationRequest(BaseModel):
name: str
version: str
entry_point_kind: Literal["driver", "tool", "skill"]
target: str
class PluginResponse(BaseModel):
name: str
version: str
entry_point_kind: str
target: str
wired: bool
class ErrorResponse(BaseModel):
detail: str
+414
View File
@@ -0,0 +1,414 @@
"""SQLite-backed store for cloud runtime state.
Owns ``cloud/cloud.sqlite3`` with four tables: ``host_registrations``,
``pooled_devices``, ``scheduled_tasks``, and ``plugins``. Uses the same
connect-per-call ``sqlite3`` pattern as ``storage/task_metadata.py`` and
``workflow/store.py``.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING, Any
from core.models import utc_now
if TYPE_CHECKING:
from cloud.pool import HostRegistration, PooledDevice
from cloud.plugins import PluginManifest
from cloud.scheduler import ScheduledTask
class CloudStore:
"""Persisted state for the cloud runtime (hosts, devices, tasks, plugins)."""
def __init__(self, db_path: str | Path = "cloud/cloud.sqlite3") -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
# ------------------------------------------------------------------ hosts
def upsert_host(
self,
host_id: str,
*,
address: str | None,
last_seen_at: Any,
) -> None:
"""Insert or update a host row.
If ``address`` is None and the host already exists, the existing
address is preserved (a heartbeat sync should not blow away a
previously-registered address).
"""
with self._connect() as connection:
existing = connection.execute(
"select address from host_registrations where host_id = ?",
(host_id,),
).fetchone()
preserved_address = (
existing["address"] if (address is None and existing is not None) else address
)
connection.execute(
"""
insert into host_registrations (host_id, address, last_seen_at)
values (?, ?, ?)
on conflict(host_id) do update set
address = excluded.address,
last_seen_at = excluded.last_seen_at
""",
(host_id, preserved_address, _iso(last_seen_at)),
)
def replace_host_devices(
self,
host_id: str,
devices: "list[PooledDevice]",
) -> None:
"""Atomically replace one host's device rows with the given list."""
with self._connect() as connection:
connection.execute(
"delete from pooled_devices where host_id = ?",
(host_id,),
)
connection.executemany(
"""
insert into pooled_devices (
device_id, host_id, driver_type, status,
capability_tags_json, synced_at
) values (?, ?, ?, ?, ?, ?)
""",
[
(
d.device_id,
d.host_id,
d.driver_type,
d.status,
json.dumps(list(d.capability_tags), ensure_ascii=False),
_iso(d.synced_at),
)
for d in devices
],
)
def list_hosts(self) -> "list[HostRegistration]":
from cloud.pool import HostRegistration
with self._connect() as connection:
rows = connection.execute(
"select host_id, address, last_seen_at from host_registrations"
).fetchall()
return [_row_to_host(row) for row in rows]
def get_host(self, host_id: str) -> "HostRegistration | None":
from cloud.pool import HostRegistration
with self._connect() as connection:
row = connection.execute(
"select host_id, address, last_seen_at from host_registrations where host_id = ?",
(host_id,),
).fetchone()
return _row_to_host(row) if row else None
def list_devices(self) -> "list[PooledDevice]":
with self._connect() as connection:
rows = connection.execute(
"""
select device_id, host_id, driver_type, status,
capability_tags_json, synced_at
from pooled_devices
"""
).fetchall()
return [_row_to_device(row) for row in rows]
def get_device(self, device_id: str) -> "PooledDevice | None":
with self._connect() as connection:
row = connection.execute(
"""
select device_id, host_id, driver_type, status,
capability_tags_json, synced_at
from pooled_devices where device_id = ?
""",
(device_id,),
).fetchone()
return _row_to_device(row) if row else None
# ----------------------------------------------------------- scheduled tasks
def enqueue_task(self, task: "ScheduledTask") -> None:
with self._connect() as connection:
connection.execute(
"""
insert into scheduled_tasks (
id, goal, workflow_definition_id,
constraints_json, status,
assigned_device_id, assigned_host_id, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task.id,
task.goal,
task.workflow_definition_id,
json.dumps(asdict(task.constraints), ensure_ascii=False),
task.status,
task.assigned_device_id,
task.assigned_host_id,
_iso(task.created_at),
),
)
def list_queued_tasks(self) -> "list[ScheduledTask]":
with self._connect() as connection:
rows = connection.execute(
"""
select id, goal, workflow_definition_id, constraints_json,
status, assigned_device_id, assigned_host_id, created_at
from scheduled_tasks
where status = 'queued'
order by created_at asc, id asc
"""
).fetchall()
return [_row_to_task(row) for row in rows]
def get_task(self, task_id: str) -> "ScheduledTask | None":
with self._connect() as connection:
row = connection.execute(
"""
select id, goal, workflow_definition_id, constraints_json,
status, assigned_device_id, assigned_host_id, created_at
from scheduled_tasks where id = ?
""",
(task_id,),
).fetchone()
return _row_to_task(row) if row else None
def update_task(
self,
task_id: str,
*,
status: str | None = None,
assigned_device_id: str | None = None,
assigned_host_id: str | None = None,
) -> None:
updates: dict[str, Any] = {}
if status is not None:
updates["status"] = status
if assigned_device_id is not None:
updates["assigned_device_id"] = assigned_device_id
if assigned_host_id is not None:
updates["assigned_host_id"] = assigned_host_id
if not updates:
return
assignments = ", ".join(f"{key} = ?" for key in updates)
values = [*updates.values(), task_id]
with self._connect() as connection:
connection.execute(
f"update scheduled_tasks set {assignments} where id = ?",
values,
)
def count_queued_tasks(self) -> int:
with self._connect() as connection:
row = connection.execute(
"select count(*) as count from scheduled_tasks where status = 'queued'"
).fetchone()
return int(row["count"])
# ----------------------------------------------------------------- plugins
def save_plugin(
self,
manifest: "PluginManifest",
*,
wired: bool,
) -> None:
with self._connect() as connection:
connection.execute(
"""
insert into plugins (
name, version, entry_point_kind, target, wired
) values (?, ?, ?, ?, ?)
on conflict(name) do update set
version = excluded.version,
entry_point_kind = excluded.entry_point_kind,
target = excluded.target,
wired = excluded.wired
""",
(
manifest.name,
manifest.version,
manifest.entry_point_kind,
manifest.target,
1 if wired else 0,
),
)
def list_plugins(self) -> "list[tuple[PluginManifest, bool]]":
from cloud.plugins import PluginManifest
with self._connect() as connection:
rows = connection.execute(
"select name, version, entry_point_kind, target, wired from plugins"
).fetchall()
return [
(
PluginManifest(
name=row["name"],
version=row["version"],
entry_point_kind=row["entry_point_kind"],
target=row["target"],
),
bool(row["wired"]),
)
for row in rows
]
def get_plugin(self, name: str) -> "tuple[PluginManifest, bool] | None":
from cloud.plugins import PluginManifest
with self._connect() as connection:
row = connection.execute(
"select name, version, entry_point_kind, target, wired from plugins where name = ?",
(name,),
).fetchone()
if row is None:
return None
manifest = PluginManifest(
name=row["name"],
version=row["version"],
entry_point_kind=row["entry_point_kind"],
target=row["target"],
)
return (manifest, bool(row["wired"]))
# ------------------------------------------------------------------- schema
def _ensure_schema(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists host_registrations (
host_id text primary key,
address text,
last_seen_at text not null
)
"""
)
connection.execute(
"""
create table if not exists pooled_devices (
device_id text primary key,
host_id text not null,
driver_type text not null,
status text not null,
capability_tags_json text not null,
synced_at text
)
"""
)
connection.execute(
"""
create table if not exists scheduled_tasks (
id text primary key,
goal text,
workflow_definition_id text,
constraints_json text not null,
status text not null,
assigned_device_id text,
assigned_host_id text,
created_at text not null
)
"""
)
connection.execute(
"""
create table if not exists plugins (
name text primary key,
version text not null,
entry_point_kind text not null,
target text not null,
wired integer not null
)
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
def _iso(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return value.isoformat()
def _parse_dt(value: Any):
if not value:
return None
if isinstance(value, str):
from datetime import datetime
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return value
def _row_to_host(row: sqlite3.Row):
from cloud.pool import HostRegistration
return HostRegistration(
host_id=row["host_id"],
address=row["address"],
last_seen_at=_parse_dt(row["last_seen_at"]) or utc_now(),
)
def _row_to_device(row: sqlite3.Row):
from cloud.pool import PooledDevice
tags_raw = row["capability_tags_json"]
try:
tags = list(json.loads(tags_raw)) if tags_raw else []
except (TypeError, ValueError):
tags = []
return PooledDevice(
device_id=row["device_id"],
host_id=row["host_id"],
driver_type=row["driver_type"],
status=row["status"],
capability_tags=tags,
synced_at=_parse_dt(row["synced_at"]),
)
def _row_to_task(row: sqlite3.Row):
from cloud.scheduler import ScheduledTask, TaskConstraints
try:
constraints_data = json.loads(row["constraints_json"]) if row["constraints_json"] else {}
except (TypeError, ValueError):
constraints_data = {}
constraints = TaskConstraints(
driver_type=constraints_data.get("driver_type"),
capability_tags=list(constraints_data.get("capability_tags") or []),
)
return ScheduledTask(
id=row["id"],
goal=row["goal"],
workflow_definition_id=row["workflow_definition_id"],
constraints=constraints,
status=row["status"],
assigned_device_id=row["assigned_device_id"],
assigned_host_id=row["assigned_host_id"],
created_at=_parse_dt(row["created_at"]) or utc_now(),
)
+50 -50
View File
@@ -1,76 +1,76 @@
## 1. Package scaffolding
- [ ] 1.1 Create the `cloud/` package (`__init__.py`, `pool.py`, `scheduler.py`, `dispatch.py`, `plugins.py`, `store.py`, `config.py`) and the `cloud/sdk/` sub-package (`__init__.py`, `api.py`, `client.py`, `models.py`)
- [ ] 1.2 Add `cloud*` to `[tool.setuptools.packages.find].include` in `pyproject.toml`
- [ ] 1.3 Promote `httpx` from `[dependency-groups].dev` to `[project].dependencies` in `pyproject.toml` (needed at runtime by `cloud/sdk/client.py`)
- [ ] 1.4 Implement `cloud/config.py`: `CloudConfig` dataclass with `sync_interval_seconds`, `stale_after_seconds`, `max_queue_depth`, `default_assignment_strategy`, `api_version_prefix` (`"/v1"`), `db_path` (`cloud/cloud.sqlite3`), each with a conservative documented default
- [ ] 1.5 Extend the project's smoke test (that imports every package) to import `cloud` and `cloud.sdk`
- [x] 1.1 Create the `cloud/` package (`__init__.py`, `pool.py`, `scheduler.py`, `dispatch.py`, `plugins.py`, `store.py`, `config.py`) and the `cloud/sdk/` sub-package (`__init__.py`, `api.py`, `client.py`, `models.py`)
- [x] 1.2 Add `cloud*` to `[tool.setuptools.packages.find].include` in `pyproject.toml`
- [x] 1.3 Promote `httpx` from `[dependency-groups].dev` to `[project].dependencies` in `pyproject.toml` (needed at runtime by `cloud/sdk/client.py`)
- [x] 1.4 Implement `cloud/config.py`: `CloudConfig` dataclass with `sync_interval_seconds`, `stale_after_seconds`, `max_queue_depth`, `default_assignment_strategy`, `api_version_prefix` (`"/v1"`), `db_path` (`cloud/cloud.sqlite3`), each with a conservative documented default
- [x] 1.5 Extend the project's smoke test (that imports every package) to import `cloud` and `cloud.sdk`
## 2. Device pool data model and store (capability: device-pool)
- [ ] 2.1 Implement `cloud/pool.py`'s data types: `HostRegistration{host_id, address, last_seen_at}`, `PooledDevice{device_id, host_id, driver_type, status, capability_tags, synced_at}`
- [ ] 2.2 Implement `cloud/store.py`'s `CloudStore(db_path)` with schema creation for `host_registrations` and `pooled_devices` tables (connect-per-call `sqlite3` pattern, following `storage/task_metadata.py`/`workflow/store.py`)
- [ ] 2.3 Implement `CloudStore.upsert_host(host_id, address, last_seen_at)` and `CloudStore.replace_host_devices(host_id, devices: list[PooledDevice])` (atomic replace of one host's device rows per sync)
- [ ] 2.4 Implement `CloudStore.list_hosts()`, `CloudStore.list_devices()`, `CloudStore.get_device(device_id)`
- [ ] 2.5 Write unit tests for `CloudStore`: host upsert + device replace round-trip; a second sync for the same host fully replaces (not appends to) its device rows; devices from two different hosts coexist without collision
- [x] 2.1 Implement `cloud/pool.py`'s data types: `HostRegistration{host_id, address, last_seen_at}`, `PooledDevice{device_id, host_id, driver_type, status, capability_tags, synced_at}`
- [x] 2.2 Implement `cloud/store.py`'s `CloudStore(db_path)` with schema creation for `host_registrations` and `pooled_devices` tables (connect-per-call `sqlite3` pattern, following `storage/task_metadata.py`/`workflow/store.py`)
- [x] 2.3 Implement `CloudStore.upsert_host(host_id, address, last_seen_at)` and `CloudStore.replace_host_devices(host_id, devices: list[PooledDevice])` (atomic replace of one host's device rows per sync)
- [x] 2.4 Implement `CloudStore.list_hosts()`, `CloudStore.list_devices()`, `CloudStore.get_device(device_id)`
- [x] 2.5 Write unit tests for `CloudStore`: host upsert + device replace round-trip; a second sync for the same host fully replaces (not appends to) its device rows; devices from two different hosts coexist without collision
## 3. DevicePool aggregation and staleness (capability: device-pool)
- [ ] 3.1 Implement `DevicePool(store: CloudStore, config: CloudConfig)` with `sync_host_devices(host_id, snapshot: list[core.models.Device])`, converting each `Device` into a `PooledDevice` and calling `CloudStore.upsert_host()`/`replace_host_devices()`
- [ ] 3.2 Implement `DevicePool.list_devices() -> list[PooledDevice]` and `DevicePool.get_device(device_id) -> PooledDevice | None`, both computing per-host staleness lazily at call time (`now - last_seen_at > config.stale_after_seconds` implies status `unreachable`, overriding the last-synced status) rather than via a background thread
- [ ] 3.3 Implement `DevicePool.list_hosts() -> list[HostRegistration]`
- [ ] 3.4 Write unit tests: new host sync creates a `HostRegistration` + `PooledDevice`s; re-sync updates last-seen and replaces devices; a host whose last-seen exceeds the staleness threshold reports all its devices `unreachable` on the next `list_devices()`/`get_device()` call; a host that resyncs after being stale immediately stops being reported unreachable; lookup for an unknown `device_id` returns `None`; empty pool returns an empty list
- [x] 3.1 Implement `DevicePool(store: CloudStore, config: CloudConfig)` with `sync_host_devices(host_id, snapshot: list[core.models.Device])`, converting each `Device` into a `PooledDevice` and calling `CloudStore.upsert_host()`/`replace_host_devices()`
- [x] 3.2 Implement `DevicePool.list_devices() -> list[PooledDevice]` and `DevicePool.get_device(device_id) -> PooledDevice | None`, both computing per-host staleness lazily at call time (`now - last_seen_at > config.stale_after_seconds` implies status `unreachable`, overriding the last-synced status) rather than via a background thread
- [x] 3.3 Implement `DevicePool.list_hosts() -> list[HostRegistration]`
- [x] 3.4 Write unit tests: new host sync creates a `HostRegistration` + `PooledDevice`s; re-sync updates last-seen and replaces devices; a host whose last-seen exceeds the staleness threshold reports all its devices `unreachable` on the next `list_devices()`/`get_device()` call; a host that resyncs after being stale immediately stops being reported unreachable; lookup for an unknown `device_id` returns `None`; empty pool returns an empty list
## 4. TaskScheduler queue and assignment (capability: task-scheduler)
- [ ] 4.1 Implement `cloud/scheduler.py`'s data types: `TaskConstraints{driver_type: str | None, capability_tags: list[str]}`, `ScheduledTask{id, goal: str | None, workflow_definition_id: str | None, constraints, status, assigned_device_id, assigned_host_id, created_at}`
- [ ] 4.2 Add `scheduled_tasks` table + `CloudStore.enqueue_task()`, `CloudStore.list_queued_tasks()`, `CloudStore.update_task()`, `CloudStore.get_task(task_id)` to `cloud/store.py`
- [ ] 4.3 Implement `AssignmentStrategy` protocol/ABC (`select(task, candidates: list[PooledDevice]) -> PooledDevice | None`) and a registry `dict[str, AssignmentStrategy]`
- [ ] 4.4 Implement the default `fifo_match` strategy: return the first candidate in `candidates` (oldest-synced-first is not required; caller already filters to idle+constraint-matching) or `None` if `candidates` is empty
- [ ] 4.5 Implement `TaskScheduler(pool: DevicePool, store: CloudStore, config: CloudConfig)` with `submit(goal=None, workflow_definition_id=None, constraints=None) -> str` (returns task id), raising a clear error if `config.max_queue_depth` queued tasks already exist
- [ ] 4.6 Implement `TaskScheduler.assign() -> list[Assignment]`: for each queued task (oldest first), filter `pool.list_devices()` to `idle` devices matching `constraints.driver_type`/`capability_tags`, call the configured `AssignmentStrategy`, and on a match transition the task to `assigned` recording `device_id`/`host_id`; leave unmatched tasks `queued`
- [ ] 4.7 Raise a clear configuration error if `config.default_assignment_strategy` names a strategy not present in the registry
- [ ] 4.8 Write unit tests: submission enqueues with status `queued`; queue-depth-limit rejection; assignment picks a matching idle device via `fifo_match`; assignment leaves a task queued when no device matches; two tasks queued in order are assigned in submission order when only one device is available; unregistered strategy name raises at configuration/first-assign time
- [x] 4.1 Implement `cloud/scheduler.py`'s data types: `TaskConstraints{driver_type: str | None, capability_tags: list[str]}`, `ScheduledTask{id, goal: str | None, workflow_definition_id: str | None, constraints, status, assigned_device_id, assigned_host_id, created_at}`
- [x] 4.2 Add `scheduled_tasks` table + `CloudStore.enqueue_task()`, `CloudStore.list_queued_tasks()`, `CloudStore.update_task()`, `CloudStore.get_task(task_id)` to `cloud/store.py`
- [x] 4.3 Implement `AssignmentStrategy` protocol/ABC (`select(task, candidates: list[PooledDevice]) -> PooledDevice | None`) and a registry `dict[str, AssignmentStrategy]`
- [x] 4.4 Implement the default `fifo_match` strategy: return the first candidate in `candidates` (oldest-synced-first is not required; caller already filters to idle+constraint-matching) or `None` if `candidates` is empty
- [x] 4.5 Implement `TaskScheduler(pool: DevicePool, store: CloudStore, config: CloudConfig)` with `submit(goal=None, workflow_definition_id=None, constraints=None) -> str` (returns task id), raising a clear error if `config.max_queue_depth` queued tasks already exist
- [x] 4.6 Implement `TaskScheduler.assign() -> list[Assignment]`: for each queued task (oldest first), filter `pool.list_devices()` to `idle` devices matching `constraints.driver_type`/`capability_tags`, call the configured `AssignmentStrategy`, and on a match transition the task to `assigned` recording `device_id`/`host_id`; leave unmatched tasks `queued`
- [x] 4.7 Raise a clear configuration error if `config.default_assignment_strategy` names a strategy not present in the registry
- [x] 4.8 Write unit tests: submission enqueues with status `queued`; queue-depth-limit rejection; assignment picks a matching idle device via `fifo_match`; assignment leaves a task queued when no device matches; two tasks queued in order are assigned in submission order when only one device is available; unregistered strategy name raises at configuration/first-assign time
## 5. TaskDispatcher composition (capability: task-scheduler)
- [ ] 5.1 Implement `cloud/dispatch.py`'s `Assignment{task_id, device_id, host_id, goal, workflow_definition_id}` and `RemoteDispatchNotSupportedError`
- [ ] 5.2 Implement `TaskDispatcher(local_host_id: str, task_runner_factory, workflow_runner_factory, store: CloudStore)` with `dispatch(assignment: Assignment) -> None`
- [ ] 5.3 Implement the goal-based dispatch path: construct `core.models.Task(goal=assignment.goal, device_id=assignment.device_id)`, call the existing `runtime.task.TaskRunner(...).run(task)` (import only, no edits to `runtime/`), and update the `ScheduledTask`'s status to `done`/`failed` from `task.status`/`task.failure_reason`
- [ ] 5.4 Implement the workflow-based dispatch path: load the referenced `WorkflowDefinition` and call the existing `workflow.runner.WorkflowRunner(...).run(definition, device_id=assignment.device_id)` (import only, no edits to `workflow/`), mapping the resulting `WorkflowRun.status` to the `ScheduledTask`'s status
- [ ] 5.5 Implement the remote-assignment guard: if `assignment.host_id != local_host_id`, raise `RemoteDispatchNotSupportedError` before constructing any `Task`/`WorkflowDefinition`, leaving the `ScheduledTask` status unchanged at `assigned`
- [ ] 5.6 Write unit tests: local goal-based dispatch runs a stubbed `TaskRunner` and updates status to `done`/`failed` correctly; local workflow-based dispatch runs a stubbed `WorkflowRunner` and updates status correctly; remote-host assignment raises `RemoteDispatchNotSupportedError` and leaves status as `assigned`
- [x] 5.1 Implement `cloud/dispatch.py`'s `Assignment{task_id, device_id, host_id, goal, workflow_definition_id}` and `RemoteDispatchNotSupportedError`
- [x] 5.2 Implement `TaskDispatcher(local_host_id: str, task_runner_factory, workflow_runner_factory, store: CloudStore)` with `dispatch(assignment: Assignment) -> None`
- [x] 5.3 Implement the goal-based dispatch path: construct `core.models.Task(goal=assignment.goal, device_id=assignment.device_id)`, call the existing `runtime.task.TaskRunner(...).run(task)` (import only, no edits to `runtime/`), and update the `ScheduledTask`'s status to `done`/`failed` from `task.status`/`task.failure_reason`
- [x] 5.4 Implement the workflow-based dispatch path: load the referenced `WorkflowDefinition` and call the existing `workflow.runner.WorkflowRunner(...).run(definition, device_id=assignment.device_id)` (import only, no edits to `workflow/`), mapping the resulting `WorkflowRun.status` to the `ScheduledTask`'s status
- [x] 5.5 Implement the remote-assignment guard: if `assignment.host_id != local_host_id`, raise `RemoteDispatchNotSupportedError` before constructing any `Task`/`WorkflowDefinition`, leaving the `ScheduledTask` status unchanged at `assigned`
- [x] 5.6 Write unit tests: local goal-based dispatch runs a stubbed `TaskRunner` and updates status to `done`/`failed` correctly; local workflow-based dispatch runs a stubbed `WorkflowRunner` and updates status correctly; remote-host assignment raises `RemoteDispatchNotSupportedError` and leaves status as `assigned`
## 6. Plugin manifest and registry (capability: plugin-system)
- [ ] 6.1 Implement `cloud/plugins.py`'s `PluginManifest{name, version, entry_point_kind: Literal["driver", "tool", "skill"], target}` with validation (all fields required, `entry_point_kind` restricted to the three literals)
- [ ] 6.2 Add a `plugins` table + `CloudStore.save_plugin()`, `CloudStore.list_plugins()`, `CloudStore.get_plugin(name)` to `cloud/store.py`, storing `wired: bool` alongside each manifest
- [ ] 6.3 Implement `PluginRegistry(store: CloudStore)` with `register(manifest: PluginManifest) -> PluginManifest`: reject unknown `entry_point_kind`, reject duplicate `name`, persist via `CloudStore.save_plugin()`
- [ ] 6.4 Implement driver-kind wiring: resolve `manifest.target` (dotted `module:attribute` string) to a `DriverFactoryBuilder` callable via `importlib`, and call `driver_registry.register_driver_type(manifest.name, builder)` if importable; raise a clear, named error if the driver-registry function is not importable in the running environment
- [ ] 6.5 Implement tool-/skill-kind handling: store the manifest with `wired=False` and do not attempt any further resolution or registration
- [ ] 6.6 Implement `PluginRegistry.discover_entry_points() -> list[PluginManifest]` using `importlib.metadata.entry_points(group="device_agent_runtime.plugins")`, resolving each entry point and registering the resulting manifest
- [ ] 6.7 Implement `PluginRegistry.discover_manifest_files(scan_path) -> list[PluginManifest]` globbing `plugin.json` under `scan_path`, parsing and registering each; on parse/validation failure, record the file path + error and continue (never abort the scan)
- [ ] 6.8 Implement `PluginRegistry.discover() -> DiscoveryResult{registered: list[PluginManifest], errors: list[str]}` combining both discovery sources
- [ ] 6.9 Write unit tests: valid manifest registers successfully; unrecognized `entry_point_kind` rejected; duplicate name rejected; driver-kind manifest registers into a fake `driver_registry.register_driver_type`; driver-kind manifest raises a named error when that function is not importable; tool-/skill-kind manifests register with `wired=False` and touch no other registry; entry-point discovery registers a fake installed plugin; manifest-file discovery registers a valid file and skips + records a malformed one without aborting
- [x] 6.1 Implement `cloud/plugins.py`'s `PluginManifest{name, version, entry_point_kind: Literal["driver", "tool", "skill"], target}` with validation (all fields required, `entry_point_kind` restricted to the three literals)
- [x] 6.2 Add a `plugins` table + `CloudStore.save_plugin()`, `CloudStore.list_plugins()`, `CloudStore.get_plugin(name)` to `cloud/store.py`, storing `wired: bool` alongside each manifest
- [x] 6.3 Implement `PluginRegistry(store: CloudStore)` with `register(manifest: PluginManifest) -> PluginManifest`: reject unknown `entry_point_kind`, reject duplicate `name`, persist via `CloudStore.save_plugin()`
- [x] 6.4 Implement driver-kind wiring: resolve `manifest.target` (dotted `module:attribute` string) to a `DriverFactoryBuilder` callable via `importlib`, and call `driver_registry.register_driver_type(manifest.name, builder)` if importable; raise a clear, named error if the driver-registry function is not importable in the running environment
- [x] 6.5 Implement tool-/skill-kind handling: store the manifest with `wired=False` and do not attempt any further resolution or registration
- [x] 6.6 Implement `PluginRegistry.discover_entry_points() -> list[PluginManifest]` using `importlib.metadata.entry_points(group="device_agent_runtime.plugins")`, resolving each entry point and registering the resulting manifest
- [x] 6.7 Implement `PluginRegistry.discover_manifest_files(scan_path) -> list[PluginManifest]` globbing `plugin.json` under `scan_path`, parsing and registering each; on parse/validation failure, record the file path + error and continue (never abort the scan)
- [x] 6.8 Implement `PluginRegistry.discover() -> DiscoveryResult{registered: list[PluginManifest], errors: list[str]}` combining both discovery sources
- [x] 6.9 Write unit tests: valid manifest registers successfully; unrecognized `entry_point_kind` rejected; duplicate name rejected; driver-kind manifest registers into a fake `driver_registry.register_driver_type`; driver-kind manifest raises a named error when that function is not importable; tool-/skill-kind manifests register with `wired=False` and touch no other registry; entry-point discovery registers a fake installed plugin; manifest-file discovery registers a valid file and skips + records a malformed one without aborting
## 7. Platform SDK REST API (capability: platform-sdk)
- [ ] 7.1 Implement `cloud/sdk/models.py`: Pydantic request/response models for task submission, task status, device listing, host listing, plugin listing, and plugin registration
- [ ] 7.2 Implement `AuthProvider` protocol (`authenticate(request) -> Principal | None`) and `NullAuthProvider` (always returns an anonymous `Principal`) in `cloud/sdk/api.py` or a small `cloud/sdk/auth.py`
- [ ] 7.3 Implement `create_cloud_router(*, pool: DevicePool, scheduler: TaskScheduler, plugin_registry: PluginRegistry, auth_provider: AuthProvider = NullAuthProvider(), version_prefix: str = "/v1") -> APIRouter` in `cloud/sdk/api.py`, mirroring `api/console.py`'s `create_console_router` shape
- [ ] 7.4 Implement `POST {prefix}/tasks` (submit via `TaskScheduler.submit()`), `GET {prefix}/tasks/{task_id}` (status via `CloudStore.get_task()`, 404 on unknown id)
- [ ] 7.5 Implement `GET {prefix}/devices` (via `DevicePool.list_devices()`) and `GET {prefix}/hosts` (via `DevicePool.list_hosts()`)
- [ ] 7.6 Implement `GET {prefix}/plugins` (via `PluginRegistry`/`CloudStore.list_plugins()`) and `POST {prefix}/plugins` (via `PluginRegistry.register()`, returning a validation/conflict error response on failure)
- [ ] 7.7 Wire every route through `auth_provider.authenticate(request)`, returning an authorization error response when it returns `None`
- [ ] 7.8 Write unit tests using FastAPI's `TestClient`: submit-then-status round trip; unknown task id returns 404; device/host listing reflects pool state; plugin listing/registration round trip; a custom rejecting `AuthProvider` causes every route to return an authorization error while the default `NullAuthProvider` allows all of the above through unchanged
- [x] 7.1 Implement `cloud/sdk/models.py`: Pydantic request/response models for task submission, task status, device listing, host listing, plugin listing, and plugin registration
- [x] 7.2 Implement `AuthProvider` protocol (`authenticate(request) -> Principal | None`) and `NullAuthProvider` (always returns an anonymous `Principal`) in `cloud/sdk/api.py` or a small `cloud/sdk/auth.py`
- [x] 7.3 Implement `create_cloud_router(*, pool: DevicePool, scheduler: TaskScheduler, plugin_registry: PluginRegistry, auth_provider: AuthProvider = NullAuthProvider(), version_prefix: str = "/v1") -> APIRouter` in `cloud/sdk/api.py`, mirroring `api/console.py`'s `create_console_router` shape
- [x] 7.4 Implement `POST {prefix}/tasks` (submit via `TaskScheduler.submit()`), `GET {prefix}/tasks/{task_id}` (status via `CloudStore.get_task()`, 404 on unknown id)
- [x] 7.5 Implement `GET {prefix}/devices` (via `DevicePool.list_devices()`) and `GET {prefix}/hosts` (via `DevicePool.list_hosts()`)
- [x] 7.6 Implement `GET {prefix}/plugins` (via `PluginRegistry`/`CloudStore.list_plugins()`) and `POST {prefix}/plugins` (via `PluginRegistry.register()`, returning a validation/conflict error response on failure)
- [x] 7.7 Wire every route through `auth_provider.authenticate(request)`, returning an authorization error response when it returns `None`
- [x] 7.8 Write unit tests using FastAPI's `TestClient`: submit-then-status round trip; unknown task id returns 404; device/host listing reflects pool state; plugin listing/registration round trip; a custom rejecting `AuthProvider` causes every route to return an authorization error while the default `NullAuthProvider` allows all of the above through unchanged
## 8. Python SDK client (capability: platform-sdk)
- [ ] 8.1 Implement `cloud/sdk/client.py`'s `CloudClient(base_url, *, http_client=None)` using `httpx`, with `submit_task()`, `get_task_status()`, `list_devices()`, `list_hosts()`, `list_plugins()`, `register_plugin()` methods matching `cloud/sdk/api.py`'s routes
- [ ] 8.2 Write unit tests for `CloudClient` against a live `TestClient`-backed instance of the router from task 7.3: submit + status round trip via the client returns the same result as calling the routes directly
- [x] 8.1 Implement `cloud/sdk/client.py`'s `CloudClient(base_url, *, http_client=None)` using `httpx`, with `submit_task()`, `get_task_status()`, `list_devices()`, `list_hosts()`, `list_plugins()`, `register_plugin()` methods matching `cloud/sdk/api.py`'s routes
- [x] 8.2 Write unit tests for `CloudClient` against a live `TestClient`-backed instance of the router from task 7.3: submit + status round trip via the client returns the same result as calling the routes directly
## 9. Composition safety checks and full-suite validation
- [ ] 9.1 Confirm no existing file under `driver/`/`core/`, `device/`, `runtime/`, `tools/`, `workflow/`, `agents/`, `storage/`, `api/console.py`, or `api/mcp.py` is modified by this change (composition via import only, per design.md's D1/D5)
- [ ] 9.2 Write a test that runs a real (non-mocked, stub-driver-backed) `runtime.task.TaskRunner` instance inside `TaskDispatcher.dispatch()`'s goal-based path, guarding against silent drift in `agent-runtime`'s public `run(task) -> Task` contract this change composes over
- [ ] 9.3 Run the full test suite (`pytest`) and confirm every existing test in `tests/` passes unmodified, with only new `tests/test_device_pool.py`, `tests/test_task_scheduler.py`, `tests/test_task_dispatcher.py`, `tests/test_plugin_registry.py`, `tests/test_cloud_sdk_api.py`, `tests/test_cloud_client.py`-style files added
- [x] 9.1 Confirm no existing file under `driver/`/`core/`, `device/`, `runtime/`, `tools/`, `workflow/`, `agents/`, `storage/`, `api/console.py`, or `api/mcp.py` is modified by this change (composition via import only, per design.md's D1/D5)
- [x] 9.2 Write a test that runs a real (non-mocked, stub-driver-backed) `runtime.task.TaskRunner` instance inside `TaskDispatcher.dispatch()`'s goal-based path, guarding against silent drift in `agent-runtime`'s public `run(task) -> Task` contract this change composes over
- [x] 9.3 Run the full test suite (`pytest`) and confirm every existing test in `tests/` passes unmodified, with only new `tests/test_device_pool.py`, `tests/test_task_scheduler.py`, `tests/test_task_dispatcher.py`, `tests/test_plugin_registry.py`, `tests/test_cloud_sdk_api.py`, `tests/test_cloud_client.py`-style files added
+2 -1
View File
@@ -7,6 +7,7 @@ dependencies = [
"anthropic>=0.69.0",
"Appium-Python-Client>=5.1.1",
"fastapi>=0.115.0",
"httpx>=0.27.0",
"mcp>=1.27,<2",
"openai>=1.0.0",
"paddleocr>=3.0.0",
@@ -19,7 +20,6 @@ build-backend = "setuptools.build_meta"
[dependency-groups]
dev = [
"httpx>=0.27.0",
"pytest>=8.3.0",
]
@@ -27,6 +27,7 @@ dev = [
include = [
"agents*",
"api*",
"cloud*",
"core*",
"device*",
"driver*",
+118
View File
@@ -0,0 +1,118 @@
"""Unit tests for cloud.sdk.client.CloudClient (task 8.2)."""
from __future__ import annotations
import pytest
from cloud.config import CloudConfig
from cloud.plugins import PluginRegistry
from cloud.pool import DevicePool
from cloud.sdk.api import create_cloud_router
from cloud.sdk.client import CloudClient
from cloud.scheduler import TaskScheduler
from cloud.store import CloudStore
from core.models import Device
pytest.importorskip("fastapi")
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
def _client_and_pool(tmp_path):
store = CloudStore(tmp_path / "cloud.sqlite3")
pool = DevicePool(store, _config())
scheduler = TaskScheduler(pool, store, _config())
plugin_registry = PluginRegistry(store)
app = FastAPI()
app.include_router(
create_cloud_router(
pool=pool,
scheduler=scheduler,
plugin_registry=plugin_registry,
)
)
test_client = TestClient(app)
cloud_client = CloudClient("http://testserver", http_client=test_client)
return cloud_client, pool
def test_client_submit_and_get_status_round_trip(tmp_path) -> None:
client, _ = _client_and_pool(tmp_path)
submission = client.submit_task(goal="open settings")
assert "task_id" in submission
task_id = submission["task_id"]
status = client.get_task_status(task_id)
assert status["id"] == task_id
assert status["status"] == "queued"
assert status["goal"] == "open settings"
def test_client_list_devices_and_hosts(tmp_path) -> None:
client, pool = _client_and_pool(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
address="a:8000",
)
devices = client.list_devices()
assert [d["device_id"] for d in devices] == ["dev-1"]
assert devices[0]["host_id"] == "host-a"
hosts = client.list_hosts()
assert [h["host_id"] for h in hosts] == ["host-a"]
def test_client_plugin_listing_and_registration(tmp_path) -> None:
client, _ = _client_and_pool(tmp_path)
assert client.list_plugins() == []
registered = client.register_plugin(
name="demo",
version="1.0.0",
entry_point_kind="tool",
target="cloud.store:CloudStore",
)
assert registered["name"] == "demo"
assert registered["wired"] is False
listed = client.list_plugins()
assert [p["name"] for p in listed] == ["demo"]
def test_client_submit_with_constraints(tmp_path) -> None:
client, pool = _client_and_pool(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
submission = client.submit_task(
goal="x",
driver_type="wda",
capability_tags=[],
)
task_id = submission["task_id"]
status = client.get_task_status(task_id)
assert status["status"] == "queued"
def test_client_unknown_task_raises(tmp_path) -> None:
import httpx
client, _ = _client_and_pool(tmp_path)
with pytest.raises(httpx.HTTPStatusError):
client.get_task_status("does-not-exist")
+106
View File
@@ -0,0 +1,106 @@
"""Composition safety checks (task 9.1).
Verifies that ``cloud/`` is purely additive: every existing module that
``cloud/`` composes (``runtime.task``, ``workflow.runner``, ``driver.registry``,
``api.console``) is itself unchanged by this change, and remains unaware of the
``cloud`` package in its source.
"""
from __future__ import annotations
import importlib
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _module_source(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _existing_module_paths() -> list[Path]:
"""Return source files cloud/ must not edit or import-back into."""
folders = [
"core",
"driver",
"device",
"runtime",
"tools",
"workflow",
"agents",
"storage",
]
files: list[Path] = []
for folder in folders:
root = PROJECT_ROOT / folder
if not root.exists():
continue
for path in root.rglob("*.py"):
files.append(path)
console = PROJECT_ROOT / "api" / "console.py"
if console.exists():
files.append(console)
mcp = PROJECT_ROOT / "api" / "mcp.py"
if mcp.exists():
files.append(mcp)
return files
def test_existing_modules_do_not_import_cloud() -> None:
"""No composed-over module imports ``cloud`` (cloud is one-directional)."""
offenders: list[str] = []
for path in _existing_module_paths():
try:
source = _module_source(path)
except OSError:
continue
# Look for an actual import of cloud, not the literal word "cloud" in comments.
for line in source.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
if (
"import cloud" in stripped
or "from cloud" in stripped
or "import cloud." in stripped
):
offenders.append(f"{path}: {stripped}")
assert not offenders, (
"cloud/ must compose other packages by import only; the following "
"existing modules import cloud back (forbidden): " + "; ".join(offenders)
)
def test_cloud_dispatch_imports_existing_runners_by_name() -> None:
"""dispatch.py should reference runtime.task.TaskRunner and workflow.runner.WorkflowRunner."""
dispatch = importlib.import_module("cloud.dispatch")
source = _module_source(Path(dispatch.__file__)) # type: ignore[arg-type]
# TaskRunner/WorkflowRunner are referenced via factory callables, not direct
# imports, so we check for the contract being composed over in docstrings/types.
assert "TaskRunner" in source or "task_runner_factory" in source
assert "WorkflowRunner" in source or "workflow_runner_factory" in source
def test_cloud_source_files_exist_only_under_cloud_directory() -> None:
"""The cloud/ change adds files only under cloud/ (and tests/, pyproject.toml, openspec)."""
cloud_dir = PROJECT_ROOT / "cloud"
assert cloud_dir.exists()
expected_files = {
"__init__.py",
"config.py",
"pool.py",
"store.py",
"scheduler.py",
"dispatch.py",
"plugins.py",
"sdk/__init__.py",
"sdk/api.py",
"sdk/client.py",
"sdk/models.py",
}
found: set[str] = set()
for path in cloud_dir.rglob("*.py"):
found.add(str(path.relative_to(cloud_dir)).replace(os.sep, "/"))
missing = expected_files - found
assert not missing, f"missing cloud source files: {sorted(missing)}"
@@ -0,0 +1,194 @@
"""Composition guard: TaskDispatcher composes a real runtime.task.TaskRunner (task 9.2).
Runs a non-mocked, stub-driver-backed TaskRunner instance inside
TaskDispatcher.dispatch()'s goal-based path. Guards against silent drift in
agent-runtime's public ``run(task) -> Task`` contract this change composes over.
"""
from __future__ import annotations
from datetime import UTC, datetime
from cloud.config import CloudConfig
from cloud.dispatch import Assignment, TaskDispatcher
from cloud.scheduler import ScheduledTask, TaskConstraints
from cloud.store import CloudStore
from core.models import Bounds, Scene, SceneElement, Task
from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep, Planner
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.artifact_store import ArtifactStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
class _ScriptedPlanner(Planner):
def __init__(self, steps: list[PlannedStep]) -> None:
self.steps = steps
def plan(self, *, goal, scene, context): # type: ignore[override]
if len(context.step_results) >= len(self.steps):
return []
return [self.steps[len(context.step_results)]]
def goal_reached(self, *, goal, scene, context): # type: ignore[override]
return len(context.step_results) >= len(self.steps) and all(
result.success for result in context.step_results
)
def _scene() -> Scene:
return Scene(
width=10,
height=20,
elements=[
SceneElement(
id="search",
type="input",
text="Search",
bounds=Bounds(1, 2, 4, 4),
)
],
)
def _real_task_runner(tmp_path) -> TaskRunner:
planner = _ScriptedPlanner(
[
PlannedStep(action="tap", description="tap search", args={"x": 3, "y": 4}),
]
)
executor = Executor(
tools={"tap": lambda **kwargs: {"ok": True, **kwargs}},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
return TaskRunner(
planner=planner,
executor=executor,
metadata_store=metadata,
timeline=timeline,
config=TaskRunnerConfig(max_steps=3),
observer=lambda device_id: _scene(),
screenshot_provider=lambda device_id: PNG_10X20,
)
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
def test_dispatcher_runs_real_task_runner_to_completion(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
runner = _real_task_runner(tmp_path)
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: None,
store=store,
)
# Enqueue a ScheduledTask in 'assigned' state (the precondition for dispatch).
task_id = "task-real"
store.enqueue_task(
ScheduledTask(
id=task_id,
goal="tap the search field",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="tap the search field",
workflow_definition_id=None,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "done"
# The TaskRunner must have observed the assignment's device_id.
# We assert via the executor's recorded outcomes indirectly by confirming
# the loop drove at least one step (metadata store now has the task as completed).
def test_dispatcher_propagates_real_failure(tmp_path) -> None:
"""If the real TaskRunner reports failure, dispatcher records ``failed``."""
class _AlwaysFailingPlanner(Planner):
def plan(self, *, goal, scene, context): # type: ignore[override]
return [
PlannedStep(action="boom", description="will fail", args={}),
]
def goal_reached(self, *, goal, scene, context): # type: ignore[override]
return False
executor = Executor(
tools={
"boom": lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
runner = TaskRunner(
planner=_AlwaysFailingPlanner(),
executor=executor,
metadata_store=metadata,
timeline=timeline,
config=TaskRunnerConfig(max_steps=1),
observer=lambda device_id: _scene(),
screenshot_provider=lambda device_id: PNG_10X20,
)
store = CloudStore(tmp_path / "cloud.sqlite3")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: None,
store=store,
)
task_id = "task-fail"
store.enqueue_task(
ScheduledTask(
id=task_id,
goal="doomed",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="doomed",
workflow_definition_id=None,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "failed"
+218
View File
@@ -0,0 +1,218 @@
"""Unit tests for cloud.sdk.api (task 7.8)."""
from __future__ import annotations
import pytest
from cloud.config import CloudConfig
from cloud.plugins import PluginRegistry
from cloud.pool import DevicePool
from cloud.sdk.api import (
AuthProvider,
NullAuthProvider,
Principal,
create_cloud_router,
)
from cloud.scheduler import TaskConstraints, TaskScheduler
from cloud.store import CloudStore
from core.models import Device
pytest.importorskip("fastapi")
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
def _build_app(
tmp_path,
*,
auth_provider: AuthProvider | None = None,
pool: DevicePool | None = None,
scheduler: TaskScheduler | None = None,
plugin_registry: PluginRegistry | None = None,
):
store = CloudStore(tmp_path / "cloud.sqlite3")
pool = pool or DevicePool(store, _config())
scheduler = scheduler or TaskScheduler(pool, store, _config())
plugin_registry = plugin_registry or PluginRegistry(store)
app = FastAPI()
app.include_router(
create_cloud_router(
pool=pool,
scheduler=scheduler,
plugin_registry=plugin_registry,
auth_provider=auth_provider,
)
)
return app, pool, scheduler, plugin_registry
def _client_for(app) -> TestClient:
return TestClient(app)
def test_null_auth_provider_allows_submit_and_status_round_trip(tmp_path) -> None:
app, pool, scheduler, _ = _build_app(tmp_path)
# Plant a device so the listing route has something to show.
pool.sync_host_devices(
"host-local",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
address="10.0.0.1:8000",
)
client = _client_for(app)
submission = client.post("/v1/tasks", json={"goal": "open settings"})
assert submission.status_code == 201, submission.text
task_id = submission.json()["task_id"]
status = client.get(f"/v1/tasks/{task_id}")
assert status.status_code == 200, status.text
body = status.json()
assert body["id"] == task_id
assert body["status"] == "queued"
assert body["goal"] == "open settings"
def test_unknown_task_id_returns_404(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
resp = client.get("/v1/tasks/does-not-exist")
assert resp.status_code == 404, resp.text
def test_device_and_host_listing_reflect_pool_state(tmp_path) -> None:
app, pool, _, _ = _build_app(tmp_path)
pool.sync_host_devices(
"host-a",
[
Device(id="a-dev-1", driver_type="wda", status="idle"), # type: ignore[arg-type]
Device(id="a-dev-2", driver_type="wda", status="busy"), # type: ignore[arg-type]
],
address="a:8000",
)
pool.sync_host_devices(
"host-b",
[Device(id="b-dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
address="b:8000",
)
client = _client_for(app)
devices = client.get("/v1/devices").json()
assert {d["device_id"] for d in devices} == {"a-dev-1", "a-dev-2", "b-dev-1"}
by_host = {d["device_id"]: d["host_id"] for d in devices}
assert by_host == {
"a-dev-1": "host-a",
"a-dev-2": "host-a",
"b-dev-1": "host-b",
}
hosts = client.get("/v1/hosts").json()
assert {h["host_id"] for h in hosts} == {"host-a", "host-b"}
assert all("last_seen_at" in h for h in hosts)
def test_plugin_listing_and_registration_round_trip(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
assert client.get("/v1/plugins").json() == []
payload = {
"name": "demo-tool",
"version": "1.0.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}
resp = client.post("/v1/plugins", json=payload)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["name"] == "demo-tool"
assert body["entry_point_kind"] == "tool"
assert body["wired"] is False
listed = client.get("/v1/plugins").json()
assert len(listed) == 1
assert listed[0]["name"] == "demo-tool"
def test_duplicate_plugin_returns_conflict(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
payload = {
"name": "dup",
"version": "1.0.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}
first = client.post("/v1/plugins", json=payload)
assert first.status_code == 201
second = client.post("/v1/plugins", json=payload)
assert second.status_code == 409
class _RejectingAuthProvider:
def authenticate(self, request: object) -> Principal | None:
return None
def test_rejecting_auth_provider_blocks_every_route(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path, auth_provider=_RejectingAuthProvider())
client = _client_for(app)
assert client.post("/v1/tasks", json={"goal": "x"}).status_code == 401
assert client.get("/v1/tasks/whatever").status_code == 401
assert client.get("/v1/devices").status_code == 401
assert client.get("/v1/hosts").status_code == 401
assert client.get("/v1/plugins").status_code == 401
assert client.post("/v1/plugins", json={
"name": "x",
"version": "1",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}).status_code == 401
def test_default_null_auth_provider_is_used_when_omitted(tmp_path) -> None:
# No auth_provider kwarg -> defaults to NullAuthProvider
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
# Should NOT 401 (i.e., NullAuthProvider lets everything through).
assert client.get("/v1/plugins").status_code == 200
assert client.get("/v1/devices").status_code == 200
def test_submit_with_constraints(tmp_path) -> None:
app, pool, _, _ = _build_app(tmp_path)
pool.sync_host_devices(
"host-local",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
client = _client_for(app)
resp = client.post(
"/v1/tasks",
json={
"goal": "x",
"constraints": {"driver_type": "wda", "capability_tags": []},
},
)
assert resp.status_code == 201, resp.text
task_id = resp.json()["task_id"]
# And the device should match if we run assign() manually via the scheduler.
status = client.get(f"/v1/tasks/{task_id}").json()
assert status["status"] == "queued"
+127
View File
@@ -0,0 +1,127 @@
"""Unit tests for cloud.store.CloudStore (task 2.5)."""
from __future__ import annotations
import pytest
from cloud.pool import HostRegistration, PooledDevice
from cloud.store import CloudStore
def _pooled(
device_id: str,
host_id: str,
*,
status: str = "idle",
driver_type: str = "wda",
tags: list[str] | None = None,
) -> PooledDevice:
from datetime import UTC, datetime
return PooledDevice(
device_id=device_id,
host_id=host_id,
driver_type=driver_type,
status=status, # type: ignore[arg-type]
capability_tags=list(tags or []),
synced_at=datetime(2026, 1, 1, tzinfo=UTC),
)
def test_upsert_host_and_replace_devices_round_trip(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
ts = datetime(2026, 7, 6, 12, 0, tzinfo=UTC)
store.upsert_host("host-a", address="10.0.0.1:8000", last_seen_at=ts)
store.replace_host_devices(
"host-a",
[_pooled("dev-1", "host-a"), _pooled("dev-2", "host-a", status="busy")],
)
hosts = store.list_hosts()
assert len(hosts) == 1
assert hosts[0].host_id == "host-a"
assert hosts[0].address == "10.0.0.1:8000"
assert hosts[0].last_seen_at == ts
devices = store.list_devices()
assert {d.device_id for d in devices} == {"dev-1", "dev-2"}
by_id = {d.device_id: d for d in devices}
assert by_id["dev-1"].host_id == "host-a"
assert by_id["dev-2"].status == "busy"
fetched = store.get_device("dev-1")
assert fetched is not None
assert fetched.host_id == "host-a"
assert store.get_device("does-not-exist") is None
def test_second_sync_fully_replaces_host_devices(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address=None, last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
store.replace_host_devices(
"host-a",
[_pooled("dev-1", "host-a"), _pooled("dev-2", "host-a"), _pooled("dev-3", "host-a")],
)
# Second sync: only dev-2 plus a new dev-4. dev-1/dev-3 must be gone.
store.replace_host_devices(
"host-a",
[_pooled("dev-2", "host-a"), _pooled("dev-4", "host-a")],
)
devices = store.list_devices()
assert {d.device_id for d in devices} == {"dev-2", "dev-4"}
assert all(d.host_id == "host-a" for d in devices)
def test_devices_from_two_hosts_coexist(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address="a", last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
store.upsert_host("host-b", address="b", last_seen_at=datetime(2026, 1, 2, tzinfo=UTC))
store.replace_host_devices("host-a", [_pooled("a-dev-1", "host-a")])
store.replace_host_devices("host-b", [_pooled("b-dev-1", "host-b"), _pooled("b-dev-2", "host-b")])
devices = store.list_devices()
assert {d.device_id for d in devices} == {"a-dev-1", "b-dev-1", "b-dev-2"}
by_host = {d.device_id: d.host_id for d in devices}
assert by_host == {"a-dev-1": "host-a", "b-dev-1": "host-b", "b-dev-2": "host-b"}
# Replacing host-a's devices must not touch host-b.
store.replace_host_devices("host-a", [_pooled("a-dev-9", "host-a")])
devices = store.list_devices()
assert {d.device_id for d in devices} == {"a-dev-9", "b-dev-1", "b-dev-2"}
def test_upsert_host_preserves_address_when_none(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address="10.0.0.1:8000", last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
# Subsequent sync with address=None should not clobber the existing address.
store.upsert_host("host-a", address=None, last_seen_at=datetime(2026, 1, 2, tzinfo=UTC))
host = store.get_host("host-a")
assert host is not None
assert host.address == "10.0.0.1:8000"
assert host.last_seen_at == datetime(2026, 1, 2, tzinfo=UTC)
def test_capability_tags_round_trip(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address="a", last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
store.replace_host_devices(
"host-a",
[_pooled("dev-1", "host-a", tags=["ios", "physical"])],
)
device = store.get_device("dev-1")
assert device is not None
assert device.capability_tags == ["ios", "physical"]
+142
View File
@@ -0,0 +1,142 @@
"""Unit tests for cloud.pool.DevicePool (task 3.4)."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from cloud.config import CloudConfig
from cloud.pool import DevicePool
from cloud.store import CloudStore
from core.models import Device
def _config(**overrides) -> CloudConfig:
base = {
"sync_interval_seconds": 30,
"stale_after_seconds": 60,
"max_queue_depth": 100,
"default_assignment_strategy": "fifo_match",
"api_version_prefix": "/v1",
"db_path": "cloud/cloud.sqlite3",
}
base.update(overrides)
return CloudConfig(**base)
def _device(device_id: str, *, status: str = "idle", driver_type: str = "wda") -> Device:
return Device(id=device_id, status=status, driver_type=driver_type) # type: ignore[arg-type]
def test_new_host_sync_creates_registration_and_devices(tmp_path) -> None:
pool = DevicePool(
CloudStore(tmp_path / "cloud.sqlite3"),
_config(),
)
pool.sync_host_devices(
"host-a",
[_device("dev-1"), _device("dev-2", status="busy")],
address="10.0.0.1:8000",
)
hosts = pool.list_hosts()
assert [h.host_id for h in hosts] == ["host-a"]
assert hosts[0].address == "10.0.0.1:8000"
devices = pool.list_devices()
assert {d.device_id for d in devices} == {"dev-1", "dev-2"}
by_id = {d.device_id: d for d in devices}
assert by_id["dev-1"].status == "idle"
assert by_id["dev-2"].status == "busy"
assert all(d.host_id == "host-a" for d in devices)
def test_resync_updates_last_seen_and_replaces_devices(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
pool.sync_host_devices("host-a", [_device("dev-1"), _device("dev-2")])
first_hosts = pool.list_hosts()
first_seen = first_hosts[0].last_seen_at
# Force time forward by directly mutating the stored timestamp.
pool.store.upsert_host(
"host-a",
address=None,
last_seen_at=datetime.now(UTC) - timedelta(seconds=10),
)
pool.sync_host_devices("host-a", [_device("dev-3")])
hosts = pool.list_hosts()
devices = pool.list_devices()
assert [h.host_id for h in hosts] == ["host-a"]
assert {d.device_id for d in devices} == {"dev-3"}
assert hosts[0].last_seen_at > first_seen
def test_stale_host_devices_reported_unreachable(tmp_path) -> None:
pool = DevicePool(
CloudStore(tmp_path / "cloud.sqlite3"),
_config(stale_after_seconds=60),
)
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
# Push the host's last_seen_at beyond the staleness threshold.
pool.store.upsert_host(
"host-a",
address=None,
last_seen_at=datetime.now(UTC) - timedelta(seconds=120),
)
devices = pool.list_devices()
assert len(devices) == 1
assert devices[0].status == "unreachable"
fetched = pool.get_device("dev-1")
assert fetched is not None
assert fetched.status == "unreachable"
def test_resync_after_stale_clears_unreachable(tmp_path) -> None:
pool = DevicePool(
CloudStore(tmp_path / "cloud.sqlite3"),
_config(stale_after_seconds=60),
)
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
pool.store.upsert_host(
"host-a",
address=None,
last_seen_at=datetime.now(UTC) - timedelta(seconds=120),
)
# Stale right now.
assert pool.list_devices()[0].status == "unreachable"
# Host resyncs with a fresh snapshot.
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
devices = pool.list_devices()
assert devices[0].status == "idle"
def test_unknown_device_returns_none(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
assert pool.get_device("does-not-exist") is None
def test_empty_pool_returns_empty_list(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
assert pool.list_devices() == []
assert pool.list_hosts() == []
def test_two_hosts_aggregate_into_one_listing(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
pool.sync_host_devices("host-a", [_device("a-dev-1")], address="a")
pool.sync_host_devices("host-b", [_device("b-dev-1"), _device("b-dev-2")], address="b")
devices = pool.list_devices()
assert {d.device_id: d.host_id for d in devices} == {
"a-dev-1": "host-a",
"b-dev-1": "host-b",
"b-dev-2": "host-b",
}
+257
View File
@@ -0,0 +1,257 @@
"""Unit tests for cloud.plugins.PluginRegistry (task 6.9)."""
from __future__ import annotations
import importlib.metadata
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from cloud.plugins import (
DiscoveryResult,
DriverRegistryUnavailableError,
DuplicatePluginError,
PluginManifest,
PluginRegistry,
PluginValidationError,
)
from cloud.store import CloudStore
import driver.registry as driver_registry_module
def _manifest(
*,
name: str = "demo",
version: str = "1.0.0",
entry_point_kind: str = "driver",
target: str = "cloud.store:CloudStore",
) -> PluginManifest:
return PluginManifest(
name=name,
version=version,
entry_point_kind=entry_point_kind, # type: ignore[arg-type]
target=target,
)
def test_valid_manifest_registers(tmp_path) -> None:
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
manifest = registry.register(_manifest(entry_point_kind="tool"))
assert manifest.name == "demo"
stored = registry.store.get_plugin("demo")
assert stored is not None
assert stored[0].entry_point_kind == "tool"
def test_unrecognized_entry_point_kind_rejected(tmp_path) -> None:
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
with pytest.raises(PluginValidationError):
PluginManifest(
name="bad",
version="1.0.0",
entry_point_kind="strategy", # type: ignore[arg-type]
target="cloud.store:CloudStore",
)
def test_duplicate_name_rejected(tmp_path) -> None:
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
registry.register(_manifest(entry_point_kind="tool"))
with pytest.raises(DuplicatePluginError):
registry.register(_manifest(entry_point_kind="tool"))
def test_driver_kind_wires_into_register_driver_type(
tmp_path,
monkeypatch,
) -> None:
"""A driver-kind manifest calls driver.registry.register_driver_type."""
calls: list[tuple[str, object]] = []
def fake_register(name: str, builder) -> None:
calls.append((name, builder))
# driver/registry.py does not yet expose register_driver_type in the real
# codebase, so injecting it via monkeypatch simulates the future state
# where it does (per design.md D6 / Open Questions).
monkeypatch.setattr(
driver_registry_module,
"register_driver_type",
fake_register,
raising=False,
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
registry.register(_manifest(entry_point_kind="driver", name="custom-driver"))
assert len(calls) == 1
registered_name, registered_builder = calls[0]
assert registered_name == "custom-driver"
assert callable(registered_builder)
stored = registry.store.get_plugin("custom-driver")
assert stored is not None
assert stored[1] is True # wired
def test_driver_kind_raises_when_extension_point_missing(
tmp_path,
monkeypatch,
) -> None:
"""When register_driver_type is not importable, registration fails loudly."""
# Ensure the attribute is genuinely absent, regardless of future state of driver/registry.py.
monkeypatch.delattr(
driver_registry_module,
"register_driver_type",
raising=False,
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
with pytest.raises(DriverRegistryUnavailableError):
registry.register(_manifest(entry_point_kind="driver"))
def test_tool_and_skill_manifests_register_unwired(tmp_path, monkeypatch) -> None:
"""tool/skill-kind manifests must not touch any other registry."""
touched: list[tuple[str, object]] = []
def fail_if_called(name: str, builder) -> None:
touched.append((name, builder))
monkeypatch.setattr(
driver_registry_module,
"register_driver_type",
fail_if_called,
raising=False,
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
registry.register(_manifest(name="a-tool", entry_point_kind="tool"))
registry.register(_manifest(name="a-skill", entry_point_kind="skill"))
assert touched == []
stored_tool = registry.store.get_plugin("a-tool")
stored_skill = registry.store.get_plugin("a-skill")
assert stored_tool is not None and stored_tool[1] is False
assert stored_skill is not None and stored_skill[1] is False
def test_entry_point_discovery_registers_plugin(
tmp_path,
monkeypatch,
) -> None:
"""discover_entry_points() resolves an installed entry point into a manifest."""
fake_ep = SimpleNamespace(
name="installed-plugin",
load=lambda: {
"name": "installed-plugin",
"version": "0.2.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
},
)
monkeypatch.setattr(
importlib.metadata,
"entry_points",
lambda **kwargs: [fake_ep],
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
manifests = registry.discover_entry_points()
assert [m.name for m in manifests] == ["installed-plugin"]
registry.register(manifests[0])
assert registry.store.get_plugin("installed-plugin") is not None
def test_manifest_file_discovery_registers_valid_and_skips_malformed(
tmp_path,
) -> None:
plugins_dir = tmp_path / "plugins"
(plugins_dir / "good").mkdir(parents=True)
(plugins_dir / "good" / "plugin.json").write_text(
json.dumps(
{
"name": "good",
"version": "1.0.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}
),
encoding="utf-8",
)
(plugins_dir / "bad").mkdir(parents=True)
(plugins_dir / "bad" / "plugin.json").write_text(
"{ not valid json",
encoding="utf-8",
)
(plugins_dir / "ugly").mkdir(parents=True)
(plugins_dir / "ugly" / "plugin.json").write_text(
json.dumps({"name": "ugly"}), # missing required fields
encoding="utf-8",
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
manifests = registry.discover_manifest_files(plugins_dir)
# Only the well-formed manifest is returned; malformed files are skipped silently.
assert [m.name for m in manifests] == ["good"]
# discover() registers the valid manifest and skips the malformed files
# without aborting the whole scan.
result = registry.discover(scan_path=plugins_dir)
assert any(m.name == "good" for m in result.registered)
assert isinstance(result, DiscoveryResult)
def test_discover_combines_entry_points_and_manifest_files(
tmp_path,
monkeypatch,
) -> None:
fake_ep = SimpleNamespace(
name="via-entry-point",
load=lambda: PluginManifest(
name="via-entry-point",
version="1.0.0",
entry_point_kind="tool",
target="cloud.store:CloudStore",
),
)
monkeypatch.setattr(
importlib.metadata,
"entry_points",
lambda **kwargs: [fake_ep],
)
plugins_dir = tmp_path / "plugins"
(plugins_dir / "via-file").mkdir(parents=True)
(plugins_dir / "via-file" / "plugin.json").write_text(
json.dumps(
{
"name": "via-file",
"version": "1.0.0",
"entry_point_kind": "skill",
"target": "cloud.store:CloudStore",
}
),
encoding="utf-8",
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
result = registry.discover(scan_path=plugins_dir)
names = {m.name for m in result.registered}
assert names == {"via-entry-point", "via-file"}
assert result.errors == []
+2
View File
@@ -7,6 +7,8 @@ def test_imports_new_packages() -> None:
for package in (
"agents",
"api",
"cloud",
"cloud.sdk",
"core",
"device",
"driver",
+293
View File
@@ -0,0 +1,293 @@
"""Unit tests for cloud.dispatch.TaskDispatcher (task 5.6)."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from cloud.config import CloudConfig
from cloud.dispatch import (
Assignment,
RemoteDispatchNotSupportedError,
TaskDispatcher,
UnknownWorkflowDefinitionError,
)
from cloud.pool import DevicePool
from cloud.scheduler import ScheduledTask, TaskConstraints
from cloud.store import CloudStore
from core.models import Task
from workflow.models import (
PlannedGoalStep,
WorkflowDefinition,
WorkflowRun,
WorkflowStepResult,
)
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
class _FakeTaskRunner:
"""A stub TaskRunner that records runs and returns a configured status."""
def __init__(self, *, status: str = "completed") -> None:
self._status = status
self.calls: list[Task] = []
def run(self, task: Task) -> Task:
self.calls.append(task)
task.status = self._status # type: ignore[assignment]
if self._status == "completed":
task.completed_at = datetime.now(UTC)
elif self._status == "failed":
task.completed_at = datetime.now(UTC)
task.failure_reason = "stub failure"
return task
class _FakeWorkflowStore:
def __init__(self, definitions: dict[str, WorkflowDefinition] | None = None) -> None:
self._definitions = definitions or {}
def get_definition(self, definition_id: str) -> WorkflowDefinition | None:
return self._definitions.get(definition_id)
class _FakeWorkflowRunner:
def __init__(
self,
*,
status: str = "completed",
store: _FakeWorkflowStore | None = None,
) -> None:
self._status = status
self.store = store or _FakeWorkflowStore()
self.calls: list[tuple[WorkflowDefinition, str]] = []
def run(
self,
definition: WorkflowDefinition,
*,
device_id: str | None = None,
) -> WorkflowRun:
self.calls.append((definition, device_id or ""))
run = WorkflowRun(
definition_id=definition.id,
status=self._status, # type: ignore[arg-type]
current_step_id=definition.entry_step_id,
variables={},
device_id=device_id,
)
return run
def _enqueue_goal_task(store: CloudStore, task_id: str = "task-1") -> str:
store.enqueue_task(
ScheduledTask(
id=task_id,
goal="open settings",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
return task_id
def test_local_goal_dispatch_marks_done(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_goal_task(store)
runner = _FakeTaskRunner(status="completed")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: _FakeWorkflowRunner(),
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="open settings",
workflow_definition_id=None,
)
)
assert len(runner.calls) == 1
assert runner.calls[0].device_id == "dev-1"
task = store.get_task(task_id)
assert task is not None
assert task.status == "done"
def test_local_goal_dispatch_marks_failed(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_goal_task(store)
runner = _FakeTaskRunner(status="failed")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: _FakeWorkflowRunner(),
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="open settings",
workflow_definition_id=None,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "failed"
def _definition() -> WorkflowDefinition:
return WorkflowDefinition(
name="linear",
entry_step_id="first",
steps=[PlannedGoalStep("first", "do thing")],
)
def _enqueue_workflow_task(store: CloudStore, definition_id: str, task_id: str = "task-wf") -> str:
store.enqueue_task(
ScheduledTask(
id=task_id,
goal=None,
workflow_definition_id=definition_id,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
return task_id
def test_local_workflow_dispatch_runs_definition_and_updates_status(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
definition = _definition()
wf_store = _FakeWorkflowStore({definition.id: definition})
runner = _FakeWorkflowRunner(status="completed", store=wf_store)
task_id = _enqueue_workflow_task(store, definition.id)
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: _FakeTaskRunner(),
workflow_runner_factory=lambda: runner,
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal=None,
workflow_definition_id=definition.id,
)
)
assert len(runner.calls) == 1
called_definition, called_device_id = runner.calls[0]
assert called_definition.id == definition.id
assert called_device_id == "dev-1"
task = store.get_task(task_id)
assert task is not None
assert task.status == "done"
def test_local_workflow_dispatch_marks_failed(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
definition = _definition()
wf_store = _FakeWorkflowStore({definition.id: definition})
runner = _FakeWorkflowRunner(status="failed", store=wf_store)
task_id = _enqueue_workflow_task(store, definition.id)
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: _FakeTaskRunner(),
workflow_runner_factory=lambda: runner,
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal=None,
workflow_definition_id=definition.id,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "failed"
def test_remote_assignment_raises_and_leaves_assigned(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_goal_task(store)
runner = _FakeTaskRunner()
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: _FakeWorkflowRunner(),
store=store,
)
with pytest.raises(RemoteDispatchNotSupportedError):
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-remote",
host_id="host-remote",
goal="open settings",
workflow_definition_id=None,
)
)
# The stubbed runner must not have been called.
assert runner.calls == []
# Status must remain unchanged from its pre-dispatch value.
task = store.get_task(task_id)
assert task is not None
assert task.status == "assigned"
def test_workflow_dispatch_with_unknown_definition_raises(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_workflow_task(store, "missing-def")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: _FakeTaskRunner(),
workflow_runner_factory=lambda: _FakeWorkflowRunner(store=_FakeWorkflowStore({})),
store=store,
)
with pytest.raises(UnknownWorkflowDefinitionError):
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal=None,
workflow_definition_id="missing-def",
)
)
+219
View File
@@ -0,0 +1,219 @@
"""Unit tests for cloud.scheduler.TaskScheduler (task 4.8)."""
from __future__ import annotations
import time
import pytest
from cloud.config import CloudConfig
from cloud.pool import DevicePool
from cloud.scheduler import (
AssignmentStrategy,
FIFO_MATCH_STRATEGY_NAME,
QueueFullError,
ScheduledTask,
TaskConstraints,
TaskScheduler,
TaskSubmissionValidationError,
UnknownAssignmentStrategyError,
)
from cloud.store import CloudStore
from core.models import Device
def _config(**overrides) -> CloudConfig:
base = {
"sync_interval_seconds": 30,
"stale_after_seconds": 60,
"max_queue_depth": 100,
"default_assignment_strategy": "fifo_match",
"api_version_prefix": "/v1",
"db_path": "cloud/cloud.sqlite3",
}
base.update(overrides)
return CloudConfig(**base)
def _device(device_id: str, *, status: str = "idle", driver_type: str = "wda") -> Device:
return Device(id=device_id, status=status, driver_type=driver_type) # type: ignore[arg-type]
def _pool_with_devices(tmp_path, *devices: Device, host_id: str = "host-local") -> DevicePool:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
pool.sync_host_devices(host_id, list(devices))
return pool
def test_submit_enqueues_with_status_queued(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(goal="open settings")
assert isinstance(task_id, str) and task_id
task = pool.store.get_task(task_id)
assert task is not None
assert task.status == "queued"
assert task.goal == "open settings"
assert task.workflow_definition_id is None
def test_submit_requires_goal_or_workflow(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
scheduler = TaskScheduler(pool, pool.store, _config())
with pytest.raises(TaskSubmissionValidationError):
scheduler.submit()
def test_queue_depth_limit_rejects_submission(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
scheduler = TaskScheduler(pool, pool.store, _config(max_queue_depth=2))
scheduler.submit(goal="one")
scheduler.submit(goal="two")
with pytest.raises(QueueFullError):
scheduler.submit(goal="three")
def test_assign_picks_matching_idle_device(tmp_path) -> None:
pool = _pool_with_devices(
tmp_path,
_device("dev-1", driver_type="wda"),
_device("dev-2", status="busy", driver_type="wda"),
)
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(
goal="x",
constraints=TaskConstraints(driver_type="wda"),
)
assignments = scheduler.assign()
assert [a.task_id for a in assignments] == [task_id]
assert assignments[0].device_id == "dev-1"
assert assignments[0].host_id == "host-local"
task = pool.store.get_task(task_id)
assert task is not None
assert task.status == "assigned"
assert task.assigned_device_id == "dev-1"
def test_assign_leaves_task_queued_when_no_device_matches(tmp_path) -> None:
pool = _pool_with_devices(tmp_path, _device("dev-1", driver_type="wda"))
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(
goal="x",
constraints=TaskConstraints(driver_type="android"),
)
assignments = scheduler.assign()
assert assignments == []
task = pool.store.get_task(task_id)
assert task is not None
assert task.status == "queued"
def test_two_tasks_assigned_in_submission_order_with_one_device(tmp_path) -> None:
pool = _pool_with_devices(tmp_path, _device("dev-1", driver_type="wda"))
scheduler = TaskScheduler(pool, pool.store, _config())
first_id = scheduler.submit(goal="first")
# Ensure a distinct created_at for the second submission so list_queued_tasks
# ordering by (created_at, id) is deterministic.
time.sleep(0.005)
second_id = scheduler.submit(goal="second")
assignments = scheduler.assign()
assert [a.task_id for a in assignments] == [first_id]
first_task = pool.store.get_task(first_id)
second_task = pool.store.get_task(second_id)
assert first_task is not None and second_task is not None
assert first_task.status == "assigned"
assert second_task.status == "queued"
def test_unknown_strategy_raises_at_init(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
with pytest.raises(UnknownAssignmentStrategyError):
TaskScheduler(
pool,
pool.store,
_config(default_assignment_strategy="nonexistent"),
)
def test_custom_strategy_can_be_registered(tmp_path) -> None:
"""A new AssignmentStrategy can be plugged in by name without scheduler edits."""
class LastDeviceStrategy:
def select(self, task: ScheduledTask, candidates): # type: ignore[override]
return candidates[-1] if candidates else None
pool = _pool_with_devices(
tmp_path,
_device("dev-1"),
_device("dev-2"),
)
scheduler = TaskScheduler(
pool,
pool.store,
_config(default_assignment_strategy="last"),
strategies={
FIFO_MATCH_STRATEGY_NAME: type(pool).__module__, # placeholder
"last": LastDeviceStrategy(), # type: ignore[dict-item]
},
)
# Replace with the real fifo strategy so other tests' default isn't relied on.
scheduler._strategies[FIFO_MATCH_STRATEGY_NAME] = type( # noqa: SLF001
pool,
).__module__
task_id = scheduler.submit(goal="x")
assignments = scheduler.assign()
assert len(assignments) == 1
assert assignments[0].device_id == "dev-2"
assert assignments[0].task_id == task_id
def test_capability_tag_constraint_filters_candidates(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
# Manually plant devices with capability_tags by going through the store.
from datetime import UTC, datetime
from cloud.pool import PooledDevice
pool.store.replace_host_devices(
"host-local",
[
PooledDevice(
device_id="dev-1",
host_id="host-local",
driver_type="wda",
status="idle",
capability_tags=["ios"],
synced_at=datetime.now(UTC),
),
PooledDevice(
device_id="dev-2",
host_id="host-local",
driver_type="wda",
status="idle",
capability_tags=["android"],
synced_at=datetime.now(UTC),
),
],
)
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(
goal="x",
constraints=TaskConstraints(capability_tags=["android"]),
)
assignments = scheduler.assign()
assert [a.task_id for a in assignments] == [task_id]
assert assignments[0].device_id == "dev-2"