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:
co-authored by
Claude Opus 5
parent
1f6108f769
commit
c66c3c6377
@@ -173,6 +173,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/world/replay", get(routes::world::world_replay))
|
.route("/api/world/replay", get(routes::world::world_replay))
|
||||||
.route("/api/nodes", get(routes::nodes::list))
|
.route("/api/nodes", get(routes::nodes::list))
|
||||||
.route("/api/fleet/capacity", get(routes::nodes::capacity))
|
.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/pair", post(routes::nodes::pair))
|
||||||
.route("/api/nodes/live", get(routes::nodes::live))
|
.route("/api/nodes/live", get(routes::nodes::live))
|
||||||
.route("/api/nodes/agent", get(routes::nodes::agent_ws))
|
.route("/api/nodes/agent", get(routes::nodes::agent_ws))
|
||||||
|
|||||||
@@ -455,3 +455,49 @@ pub async fn capacity(
|
|||||||
pub struct CapacityQuery {
|
pub struct CapacityQuery {
|
||||||
pub backend: Option<String>,
|
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 [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
|
||||||
const [cron, setCron] = useState("0 */6 * * *");
|
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>("");
|
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<
|
const [onlineNodes, setOnlineNodes] = useState<
|
||||||
Array<{ id: string; name: string }>
|
Array<{ id: string; name: string }>
|
||||||
>([]);
|
>([]);
|
||||||
@@ -220,6 +245,10 @@ export function MissionWizard({
|
|||||||
runtime_kind: runtimeKind,
|
runtime_kind: runtimeKind,
|
||||||
target_node_id:
|
target_node_id:
|
||||||
runtimeKind === "local_herdr" ? targetNodeId : undefined,
|
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 },
|
config: { phase_teams },
|
||||||
});
|
});
|
||||||
onCreated(created.id);
|
onCreated(created.id);
|
||||||
@@ -627,6 +656,45 @@ export function MissionWizard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
<span style={{ ...labelStyle, marginTop: 6 }}>Schedule</span>
|
||||||
<label style={radioRowStyle(scheduleKind === "one_shot")}>
|
<label style={radioRowStyle(scheduleKind === "one_shot")}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export interface Schedule {
|
|||||||
event?: string;
|
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 {
|
export interface Mission {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -215,6 +218,9 @@ export interface CreateMissionRequest {
|
|||||||
phases?: PhaseSpec[];
|
phases?: PhaseSpec[];
|
||||||
runtime_kind?: RuntimeKind;
|
runtime_kind?: RuntimeKind;
|
||||||
target_node_id?: string;
|
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> {
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user