cloud
This commit is contained in:
@@ -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",
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user