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
+11
View File
@@ -158,6 +158,17 @@ def create_app(
app = FastAPI(title="Device Cloud API", lifespan=lifespan) app = FastAPI(title="Device Cloud API", lifespan=lifespan)
if control_config.cors_allowed_origins:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=list(control_config.cors_allowed_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http") @app.middleware("http")
async def correlation_logging(request: Request, call_next): async def correlation_logging(request: Request, call_next):
correlation_id = normalize_correlation_id( correlation_id = normalize_correlation_id(
+77
View File
@@ -608,3 +608,80 @@ def test_production_app_rejects_missing_credentials() -> None:
database_url="postgresql://db/cloud", database_url="postgresql://db/cloud",
) )
) )
def test_cors_headers_are_absent_when_allow_list_is_empty() -> None:
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
response = client.options(
"/health/live",
headers={
"Origin": "http://console.example",
"Access-Control-Request-Method": "GET",
},
)
assert response.status_code >= 400
assert "access-control-allow-origin" not in {
key.lower() for key in response.headers
}
def test_cors_headers_reflect_configured_origin_only() -> None:
app = create_app(
config=CloudControlConfig(
database_url="sqlite:///:memory:",
cors_allowed_origins=("http://console.example",),
)
)
with TestClient(app) as client:
allowed = client.options(
"/health/live",
headers={
"Origin": "http://console.example",
"Access-Control-Request-Method": "GET",
},
)
blocked = client.options(
"/health/live",
headers={
"Origin": "http://attacker.example",
"Access-Control-Request-Method": "GET",
},
)
assert allowed.status_code in {200, 204}
assert allowed.headers["access-control-allow-origin"] == "http://console.example"
# An origin that is not on the allow-list must not be echoed back.
assert (
blocked.headers.get("access-control-allow-origin") != "http://attacker.example"
)
def test_load_control_config_parses_cors_allow_list() -> None:
from cloud.control_config import load_control_config
config = load_control_config(
env={
"CLOUD_ENVIRONMENT": "local",
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
"CLOUD_CONSOLE_CORS_ORIGINS": (
"http://console.example, https://console.example"
),
}
)
assert config.cors_allowed_origins == (
"http://console.example",
"https://console.example",
)
def test_load_control_config_defaults_to_empty_cors_allow_list() -> None:
from cloud.control_config import load_control_config
config = load_control_config(
env={
"CLOUD_ENVIRONMENT": "local",
"CLOUD_DATABASE_URL": "sqlite:///:memory:",
}
)
assert config.cors_allowed_origins == ()
+1
View File
@@ -0,0 +1 @@
VITE_CLOUD_API_BASE_URL=http://127.0.0.1:8001
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.local
+95
View File
@@ -0,0 +1,95 @@
# Cloud Console
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
bearer token; the console stores it in `sessionStorage`, attaches
`Authorization: Bearer <token>` to every request, and clears it whenever the
Cloud API responds `401` or `403`.
The app talks only to the platform SDK surface (`/v1/...`) and consumes the
two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`,
`GET /v1/tasks/{task_id}/attempts`) alongside the existing
`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes.
## Prerequisites
- Node.js 20+ (matching the existing `console/` SPA project)
- A running Cloud API (`apps/cloud-api`) reachable from your browser
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
what you intend to do from the console. Recommended least-privilege set:
- `tasks:read` — task list and attempt history views
- `pool:read` — device and host views
- `plugins:read` — plugin list
- Add `tasks:submit`/`plugins:admin` only if you need the write actions from
the same tab.
## Configure the backend CORS allow-list
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin your dev server prints (scheme + host + port —
no trailing slash):
```bash
# Example: allow the default Vite dev origin
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
```
Restart `apps/cloud-api` after changing this env. Tokens are still required —
the allow-list only says which browser origins may send them.
## Run the dev server
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
bearer token, and the task/device/host/plugin dashboards become available.
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
(defaults to `http://127.0.0.1:8001`).
## Build for production
```bash
npm run build # type-checks with vue-tsc, then emits dist/
npm run preview # serves the built bundle locally
```
`dist/` is a static bundle — host it behind any static file server or CDN and
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
time.
## Token handling
- The token is held in `sessionStorage` only. Closing the tab discards it.
- Every API request attaches `Authorization: Bearer <token>` and targets only
the configured `VITE_CLOUD_API_BASE_URL`.
- A `401`/`403` response clears the stored token and returns the operator to
the token-entry screen with the API's error detail.
## Project layout
```
cloud-console/
├── src/
│ ├── api.ts # API client wrapper (token storage, fetch, errors)
│ ├── types.ts # TS interfaces mirroring the REST models
│ ├── App.vue # Shell: token gate, nav, view router
│ ├── main.ts # Vue bootstrap
│ ├── style.css # Dark theme styles
│ └── views/
│ ├── TokenScreen.vue
│ ├── TasksView.vue # list + detail with attempt history
│ ├── DevicesView.vue # device pool + host registry
│ └── PluginsView.vue # registry list + registration form
├── index.html
├── package.json
├── tsconfig.json / tsconfig.node.json
└── vite.config.ts
```
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cloud Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1211
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "cloud-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vue-tsc": "^3.3.6"
}
}
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Component } from "vue";
import {
Boxes,
ListChecks,
LogOut,
MonitorSmartphone,
Puzzle,
} from "@lucide/vue";
import {
TOKEN_INVALID_EVENT,
clearStoredToken,
getStoredToken,
} from "./api";
import TokenScreen from "./views/TokenScreen.vue";
import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
type ViewId = "tasks" | "devices" | "plugins";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
const activeView = ref<ViewId>("tasks");
const tokenRejectedMessage = ref("");
const hasToken = ref(false);
function refreshTokenState() {
hasToken.value = getStoredToken() !== null;
}
function onTokenInvalid() {
hasToken.value = false;
tokenRejectedMessage.value =
"the cloud api rejected the stored token (401/403). paste a new token to continue.";
}
function onStorage(event: StorageEvent) {
if (event.key === null) {
// Tab-wide sessionStorage clear (some browsers fire this on logout).
refreshTokenState();
}
}
function signOut() {
clearStoredToken();
hasToken.value = false;
tokenRejectedMessage.value = "";
}
onMounted(() => {
refreshTokenState();
window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.addEventListener("storage", onStorage as EventListener);
});
onUnmounted(() => {
window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.removeEventListener("storage", onStorage as EventListener);
});
const activeComponent = computed(() => {
switch (activeView.value) {
case "tasks":
return TasksView;
case "devices":
return DevicesView;
case "plugins":
return PluginsView;
}
return TasksView;
});
function onTokenSubmitted() {
tokenRejectedMessage.value = "";
refreshTokenState();
}
</script>
<template>
<TokenScreen
v-if="!hasToken"
:rejection-message="tokenRejectedMessage"
@submitted="onTokenSubmitted"
/>
<div v-else class="app-shell">
<nav class="app-nav">
<h1>
<Boxes :size="14" />
Cloud Console
</h1>
<button
v-for="item in navItems"
:key="item.id"
:class="{ active: activeView === item.id }"
@click="activeView = item.id"
>
<component :is="item.icon" :size="14" />
{{ item.label }}
</button>
<div class="spacer" />
<button @click="signOut">
<LogOut :size="14" />
Clear token
</button>
</nav>
<main class="app-main">
<component :is="activeComponent" />
</main>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
import type {
DeviceRecord,
HostRecord,
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
TaskListResponse,
TaskStatus,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
| string
| undefined;
export const API_BASE_URL = (
configuredBaseUrl || "http://127.0.0.1:8001"
).replace(/\/$/, "");
const TOKEN_STORAGE_KEY = "cloudConsole.bearerToken";
export const TOKEN_INVALID_EVENT = "cloud-console:token-invalid";
export class CloudApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "CloudApiError";
}
}
export function getStoredToken(): string | null {
try {
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
}
export function storeToken(token: string): void {
sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearStoredToken(): void {
sessionStorage.removeItem(TOKEN_STORAGE_KEY);
}
interface RequestInitLike {
method?: string;
body?: string | null;
headers?: Record<string, string>;
}
async function request<T>(path: string, init: RequestInitLike = {}): Promise<T> {
const token = getStoredToken();
if (!token) {
throw new CloudApiError(401, "no bearer token stored");
}
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...init.headers,
};
if (init.body !== undefined && init.body !== null) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: init.method || "GET",
body: init.body ?? null,
headers,
});
if (response.status === 401 || response.status === 403) {
clearStoredToken();
window.dispatchEvent(new CustomEvent(TOKEN_INVALID_EVENT));
let detail = "token rejected by cloud api";
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
detail = payload.detail;
}
} catch {
// fall back to the default detail
}
throw new CloudApiError(response.status, detail);
}
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
message = payload.detail;
} else if (payload.detail) {
message = JSON.stringify(payload.detail);
}
} catch {
message = await response.text().catch(() => message);
}
throw new CloudApiError(response.status, message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function listTasks(options?: {
status?: TaskStatus;
limit?: number;
offset?: number;
}): Promise<TaskListResponse> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
params.set("limit", String(options?.limit ?? 50));
params.set("offset", String(options?.offset ?? 0));
const query = params.toString();
return request<TaskListResponse>(`/v1/tasks${query ? `?${query}` : ""}`);
}
export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(
`/v1/tasks/${encodeURIComponent(taskId)}/attempts`,
);
}
export function listDevices(): Promise<DeviceRecord[]> {
return request<DeviceRecord[]>("/v1/devices");
}
export function listHosts(): Promise<HostRecord[]> {
return request<HostRecord[]>("/v1/hosts");
}
export function listPlugins(): Promise<PluginRecord[]> {
return request<PluginRecord[]>("/v1/plugins");
}
export function registerPlugin(
payload: PluginRegistrationPayload,
): Promise<PluginRecord> {
return request<PluginRecord>("/v1/plugins", {
method: "POST",
body: JSON.stringify(payload),
});
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CLOUD_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
+350
View File
@@ -0,0 +1,350 @@
:root {
--bg: #0f172a;
--bg-elev: #1e293b;
--bg-elev-2: #273449;
--border: #334155;
--text: #e2e8f0;
--text-muted: #94a3b8;
--text-dim: #64748b;
--accent: #38bdf8;
--accent-hover: #7dd3fc;
--danger: #f87171;
--danger-bg: #7f1d1d;
--success: #4ade80;
--warning: #fbbf24;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
button {
font: inherit;
cursor: pointer;
background: var(--bg-elev-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
transition: background 0.15s ease;
}
button:hover:not(:disabled) {
background: var(--border);
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.primary {
background: var(--accent);
color: #0b1220;
border-color: var(--accent);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
input,
select,
textarea {
font: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
label {
color: var(--text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
a {
color: var(--accent);
}
.app-shell {
display: flex;
height: 100%;
}
.app-nav {
width: 220px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-nav h1 {
font-size: 14px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin: 0 0 12px;
}
.app-nav button {
text-align: left;
background: transparent;
border-color: transparent;
display: flex;
align-items: center;
gap: 8px;
}
.app-nav button:hover:not(:disabled) {
background: var(--bg-elev-2);
}
.app-nav button.active {
background: var(--bg-elev-2);
color: var(--accent);
border-color: var(--border);
}
.app-nav .spacer {
flex: 1;
}
.app-main {
flex: 1;
overflow: auto;
padding: 24px 32px;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.toolbar h2 {
margin: 0;
font-size: 20px;
}
.panel {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
color: var(--text-muted);
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
tr.row-selectable {
cursor: pointer;
}
tr.row-selectable:hover {
background: var(--bg-elev-2);
}
tr.row-selected {
background: var(--bg-elev-2);
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: lowercase;
}
.status-badge.queued,
.status-badge.assigned,
.status-badge.dispatched {
background: rgba(56, 189, 248, 0.18);
color: var(--accent);
}
.status-badge.done {
background: rgba(74, 222, 128, 0.18);
color: var(--success);
}
.status-badge.failed {
background: rgba(248, 113, 113, 0.18);
color: var(--danger);
}
.status-badge.unreachable {
background: rgba(248, 113, 113, 0.18);
color: var(--danger);
}
.status-badge.idle,
.status-badge.wired {
background: rgba(74, 222, 128, 0.18);
color: var(--success);
}
.status-badge.busy {
background: rgba(251, 191, 36, 0.18);
color: var(--warning);
}
.notice {
padding: 10px 12px;
border-radius: 6px;
background: var(--bg-elev-2);
border: 1px solid var(--border);
color: var(--text-muted);
}
.notice.error {
background: rgba(127, 29, 29, 0.4);
border-color: var(--danger);
color: var(--text);
}
.notice.success {
background: rgba(34, 197, 94, 0.18);
border-color: var(--success);
color: var(--text);
}
.token-screen {
max-width: 480px;
margin: 80px auto;
padding: 32px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 12px;
}
.token-screen h1 {
margin: 0 0 8px;
font-size: 24px;
}
.token-screen p {
color: var(--text-muted);
margin: 0 0 24px;
}
.token-screen label {
display: block;
margin-bottom: 6px;
}
.token-screen textarea {
width: 100%;
min-height: 88px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
}
.token-screen .actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-grid label {
display: block;
margin-bottom: 4px;
}
.form-grid .field-full {
grid-column: 1 / -1;
}
.muted {
color: var(--text-muted);
}
.dim {
color: var(--text-dim);
font-size: 12px;
}
.attempt-result {
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.pagination {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
color: var(--text-muted);
}
.loader {
display: inline-block;
animation: spin 1.2s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+70
View File
@@ -0,0 +1,70 @@
export type TaskStatus =
| "queued"
| "assigned"
| "dispatched"
| "done"
| "failed";
export interface TaskListItem {
id: string;
status: TaskStatus;
goal: string | null;
workflow_definition_id: string | null;
assigned_device_id: string | null;
assigned_host_id: string | null;
attempt_count: number;
failure_reason: string | null;
created_at: string;
}
export interface TaskListResponse {
items: TaskListItem[];
total: number;
limit: number;
offset: number;
}
export interface TaskAttempt {
task_id: string;
attempt: number;
lease_id: string;
host_id: string;
device_id: string;
status: string;
lease_expires_at: string;
created_at: string;
completed_at: string | null;
failure_reason: string | null;
terminal_result: Record<string, unknown> | null;
}
export interface DeviceRecord {
device_id: string;
host_id: string;
driver_type: string;
status: string;
capability_tags: string[];
}
export interface HostRecord {
host_id: string;
address: string | null;
last_seen_at: string;
}
export type PluginEntryPointKind = "driver" | "tool" | "skill";
export interface PluginRecord {
name: string;
version: string;
entry_point_kind: string;
target: string;
wired: boolean;
}
export interface PluginRegistrationPayload {
name: string;
version: string;
entry_point_kind: PluginEntryPointKind;
target: string;
}
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import { CloudApiError, listDevices, listHosts } from "../api";
import type { DeviceRecord, HostRecord } from "../types";
const loading = ref(false);
const errorMessage = ref("");
const devices = ref<DeviceRecord[]>([]);
const hosts = ref<HostRecord[]>([]);
const staleAfterSeconds = ref(120);
const staleHostIds = computed(() => {
const cutoff = Date.now() - staleAfterSeconds.value * 1000;
return new Set(
hosts.value
.filter((host) => Date.parse(host.last_seen_at) < cutoff)
.map((host) => host.host_id),
);
});
function hostIsStale(hostId: string): boolean {
return staleHostIds.value.has(hostId);
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
[devices.value, hosts.value] = await Promise.all([listDevices(), listHosts()]);
} catch (err) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
} else if (err instanceof Error) {
errorMessage.value = err.message;
} else {
errorMessage.value = "failed to load device pool";
}
} finally {
loading.value = false;
}
}
function formatTime(value: string): string {
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return value;
const date = new Date(parsed);
const secondsAgo = Math.round((Date.now() - parsed) / 1000);
const relative =
secondsAgo < 60
? `${secondsAgo}s ago`
: `${Math.round(secondsAgo / 60)}m ago`;
return `${date.toLocaleString()} (${relative})`;
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>Device pool & host registry</h2>
<label>
Stale after (s)
<input
v-model.number="staleAfterSeconds"
type="number"
min="5"
step="5"
style="width: 80px"
/>
</label>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
Refresh
</button>
<span v-if="loading" class="muted">
<LoaderCircle :size="14" class="loader" /> loading
</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div class="panel">
<h3>Hosts</h3>
<table v-if="hosts.length">
<thead>
<tr>
<th>Host ID</th>
<th>Address</th>
<th>Last seen</th>
<th>State</th>
</tr>
</thead>
<tbody>
<tr v-for="host in hosts" :key="host.host_id">
<td>
<code>{{ host.host_id }}</code>
</td>
<td>{{ host.address || "—" }}</td>
<td class="dim">{{ formatTime(host.last_seen_at) }}</td>
<td>
<span
:class="
hostIsStale(host.host_id)
? 'status-badge unreachable'
: 'status-badge idle'
"
>
{{ hostIsStale(host.host_id) ? "stale" : "healthy" }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No hosts registered.</div>
</div>
<div class="panel">
<h3>Devices</h3>
<table v-if="devices.length">
<thead>
<tr>
<th>Device ID</th>
<th>Host</th>
<th>Driver</th>
<th>Status</th>
<th>Capabilities</th>
</tr>
</thead>
<tbody>
<tr
v-for="device in devices"
:key="`${device.host_id}/${device.device_id}`"
>
<td><code>{{ device.device_id }}</code></td>
<td>
<code>{{ device.host_id }}</code>
<span v-if="hostIsStale(device.host_id)" class="status-badge unreachable">
host stale
</span>
</td>
<td>{{ device.driver_type }}</td>
<td>
<span
:class="
hostIsStale(device.host_id)
? 'status-badge unreachable'
: `status-badge ${device.status}`
"
>
{{ hostIsStale(device.host_id) ? "unreachable" : device.status }}
</span>
</td>
<td>
<span v-if="device.capability_tags.length">
{{ device.capability_tags.join(", ") }}
</span>
<span v-else class="dim"></span>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No devices pooled.</div>
</div>
</div>
</template>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { LoaderCircle, Plus, RefreshCw } from "@lucide/vue";
import { CloudApiError, listPlugins, registerPlugin } from "../api";
import type {
PluginEntryPointKind,
PluginRecord,
} from "../types";
const loading = ref(false);
const errorMessage = ref("");
const plugins = ref<PluginRecord[]>([]);
const showForm = ref(false);
const formError = ref("");
const formSuccess = ref("");
const submitting = ref(false);
const ENTRY_POINT_KINDS: PluginEntryPointKind[] = ["driver", "tool", "skill"];
const form = reactive({
name: "",
version: "",
entry_point_kind: "tool" as PluginEntryPointKind,
target: "",
});
function resetForm() {
form.name = "";
form.version = "";
form.entry_point_kind = "tool";
form.target = "";
formError.value = "";
formSuccess.value = "";
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
plugins.value = await listPlugins();
} catch (err) {
describeError(err, "failed to load plugin registry");
} finally {
loading.value = false;
}
}
function describeError(err: unknown, fallback: string) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
} else if (err instanceof Error) {
errorMessage.value = err.message;
} else {
errorMessage.value = fallback;
}
}
async function submit() {
formError.value = "";
formSuccess.value = "";
if (!form.name.trim() || !form.version.trim() || !form.target.trim()) {
formError.value = "name, version, and target are required";
return;
}
submitting.value = true;
try {
const created = await registerPlugin({
name: form.name.trim(),
version: form.version.trim(),
entry_point_kind: form.entry_point_kind,
target: form.target.trim(),
});
formSuccess.value = `registered ${created.name}@${created.version}`;
resetForm();
showForm.value = false;
await refresh();
} catch (err) {
if (err instanceof CloudApiError) {
formError.value = err.message;
} else if (err instanceof Error) {
formError.value = err.message;
} else {
formError.value = "registration failed";
}
} finally {
submitting.value = false;
}
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>Plugin registry</h2>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
Refresh
</button>
<button class="primary" @click="showForm = !showForm">
<Plus :size="14" />
{{ showForm ? "Close form" : "Register plugin" }}
</button>
<span v-if="loading" class="muted">
<LoaderCircle :size="14" class="loader" /> loading
</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div v-if="formSuccess" class="notice success">{{ formSuccess }}</div>
<div class="panel" v-if="showForm">
<h3>Register a plugin</h3>
<p class="dim">
The cloud api requires the <code>plugins:admin</code> scope for this
call. Without it the api will respond with <code>403</code>, which the
form surfaces below.
</p>
<form @submit.prevent="submit">
<div class="form-grid">
<div>
<label for="plugin-name">Name</label>
<input id="plugin-name" v-model="form.name" autocomplete="off" />
</div>
<div>
<label for="plugin-version">Version</label>
<input id="plugin-version" v-model="form.version" autocomplete="off" />
</div>
<div>
<label for="plugin-kind">Entry point kind</label>
<select id="plugin-kind" v-model="form.entry_point_kind">
<option v-for="kind in ENTRY_POINT_KINDS" :key="kind" :value="kind">
{{ kind }}
</option>
</select>
</div>
<div>
<label for="plugin-target">Target</label>
<input
id="plugin-target"
v-model="form.target"
placeholder="module.path:AttributeName"
autocomplete="off"
/>
</div>
</div>
<div v-if="formError" class="notice error" style="margin-top: 12px">
{{ formError }}
</div>
<div class="toolbar" style="margin-top: 12px">
<button type="submit" class="primary" :disabled="submitting">
Submit
</button>
<button type="button" @click="resetForm">Clear</button>
</div>
</form>
</div>
<div class="panel">
<table v-if="plugins.length">
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Kind</th>
<th>Target</th>
<th>Wired</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in plugins" :key="plugin.name">
<td><code>{{ plugin.name }}</code></td>
<td>{{ plugin.version }}</td>
<td>
<span class="status-badge queued">{{ plugin.entry_point_kind }}</span>
</td>
<td class="dim">{{ plugin.target }}</td>
<td>
<span :class="plugin.wired ? 'status-badge wired' : 'status-badge failed'">
{{ plugin.wired ? "wired" : "not wired" }}
</span>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No plugins registered.</div>
</div>
</div>
</template>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import {
CloudApiError,
getTaskAttempts,
listTasks,
} from "../api";
import type {
TaskAttempt,
TaskListItem,
TaskListResponse,
TaskStatus,
} from "../types";
const STATUSES: TaskStatus[] = [
"queued",
"assigned",
"dispatched",
"done",
"failed",
];
const statusFilter = ref<TaskStatus | "">("");
const pageSize = ref(50);
const offset = ref(0);
const loading = ref(false);
const errorMessage = ref("");
const result = ref<TaskListResponse | null>(null);
const selectedTask = ref<TaskListItem | null>(null);
const attempts = ref<TaskAttempt[]>([]);
const attemptsLoading = ref(false);
const attemptsError = ref("");
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
result.value = await listTasks({
status: statusFilter.value === "" ? undefined : statusFilter.value,
limit: pageSize.value,
offset: offset.value,
});
if (selectedTask.value) {
const stillPresent = result.value.items.find(
(item) => item.id === selectedTask.value?.id,
);
if (!stillPresent) {
selectedTask.value = null;
attempts.value = [];
}
}
} catch (err) {
handleError(err, "failed to load tasks");
} finally {
loading.value = false;
}
}
async function selectTask(task: TaskListItem) {
selectedTask.value = task;
attempts.value = [];
attemptsError.value = "";
attemptsLoading.value = true;
try {
attempts.value = await getTaskAttempts(task.id);
} catch (err) {
if (err instanceof CloudApiError && err.status === 404) {
// Task was deleted between list and detail load.
selectedTask.value = null;
await refresh();
} else {
handleError(err, "failed to load task attempts");
attemptsError.value = errorMessage.value;
errorMessage.value = "";
}
} finally {
attemptsLoading.value = false;
}
}
function handleError(err: unknown, fallback: string) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
} else if (err instanceof Error) {
errorMessage.value = err.message;
} else {
errorMessage.value = fallback;
}
}
function goPrevPage() {
offset.value = Math.max(0, offset.value - pageSize.value);
}
function goNextPage() {
if (!result.value) return;
if (offset.value + pageSize.value >= result.value.total) return;
offset.value = offset.value + pageSize.value;
}
function clearSelection() {
selectedTask.value = null;
attempts.value = [];
}
watch(statusFilter, () => {
offset.value = 0;
refresh();
});
watch(pageSize, () => {
offset.value = 0;
refresh();
});
watch(offset, refresh);
onMounted(refresh);
function formatTime(value: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function attemptOutcomeClass(status: string): string {
if (status === "done") return "status-badge done";
if (status === "failed" || status === "expired") return "status-badge failed";
return "status-badge queued";
}
function formatTerminalResult(attempt: TaskAttempt): string {
if (!attempt.terminal_result) return "—";
try {
return JSON.stringify(attempt.terminal_result, null, 2);
} catch {
return String(attempt.terminal_result);
}
}
</script>
<template>
<div>
<div class="toolbar">
<h2>Tasks</h2>
<label>
Status
<select v-model="statusFilter">
<option value="">any</option>
<option v-for="s in STATUSES" :key="s" :value="s">{{ s }}</option>
</select>
</label>
<label>
Page size
<select v-model.number="pageSize">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</label>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
Refresh
</button>
<span v-if="loading" class="muted">
<LoaderCircle :size="14" class="loader" /> loading
</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div class="panel" v-if="!selectedTask">
<table v-if="result && result.items.length">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Goal / Workflow</th>
<th>Assignment</th>
<th>Attempts</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr
v-for="task in result.items"
:key="task.id"
class="row-selectable"
@click="selectTask(task)"
>
<td>
<code>{{ task.id.slice(0, 8) }}</code>
</td>
<td>
<span class="status-badge" :class="task.status">{{ task.status }}</span>
</td>
<td>
<div v-if="task.goal">{{ task.goal }}</div>
<div v-else-if="task.workflow_definition_id" class="muted">
wf: {{ task.workflow_definition_id }}
</div>
<div v-else class="dim"></div>
<div v-if="task.failure_reason" class="dim">
{{ task.failure_reason }}
</div>
</td>
<td>
<div v-if="task.assigned_device_id">
{{ task.assigned_device_id }}
<span class="dim">on {{ task.assigned_host_id }}</span>
</div>
<div v-else class="dim">unassigned</div>
</td>
<td>{{ task.attempt_count }}</td>
<td class="dim">{{ formatTime(task.created_at) }}</td>
</tr>
</tbody>
</table>
<div v-else-if="result" class="muted">No tasks match the current filter.</div>
<div class="pagination" v-if="result">
<span>
showing
{{ result.offset + 1 }}{{
Math.min(result.offset + result.items.length, result.total)
}}
of {{ result.total }}
</span>
<button :disabled="offset === 0" @click="goPrevPage">Prev</button>
<button
:disabled="offset + pageSize >= result.total"
@click="goNextPage"
>
Next
</button>
</div>
</div>
<div class="panel" v-else>
<div class="toolbar">
<h2>
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
</h2>
<button @click="clearSelection">Back to list</button>
</div>
<p class="muted">
Status: <span class="status-badge" :class="selectedTask.status">{{ selectedTask.status }}</span>
· Attempts: {{ selectedTask.attempt_count }}
</p>
<p v-if="selectedTask.goal">
<strong>Goal:</strong> {{ selectedTask.goal }}
</p>
<p v-if="selectedTask.workflow_definition_id">
<strong>Workflow:</strong>
<code>{{ selectedTask.workflow_definition_id }}</code>
</p>
<p v-if="selectedTask.failure_reason">
<strong class="text-danger">Failure reason:</strong>
{{ selectedTask.failure_reason }}
</p>
<h3>Attempt history</h3>
<div v-if="attemptsLoading" class="muted">loading attempts</div>
<div v-else-if="attemptsError" class="notice error">{{ attemptsError }}</div>
<table v-else-if="attempts.length">
<thead>
<tr>
<th>#</th>
<th>Status</th>
<th>Host / Device</th>
<th>Lease expires</th>
<th>Created</th>
<th>Completed</th>
<th>Failure</th>
<th>Terminal result</th>
</tr>
</thead>
<tbody>
<tr v-for="attempt in attempts" :key="attempt.attempt">
<td>{{ attempt.attempt }}</td>
<td>
<span :class="attemptOutcomeClass(attempt.status)">{{ attempt.status }}</span>
</td>
<td>
{{ attempt.host_id }}
<span class="dim">/ {{ attempt.device_id }}</span>
</td>
<td class="dim">{{ formatTime(attempt.lease_expires_at) }}</td>
<td class="dim">{{ formatTime(attempt.created_at) }}</td>
<td class="dim">{{ formatTime(attempt.completed_at) }}</td>
<td v-if="attempt.failure_reason">{{ attempt.failure_reason }}</td>
<td v-else class="dim"></td>
<td>
<pre class="attempt-result">{{ formatTerminalResult(attempt) }}</pre>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">No attempts recorded for this task yet.</div>
</div>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref } from "vue";
import { API_BASE_URL, storeToken } from "../api";
defineProps<{ rejectionMessage?: string }>();
const emit = defineEmits<{ (e: "submitted"): void }>();
const token = ref("");
const error = ref("");
function submit() {
const trimmed = token.value.trim();
if (!trimmed) {
error.value = "paste a bearer token issued by the cloud control plane";
return;
}
storeToken(trimmed);
error.value = "";
emit("submitted");
}
</script>
<template>
<div class="token-screen">
<h1>Cloud Console</h1>
<p>
Paste an operator bearer token scoped to the Cloud Control Plane at
<code>{{ API_BASE_URL }}</code>. The token is held in
<code>sessionStorage</code> only close this tab to discard it.
</p>
<div v-if="rejectionMessage" class="notice error" style="margin-bottom: 16px">
{{ rejectionMessage }}
</div>
<form @submit.prevent="submit">
<label for="token">Bearer token</label>
<textarea
id="token"
v-model="token"
autocomplete="off"
spellcheck="false"
placeholder="paste a token scoped at least to tasks:read, pool:read, plugins:read"
></textarea>
<div v-if="error" class="notice error" style="margin-top: 12px">
{{ error }}
</div>
<div class="actions">
<button class="primary" type="submit">Connect</button>
</div>
</form>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
+68
View File
@@ -183,6 +183,74 @@ finally:
PY PY
``` ```
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders the task queue/history, device pool, host registry, and plugin
registry, and exposes the existing plugin-registration action. It authenticates
the same way `CloudClient` does: by attaching a pre-issued bearer token to
every request. There is no login or session system.
### Provision an operator bearer token
Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the
console operators need to do. The least-privilege set for read-only dashboards
is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if
operators should submit ad-hoc tasks from the same tab, and `plugins:admin`
only if operators should register plugins:
```json
[
{
"principal_id": "console-operator",
"token": "replace-with-a-long-random-opaque-token",
"scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"]
}
]
```
Rotate the token the same way as any other credential entry: deploy the
updated Cloud API credential set and instruct operators to paste the new token
into the console. The console keeps the token only in browser `sessionStorage`
for that tab; closing the tab discards it.
### Configure the CORS allow-list
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin (scheme + host + port, no trailing slash) the
operator's browser will load the console from:
```bash
# Allow a local Vite dev server
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
# Or a deployed origin
export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
```
Restart the Cloud API after changing this env. The middleware is added only
when the allow-list is non-empty — existing deployments see no behavior change
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
supported because every console request carries a bearer token.
### Run the console
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL,
paste the operator token, and the dashboards become available.
For a production build, run `npm run build` and serve the resulting `dist/`
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
## Runtime AI Planner ## Runtime AI Planner
The Host Agent reuses the local Runtime planner. AI planning is disabled by The Host Agent reuses the local Runtime planner. AI planning is disabled by
+20 -20
View File
@@ -1,42 +1,42 @@
## 1. Repository: bounded task listing ## 1. Repository: bounded task listing
- [ ] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py` - [x] 1.1 Add `list_tasks(*, status, limit, offset)` and `count_tasks(status)` to the `CloudRepository` Protocol in `repository.py`
- [ ] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first - [x] 1.2 Implement both methods in `sql_repository.py` using the existing SQLAlchemy query builder (no dialect-specific SQL), ordered most-recent-first
- [ ] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL - [x] 1.3 Add unit/integration tests covering status filtering, pagination bounds, and empty results against both SQLite and PostgreSQL
## 2. Platform SDK API: task listing & attempt history ## 2. Platform SDK API: task listing & attempt history
- [ ] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py` - [x] 2.1 Add response models (task summary list item, task attempt) to `cloud/sdk/models.py`
- [ ] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods - [x] 2.2 Implement `GET /v1/tasks` in `cloud/sdk/api.py`: `tasks:read` scope, optional `status` query param, `limit` (default 50, max 100) / `offset` query params, calling the new repository methods
- [ ] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts` - [x] 2.3 Implement `GET /v1/tasks/{task_id}/attempts` in `cloud/sdk/api.py`: `tasks:read` scope, 404 on unknown task id, calling `list_task_attempts`
- [ ] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403) - [x] 2.4 Add tests for both endpoints: filtered/unfiltered listing, page-size-exceeds-max rejection, attempts for known/unknown task id, and scope enforcement (401/403)
## 3. Python SDK client parity ## 3. Python SDK client parity
- [ ] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py` - [x] 3.1 Add `list_tasks(...)` and `get_task_attempts(task_id)` methods to `CloudClient` in `cloud/sdk/client.py`
- [ ] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints - [x] 3.2 Add client tests asserting parity with direct HTTP calls to the two new endpoints
## 4. Cloud API CORS configuration ## 4. Cloud API CORS configuration
- [ ] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()` - [x] 4.1 Add a `cors_allowed_origins` field (env `CLOUD_CONSOLE_CORS_ORIGINS`, comma-separated, default empty) to `CloudControlConfig`/`load_control_config()`
- [ ] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty - [x] 4.2 Wire `CORSMiddleware` into `apps/cloud-api/cloud_api/app.py`'s `create_app()`, added only when the allow-list is non-empty
- [ ] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin - [x] 4.3 Add a config/app test confirming CORS headers are absent by default and present only for a configured origin
## 5. Cloud console frontend (independent SPA) ## 5. Cloud console frontend (independent SPA)
- [ ] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`) - [x] 5.1 Scaffold an independent Vue 3 + Vite SPA project at `cloud-console/` (own `package.json`/build tooling, sibling to `console/`)
- [ ] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403` - [x] 5.2 Implement the token-entry screen and an API client wrapper that stores the bearer token in `sessionStorage` and attaches it to every request, clearing it and returning to the entry screen on `401`/`403`
- [ ] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts` - [x] 5.3 Implement the task view: filterable/paginated list against `GET /v1/tasks`, and a detail view with attempt history against `GET /v1/tasks/{id}/attempts`
- [ ] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts` - [x] 5.4 Implement the device pool and host registry views against `GET /v1/devices` and `GET /v1/hosts`
- [ ] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API - [x] 5.5 Implement the plugin registry view (list) and registration form against `GET /v1/plugins` and `POST /v1/plugins`, surfacing validation/conflict/authorization errors from the API
- [ ] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured - [x] 5.6 Document how to run the frontend dev server against a Cloud API base URL (env config) and the CORS origin it needs configured
## 6. Documentation ## 6. Documentation
- [ ] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS` - [x] 6.1 Add a section to `docs/CLOUD_DEPLOYMENT.md` covering: running the console, provisioning an operator bearer token (least-privilege scopes), and configuring `CLOUD_CONSOLE_CORS_ORIGINS`
## 7. Verification ## 7. Verification
- [ ] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions - [x] 7.1 Run the full backend test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions
- [ ] 7.2 Run the PostgreSQL-backed repository/integration tests for the new listing methods - [ ] 7.2 Run the PostgreSQL-backed repository/integration tests for the new listing methods
- [ ] 7.3 Manually verify end-to-end: submit a task via the existing SDK, confirm it appears in the console's task list, transitions status, and its attempt history renders; confirm device/host/plugin views render against a running Host Agent - [ ] 7.3 Manually verify end-to-end: submit a task via the existing SDK, confirm it appears in the console's task list, transitions status, and its attempt history renders; confirm device/host/plugin views render against a running Host Agent
@@ -32,6 +32,7 @@ class CloudControlConfig:
allow_insecure_anonymous: bool = False allow_insecure_anonymous: bool = False
credentials: tuple[BearerCredential, ...] = () credentials: tuple[BearerCredential, ...] = ()
enrollment_credentials: tuple[EnrollmentCredential, ...] = () enrollment_credentials: tuple[EnrollmentCredential, ...] = ()
cors_allowed_origins: tuple[str, ...] = ()
def load_control_config( def load_control_config(
@@ -90,6 +91,9 @@ def load_control_config(
enrollment_credentials=_parse_enrollment_credentials( enrollment_credentials=_parse_enrollment_credentials(
values.get("CLOUD_ENROLLMENT_TOKENS_JSON") values.get("CLOUD_ENROLLMENT_TOKENS_JSON")
), ),
cors_allowed_origins=_parse_cors_origins(
values.get("CLOUD_CONSOLE_CORS_ORIGINS")
),
) )
validate_control_config(config) validate_control_config(config)
return config return config
@@ -219,3 +223,9 @@ def _parse_bool(value: str | None, *, default: bool) -> bool:
if normalized in {"0", "false", "no", "off", "disabled", ""}: if normalized in {"0", "false", "no", "off", "disabled", ""}:
return False return False
raise CloudConfigurationError("boolean configuration value is invalid") 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: if TYPE_CHECKING:
from cloud.plugins import PluginManifest from cloud.plugins import PluginManifest
from cloud.pool import HostRegistration, PooledDevice from cloud.pool import HostRegistration, PooledDevice
from cloud.scheduler import ScheduledTask from cloud.scheduler import ScheduledTask, ScheduledTaskStatus
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"] AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -154,6 +154,16 @@ class CloudRepository(Protocol):
def list_queued_tasks(self) -> list[ScheduledTask]: ... 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 get_task(self, task_id: str) -> ScheduledTask | None: ...
def update_task( 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 __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
from cloud.auth import ( from cloud.auth import (
PLUGINS_ADMIN_SCOPE, PLUGINS_ADMIN_SCOPE,
@@ -28,11 +28,14 @@ from cloud.sdk.models import (
HostResponse, HostResponse,
PluginRegistrationRequest, PluginRegistrationRequest,
PluginResponse, PluginResponse,
TaskAttemptResponse,
TaskListItem,
TaskListResponse,
TaskStatusResponse, TaskStatusResponse,
TaskSubmissionRequest, TaskSubmissionRequest,
TaskSubmissionResponse, TaskSubmissionResponse,
) )
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Query, Request, status
if TYPE_CHECKING: if TYPE_CHECKING:
from cloud.plugins import PluginRegistry from cloud.plugins import PluginRegistry
@@ -112,6 +115,76 @@ def create_cloud_router(
failure_reason=task.failure_reason, 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]) @router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]: def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request, POOL_READ_SCOPE) _authorize(request, POOL_READ_SCOPE)
@@ -89,6 +89,23 @@ class CloudClient:
resp = self._request("GET", f"/tasks/{task_id}") resp = self._request("GET", f"/tasks/{task_id}")
return resp.json() 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 # ----------------------------------------------------------------- devices
def list_devices(self) -> list[dict[str, Any]]: def list_devices(self) -> list[dict[str, Any]]:
@@ -133,11 +150,13 @@ class CloudClient:
path: str, path: str,
*, *,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> httpx.Response: ) -> httpx.Response:
response = self._http.request( response = self._http.request(
method, method,
self._url(path), self._url(path),
json=json, json=json,
params=params,
headers=self._headers, headers=self._headers,
auth=self._auth, auth=self._auth,
) )
+34 -1
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -35,6 +35,39 @@ class TaskStatusResponse(BaseModel):
failure_reason: str | None = None 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): class DeviceResponse(BaseModel):
device_id: str device_id: str
host_id: str host_id: str
@@ -368,6 +368,36 @@ class SQLAlchemyCloudRepository:
).all() ).all()
return [_task_from_row(row) for row in rows] 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: def get_task(self, task_id: str) -> Any | None:
with self._sessions() as session: with self._sessions() as session:
row = session.get(ScheduledTaskRow, task_id) row = session.get(ScheduledTaskRow, task_id)
+70
View File
@@ -150,6 +150,8 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None:
task_id = client.submit_task(goal="authenticated")["task_id"] task_id = client.submit_task(goal="authenticated")["task_id"]
assert client.get_task_status(task_id)["status"] == "queued" assert client.get_task_status(task_id)["status"] == "queued"
assert client.list_tasks()["total"] == 1
assert client.get_task_attempts(task_id) == []
assert client.list_devices() == [] assert client.list_devices() == []
assert client.list_hosts() == [] assert client.list_hosts() == []
assert client.list_plugins() == [] assert client.list_plugins() == []
@@ -164,6 +166,74 @@ def test_client_applies_bearer_token_to_every_public_method(tmp_path) -> None:
) )
def test_client_list_tasks_and_get_attempts_match_direct_http(tmp_path) -> None:
from datetime import UTC, datetime
from core.models import Device
client, pool = _client_and_pool(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="dev-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
first_id = client.submit_task(goal="first")["task_id"]
second_id = client.submit_task(goal="second")["task_id"]
# Drive one task through an attempt so get_task_attempts has data.
scheduler = TaskScheduler(pool, CloudStore(tmp_path / "cloud.sqlite3"), _config())
scheduler.assign()
task = scheduler.store.get_task(first_id)
if task is not None and task.status == "assigned":
scheduler.store.record_task_result(
task_id=first_id,
attempt=task.attempt_count,
lease_id=task.lease_id or "",
host_id=task.assigned_host_id or "",
status="failed",
failure_reason="boom",
terminal_result={"exit_code": 1},
completed_at=datetime.now(UTC),
)
# The TestClient is the HTTP boundary — issuing direct calls through it
# exercises the same FastAPI routes the client does, which is the parity
# contract platform-sdk already relies on.
http_client = client._http # type: ignore[attr-defined]
direct_list = http_client.get(
client._url("/tasks"), # type: ignore[attr-defined]
params={"limit": 50, "offset": 0},
headers=client._headers, # type: ignore[attr-defined]
).json()
direct_attempts = http_client.get(
f"{client._url('/tasks')}/{first_id}/attempts", # type: ignore[attr-defined]
headers=client._headers, # type: ignore[attr-defined]
).json()
via_client = client.list_tasks()
assert via_client == direct_list
assert via_client["total"] == 2
# Most-recent-first: second (the newer) before first.
assert [item["id"] for item in via_client["items"]] == [second_id, first_id]
failed_only = client.list_tasks(status="failed")
assert failed_only["total"] == 1
assert failed_only["items"][0]["id"] == first_id
attempts = client.get_task_attempts(first_id)
assert attempts == direct_attempts
assert len(attempts) == 1
assert attempts[0]["status"] == "failed"
assert attempts[0]["failure_reason"] == "boom"
def test_client_get_attempts_raises_for_unknown_task(tmp_path) -> None:
import httpx
client, _ = _client_and_pool(tmp_path)
with pytest.raises(httpx.HTTPStatusError):
client.get_task_attempts("does-not-exist")
def test_client_raises_typed_authorization_error_without_exposing_token( def test_client_raises_typed_authorization_error_without_exposing_token(
tmp_path, tmp_path,
) -> None: ) -> None:
+101
View File
@@ -73,6 +73,8 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
"list_devices", "list_devices",
"enqueue_task", "enqueue_task",
"get_task", "get_task",
"list_tasks",
"count_tasks",
"save_plugin", "save_plugin",
"assign_task", "assign_task",
"claim_assignment", "claim_assignment",
@@ -453,6 +455,105 @@ def test_task_attempt_history_is_ordered_and_complete(database_url: str) -> None
database.close() database.close()
def test_list_tasks_returns_empty_when_repository_has_no_tasks(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
try:
assert database.repository.list_tasks() == []
assert database.repository.count_tasks() == 0
assert database.repository.list_tasks(status="queued") == []
assert database.repository.count_tasks(status="queued") == 0
finally:
database.close()
def test_list_tasks_returns_most_recent_first_with_optional_status_filter(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
base = datetime(2026, 7, 12, 6, 0, tzinfo=UTC)
queued_ids = [_unique_id("list-task") for _ in range(2)]
failed_ids = [_unique_id("list-task") for _ in range(2)]
try:
for index, task_id in enumerate(queued_ids):
database.repository.enqueue_task(
ScheduledTask(
id=task_id,
goal="queued goal",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="queued",
created_at=base + timedelta(seconds=index),
)
)
for index, task_id in enumerate(failed_ids):
database.repository.enqueue_task(
ScheduledTask(
id=task_id,
goal="failed goal",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="failed",
failure_reason="boom",
created_at=base + timedelta(seconds=10 + index),
)
)
unfiltered = database.repository.list_tasks()
assert [task.id for task in unfiltered] == (
list(reversed(failed_ids)) + list(reversed(queued_ids))
)
assert database.repository.count_tasks() == 4
queued = database.repository.list_tasks(status="queued")
assert [task.id for task in queued] == list(reversed(queued_ids))
assert all(task.status == "queued" for task in queued)
assert database.repository.count_tasks(status="queued") == 2
failed = database.repository.list_tasks(status="failed")
assert [task.id for task in failed] == list(reversed(failed_ids))
assert database.repository.count_tasks(status="failed") == 2
# A status with no matches returns an empty page and zero count.
assert database.repository.list_tasks(status="done") == []
assert database.repository.count_tasks(status="done") == 0
finally:
database.close()
def test_list_tasks_pagination_bounds(database_url: str) -> None:
database = CloudDatabase(database_url)
base = datetime(2026, 7, 12, 7, 0, tzinfo=UTC)
task_ids = [_unique_id("page-task") for _ in range(4)]
try:
for index, task_id in enumerate(task_ids):
database.repository.enqueue_task(
ScheduledTask(
id=task_id,
goal=f"goal-{index}",
workflow_definition_id=None,
constraints=TaskConstraints(),
created_at=base + timedelta(seconds=index),
)
)
# Most-recent-first ordering means page 1 returns the newest two ids.
page_one = database.repository.list_tasks(limit=2, offset=0)
assert [task.id for task in page_one] == [task_ids[3], task_ids[2]]
page_two = database.repository.list_tasks(limit=2, offset=2)
assert [task.id for task in page_two] == [task_ids[1], task_ids[0]]
# An offset past the end of the result set returns an empty page,
# not an error — the caller is expected to consult count_tasks().
assert database.repository.list_tasks(limit=10, offset=100) == []
finally:
database.close()
def test_atomic_assignment_creates_lease_attempt_and_reservation( def test_atomic_assignment_creates_lease_attempt_and_reservation(
database_url: str, database_url: str,
) -> None: ) -> None:
+98
View File
@@ -272,6 +272,8 @@ def test_submit_with_constraints(tmp_path) -> None:
[ [
("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"), ("post", "/v1/tasks", {"goal": "x"}, "tasks:submit"),
("get", "/v1/tasks/missing", None, "tasks:read"), ("get", "/v1/tasks/missing", None, "tasks:read"),
("get", "/v1/tasks", None, "tasks:read"),
("get", "/v1/tasks/missing/attempts", None, "tasks:read"),
("get", "/v1/devices", None, "pool:read"), ("get", "/v1/devices", None, "pool:read"),
("get", "/v1/hosts", None, "pool:read"), ("get", "/v1/hosts", None, "pool:read"),
("get", "/v1/plugins", None, "plugins:read"), ("get", "/v1/plugins", None, "plugins:read"),
@@ -332,6 +334,102 @@ def test_every_public_route_enforces_its_scope(
assert authorized.status_code not in {401, 403} assert authorized.status_code not in {401, 403}
def test_list_tasks_returns_summary_with_pagination_and_status_filter(
tmp_path,
) -> None:
app, pool, scheduler, _ = _build_app(tmp_path)
# Submit three tasks; assign one so the population covers multiple statuses.
first_id = scheduler.submit(goal="first")
second_id = scheduler.submit(goal="second")
pool.sync_host_devices(
"host-a",
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
scheduler.assign()
# assigned_id is whichever task the scheduler picked (oldest-first = first_id).
assigned_id = first_id
client = _client_for(app)
unfiltered = client.get("/v1/tasks").json()
assert unfiltered["total"] == 2
assert unfiltered["limit"] == 50
assert unfiltered["offset"] == 0
assert [item["id"] for item in unfiltered["items"]] == [second_id, assigned_id]
# Lease id must not leak through the summary surface.
assert all("lease_id" not in item for item in unfiltered["items"])
queued_only = client.get("/v1/tasks", params={"status": "queued"}).json()
assert queued_only["total"] == 1
assert [item["id"] for item in queued_only["items"]] == [second_id]
assert all(item["status"] == "queued" for item in queued_only["items"])
assigned_only = client.get(
"/v1/tasks", params={"status": "assigned"}
).json()
assert assigned_only["total"] == 1
assert [item["id"] for item in assigned_only["items"]] == [assigned_id]
def test_list_tasks_rejects_page_size_above_maximum(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
too_large = client.get("/v1/tasks", params={"limit": 101})
assert too_large.status_code == 422
# And the boundary value is accepted.
boundary = client.get("/v1/tasks", params={"limit": 100})
assert boundary.status_code == 200
def test_list_tasks_rejects_negative_offset(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
response = client.get("/v1/tasks", params={"offset": -1})
assert response.status_code == 422
def test_list_task_attempts_returns_chronological_history(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="attempt 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="failed",
failure_reason="boom",
terminal_result={"exit_code": 1},
completed_at=datetime.now(UTC),
)
client = _client_for(app)
resp = client.get(f"/v1/tasks/{task_id}/attempts")
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body) == 1
assert body[0]["task_id"] == task_id
assert body[0]["status"] == "failed"
assert body[0]["failure_reason"] == "boom"
assert body[0]["terminal_result"] == {"exit_code": 1}
assert body[0]["host_id"] == "host-a"
assert body[0]["device_id"] == "device-a"
def test_list_task_attempts_returns_404_for_unknown_task(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
resp = client.get("/v1/tasks/does-not-exist/attempts")
assert resp.status_code == 404, resp.text
assert "does-not-exist" in resp.json()["detail"]
def test_plugin_admin_scope_is_checked_before_registration( def test_plugin_admin_scope_is_checked_before_registration(
tmp_path, tmp_path,
monkeypatch, monkeypatch,