Add public SDK cancel endpoint and CloudClient method
- POST /v1/tasks/{task_id}/cancel: tasks:submit scoped, 200 for
immediate/idempotent cancellation, 202 for newly recorded pending
cancellation, 404 for unknown task, 409 for terminal task.
- TaskCancellationResponse{task_id, status} model.
- Widen list_tasks status_filter Literal to include "cancelled".
- CloudClient.cancel_task(task_id).
- SDK-level tests covering queued/assigned/idempotent/404/409/scope
cases for both the router and CloudClient.
Task 5/9 of task-cancellation change.
This commit is contained in:
@@ -339,6 +339,7 @@ def test_submit_rejects_incomplete_or_foreign_target(tmp_path) -> None:
|
||||
("get", "/v1/tasks", None, "tasks:read"),
|
||||
("get", "/v1/tasks/missing/attempts", None, "tasks:read"),
|
||||
("get", "/v1/tasks/missing/planner-decisions?attempt=0", None, "tasks:read"),
|
||||
("post", "/v1/tasks/missing/cancel", None, "tasks:submit"),
|
||||
("get", "/v1/devices", None, "pool:read"),
|
||||
("get", "/v1/hosts", None, "pool:read"),
|
||||
("get", "/v1/plugins", None, "plugins:read"),
|
||||
@@ -495,6 +496,130 @@ def test_list_task_attempts_returns_404_for_unknown_task(tmp_path) -> None:
|
||||
assert "does-not-exist" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_cancel_queued_task_transitions_immediately(tmp_path) -> None:
|
||||
app, _, scheduler, _ = _build_app(tmp_path)
|
||||
task_id = scheduler.submit(goal="cancel me")
|
||||
|
||||
resp = _client_for(app).post(f"/v1/tasks/{task_id}/cancel")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"task_id": task_id, "status": "cancelled"}
|
||||
assert scheduler.store.get_task(task_id).status == "cancelled"
|
||||
|
||||
|
||||
def test_cancel_assigned_task_records_pending_request(tmp_path) -> None:
|
||||
app, pool, scheduler, _ = _build_app(tmp_path)
|
||||
pool.sync_host_devices(
|
||||
"host-a",
|
||||
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
task_id = scheduler.submit(goal="cancel me mid-flight")
|
||||
scheduler.assign()
|
||||
|
||||
resp = _client_for(app).post(f"/v1/tasks/{task_id}/cancel")
|
||||
|
||||
assert resp.status_code == 202, resp.text
|
||||
assert resp.json() == {"task_id": task_id, "status": "assigned"}
|
||||
task = scheduler.store.get_task(task_id)
|
||||
assert task.status == "assigned"
|
||||
assert task.cancel_requested_at is not None
|
||||
|
||||
|
||||
def test_cancel_repeat_call_on_pending_request_is_idempotent(tmp_path) -> None:
|
||||
app, pool, scheduler, _ = _build_app(tmp_path)
|
||||
pool.sync_host_devices(
|
||||
"host-a",
|
||||
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
task_id = scheduler.submit(goal="cancel me twice")
|
||||
scheduler.assign()
|
||||
client = _client_for(app)
|
||||
|
||||
first = client.post(f"/v1/tasks/{task_id}/cancel")
|
||||
second = client.post(f"/v1/tasks/{task_id}/cancel")
|
||||
|
||||
assert first.status_code == 202, first.text
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json() == {"task_id": task_id, "status": "assigned"}
|
||||
|
||||
|
||||
def test_cancel_repeat_call_on_already_cancelled_task_is_idempotent(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
app, _, scheduler, _ = _build_app(tmp_path)
|
||||
task_id = scheduler.submit(goal="cancel me twice")
|
||||
client = _client_for(app)
|
||||
|
||||
first = client.post(f"/v1/tasks/{task_id}/cancel")
|
||||
second = client.post(f"/v1/tasks/{task_id}/cancel")
|
||||
|
||||
assert first.status_code == 200, first.text
|
||||
assert second.status_code == 200, second.text
|
||||
assert second.json() == {"task_id": task_id, "status": "cancelled"}
|
||||
|
||||
|
||||
def test_cancel_unknown_task_returns_404(tmp_path) -> None:
|
||||
app, _, _, _ = _build_app(tmp_path)
|
||||
resp = _client_for(app).post("/v1/tasks/does-not-exist/cancel")
|
||||
assert resp.status_code == 404, resp.text
|
||||
assert "does-not-exist" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_cancel_terminal_task_returns_409(tmp_path) -> None:
|
||||
app, pool, scheduler, _ = _build_app(tmp_path)
|
||||
pool.sync_host_devices(
|
||||
"host-a",
|
||||
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
task_id = scheduler.submit(goal="finish me")
|
||||
scheduler.assign()
|
||||
task = scheduler.store.get_task(task_id)
|
||||
scheduler.store.record_task_result(
|
||||
task_id=task_id,
|
||||
attempt=task.attempt_count,
|
||||
lease_id=task.lease_id or "",
|
||||
host_id=task.assigned_host_id or "",
|
||||
status="done",
|
||||
failure_reason=None,
|
||||
terminal_result={"runtime_status": "completed"},
|
||||
completed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
resp = _client_for(app).post(f"/v1/tasks/{task_id}/cancel")
|
||||
|
||||
assert resp.status_code == 409, resp.text
|
||||
assert "terminal" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_cancel_scope_rejected_before_reaching_scheduler(tmp_path, monkeypatch) -> None:
|
||||
provider = ConfiguredBearerAuthProvider(
|
||||
[
|
||||
BearerCredential(
|
||||
principal_id="reader",
|
||||
token="reader-token",
|
||||
scopes=frozenset({"tasks:read"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
app, _, scheduler, _ = _build_app(tmp_path, auth_provider=provider)
|
||||
called = False
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
nonlocal called
|
||||
called = True
|
||||
raise AssertionError("cancellation must not run before authorization")
|
||||
|
||||
monkeypatch.setattr(scheduler.store, "request_task_cancellation", fail_if_called)
|
||||
|
||||
resp = _client_for(app).post(
|
||||
"/v1/tasks/does-not-exist/cancel",
|
||||
headers={"Authorization": "Bearer reader-token"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_plugin_admin_scope_is_checked_before_registration(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
|
||||
Reference in New Issue
Block a user