## Context `apps/cloud-api/cloud_api/app.py` composes exactly two routers today: `create_cloud_router` (the versioned `/v1` platform SDK, `packages/cloud-platform/cloud/sdk/api.py`) and `create_internal_router` (Host Agent-only heartbeat/claim/renew/result). Neither mounts any static assets or HTML — confirmed by reading `app.py` in full, it has no `StaticFiles`/template mount. There is also **no CORS middleware configured** on the Cloud API app, unlike the local Runtime's `api/rest.py`, which enables permissive CORS specifically so the existing `console/` SPA can call it cross-origin during `npm run dev`. Auth on `/v1/...` is `ConfiguredBearerAuthProvider` (`cloud/auth.py`): a flat list of pre-shared opaque tokens, each mapped to a `Principal(id, scopes, host_id)`. There is no login flow, no session, no user directory — every integrator (Host Agent, `CloudClient`, and now this console) authenticates the same way, by presenting `Authorization: Bearer ` on each request. The repository (`cloud/repository.py` Protocol, implemented in `sql_repository.py` for both SQLite and PostgreSQL via the same SQLAlchemy code path) exposes `list_queued_tasks()` (queued-only, used internally by `TaskScheduler.assign()`) and `get_task(task_id)` (single lookup, backs `GET /v1/tasks/{id}`), but nothing that lists tasks across all statuses, and nothing paginated. `list_task_attempts(task_id)` is fully implemented (`sql_repository.py:531`) but has no route anywhere — it exists only for internal/audit use today. ## Goals / Non-Goals **Goals:** - Give an operator read visibility into tasks (all statuses, including history and per-attempt outcomes), the device pool, the host registry, and the plugin registry, without scripting REST calls. - Let an operator perform the two write actions that are already safe, scope-gated, and exposed today — submit an ad-hoc task and register a plugin manifest — from the same UI, instead of adding new write capabilities. - Reuse the Cloud Control Plane's existing bearer-token auth model exactly as `CloudClient` does — zero new auth surface. **Non-Goals:** - Task cancellation or forced retry — no scheduler/repository operation for this exists (`assign`/`claim`/`renew`/`record_result` only); adding one is a scheduling-capability change out of this proposal's scope. - Host Agent remote start/stop — the Host Agent only initiates outbound calls (heartbeat/claim/renew/result); the control plane has no channel to push commands to it. - Plugin de-registration — `CloudRepository` has no delete/deregister method for plugins. - A new login/session/RBAC/user-management system — the console is just another bearer-token holder, provisioned the same way as any `CLOUD_PUBLIC_CREDENTIALS_JSON` entry. - Real-time push (WebSocket/SSE) — polling only. - A shared component library or JS monorepo tooling spanning `console/` and the new `cloud-console/` — they stay two fully independent SPA projects, matching `console/README.md`'s own "Independent Vue 3 + Vite SPA" precedent. - Multi-cluster/multi-control-plane aggregation — `docs/CLOUD_DEPLOYMENT.md` already limits deployment to one scheduler-enabled Cloud API process; the console targets one Cloud API base URL at a time. ## Decisions - **Extend `platform-sdk` rather than invent a parallel console-only API.** Add `GET /v1/tasks` and `GET /v1/tasks/{task_id}/attempts` to the existing `cloud/sdk/api.py` router, under the same `/v1` prefix, same `tasks:read` scope, same `AuthProvider` hook. Rejected alternative: a separate `console-api` router mirroring the local Runtime's `console-status-api`/`console-config-api` split — rejected because the Cloud Control Plane's `/v1` surface is already the one stable, versioned, scope-gated contract every integrator uses; a second parallel surface would duplicate auth wiring for no benefit and would fork `platform-sdk`'s existing "client mirrors REST API" requirement into two inconsistent contracts. - **New bounded, filterable task listing at the repository layer.** Add `list_tasks(*, status: ScheduledTaskStatus | None, limit: int, offset: int) -> list[ScheduledTask]` and `count_tasks(status: ScheduledTaskStatus | None) -> int` to the `CloudRepository` Protocol, implemented via the same SQLAlchemy query builder `sql_repository.py` already uses for both SQLite and PostgreSQL (no dialect-specific SQL branch). `GET /v1/tasks` caps `limit` server-side (`Field(..., le=100)`, default 50), ordered most-recent-first, mirroring the bounded-`Field` pattern `internal_api/models.py` already uses for `ClaimRequest.timeout_seconds`. Rejected alternative: reusing/widening `list_queued_tasks()` — rejected because it is scheduler-internal (assign-loop candidate selection), has no status filter or pagination, and callers outside the scheduler have no business depending on its exact contract. - **`GET /v1/tasks/{task_id}/attempts` is a thin pass-through** to the existing `list_task_attempts()`, no new repository work — it just needed a route and a response model. - **Extend `CloudClient` in the same change.** `platform-sdk`'s existing requirement states the Python client mirrors every `/v1/...` route; adding routes without adding client methods would silently break that invariant. Add `list_tasks(...)` and `get_task_attempts(...)` to `cloud/sdk/client.py` with matching tests. - **Bearer token pasted by the operator, held in `sessionStorage`, never in `localStorage`.** On load, the console shows a token-entry screen if no token is present; every subsequent request attaches `Authorization: Bearer `, exactly like `CloudClient`. Rejected alternative: building any login/username-password flow — there is no user directory to authenticate against; the Cloud Control Plane's entire identity model is pre-issued scoped tokens, and inventing a session layer on top would be new auth infrastructure this proposal has no reason to add. Operators are expected to hold a token scoped at least to `tasks:read`+`pool:read`+`plugins:read` (add `tasks:submit`/`plugins:admin` only if the console's write actions are needed), provisioned the same way as any other `CLOUD_PUBLIC_CREDENTIALS_JSON` entry. - **Add configurable, closed-by-default CORS to the Cloud API.** `apps/cloud-api/cloud_api/app.py` currently has zero CORS middleware. Add an env-driven allow-list (e.g. `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, empty by default) wired through `CloudControlConfig`/`load_control_config()`, applied via FastAPI's `CORSMiddleware` only when non-empty. Rejected alternative: permissive CORS like the local Runtime's dev-only setup — rejected because the Cloud API, unlike the single-developer local Runtime, is meant for real deployment (PostgreSQL, production `CLOUD_ENVIRONMENT`) where the entire auth model is a bearer token; blanket `allow_origins=["*"]` would let any origin that tricks an operator's browser into sending that token succeed. Default-empty keeps every existing deployment's behavior unchanged until an operator opts in with their console's actual origin. - **New top-level `cloud-console/` project, sibling to `console/`, not nested under `packages/cloud-platform` or `apps/cloud-api`.** Mirrors the existing `console/` precedent (independent Node/Vue toolchain kept out of the Python `uv` workspace) and keeps the two SPAs — and the two backends and auth models they talk to — visibly separate rather than implying a shared deployment unit. ## Risks / Trade-offs - [No CORS today on the Cloud API] → Mitigation: ship the allow-list closed by default; existing deployments see no behavior change until they configure their console's origin. - [Bearer token lives in browser storage] → Mitigation: `sessionStorage` only (cleared on tab close), never logged, sent only to the configured Cloud API base URL; document least-privilege scoping and the existing token-rotation guidance from `docs/CLOUD_DEPLOYMENT.md`. - [Task history is unbounded over time, unlike the depth-capped queue] → Mitigation: server-side hard cap on page size (`le=100`), default ordering most-recent-first; client paginates rather than fetching everything. - [New repository method must behave identically on SQLite and PostgreSQL] → Mitigation: implement through the same SQLAlchemy query builder every other `sql_repository.py` method already uses; cover both backends in the existing dual-backend repository test suite. - [Two independent frontends (`console/`, `cloud-console/`) can drift in look-and-feel] → Mitigation: accepted for this change's scope, matching `console/`'s own "independent" precedent; revisit a shared design system only if a third console appears. ## Migration Plan 1. Add `list_tasks`/`count_tasks` to the `CloudRepository` Protocol and `sql_repository.py`, with unit/integration tests against both SQLite and PostgreSQL. 2. Add `GET /v1/tasks` and `GET /v1/tasks/{task_id}/attempts` to `cloud/sdk/api.py`, with response models in `cloud/sdk/models.py`. 3. Add matching `CloudClient` methods and tests in `cloud/sdk/client.py`. 4. Add the closed-by-default CORS allow-list to `apps/cloud-api/cloud_api/app.py` / `cloud/control_config.py`. 5. Scaffold `cloud-console/` (Vue 3 + Vite): token-entry screen, an API client wrapper that attaches the bearer token, and routing shell. 6. Build the dashboard views: task list/detail/attempts, device pool, host registry, plugin registry + registration form. 7. Document console usage and operator token provisioning in `docs/CLOUD_DEPLOYMENT.md`. 8. Rollback: everything is additive — two new GET routes, two new repository methods, opt-in CORS middleware, a new frontend project. No schema migration and no changes to existing task/attempt table columns, so rollback is simply removing the new routes/middleware and not deploying the frontend. ## Open Questions - Should live task status use SSE/WebSocket push instead of polling? Deferred — polling matches this change's scale; revisit only if operators report polling latency is actually a problem. - Should credential provisioning offer a bundled "console" scope alias instead of operators requesting `tasks:read`+`pool:read`+`plugins:read` separately? Deferred — documenting the combination in `docs/CLOUD_DEPLOYMENT.md` is enough for now; scope aliasing is an auth-model change beyond this proposal's impact.