From c66c3c6377a1a27af7711dc69c26f18ce60708a4 Mon Sep 17 00:00:00 2001 From: Omar Sobh Date: Sun, 9 Aug 2026 15:09:07 -0700 Subject: [PATCH] feat(ui): the microVM path is reachable from the mission wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything built today — Firecracker missions, the four backends, the local GPU one — was unreachable from the dashboard. The wizard offered `zeroclaw` and `local_herdr` and nothing else, so a mission created in the UI could not be a microVM mission at all, and `local-ornith`/`glm`/`kimi` were API-only. Testing "our workflows in the UI" would have exercised none of it. Adds the runtime option and a backend picker, fed by a new `GET /api/fleet/backends` that returns `mission_roster::available_backends` verbatim — the SAME list the roster planner is handed, not a second one. Its two rules are both load-bearing and neither is visible from a node's capabilities alone: the image must be built on an online node, and the backend must have a credential contract. `agent-terminal` passes the first and fails the second — bootable, with nothing for the agent inside to authenticate with — so offering it would produce a mission that validates, launches, and dies at the agent turn. Ids are deployment vocabulary, so the picker labels them: a user choosing between `local-ornith` and `canary-claude` should not have to know which company each one bills. An empty list says why (no rootfs built) instead of showing an empty dropdown, and no node is chosen for a microVM mission because `vm_placement` picks it per phase. Co-Authored-By: Claude Opus 5 --- crates/cm-api/src/lib.rs | 1 + crates/cm-api/src/routes/nodes.rs | 46 ++++++++++++ .../components/dashboard/MissionWizard.tsx | 70 ++++++++++++++++++- frontend/src/lib/api/missions.ts | 8 ++- 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/crates/cm-api/src/lib.rs b/crates/cm-api/src/lib.rs index b7806e3..47ff050 100644 --- a/crates/cm-api/src/lib.rs +++ b/crates/cm-api/src/lib.rs @@ -173,6 +173,7 @@ pub fn router(state: AppState) -> Router { .route("/api/world/replay", get(routes::world::world_replay)) .route("/api/nodes", get(routes::nodes::list)) .route("/api/fleet/capacity", get(routes::nodes::capacity)) + .route("/api/fleet/backends", get(routes::nodes::backends)) .route("/api/nodes/pair", post(routes::nodes::pair)) .route("/api/nodes/live", get(routes::nodes::live)) .route("/api/nodes/agent", get(routes::nodes::agent_ws)) diff --git a/crates/cm-api/src/routes/nodes.rs b/crates/cm-api/src/routes/nodes.rs index c2511a2..418385f 100644 --- a/crates/cm-api/src/routes/nodes.rs +++ b/crates/cm-api/src/routes/nodes.rs @@ -455,3 +455,49 @@ pub async fn capacity( pub struct CapacityQuery { pub backend: Option, } + +/// `GET /api/fleet/backends` — the microVM backends a mission may actually use. +/// +/// The SAME `available_backends` the roster planner is handed, not a second +/// list. The two rules it applies are both load-bearing and neither is obvious +/// from a node's capabilities alone: a backend must be built on an online node, +/// and it must have a credential contract. `agent-terminal` satisfies the first +/// and not the second — bootable, with nothing for the agent inside to +/// authenticate with — so offering it would produce a mission that validates, +/// launches, and fails at the agent turn, which is the expensive kind of late. +/// +/// Exists because the UI had no backend selector at all: every mission created +/// from the dashboard ran on `claude`, so `local-ornith`, `glm` and `kimi` were +/// reachable only by calling the API directly. +pub async fn backends( + State(state): State, + Authed(user): Authed, +) -> Result, ApiError> { + let ws = user.workspace_id.as_uuid().to_owned(); + let list = crate::mission_roster::available_backends(&state.pool, ws) + .await + .map_err(|e| { + eprintln!("fleet backends: {e}"); + ApiError::Internal + })?; + Ok(Json(json!({ + "backends": list.iter().map(|b| json!({ + "id": b, + "label": backend_label(b), + })).collect::>(), + }))) +} + +/// A name a person can choose between. The ids are deployment vocabulary +/// (`local-ornith`, `canary-claude`); a picker showing those alone asks the user +/// to know which company each one bills. +fn backend_label(id: &str) -> String { + match id { + "claude" | "default" => "Claude (Anthropic subscription)".into(), + "canary-claude" => "Claude — candidate CLI (canary)".into(), + "glm" => "GLM 4.7 (z.ai)".into(), + "kimi" => "Kimi (Moonshot)".into(), + "local-ornith" => "Ornith 9B — this fleet's own GPU".into(), + other => other.to_string(), + } +} diff --git a/frontend/src/components/dashboard/MissionWizard.tsx b/frontend/src/components/dashboard/MissionWizard.tsx index 2785bfc..f2b5a54 100644 --- a/frontend/src/components/dashboard/MissionWizard.tsx +++ b/frontend/src/components/dashboard/MissionWizard.tsx @@ -106,8 +106,33 @@ export function MissionWizard({ })); const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot"); const [cron, setCron] = useState("0 */6 * * *"); - const [runtimeKind, setRuntimeKind] = useState<"zeroclaw" | "local_herdr">("zeroclaw"); + const [runtimeKind, setRuntimeKind] = useState< + "zeroclaw" | "local_herdr" | "microvm" + >("zeroclaw"); const [targetNodeId, setTargetNodeId] = useState(""); + // Which microVM images the fleet can actually boot right now, from + // /api/fleet/backends — the SAME list the roster planner is handed, so the + // picker cannot offer something a mission would fail on. An empty list means + // no online node has a rootfs built, which the UI has to say out loud rather + // than present an empty dropdown. + const [backend, setBackend] = useState("claude"); + const [backends, setBackends] = useState>( + [], + ); + useEffect(() => { + (async () => { + try { + const r = await fetch("/api/fleet/backends"); + if (!r.ok) return; + const data = (await r.json()) as { + backends?: Array<{ id: string; label: string }>; + }; + setBackends(data.backends ?? []); + } catch { + // Non-fatal: the microVM option reports the empty list below. + } + })(); + }, []); const [onlineNodes, setOnlineNodes] = useState< Array<{ id: string; name: string }> >([]); @@ -220,6 +245,10 @@ export function MissionWizard({ runtime_kind: runtimeKind, target_node_id: runtimeKind === "local_herdr" ? targetNodeId : undefined, + // Only ever sent for microVM missions. `microvm_credential_for` refuses + // a backend it does not know, so sending one on a runtime that ignores + // it would be a silent no-op at best. + backend: runtimeKind === "microvm" ? backend : undefined, config: { phase_teams }, }); onCreated(created.id); @@ -627,6 +656,45 @@ export function MissionWizard({ )} + Schedule