feat(ui): the microVM path is reachable from the mission wizard

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 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 15:09:07 -07:00
co-authored by Claude Opus 5
parent 1f6108f769
commit c66c3c6377
4 changed files with 123 additions and 2 deletions
+1
View File
@@ -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))
+46
View File
@@ -455,3 +455,49 @@ pub async fn capacity(
pub struct CapacityQuery {
pub backend: Option<String>,
}
/// `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<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, 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::<Vec<_>>(),
})))
}
/// 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(),
}
}
@@ -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<string>("");
// 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<string>("claude");
const [backends, setBackends] = useState<Array<{ id: string; label: string }>>(
[],
);
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({
)}
</div>
</label>
<label style={radioRowStyle(runtimeKind === "microvm")}>
<input
type="radio"
checked={runtimeKind === "microvm"}
onChange={() => {
setRuntimeKind("microvm");
setTargetNodeId("");
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
Firecracker microVM
</div>
<div style={hintStyle}>
One VM per phase on a fleet node: the repo goes in as a tar,
the work comes back as a tar, and the guest is destroyed.
Placement picks the node, so no node is chosen here.
</div>
{runtimeKind === "microvm" && backends.length > 0 && (
<select
value={backend}
onChange={(e) => setBackend(e.target.value)}
style={{ ...fieldStyle, marginTop: 6 }}
>
{backends.map((b) => (
<option key={b.id} value={b.id}>
{b.label}
</option>
))}
</select>
)}
{runtimeKind === "microvm" && backends.length === 0 && (
<div style={{ ...hintStyle, color: "#ff8a7a", marginTop: 4 }}>
No fleet node has a microVM image built. Run
scripts/fc-build-rootfs.sh on a node first.
</div>
)}
</div>
</label>
<span style={{ ...labelStyle, marginTop: 6 }}>Schedule</span>
<label style={radioRowStyle(scheduleKind === "one_shot")}>
<input
+7 -1
View File
@@ -51,7 +51,10 @@ export interface Schedule {
event?: string;
}
export type RuntimeKind = "zeroclaw" | "local_herdr";
// `microvm` is the Firecracker path: one VM per phase on a fleet node, chosen
// by `vm_placement` rather than by the caller — which is why it takes a
// `backend` (the rootfs image) and no `target_node_id`.
export type RuntimeKind = "zeroclaw" | "local_herdr" | "microvm";
export interface Mission {
id: string;
@@ -215,6 +218,9 @@ export interface CreateMissionRequest {
phases?: PhaseSpec[];
runtime_kind?: RuntimeKind;
target_node_id?: string;
/// microVM only: which rootfs image to boot (`claude`, `local-ornith`, …).
/// Server-side `microvm_credential_for` refuses one it does not know.
backend?: string;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {