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:
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user