cloud
This commit is contained in:
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 == []
|
||||
@@ -7,6 +7,8 @@ def test_imports_new_packages() -> None:
|
||||
for package in (
|
||||
"agents",
|
||||
"api",
|
||||
"cloud",
|
||||
"cloud.sdk",
|
||||
"core",
|
||||
"device",
|
||||
"driver",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user