Add Host Agent local console task cancellation (task-cancellation 7.1-7.3)
- New internal API route POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel,
authenticated via the host's own bearer credential (authorize_host) with an
ownership check, since host tokens carry no scopes and cannot reach the
public SDK's tasks:submit-scoped cancel endpoint.
- HostAgentClient.cancel_task() calls the new internal route directly.
- create_console_app() gains a cancel_task callable with automatic default
wiring from host_client, so production app.py needs no changes.
- Local console: POST /tasks/{task_id}/cancel route resolves the local
execution id to its Cloud source_task_id before cancelling, and the task
detail page/template show a Cancel button plus notice/error banners.
- Tests across all three layers: internal API route, Jinja2 template
rendering, and FastAPI console route behavior.
This commit is contained in:
@@ -41,7 +41,9 @@ CSRF_FORM_FIELD = "csrf_token"
|
||||
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
TaskSubmissionCallable = Callable[..., Awaitable[str]]
|
||||
TaskCancellationCallable = Callable[..., Awaitable[Any]]
|
||||
AUTOMATIC_DEVICE_VALUE = "__automatic__"
|
||||
_TERMINAL_LOCAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
||||
|
||||
_ENV = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
|
||||
@@ -217,6 +219,7 @@ def create_console_app(
|
||||
enrollment_client: HostAgentEnrollmentClient | None,
|
||||
host_client: HostAgentClient | None = None,
|
||||
submit_self_task: TaskSubmissionCallable | None = None,
|
||||
cancel_task: TaskCancellationCallable | None = None,
|
||||
metadata_store: TaskMetadataStore | None = None,
|
||||
timeline: Timeline | None = None,
|
||||
executor: AssignmentExecutor | None = None,
|
||||
@@ -225,6 +228,8 @@ def create_console_app(
|
||||
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
||||
if submit_self_task is None and host_client is not None:
|
||||
submit_self_task = host_client.submit_self_task
|
||||
if cancel_task is None and host_client is not None:
|
||||
cancel_task = host_client.cancel_task
|
||||
submission_available = submit_self_task is not None
|
||||
|
||||
def _running_devices() -> list[dict[str, str]]:
|
||||
@@ -709,6 +714,7 @@ def create_console_app(
|
||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||
async def task_detail_page(
|
||||
task_id: str,
|
||||
request: Request,
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> HTMLResponse:
|
||||
if metadata_store is None:
|
||||
@@ -738,13 +744,57 @@ def create_console_app(
|
||||
if task.get(key) is not None
|
||||
]
|
||||
timeline_steps = [_timeline_step_context(record) for record in timeline_records]
|
||||
can_cancel = (
|
||||
cancel_task is not None
|
||||
and task.get("source_task_id") is not None
|
||||
and task.get("status") not in _TERMINAL_LOCAL_TASK_STATUSES
|
||||
)
|
||||
cancel_notice = (
|
||||
"Cancellation requested. It may take a moment to take effect."
|
||||
if request.query_params.get("cancelled") == "1"
|
||||
else None
|
||||
)
|
||||
cancel_error = (
|
||||
"Failed to request cancellation. Try again."
|
||||
if request.query_params.get("cancel_error") == "1"
|
||||
else None
|
||||
)
|
||||
return _render(
|
||||
"task_detail.html",
|
||||
title=f"Task {task_id}",
|
||||
session=session,
|
||||
csrf_token=session.csrf_token,
|
||||
task=task,
|
||||
task_rows=task_rows,
|
||||
timeline_steps=timeline_steps,
|
||||
can_cancel=can_cancel,
|
||||
cancel_notice=cancel_notice,
|
||||
cancel_error=cancel_error,
|
||||
)
|
||||
|
||||
@app.post("/tasks/{task_id}/cancel")
|
||||
async def tasks_cancel(
|
||||
task_id: str,
|
||||
session: SessionState = Depends(require_csrf),
|
||||
) -> Response:
|
||||
if metadata_store is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="task metadata store not configured"
|
||||
)
|
||||
task = await asyncio.to_thread(metadata_store.get_task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
source_task_id = task.get("source_task_id")
|
||||
if cancel_task is None or source_task_id is None:
|
||||
return RedirectResponse(
|
||||
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
|
||||
)
|
||||
try:
|
||||
await cancel_task(source_task_id)
|
||||
except HostAgentAPIError:
|
||||
return RedirectResponse(
|
||||
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
|
||||
)
|
||||
return RedirectResponse(url=f"/tasks/{task_id}?cancelled=1", status_code=303)
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user