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
+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>