294 lines
8.6 KiB
Python
294 lines
8.6 KiB
Python
"""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",
|
|
)
|
|
)
|