feat(cloud-console): task listing, attempt history, CORS, and console SPA

Implements the cloud-console OpenSpec change: adds GET /v1/tasks (filterable,
bounded pagination, tasks:read) and GET /v1/tasks/{id}/attempts (404 on unknown
task) to the platform SDK, with matching CloudClient methods and a closed-by-
default CLOUD_CONSOLE_CORS_ORIGINS allow-list wired through CloudControlConfig.
Ships an independent Vue 3 + Vite SPA at cloud-console/ that authenticates with
an operator-supplied bearer token held in sessionStorage, renders tasks with
attempt history, device pool, host registry, and the plugin registry with a
registration form.

Backend test suite: 438 passed (-m "not integration"); cloud-console typecheck
and production build both succeed. PostgreSQL-backed repository tests and
manual end-to-end verification remain pending external infrastructure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:00:23 +08:00
co-authored by Claude Opus 4.6
parent 62923b9285
commit 2169bb03d9
32 changed files with 3415 additions and 24 deletions
@@ -32,6 +32,7 @@ class CloudControlConfig:
allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
cors_allowed_origins: tuple[str, ...] = ()
def load_control_config(
@@ -90,6 +91,9 @@ def load_control_config(
enrollment_credentials=_parse_enrollment_credentials(
values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
),
cors_allowed_origins=_parse_cors_origins(
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
),
)
validate_control_config(config)
return config
@@ -219,3 +223,9 @@ def _parse_bool(value: str | None, *, default: bool) -> bool:
if normalized in {"0", "false", "no", "off", "disabled", ""}:
return False
raise CloudConfigurationError("boolean configuration value is invalid")
def _parse_cors_origins(raw_value: str | None) -> tuple[str, ...]:
if raw_value is None or not raw_value.strip():
return ()
return tuple(origin.strip() for origin in raw_value.split(",") if origin.strip())
+11 -1
View File
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol
if TYPE_CHECKING:
from cloud.plugins import PluginManifest
from cloud.pool import HostRegistration, PooledDevice
from cloud.scheduler import ScheduledTask
from cloud.scheduler import ScheduledTask, ScheduledTaskStatus
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -154,6 +154,16 @@ class CloudRepository(Protocol):
def list_queued_tasks(self) -> list[ScheduledTask]: ...
def list_tasks(
self,
*,
status: ScheduledTaskStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> list[ScheduledTask]: ...
def count_tasks(self, status: ScheduledTaskStatus | None = None) -> int: ...
def get_task(self, task_id: str) -> ScheduledTask | None: ...
def update_task(
+75 -2
View File
@@ -10,7 +10,7 @@ authentication can be added later without changing route signatures.
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from cloud.auth import (
PLUGINS_ADMIN_SCOPE,
@@ -28,11 +28,14 @@ from cloud.sdk.models import (
HostResponse,
PluginRegistrationRequest,
PluginResponse,
TaskAttemptResponse,
TaskListItem,
TaskListResponse,
TaskStatusResponse,
TaskSubmissionRequest,
TaskSubmissionResponse,
)
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, HTTPException, Query, Request, status
if TYPE_CHECKING:
from cloud.plugins import PluginRegistry
@@ -112,6 +115,76 @@ def create_cloud_router(
failure_reason=task.failure_reason,
)
@router.get("/tasks", response_model=TaskListResponse)
def list_tasks(
request: Request,
status_filter: Literal[
"queued", "assigned", "dispatched", "done", "failed"
]
| None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> TaskListResponse:
_authorize(request, TASKS_READ_SCOPE)
tasks = scheduler.store.list_tasks(
status=status_filter,
limit=limit,
offset=offset,
)
total = scheduler.store.count_tasks(status=status_filter)
return TaskListResponse(
items=[
TaskListItem(
id=task.id,
status=task.status,
goal=task.goal,
workflow_definition_id=task.workflow_definition_id,
assigned_device_id=task.assigned_device_id,
assigned_host_id=task.assigned_host_id,
attempt_count=task.attempt_count,
failure_reason=task.failure_reason,
created_at=task.created_at,
)
for task in tasks
],
total=total,
limit=limit,
offset=offset,
)
@router.get(
"/tasks/{task_id}/attempts",
response_model=list[TaskAttemptResponse],
)
def list_task_attempts(
task_id: str,
request: Request,
) -> list[TaskAttemptResponse]:
_authorize(request, TASKS_READ_SCOPE)
task = scheduler.store.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task {task_id!r} not found",
)
attempts = scheduler.store.list_task_attempts(task_id)
return [
TaskAttemptResponse(
task_id=attempt.task_id,
attempt=attempt.attempt,
lease_id=attempt.lease_id,
host_id=attempt.host_id,
device_id=attempt.device_id,
status=attempt.status,
lease_expires_at=attempt.lease_expires_at,
created_at=attempt.created_at,
completed_at=attempt.completed_at,
failure_reason=attempt.failure_reason,
terminal_result=attempt.terminal_result,
)
for attempt in attempts
]
@router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request, POOL_READ_SCOPE)
@@ -89,6 +89,23 @@ class CloudClient:
resp = self._request("GET", f"/tasks/{task_id}")
return resp.json()
def list_tasks(
self,
*,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status is not None:
params["status"] = status
resp = self._request("GET", "/tasks", params=params)
return resp.json()
def get_task_attempts(self, task_id: str) -> list[dict[str, Any]]:
resp = self._request("GET", f"/tasks/{task_id}/attempts")
return resp.json()
# ----------------------------------------------------------------- devices
def list_devices(self) -> list[dict[str, Any]]:
@@ -133,11 +150,13 @@ class CloudClient:
path: str,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
response = self._http.request(
method,
self._url(path),
json=json,
params=params,
headers=self._headers,
auth=self._auth,
)
+34 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -35,6 +35,39 @@ class TaskStatusResponse(BaseModel):
failure_reason: str | None = None
class TaskListItem(BaseModel):
id: str
status: str
goal: str | None = None
workflow_definition_id: str | None = None
assigned_device_id: str | None = None
assigned_host_id: str | None = None
attempt_count: int = 0
failure_reason: str | None = None
created_at: datetime
class TaskListResponse(BaseModel):
items: list[TaskListItem]
total: int
limit: int
offset: int
class TaskAttemptResponse(BaseModel):
task_id: str
attempt: int
lease_id: str
host_id: str
device_id: str
status: str
lease_expires_at: datetime
created_at: datetime
completed_at: datetime | None = None
failure_reason: str | None = None
terminal_result: dict[str, Any] | None = None
class DeviceResponse(BaseModel):
device_id: str
host_id: str
@@ -368,6 +368,36 @@ class SQLAlchemyCloudRepository:
).all()
return [_task_from_row(row) for row in rows]
def list_tasks(
self,
*,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Any]:
with self._sessions() as session:
statement = select(ScheduledTaskRow)
if status is not None:
statement = statement.where(ScheduledTaskRow.status == status)
statement = (
statement.order_by(
ScheduledTaskRow.created_at.desc(),
ScheduledTaskRow.id.desc(),
)
.limit(limit)
.offset(offset)
)
rows = session.scalars(statement).all()
return [_task_from_row(row) for row in rows]
def count_tasks(self, status: str | None = None) -> int:
with self._sessions() as session:
statement = select(func.count()).select_from(ScheduledTaskRow)
if status is not None:
statement = statement.where(ScheduledTaskRow.status == status)
count = session.scalar(statement)
return int(count or 0)
def get_task(self, task_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(ScheduledTaskRow, task_id)