From bc4198be846c22b9ed5fc14ba10c5e48d0550689 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 12 Mar 2026 14:51:33 -0400 Subject: [PATCH 1/9] Add control-plane setup readiness surfaces --- interface/src/api/client.test.ts | 50 +++ interface/src/api/client.ts | 67 ++++ interface/src/components/SetupBanner.tsx | 25 +- .../src/components/SetupReadiness.test.ts | 219 +++++++++++ interface/src/components/SetupReadiness.tsx | 340 ++++++++++++++++++ interface/src/router.tsx | 2 + interface/src/routes/Overview.tsx | 2 + interface/src/routes/Settings.tsx | 263 +++++++++++++- 8 files changed, 942 insertions(+), 26 deletions(-) create mode 100644 interface/src/api/client.test.ts create mode 100644 interface/src/components/SetupReadiness.test.ts create mode 100644 interface/src/components/SetupReadiness.tsx diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts new file mode 100644 index 000000000..4ca5cdcae --- /dev/null +++ b/interface/src/api/client.test.ts @@ -0,0 +1,50 @@ +import {afterEach, beforeEach, describe, expect, mock, test} from "bun:test"; + +if (!("window" in globalThis)) { + (globalThis as typeof globalThis & {window: Record}).window = {}; +} + +const {api} = await import("./client"); + +const originalFetch = globalThis.fetch; + +describe("api client runtime health actions", () => { + beforeEach(() => { + globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("triggerWarmup posts agent and force payload", async () => { + await api.triggerWarmup({ agentId: "main", force: true }); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/agents/warmup", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: "main", force: true }), + }), + ); + }); + + test("reconnectMcpServer posts to the reconnect endpoint", async () => { + await api.reconnectMcpServer("filesystem"); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/mcp/servers/filesystem/reconnect", + expect.objectContaining({ + method: "POST", + }), + ); + }); +}); diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index e89ca0abb..c0fb15394 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -940,6 +940,31 @@ export interface ProvidersResponse { has_any: boolean; } +export type WarmupState = "cold" | "warming" | "warm" | "degraded"; + +export interface WarmupStatus { + state: WarmupState; + embedding_ready: boolean; + last_refresh_unix_ms: number | null; + last_error: string | null; + bulletin_age_secs: number | null; +} + +export interface WarmupStatusEntry { + agent_id: string; + status: WarmupStatus; +} + +export interface WarmupStatusResponse { + statuses: WarmupStatusEntry[]; +} + +export interface WarmupTriggerResponse { + status: string; + forced: boolean; + accepted_agents: string[]; +} + export interface ProviderActionResponse { success: boolean; message: string; @@ -1220,6 +1245,23 @@ export interface MessagingInstanceActionResponse { message: string; } +export interface McpServerStatusInfo { + name: string; + transport: string; + enabled: boolean; + state: string; +} + +export interface McpAgentStatus { + agent_id: string; + servers: McpServerStatusInfo[]; +} + +export interface McpMutationResponse { + success: boolean; + message: string; +} + export interface BindingInfo { agent_id: string; channel: string; @@ -1931,6 +1973,21 @@ export const api = { // Provider management providers: () => fetchJson("/providers"), + warmupStatus: () => fetchJson("/agents/warmup"), + triggerWarmup: async (params?: {agentId?: string; force?: boolean}) => { + const response = await fetch(`${API_BASE}/agents/warmup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + agent_id: params?.agentId ?? null, + force: params?.force ?? false, + }), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, updateProvider: async (provider: string, apiKey: string, model: string) => { const response = await fetch(`${API_BASE}/providers`, { method: "PUT", @@ -2035,6 +2092,16 @@ export const api = { // Messaging / Bindings API messagingStatus: () => fetchJson("/messaging/status"), + mcpStatus: () => fetchJson("/mcp/status"), + reconnectMcpServer: async (serverName: string) => { + const response = await fetch(`${API_BASE}/mcp/servers/${encodeURIComponent(serverName)}/reconnect`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, bindings: (agentId?: string) => { const params = agentId diff --git a/interface/src/components/SetupBanner.tsx b/interface/src/components/SetupBanner.tsx index 62b79da81..d196757f9 100644 --- a/interface/src/components/SetupBanner.tsx +++ b/interface/src/components/SetupBanner.tsx @@ -1,24 +1 @@ -import { useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { api } from "@/api/client"; -import { Banner } from "@/ui"; - -export function SetupBanner() { - const { data } = useQuery({ - queryKey: ["providers"], - queryFn: api.providers, - staleTime: 10_000, - }); - - if (!data || data.has_any) return null; - - return ( - - No LLM provider configured.{" "} - - Add an API key in Settings - {" "} - to get started. - - ); -} +export {SetupBanner} from "./SetupReadiness"; diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts new file mode 100644 index 000000000..7334ecd6c --- /dev/null +++ b/interface/src/components/SetupReadiness.test.ts @@ -0,0 +1,219 @@ +import {describe, expect, test} from "bun:test"; +import type { + McpAgentStatus, + MessagingStatusResponse, + ProvidersResponse, + SecretStoreStatus, + WarmupStatusResponse, +} from "@/api/client"; + +if (!("window" in globalThis)) { + (globalThis as typeof globalThis & {window: Record}).window = {}; +} + +const {classifySetupReadiness} = await import("./SetupReadiness"); + +function configuredProviders(hasAny = true): ProvidersResponse { + return { + has_any: hasAny, + providers: { + anthropic: hasAny, + openai: false, + openai_chatgpt: false, + openrouter: false, + kilo: false, + zhipu: false, + groq: false, + together: false, + fireworks: false, + deepseek: false, + xai: false, + mistral: false, + gemini: false, + ollama: false, + opencode_zen: false, + opencode_go: false, + nvidia: false, + minimax: false, + minimax_cn: false, + moonshot: false, + zai_coding_plan: false, + }, + }; +} + +function secretsStatus(overrides: Partial = {}): SecretStoreStatus { + return { + state: "unlocked", + encrypted: true, + secret_count: 0, + system_count: 0, + tool_count: 0, + platform_managed: false, + ...overrides, + }; +} + +function messagingStatus( + instances: MessagingStatusResponse["instances"] = [], +): MessagingStatusResponse { + return { + discord: {configured: false, enabled: false}, + slack: {configured: false, enabled: false}, + telegram: {configured: false, enabled: false}, + webhook: {configured: false, enabled: false}, + twitch: {configured: false, enabled: false}, + email: {configured: false, enabled: false}, + instances, + }; +} + +function warmupStatus( + statuses: WarmupStatusResponse["statuses"], +): WarmupStatusResponse { + return {statuses}; +} + +function mcpStatus(servers: McpAgentStatus["servers"]): McpAgentStatus[] { + return [{agent_id: "main", servers}]; +} + +describe("classifySetupReadiness", () => { + test("flags missing provider as a blocker", () => { + const items = classifySetupReadiness({ + providers: configuredProviders(false), + }); + + expect(items).toEqual([ + expect.objectContaining({ + id: "provider", + severity: "blocker", + tab: "providers", + }), + ]); + }); + + test("flags locked and unencrypted secrets separately", () => { + const lockedItems = classifySetupReadiness({ + providers: configuredProviders(), + secrets: secretsStatus({state: "locked", encrypted: true, secret_count: 2}), + }); + expect(lockedItems).toContainEqual( + expect.objectContaining({ + id: "secrets_locked", + severity: "warning", + tab: "secrets", + }), + ); + + const unencryptedItems = classifySetupReadiness({ + providers: configuredProviders(), + secrets: secretsStatus({encrypted: false, secret_count: 2}), + }); + expect(unencryptedItems).toContainEqual( + expect.objectContaining({ + id: "secrets_unencrypted", + severity: "warning", + tab: "secrets", + }), + ); + }); + + test("reports degraded warmup only when providers are configured", () => { + const items = classifySetupReadiness({ + providers: configuredProviders(), + warmup: warmupStatus([ + { + agent_id: "alpha", + status: { + state: "degraded", + embedding_ready: false, + last_refresh_unix_ms: null, + last_error: "boom", + bulletin_age_secs: null, + }, + }, + ]), + }); + + expect(items).toContainEqual( + expect.objectContaining({ + id: "warmup_degraded", + severity: "warning", + }), + ); + + const noProviderItems = classifySetupReadiness({ + providers: configuredProviders(false), + warmup: warmupStatus([ + { + agent_id: "alpha", + status: { + state: "degraded", + embedding_ready: false, + last_refresh_unix_ms: null, + last_error: "boom", + bulletin_age_secs: null, + }, + }, + ]), + }); + + expect(noProviderItems.some((item) => item.id === "warmup_degraded")).toBe(false); + }); + + test("reports disconnected MCP servers and ignores disabled ones", () => { + const items = classifySetupReadiness({ + providers: configuredProviders(), + mcp: mcpStatus([ + {name: "filesystem", transport: "stdio", enabled: true, state: "failed: timeout"}, + {name: "disabled", transport: "stdio", enabled: false, state: "failed: ignored"}, + ]), + }); + + expect(items).toContainEqual( + expect.objectContaining({ + id: "mcp_failed", + severity: "warning", + }), + ); + expect(items.some((item) => item.description.includes("2 enabled MCP servers"))).toBe(false); + }); + + test("reports messaging setup and binding gaps as informational", () => { + const missingItems = classifySetupReadiness({ + providers: configuredProviders(), + messaging: messagingStatus(), + }); + + expect(missingItems).toContainEqual( + expect.objectContaining({ + id: "messaging_missing", + severity: "info", + tab: "channels", + }), + ); + + const unboundItems = classifySetupReadiness({ + providers: configuredProviders(), + messaging: messagingStatus([ + { + platform: "discord", + name: null, + runtime_key: "discord", + configured: true, + enabled: true, + binding_count: 0, + }, + ]), + }); + + expect(unboundItems).toContainEqual( + expect.objectContaining({ + id: "messaging_unbound", + severity: "info", + tab: "channels", + }), + ); + }); +}); diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx new file mode 100644 index 000000000..fea01a4ea --- /dev/null +++ b/interface/src/components/SetupReadiness.tsx @@ -0,0 +1,340 @@ +import {useMemo} from "react"; +import {useQueries} from "@tanstack/react-query"; +import {Link} from "@tanstack/react-router"; +import { + api, + type McpAgentStatus, + type MessagingStatusResponse, + type ProvidersResponse, + type SecretStoreStatus, + type WarmupStatusResponse, +} from "@/api/client"; +import {Banner, BannerActions, cx} from "@/ui"; + +type SetupSeverity = "blocker" | "warning" | "info"; +type SettingsTab = "providers" | "secrets" | "channels"; + +interface SetupReadinessItem { + id: string; + severity: SetupSeverity; + title: string; + description: string; + tab?: SettingsTab; +} + +interface SetupReadinessState { + items: SetupReadinessItem[]; + actionableItems: SetupReadinessItem[]; + blockerCount: number; + warningCount: number; + isLoading: boolean; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function formatAgentList(agentIds: string[]) { + if (agentIds.length === 0) return ""; + if (agentIds.length === 1) return agentIds[0]; + if (agentIds.length === 2) return `${agentIds[0]} and ${agentIds[1]}`; + return `${agentIds.slice(0, 2).join(", ")}, and ${agentIds.length - 2} more`; +} + +export function classifySetupReadiness(params: { + providers?: ProvidersResponse; + secrets?: SecretStoreStatus; + messaging?: MessagingStatusResponse; + warmup?: WarmupStatusResponse; + mcp?: McpAgentStatus[]; +}): SetupReadinessItem[] { + const items: SetupReadinessItem[] = []; + const {providers, secrets, messaging, warmup, mcp} = params; + + if (providers && !providers.has_any) { + items.push({ + id: "provider", + severity: "blocker", + title: "No LLM provider configured", + description: "Add a provider key or OAuth connection before Spacebot can do useful work.", + tab: "providers", + }); + } + + if (secrets?.state === "locked") { + items.push({ + id: "secrets_locked", + severity: "warning", + title: "Secrets store is locked", + description: "Unlock the secret store before editing or using encrypted instance secrets.", + tab: "secrets", + }); + } else if ( + secrets && + !secrets.platform_managed && + !secrets.encrypted && + secrets.secret_count > 0 + ) { + items.push({ + id: "secrets_unencrypted", + severity: "warning", + title: "Stored secrets are unencrypted", + description: "Enable encryption so instance secrets are protected at rest.", + tab: "secrets", + }); + } + + if (providers?.has_any && warmup?.statuses.length) { + const degradedAgents = warmup.statuses + .filter((entry) => entry.status.state === "degraded") + .map((entry) => entry.agent_id); + const warmAgents = warmup.statuses.filter( + (entry) => entry.status.state === "warm", + ); + + if (degradedAgents.length > 0) { + items.push({ + id: "warmup_degraded", + severity: "warning", + title: "Warmup is degraded", + description: `Warmup is degraded for ${formatAgentList(degradedAgents)}.`, + }); + } else if (warmAgents.length === 0) { + items.push({ + id: "warmup_pending", + severity: "info", + title: "Warmup is still settling", + description: "Models and bulletin state are still warming up, so first responses may be slower.", + }); + } + } + + if (mcp) { + const enabledServers = mcp.flatMap((agentStatus) => + agentStatus.servers + .filter((server) => server.enabled) + .map((server) => ({agent_id: agentStatus.agent_id, ...server})), + ); + const failedServers = enabledServers.filter( + (server) => + server.state !== "connected" && server.state !== "connecting", + ); + const connectingServers = enabledServers.filter( + (server) => server.state === "connecting", + ); + + if (failedServers.length > 0) { + items.push({ + id: "mcp_failed", + severity: "warning", + title: "Some MCP servers are disconnected", + description: `${pluralize(failedServers.length, "enabled MCP server")} ${failedServers.length === 1 ? "is" : "are"} not connected.`, + }); + } else if (connectingServers.length > 0) { + items.push({ + id: "mcp_connecting", + severity: "info", + title: "MCP servers are still connecting", + description: `${pluralize(connectingServers.length, "enabled MCP server")} ${connectingServers.length === 1 ? "is" : "are"} still connecting.`, + }); + } + } + + if (messaging) { + const configuredInstances = messaging.instances.filter( + (instance) => instance.configured, + ); + const boundInstances = configuredInstances.filter( + (instance) => instance.binding_count > 0, + ); + + if (configuredInstances.length === 0) { + items.push({ + id: "messaging_missing", + severity: "info", + title: "No messaging platforms configured", + description: "Connect Discord, Telegram, Slack, or another adapter when you want Spacebot to handle real conversations.", + tab: "channels", + }); + } else if (boundInstances.length === 0) { + items.push({ + id: "messaging_unbound", + severity: "info", + title: "Messaging is configured but not routed", + description: "Add bindings so configured platforms actually deliver conversations to an agent.", + tab: "channels", + }); + } + } + + return items; +} + +export function useSetupReadiness(): SetupReadinessState { + const [providersQuery, secretsQuery, messagingQuery, warmupQuery, mcpQuery] = useQueries({ + queries: [ + { + queryKey: ["providers"], + queryFn: api.providers, + staleTime: 10_000, + }, + { + queryKey: ["secrets-status"], + queryFn: api.secretsStatus, + staleTime: 10_000, + retry: false, + }, + { + queryKey: ["messaging-status"], + queryFn: api.messagingStatus, + staleTime: 10_000, + }, + { + queryKey: ["warmup-status"], + queryFn: api.warmupStatus, + staleTime: 10_000, + }, + { + queryKey: ["mcp-status"], + queryFn: api.mcpStatus, + staleTime: 10_000, + }, + ], + }); + + return useMemo(() => { + const items = classifySetupReadiness({ + providers: providersQuery.data, + secrets: secretsQuery.data, + messaging: messagingQuery.data, + warmup: warmupQuery.data, + mcp: mcpQuery.data, + }); + const actionableItems = items.filter((item) => item.severity !== "info"); + const blockerCount = items.filter((item) => item.severity === "blocker").length; + const warningCount = items.filter((item) => item.severity === "warning").length; + const isLoading = [providersQuery, secretsQuery, messagingQuery, warmupQuery, mcpQuery] + .some((query) => query.isLoading && !query.data); + + return { + items, + actionableItems, + blockerCount, + warningCount, + isLoading, + }; + }, [mcpQuery, messagingQuery, providersQuery, secretsQuery, warmupQuery]); +} + +export function SetupBanner() { + const readiness = useSetupReadiness(); + + if (readiness.isLoading || readiness.actionableItems.length === 0) { + return null; + } + + const primaryItem = readiness.actionableItems[0]; + const remainingCount = readiness.actionableItems.length - 1; + const bannerVariant = readiness.blockerCount > 0 ? "error" : "warning"; + + return ( + + {primaryItem.title}. + {primaryItem.description} + {remainingCount > 0 && ( + + {` ${pluralize(remainingCount, "more issue")} need attention.`} + + )} + + {primaryItem.tab ? ( + + Open Settings + + ) : ( + + View Overview + + )} + + + ); +} + +const severityStyles: Record = { + blocker: "border-red-500/20 bg-red-500/8 text-red-300", + warning: "border-amber-500/20 bg-amber-500/8 text-amber-300", + info: "border-blue-500/20 bg-blue-500/8 text-blue-300", +}; + +const severityLabelStyles: Record = { + blocker: "bg-red-500/15 text-red-300", + warning: "bg-amber-500/15 text-amber-300", + info: "bg-blue-500/15 text-blue-300", +}; + +export function SetupReadinessCard() { + const readiness = useSetupReadiness(); + + if (readiness.isLoading || readiness.items.length === 0) { + return null; + } + + return ( +
+
+
+

Setup Readiness

+

+ Spacebot surfaces setup and runtime readiness here in the control plane instead of through a separate doctor command. +

+
+ +
+ {readiness.items.map((item) => ( +
+
+
+
+ + {item.severity} + +

{item.title}

+
+

{item.description}

+
+ {item.tab ? ( + + Fix + + ) : null} +
+
+ ))} +
+
+
+ ); +} diff --git a/interface/src/router.tsx b/interface/src/router.tsx index 1914bda3e..432410ff3 100644 --- a/interface/src/router.tsx +++ b/interface/src/router.tsx @@ -8,6 +8,7 @@ import {useQuery} from "@tanstack/react-query"; import {api, BASE_PATH} from "@/api/client"; import {ConnectionBanner} from "@/components/ConnectionBanner"; import {TopBar, TopBarProvider, useSetTopBar} from "@/components/TopBar"; +import {SetupBanner} from "@/components/SetupBanner"; import {Sidebar} from "@/components/Sidebar"; import {Overview} from "@/routes/Overview"; import {AgentDetail} from "@/routes/AgentDetail"; @@ -37,6 +38,7 @@ function RootLayout() {
+
diff --git a/interface/src/routes/Overview.tsx b/interface/src/routes/Overview.tsx index c852bc92c..e4d252f55 100644 --- a/interface/src/routes/Overview.tsx +++ b/interface/src/routes/Overview.tsx @@ -5,6 +5,7 @@ import {SparklesIcon, IdeaIcon} from "@hugeicons/core-free-icons"; import {HugeiconsIcon} from "@hugeicons/react"; import {api} from "@/api/client"; import {CreateAgentDialog} from "@/components/CreateAgentDialog"; +import {SetupReadinessCard} from "@/components/SetupReadiness"; import {TopologyGraph} from "@/components/TopologyGraph"; import {UpdatePill} from "@/components/UpdatePill"; import {useSetTopBar} from "@/components/TopBar"; @@ -119,6 +120,7 @@ export function Overview({liveStates, activeLinks}: OverviewProps) { return (
+ {/* Full-screen topology */}
{overviewLoading ? ( diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 8086d9194..1bc1c3fbc 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, type GlobalSettingsResponse, type UpdateStatus, type SecretCategory, type SecretListItem, type StoreState } from "@/api/client"; +import { api, type GlobalSettingsResponse, type McpServerStatusInfo, type SecretCategory, type SecretListItem, type StoreState, type UpdateStatus, type WarmupStatusEntry } from "@/api/client"; import { Badge, Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle } from "@/ui"; import { useSearch, useNavigate } from "@tanstack/react-router"; import { PlatformCatalog, InstanceCard, AddInstanceCard } from "@/components/ChannelSettingCard"; import { ModelSelect } from "@/components/ModelSelect"; +import { useSetupReadiness } from "@/components/SetupReadiness"; import { ProviderIcon } from "@/lib/providerIcons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; @@ -14,7 +15,7 @@ import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme"; import { Markdown } from "@/components/Markdown"; import { useSetTopBar } from "@/components/TopBar"; -type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "server" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog"; +type SectionId = "appearance" | "providers" | "channels" | "api-keys" | "secrets" | "system-health" | "server" | "opencode" | "worker-logs" | "updates" | "config-file" | "changelog"; const SECTIONS = [ { @@ -41,6 +42,12 @@ const SECTIONS = [ group: "general" as const, description: "Encrypted secret storage", }, + { + id: "system-health" as const, + label: "System Health", + group: "system" as const, + description: "Warmup and MCP runtime readiness", + }, { id: "server" as const, label: "Server", @@ -712,6 +719,8 @@ export function Settings() { ) : activeSection === "secrets" ? ( + ) : activeSection === "system-health" ? ( + ) : activeSection === "server" ? ( ) : activeSection === "opencode" ? ( @@ -1624,6 +1633,256 @@ function SecretsSection() { ); } +function warmupBadgeVariant(state: WarmupStatusEntry["status"]["state"]) { + switch (state) { + case "warm": + return "green"; + case "warming": + return "blue"; + case "degraded": + return "red"; + case "cold": + default: + return "amber"; + } +} + +function formatWarmupDetails(entry: WarmupStatusEntry) { + const details: string[] = []; + if (entry.status.embedding_ready) { + details.push("embeddings ready"); + } + if (entry.status.bulletin_age_secs != null) { + details.push(`bulletin age ${entry.status.bulletin_age_secs}s`); + } + if (entry.status.last_error) { + details.push(entry.status.last_error); + } + return details; +} + +function mcpBadgeVariant(server: McpServerStatusInfo) { + if (server.state === "connected") return "green"; + if (server.state === "connecting") return "blue"; + if (server.state.startsWith("failed")) return "red"; + return "amber"; +} + +function SystemHealthSection() { + const queryClient = useQueryClient(); + const readiness = useSetupReadiness(); + const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); + + const { data: warmupData, isLoading: warmupLoading } = useQuery({ + queryKey: ["warmup-status"], + queryFn: api.warmupStatus, + staleTime: 5_000, + }); + + const { data: mcpData, isLoading: mcpLoading } = useQuery({ + queryKey: ["mcp-status"], + queryFn: api.mcpStatus, + staleTime: 5_000, + }); + + const triggerWarmupMutation = useMutation({ + mutationFn: (params?: {agentId?: string; force?: boolean}) => api.triggerWarmup(params), + onSuccess: (result) => { + setMessage({ + text: result.accepted_agents.length > 0 + ? `Warmup triggered for ${result.accepted_agents.join(", ")}.` + : "Warmup trigger accepted.", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["warmup-status"] }); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + }, + }); + + const reconnectMcpMutation = useMutation({ + mutationFn: (serverName: string) => api.reconnectMcpServer(serverName), + onSuccess: (result) => { + setMessage({ + text: result.message, + type: result.success ? "success" : "error", + }); + queryClient.invalidateQueries({ queryKey: ["mcp-status"] }); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + }, + }); + + const warmupStatuses = warmupData?.statuses ?? []; + const enabledMcpServers = (mcpData ?? []).flatMap((agentStatus) => + agentStatus.servers + .filter((server) => server.enabled) + .map((server) => ({agent_id: agentStatus.agent_id, ...server})), + ); + const actionableCount = readiness.blockerCount + readiness.warningCount; + + return ( +
+
+

System Health

+

+ Runtime readiness for warmup and MCP, using the same control-plane signals surfaced on the overview page. +

+
+ +
+
+
+

+ {actionableCount > 0 + ? `${actionableCount} setup or runtime issue${actionableCount === 1 ? "" : "s"} need attention` + : "No blocking setup issues detected"} +

+

+ Providers, secrets, messaging, warmup, and MCP are all evaluated from live control-plane state. +

+
+ +
+
+ +
+
+
+

Warmup

+

+ Per-agent readiness for embeddings and bulletin-backed startup warmup. +

+
+ + {warmupLoading ? ( +
+
+ Loading warmup status... +
+ ) : warmupStatuses.length > 0 ? ( +
+ {warmupStatuses.map((entry) => { + const details = formatWarmupDetails(entry); + return ( +
+
+
+
+

{entry.agent_id}

+ + {entry.status.state} + +
+ {details.length > 0 && ( +
+ {details.map((detail) => ( +

{detail}

+ ))} +
+ )} +
+ +
+
+ ); + })} +
+ ) : ( +
+ No agents are currently reporting warmup state. +
+ )} +
+ +
+
+

MCP Connections

+

+ Live status for enabled MCP servers across all agents. +

+
+ + {mcpLoading ? ( +
+
+ Loading MCP status... +
+ ) : enabledMcpServers.length > 0 ? ( +
+ {enabledMcpServers.map((server) => ( +
+
+
+
+

{server.name}

+ + {server.state} + +
+

+ Agent {server.agent_id} via {server.transport} +

+
+ {server.state !== "connected" && ( + + )} +
+
+ ))} +
+ ) : ( +
+ No enabled MCP servers are configured. +
+ )} +
+
+ + {message && ( +
+ {message.text} +
+ )} +
+ ); +} + interface GlobalSettingsSectionProps { settings: GlobalSettingsResponse | undefined; isLoading: boolean; From 7e4df0e446a360d12afb4cfd10293cc6e5d4c4e6 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 12 Mar 2026 16:24:05 -0400 Subject: [PATCH 2/9] Polish system health settings and docs --- docs/content/docs/(deployment)/roadmap.mdx | 2 +- .../docs/(getting-started)/quickstart.mdx | 4 ++ interface/src/api/client.test.ts | 14 ++++++ interface/src/routes/Settings.tsx | 50 ++++++++++++++++++- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index 61afccb9e..33e72f1d1 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -125,7 +125,7 @@ If a future channel adapter requires inbound HTTP callbacks (e.g. WhatsApp Busin Some agent frameworks ship a `doctor` CLI command that checks daemon heartbeats, scheduler health, and channel connectivity. This usually exists to compensate for a fragile runtime — if you need a separate command to tell you your process is dead, the process management is the problem. -Spacebot handles this through its control interface and the cortex. The embedded UI shows adapter status, agent health, and active connections in real-time. The cortex observes system-wide signals and surfaces issues proactively. Channel token validation happens at setup time through the bindings API — if something is misconfigured, you find out immediately when you add the binding, not by running `doctor` ten minutes later. +Spacebot handles this through its control interface and the cortex. The embedded UI now keeps setup and runtime readiness visible in the normal workflow: the shell banner and Overview page call out missing providers, secrets problems, warmup drift, and MCP connectivity issues, while **Settings → System Health** shows per-agent warmup state and enabled MCP server status. The cortex still observes system-wide signals and surfaces issues proactively. Channel token validation happens at setup time through the bindings API — if something is misconfigured, you find out immediately when you add the binding, not by running `doctor` ten minutes later. ## Post-Launch diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx index 1c33d0beb..facdce53e 100644 --- a/docs/content/docs/(getting-started)/quickstart.mdx +++ b/docs/content/docs/(getting-started)/quickstart.mdx @@ -86,6 +86,8 @@ This is enough. Spacebot will create a default `main` agent with sensible defaul Just run `spacebot` with no config file and no API key env var set. It will walk you through provider selection, API key entry, agent naming, and optional Discord setup. +After first launch, the web UI keeps the remaining setup work visible. The Overview page shows a setup readiness checklist, and **Settings → System Health** shows live warmup and MCP connection state so you can fix runtime issues from the control interface instead of guessing. + ### Option C: Config file Create `~/.spacebot/config.toml`: @@ -227,6 +229,8 @@ cd interface && bun run dev # UI at http://localhost:3000, proxies /api to http://localhost:19898 ``` +If the instance is only partially configured, the UI surfaces that immediately with a setup banner on the shell, a fuller checklist on the Overview page, and runtime details in **Settings → System Health**. + ## Messaging platforms | Platform | Status | Setup guide | diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts index 4ca5cdcae..98ca7e36c 100644 --- a/interface/src/api/client.test.ts +++ b/interface/src/api/client.test.ts @@ -36,6 +36,20 @@ describe("api client runtime health actions", () => { ); }); + test("triggerWarmup defaults to all agents without force", async () => { + await api.triggerWarmup(); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/agents/warmup", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ agent_id: null, force: false }), + }), + ); + }); + test("reconnectMcpServer posts to the reconnect endpoint", async () => { await api.reconnectMcpServer("filesystem"); diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 1bc1c3fbc..55a0acd15 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1658,7 +1658,10 @@ function formatWarmupDetails(entry: WarmupStatusEntry) { if (entry.status.last_error) { details.push(entry.status.last_error); } - return details; + if (details.length > 0) { + return details; + } + return [entry.status.state === "warm" ? "Warmup is healthy." : "Warmup has not completed yet."]; } function mcpBadgeVariant(server: McpServerStatusInfo) { @@ -1723,6 +1726,10 @@ function SystemHealthSection() { .map((server) => ({agent_id: agentStatus.agent_id, ...server})), ); const actionableCount = readiness.blockerCount + readiness.warningCount; + const warmAgentsCount = warmupStatuses.filter((entry) => entry.status.state === "warm").length; + const degradedAgentsCount = warmupStatuses.filter((entry) => entry.status.state === "degraded").length; + const connectedMcpCount = enabledMcpServers.filter((server) => server.state === "connected").length; + const disconnectedMcpCount = enabledMcpServers.filter((server) => server.state !== "connected").length; return (
@@ -1754,6 +1761,42 @@ function SystemHealthSection() { Trigger all warmups
+
+
+

Actionable

+

{actionableCount}

+

+ {readiness.blockerCount} blocker{readiness.blockerCount === 1 ? "" : "s"}, {readiness.warningCount} warning{readiness.warningCount === 1 ? "" : "s"} +

+
+
+

Warm Agents

+

{warmAgentsCount}

+

+ {degradedAgentsCount > 0 + ? `${degradedAgentsCount} degraded` + : warmupStatuses.length > 0 + ? "No degraded agents" + : "No warmup data yet"} +

+
+
+

MCP Connected

+

{connectedMcpCount}

+

+ {enabledMcpServers.length > 0 + ? `${enabledMcpServers.length} enabled server${enabledMcpServers.length === 1 ? "" : "s"}` + : "No enabled servers"} +

+
+
+

Needs Reconnect

+

{disconnectedMcpCount}

+

+ {disconnectedMcpCount > 0 ? "Reconnect from this panel" : "All enabled servers are live"} +

+
+
@@ -1846,6 +1889,11 @@ function SystemHealthSection() {

Agent {server.agent_id} via {server.transport}

+ {server.state !== "connected" && ( +

+ State: {server.state} +

+ )}
{server.state !== "connected" && (
-
@@ -1834,20 +1850,20 @@ function SystemHealthSection() { {entry.status.state}
- {details.length > 0 && ( -
- {details.map((detail) => ( -

{detail}

- ))} -
- )} -
-
+
@@ -1899,16 +1915,16 @@ function SystemHealthSection() {

)} - {needsMcpReconnect(server) && ( - )} diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs index b2232f637..f294d5f6b 100644 --- a/src/tools/send_message_to_another_channel.rs +++ b/src/tools/send_message_to_another_channel.rs @@ -427,9 +427,110 @@ fn parse_explicit_email_target(raw: &str) -> Option>, + } + + impl TestSignalAdapter { + fn new(name: impl Into) -> Self { + Self { + name: name.into(), + broadcasts: Mutex::new(Vec::new()), + } + } + + fn broadcasts(&self) -> Vec { + self.broadcasts + .lock() + .expect("broadcast mutex poisoned") + .clone() + } + } + + impl Messaging for TestSignalAdapter { + fn name(&self) -> &str { + &self.name + } + + async fn start(&self) -> crate::error::Result { + Ok(Box::pin(tokio_stream::empty())) + } + + async fn respond( + &self, + _message: &crate::InboundMessage, + _response: OutboundResponse, + ) -> crate::error::Result<()> { + Ok(()) + } + + async fn send_status( + &self, + _message: &crate::InboundMessage, + _status: StatusUpdate, + ) -> crate::error::Result<()> { + Ok(()) + } + + async fn broadcast( + &self, + target: &str, + response: OutboundResponse, + ) -> crate::error::Result<()> { + self.broadcasts + .lock() + .expect("broadcast mutex poisoned") + .push(BroadcastRecord { + target: target.to_string(), + response, + }); + Ok(()) + } + + async fn health_check(&self) -> crate::error::Result<()> { + Ok(()) + } + } + + async fn test_send_message_tool( + messaging_manager: Arc, + current_adapter: Option, + ) -> SendMessageTool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("sqlite memory pool"); + let channel_store = ChannelStore::new(pool.clone()); + let conversation_logger = ConversationLogger::new(pool); + + SendMessageTool::new( + messaging_manager, + channel_store, + conversation_logger, + "Spacebot".to_string(), + current_adapter, + ) + } #[test] fn parses_prefixed_email_target() { @@ -620,4 +721,37 @@ mod tests { .expect_err("should be validation error"); assert!(error.contains("requires an ID"), "{error}"); } + + #[tokio::test] + async fn explicit_signal_prefix_uses_named_current_adapter() { + let messaging_manager = Arc::new(MessagingManager::new()); + let signal_adapter = Arc::new(TestSignalAdapter::new("signal:gvoice1")); + messaging_manager + .register_shared(signal_adapter.clone()) + .await; + + let tool = test_send_message_tool( + messaging_manager, + Some("signal:gvoice1".to_string()), + ) + .await; + + let output = tool + .call(SendMessageArgs { + target: "signal:+1234567890".to_string(), + message: "hello".to_string(), + }) + .await + .expect("send message succeeds"); + + assert_eq!(output.platform, "signal:gvoice1"); + assert_eq!(output.target, "+1234567890"); + let broadcasts = signal_adapter.broadcasts(); + assert_eq!(broadcasts.len(), 1); + assert_eq!(broadcasts[0].target, "+1234567890"); + assert!(matches!( + &broadcasts[0].response, + OutboundResponse::Text(message) if message == "hello" + )); + } } From 6fbf3e1ffaa011618f4fe4b338d916dd8c05c8c9 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Thu, 12 Mar 2026 22:21:21 -0400 Subject: [PATCH 5/9] Format signal adapter regression test --- src/tools/send_message_to_another_channel.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs index f294d5f6b..840139a39 100644 --- a/src/tools/send_message_to_another_channel.rs +++ b/src/tools/send_message_to_another_channel.rs @@ -427,15 +427,15 @@ fn parse_explicit_email_target(raw: &str) -> Option Date: Thu, 12 Mar 2026 23:14:24 -0400 Subject: [PATCH 6/9] Surface system health fetch errors --- interface/src/routes/Settings.tsx | 42 +++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 89ac94c64..eeb91023e 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1677,6 +1677,10 @@ function needsMcpReconnect(server: McpServerStatusInfo) { return server.state === "disconnected" || server.state.startsWith("failed"); } +function describeQueryError(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + function SystemHealthSection() { const queryClient = useQueryClient(); const readiness = useSetupReadiness(); @@ -1684,13 +1688,23 @@ function SystemHealthSection() { const [pendingWarmupTarget, setPendingWarmupTarget] = useState(null); const [pendingReconnectTarget, setPendingReconnectTarget] = useState(null); - const { data: warmupData, isLoading: warmupLoading } = useQuery({ + const { + data: warmupData, + isLoading: warmupLoading, + isError: warmupError, + error: warmupFetchError, + } = useQuery({ queryKey: ["warmup-status"], queryFn: api.warmupStatus, staleTime: 5_000, }); - const { data: mcpData, isLoading: mcpLoading } = useQuery({ + const { + data: mcpData, + isLoading: mcpLoading, + isError: mcpError, + error: mcpFetchError, + } = useQuery({ queryKey: ["mcp-status"], queryFn: api.mcpStatus, staleTime: 5_000, @@ -1828,10 +1842,14 @@ function SystemHealthSection() {

- {warmupLoading ? ( -
-
- Loading warmup status... + {warmupError ? ( +
+ Failed to load warmup status: {describeQueryError(warmupFetchError)} +
+ ) : warmupLoading ? ( +
+
+ Loading warmup status...
) : warmupStatuses.length > 0 ? (
@@ -1886,10 +1904,14 @@ function SystemHealthSection() {

- {mcpLoading ? ( -
-
- Loading MCP status... + {mcpError ? ( +
+ Failed to load MCP status: {describeQueryError(mcpFetchError)} +
+ ) : mcpLoading ? ( +
+
+ Loading MCP status...
) : enabledMcpServers.length > 0 ? (
From 4dad55562cd6cd4ff40e978a976235c5603d946b Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Fri, 13 Mar 2026 00:02:04 -0400 Subject: [PATCH 7/9] Fix interface typecheck for Bun tests --- interface/bun.lock | 7 ++++++ interface/package.json | 1 + interface/src/api/client.test.ts | 23 ++++++++++--------- .../src/components/SetupReadiness.test.ts | 4 +++- interface/src/components/SetupReadiness.tsx | 14 +++++------ interface/tsconfig.json | 2 +- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/interface/bun.lock b/interface/bun.lock index e3aa5cebf..95e6002e5 100644 --- a/interface/bun.lock +++ b/interface/bun.lock @@ -63,6 +63,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.18", + "bun-types": "^1.3.10", "postcss": "^8.4.36", "sass": "^1.72.0", "tailwindcss": "^3.4.1", @@ -603,6 +604,8 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, ""], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, ""], @@ -665,6 +668,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, ""], + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, ""], @@ -1499,6 +1504,8 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, ""], "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], diff --git a/interface/package.json b/interface/package.json index 4eae240e7..b95f6f3ec 100644 --- a/interface/package.json +++ b/interface/package.json @@ -67,6 +67,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.18", + "bun-types": "^1.3.10", "postcss": "^8.4.36", "sass": "^1.72.0", "tailwindcss": "^3.4.1", diff --git a/interface/src/api/client.test.ts b/interface/src/api/client.test.ts index 4e1958d13..2bfdfa8d3 100644 --- a/interface/src/api/client.test.ts +++ b/interface/src/api/client.test.ts @@ -1,7 +1,8 @@ import {afterEach, beforeEach, describe, expect, mock, test} from "bun:test"; if (!("window" in globalThis)) { - (globalThis as typeof globalThis & {window: Record}).window = {}; + (globalThis as typeof globalThis & {window: Window & typeof globalThis}).window = + globalThis as Window & typeof globalThis; } const {api} = await import("./client"); @@ -9,14 +10,14 @@ const {api} = await import("./client"); const originalFetch = globalThis.fetch; describe("api client runtime health actions", () => { - beforeEach(() => { - globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => { - return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }) as typeof fetch; - }); + beforeEach(() => { + globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response(JSON.stringify({ success: true, status: "accepted", forced: true, accepted_agents: ["main"], message: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + }); afterEach(() => { globalThis.fetch = originalFetch; @@ -67,7 +68,7 @@ describe("api client runtime health actions", () => { test("triggerWarmup includes response text in thrown API errors", async () => { globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => { return new Response("warmup unavailable", { status: 503 }); - }) as typeof fetch; + }) as unknown as typeof fetch; await expect(api.triggerWarmup({ agentId: "main" })).rejects.toThrow( "API error: 503: warmup unavailable", @@ -77,7 +78,7 @@ describe("api client runtime health actions", () => { test("reconnectMcpServer includes response text in thrown API errors", async () => { globalThis.fetch = mock(async (_input: RequestInfo | URL, _init?: RequestInit) => { return new Response("reconnect failed", { status: 500 }); - }) as typeof fetch; + }) as unknown as typeof fetch; await expect( api.reconnectMcpServer({ agentId: "main", serverName: "filesystem" }), diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts index 3dbca4df9..d050980f1 100644 --- a/interface/src/components/SetupReadiness.test.ts +++ b/interface/src/components/SetupReadiness.test.ts @@ -8,7 +8,8 @@ import type { } from "@/api/client"; if (!("window" in globalThis)) { - (globalThis as typeof globalThis & {window: Record}).window = {}; + (globalThis as typeof globalThis & {window: Window & typeof globalThis}).window = + globalThis as Window & typeof globalThis; } const {classifySetupReadiness} = await import("./SetupReadiness"); @@ -38,6 +39,7 @@ function configuredProviders(hasAny = true): ProvidersResponse { minimax_cn: false, moonshot: false, zai_coding_plan: false, + github_copilot: false, }, }; } diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx index c82d533fc..39c061a46 100644 --- a/interface/src/components/SetupReadiness.tsx +++ b/interface/src/components/SetupReadiness.tsx @@ -223,14 +223,14 @@ export function useSetupReadiness(): SetupReadinessState { return useMemo(() => { const probeErrors = [ - ["providers", providersQuery], - ["secrets", secretsQuery], - ["messaging", messagingQuery], - ["warmup", warmupQuery], - ["mcp", mcpQuery], + {label: "providers", isError: providersQuery.isError}, + {label: "secrets", isError: secretsQuery.isError}, + {label: "messaging", isError: messagingQuery.isError}, + {label: "warmup", isError: warmupQuery.isError}, + {label: "mcp", isError: mcpQuery.isError}, ] - .filter(([, query]) => query.isError) - .map(([label]) => label); + .filter((query) => query.isError) + .map((query) => query.label); const items = classifySetupReadiness({ providers: providersQuery.data, secrets: secretsQuery.data, diff --git a/interface/tsconfig.json b/interface/tsconfig.json index a292be7f9..2b07a7007 100644 --- a/interface/tsconfig.json +++ b/interface/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], - "types": ["vite/client"], + "types": ["vite/client", "bun-types"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", From 977587cb8171b2fcbf26fd8847c7af4c9a581e84 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Fri, 13 Mar 2026 00:05:15 -0400 Subject: [PATCH 8/9] Harden system health status rendering --- interface/src/routes/Settings.tsx | 111 ++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 30 deletions(-) diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index eeb91023e..1a9fed1d4 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1658,7 +1658,7 @@ function formatWarmupDetails(entry: WarmupStatusEntry) { details.push(`bulletin age ${entry.status.bulletin_age_secs}s`); } if (entry.status.last_error) { - details.push(entry.status.last_error); + details.push(sanitizeRuntimeError(entry.status.last_error)); } if (details.length > 0) { return details; @@ -1681,12 +1681,43 @@ function describeQueryError(error: unknown) { return error instanceof Error ? error.message : String(error); } +function sanitizeRuntimeError(errorText: string) { + const normalized = errorText.toLowerCase(); + let code = "internal"; + if (normalized.includes("timeout") || normalized.includes("deadline")) { + code = "timeout"; + } else if ( + normalized.includes("unauthorized") || + normalized.includes("forbidden") || + normalized.includes("auth") || + normalized.includes("401") || + normalized.includes("403") + ) { + code = "auth"; + } else if (normalized.includes("not found") || normalized.includes("404")) { + code = "missing_dependency"; + } else if ( + normalized.includes("unavailable") || + normalized.includes("connection refused") || + normalized.includes("econnrefused") || + normalized.includes("econnreset") || + normalized.includes("network") || + normalized.includes("503") + ) { + code = "unavailable"; + } else if (normalized.includes("rate limit") || normalized.includes("429")) { + code = "rate_limited"; + } + + return `Error occurred (code: ${code})`; +} + function SystemHealthSection() { const queryClient = useQueryClient(); const readiness = useSetupReadiness(); const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); - const [pendingWarmupTarget, setPendingWarmupTarget] = useState(null); - const [pendingReconnectTarget, setPendingReconnectTarget] = useState(null); + const [pendingWarmupTargets, setPendingWarmupTargets] = useState>(() => new Set()); + const [pendingReconnectTargets, setPendingReconnectTargets] = useState>(() => new Set()); const { data: warmupData, @@ -1713,7 +1744,12 @@ function SystemHealthSection() { const triggerWarmupMutation = useMutation({ mutationFn: (params?: {agentId?: string; force?: boolean}) => api.triggerWarmup(params), onMutate: (params) => { - setPendingWarmupTarget(params?.agentId ?? "__all__"); + const targetId = params?.agentId ?? "__all__"; + setPendingWarmupTargets((currentTargets) => { + const nextTargets = new Set(currentTargets); + nextTargets.add(targetId); + return nextTargets; + }); }, onSuccess: (result) => { setMessage({ @@ -1728,15 +1764,25 @@ function SystemHealthSection() { onError: (error) => { setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, - onSettled: () => { - setPendingWarmupTarget(null); + onSettled: (_result, _error, params) => { + const targetId = params?.agentId ?? "__all__"; + setPendingWarmupTargets((currentTargets) => { + const nextTargets = new Set(currentTargets); + nextTargets.delete(targetId); + return nextTargets; + }); }, }); const reconnectMcpMutation = useMutation({ mutationFn: (params: {agentId: string; serverName: string}) => api.reconnectMcpServer(params), onMutate: (params) => { - setPendingReconnectTarget(`${params.agentId}:${params.serverName}`); + const targetId = `${params.agentId}:${params.serverName}`; + setPendingReconnectTargets((currentTargets) => { + const nextTargets = new Set(currentTargets); + nextTargets.add(targetId); + return nextTargets; + }); }, onSuccess: (result) => { setMessage({ @@ -1748,8 +1794,13 @@ function SystemHealthSection() { onError: (error) => { setMessage({ text: `Failed: ${error.message}`, type: "error" }); }, - onSettled: () => { - setPendingReconnectTarget(null); + onSettled: (_result, _error, params) => { + const targetId = `${params.agentId}:${params.serverName}`; + setPendingReconnectTargets((currentTargets) => { + const nextTargets = new Set(currentTargets); + nextTargets.delete(targetId); + return nextTargets; + }); }, }); @@ -1786,12 +1837,12 @@ function SystemHealthSection() { Providers, secrets, messaging, warmup, and MCP are all evaluated from live control-plane state.

-
@@ -1876,12 +1927,12 @@ function SystemHealthSection() {
)}
-
@@ -1938,15 +1989,15 @@ function SystemHealthSection() { )}
{needsMcpReconnect(server) && ( - )} From e220f2eb385e242790b8b5d6f665e4dec9384b77 Mon Sep 17 00:00:00 2001 From: Victor Sumner Date: Fri, 13 Mar 2026 08:43:19 -0400 Subject: [PATCH 9/9] Ungate warmup readiness from provider status --- interface/src/components/SetupReadiness.test.ts | 11 ++++++++--- interface/src/components/SetupReadiness.tsx | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/interface/src/components/SetupReadiness.test.ts b/interface/src/components/SetupReadiness.test.ts index d050980f1..a1d20cce8 100644 --- a/interface/src/components/SetupReadiness.test.ts +++ b/interface/src/components/SetupReadiness.test.ts @@ -121,7 +121,7 @@ describe("classifySetupReadiness", () => { ); }); - test("reports degraded warmup only when providers are configured", () => { + test("reports degraded warmup whenever warmup data is present", () => { const items = classifySetupReadiness({ providers: configuredProviders(), warmup: warmupStatus([ @@ -147,7 +147,6 @@ describe("classifySetupReadiness", () => { ); const noProviderItems = classifySetupReadiness({ - providers: configuredProviders(false), warmup: warmupStatus([ { agent_id: "alpha", @@ -162,7 +161,13 @@ describe("classifySetupReadiness", () => { ]), }); - expect(noProviderItems.some((item) => item.id === "warmup_degraded")).toBe(false); + expect(noProviderItems).toContainEqual( + expect.objectContaining({ + id: "warmup_degraded", + severity: "warning", + tab: "system-health", + }), + ); }); test("reports disconnected MCP servers and ignores disabled ones", () => { diff --git a/interface/src/components/SetupReadiness.tsx b/interface/src/components/SetupReadiness.tsx index 39c061a46..32714d1c4 100644 --- a/interface/src/components/SetupReadiness.tsx +++ b/interface/src/components/SetupReadiness.tsx @@ -94,7 +94,7 @@ export function classifySetupReadiness(params: { }); } - if (providers?.has_any && warmup?.statuses.length) { + if (warmup?.statuses.length) { const degradedAgents = warmup.statuses .filter((entry) => entry.status.state === "degraded") .map((entry) => entry.agent_id);