- Add nullable cancel_requested_at column (migration 0012) - Widen ScheduledTaskStatus/TerminalTaskStatus to include cancelled - Add CancellationRequestStatus + request_task_cancellation() to CloudRepository protocol and SQLAlchemy implementation - renew_lease() now returns LeaseRenewalResult, surfacing whether cancellation is pending, instead of a bare status string - reap_expired_leases() resolves pending-cancellation tasks to cancelled instead of requeuing/failing them - record_task_result() accepts cancelled and clears cancel_requested_at on any terminal write Note: internal_api/api.py's renew_assignment route still compares renew_lease()'s return value against a bare string; it will be updated in the next task (Internal Host<->Cloud protocol) to consume LeaseRenewalResult and populate the new cancel_requested wire field.
318 lines
10 KiB
Python
318 lines
10 KiB
Python
"""Repository contract tests for cloud task progress columns (task 3.6).
|
|
|
|
Separate from ``test_cloud_repository_contract.py``; covers the new
|
|
``progress_*`` fields added by migration 0008.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from cloud.database import CloudDatabase
|
|
from cloud.pool import PooledDevice
|
|
from cloud.repository import AssignmentProgressSnapshot
|
|
from cloud.scheduler import ScheduledTask, TaskConstraints
|
|
|
|
|
|
def _device(device_id: str, host_id: str) -> PooledDevice:
|
|
return PooledDevice(
|
|
device_id=device_id,
|
|
host_id=host_id,
|
|
driver_type="wda",
|
|
status="idle",
|
|
capability_tags=["ios"],
|
|
synced_at=datetime(2026, 7, 12, tzinfo=UTC),
|
|
)
|
|
|
|
|
|
def _task(task_id: str, *, created_at: datetime | None = None) -> ScheduledTask:
|
|
return ScheduledTask(
|
|
id=task_id,
|
|
goal="test progress",
|
|
workflow_definition_id=None,
|
|
constraints=TaskConstraints(),
|
|
created_at=created_at or datetime(2026, 7, 12, tzinfo=UTC),
|
|
)
|
|
|
|
|
|
def _setup_assigned_and_claimed(
|
|
database: CloudDatabase, host_id: str, device_id: str, task_id: str
|
|
):
|
|
"""Enqueue, assign, and claim a task. Returns LeasedAssignment."""
|
|
now = datetime(2026, 7, 12, 5, 0, tzinfo=UTC)
|
|
database.repository.upsert_host(host_id, address=None, last_seen_at=now)
|
|
database.repository.replace_host_devices(host_id, [_device(device_id, host_id)])
|
|
database.repository.enqueue_task(_task(task_id, created_at=now))
|
|
database.repository.assign_task(
|
|
task_id=task_id,
|
|
host_id=host_id,
|
|
device_id=device_id,
|
|
lease_id="lease-1",
|
|
lease_expires_at=now + timedelta(minutes=10),
|
|
now=now,
|
|
)
|
|
assignment = database.repository.claim_assignment(
|
|
host_id=host_id,
|
|
now=now + timedelta(seconds=1),
|
|
)
|
|
assert assignment is not None
|
|
return assignment, now
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# renew_lease writes progress on success
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_renew_lease_writes_progress_on_success(tmp_path) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress.sqlite3').as_posix()}")
|
|
host_id = "progress-host"
|
|
device_id = "progress-device"
|
|
task_id = "progress-task"
|
|
|
|
try:
|
|
assignment, now = _setup_assigned_and_claimed(
|
|
database, host_id, device_id, task_id
|
|
)
|
|
|
|
progress = AssignmentProgressSnapshot(
|
|
step_index=2,
|
|
step_status="running",
|
|
summary="executing step 2",
|
|
updated_at=now + timedelta(seconds=5),
|
|
)
|
|
result = database.repository.renew_lease(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
lease_expires_at=now + timedelta(minutes=15),
|
|
now=now + timedelta(seconds=5),
|
|
progress=progress,
|
|
)
|
|
assert result.status == "renewed"
|
|
assert result.cancel_requested is False
|
|
|
|
task = database.repository.get_task(task_id)
|
|
assert task is not None
|
|
assert task.progress_step_index == 2
|
|
assert task.progress_step_status == "running"
|
|
assert task.progress_summary == "executing step 2"
|
|
assert task.progress_updated_at is not None
|
|
finally:
|
|
database.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# renew_lease without progress leaves previous untouched
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_renew_lease_without_progress_leaves_previous_untouched(tmp_path) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress2.sqlite3').as_posix()}")
|
|
host_id = "retain-host"
|
|
device_id = "retain-device"
|
|
task_id = "retain-task"
|
|
|
|
try:
|
|
assignment, now = _setup_assigned_and_claimed(
|
|
database, host_id, device_id, task_id
|
|
)
|
|
|
|
# First renew WITH progress.
|
|
progress = AssignmentProgressSnapshot(
|
|
step_index=1,
|
|
step_status="completed",
|
|
summary="step 1 done",
|
|
updated_at=now + timedelta(seconds=2),
|
|
)
|
|
database.repository.renew_lease(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
lease_expires_at=now + timedelta(minutes=15),
|
|
now=now + timedelta(seconds=2),
|
|
progress=progress,
|
|
)
|
|
|
|
# Second renew WITHOUT progress (None).
|
|
database.repository.renew_lease(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
lease_expires_at=now + timedelta(minutes=20),
|
|
now=now + timedelta(seconds=4),
|
|
progress=None,
|
|
)
|
|
|
|
task = database.repository.get_task(task_id)
|
|
assert task is not None
|
|
# Previous values must be retained, not cleared.
|
|
assert task.progress_step_index == 1
|
|
assert task.progress_step_status == "completed"
|
|
assert task.progress_summary == "step 1 done"
|
|
finally:
|
|
database.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# record_task_result clears progress on terminal
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_record_task_result_clears_progress_on_terminal(tmp_path) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'clear.sqlite3').as_posix()}")
|
|
host_id = "clear-host"
|
|
device_id = "clear-device"
|
|
task_id = "clear-task"
|
|
|
|
try:
|
|
assignment, now = _setup_assigned_and_claimed(
|
|
database, host_id, device_id, task_id
|
|
)
|
|
|
|
# Write progress first.
|
|
progress = AssignmentProgressSnapshot(
|
|
step_index=3,
|
|
step_status="running",
|
|
summary="almost done",
|
|
updated_at=now + timedelta(seconds=3),
|
|
)
|
|
database.repository.renew_lease(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
lease_expires_at=now + timedelta(minutes=15),
|
|
now=now + timedelta(seconds=3),
|
|
progress=progress,
|
|
)
|
|
|
|
task = database.repository.get_task(task_id)
|
|
assert task is not None
|
|
assert task.progress_step_index == 3
|
|
|
|
# Record terminal result.
|
|
result = database.repository.record_task_result(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
status="done",
|
|
failure_reason=None,
|
|
terminal_result={"output": "success"},
|
|
completed_at=now + timedelta(seconds=10),
|
|
)
|
|
assert result == "recorded"
|
|
|
|
task = database.repository.get_task(task_id)
|
|
assert task is not None
|
|
assert task.status == "done"
|
|
assert task.progress_step_index is None
|
|
assert task.progress_step_status is None
|
|
assert task.progress_summary is None
|
|
assert task.progress_updated_at is None
|
|
finally:
|
|
database.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Task list/detail response includes progress fields
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_list_tasks_includes_progress_fields(tmp_path) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'list.sqlite3').as_posix()}")
|
|
host_id = "list-host"
|
|
device_id = "list-device"
|
|
task_id = "list-task"
|
|
|
|
try:
|
|
assignment, now = _setup_assigned_and_claimed(
|
|
database, host_id, device_id, task_id
|
|
)
|
|
|
|
progress = AssignmentProgressSnapshot(
|
|
step_index=1,
|
|
step_status="running",
|
|
summary="running step 1",
|
|
updated_at=now + timedelta(seconds=5),
|
|
)
|
|
database.repository.renew_lease(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
lease_expires_at=now + timedelta(minutes=15),
|
|
now=now + timedelta(seconds=5),
|
|
progress=progress,
|
|
)
|
|
|
|
tasks = database.repository.list_tasks()
|
|
matching = [t for t in tasks if t.id == task_id]
|
|
assert len(matching) == 1
|
|
task = matching[0]
|
|
assert task.progress_step_index == 1
|
|
assert task.progress_step_status == "running"
|
|
assert task.progress_summary == "running step 1"
|
|
assert task.progress_updated_at is not None
|
|
finally:
|
|
database.close()
|
|
|
|
|
|
def test_get_task_includes_progress_fields(tmp_path) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'get.sqlite3').as_posix()}")
|
|
host_id = "get-host"
|
|
device_id = "get-device"
|
|
task_id = "get-task"
|
|
|
|
try:
|
|
assignment, now = _setup_assigned_and_claimed(
|
|
database, host_id, device_id, task_id
|
|
)
|
|
|
|
progress = AssignmentProgressSnapshot(
|
|
step_index=5,
|
|
step_status="failed",
|
|
summary="step 5 failed",
|
|
updated_at=now + timedelta(seconds=8),
|
|
)
|
|
database.repository.renew_lease(
|
|
task_id=task_id,
|
|
attempt=assignment.attempt,
|
|
lease_id=assignment.lease_id,
|
|
host_id=host_id,
|
|
lease_expires_at=now + timedelta(minutes=15),
|
|
now=now + timedelta(seconds=8),
|
|
progress=progress,
|
|
)
|
|
|
|
task = database.repository.get_task(task_id)
|
|
assert task is not None
|
|
assert task.progress_step_index == 5
|
|
assert task.progress_step_status == "failed"
|
|
assert task.progress_summary == "step 5 failed"
|
|
finally:
|
|
database.close()
|
|
|
|
|
|
def test_progress_fields_default_null_before_any_renewal(tmp_path) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'null.sqlite3').as_posix()}")
|
|
host_id = "null-host"
|
|
device_id = "null-device"
|
|
task_id = "null-task"
|
|
|
|
try:
|
|
_setup_assigned_and_claimed(database, host_id, device_id, task_id)
|
|
|
|
task = database.repository.get_task(task_id)
|
|
assert task is not None
|
|
assert task.progress_step_index is None
|
|
assert task.progress_step_status is None
|
|
assert task.progress_summary is None
|
|
assert task.progress_updated_at is None
|
|
finally:
|
|
database.close()
|