herdr phase 1c: wizard runtime picker + on_launch auto-dispatch

Closes the operator loop for the second-runtime path. Missions
created with runtime='local_herdr' now spawn a Herdr pane on their
target_node automatically on draft→running.

Frontend (MissionWizard):
  - Step 4 grows a "Runtime" section above Schedule
  - Radio: "Hosted (ZeroClaw)" default | "On a fleet node (Herdr)"
  - Local-Herdr shows a dropdown of ONLINE nodes only (from
    /api/nodes filtered by status='online')
  - canNext blocks Next when local_herdr picked without a node
  - Review step shows "Runtime: Herdr on <node-name>" or "Hosted"
  - Empty-online-nodes state hints "Connect one from INFRA first"

Backend:
  - mission_orchestrator::on_launch grows a NodeHub param; when
    mission.runtime_kind='local_herdr' + target_node_id set +
    hub present → calls fleet_herdr::dispatch(). Non-fatal:
    logs and continues so a research_only mission with a Herdr
    runtime chosen accidentally still boots the team.
  - routes::missions::set_status passes state.node_hub through.
  - Test call sites updated to pass None for the new param
    (integration tests don't drive real fleet nodes).

CLI stub: on_launch currently hard-codes cli="claude" for the
Herdr pane. Phase 4 will read that from the team template so a
research team → kimi, gpu team → claude, etc.

Verified: cargo check --workspace + cargo test
-p cm-api --test mission_orchestrator + tsc --noEmit all green.
This commit is contained in:
Omar Sobh
2026-07-20 10:02:40 -07:00
parent 47f986257f
commit 2b3ec27757
4 changed files with 137 additions and 7 deletions
@@ -59,6 +59,29 @@ 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 [targetNodeId, setTargetNodeId] = useState<string>("");
const [onlineNodes, setOnlineNodes] = useState<
Array<{ id: string; name: string }>
>([]);
useEffect(() => {
(async () => {
try {
const r = await fetch("/api/nodes");
if (!r.ok) return;
const data = (await r.json()) as {
nodes?: Array<{ id: string; name: string; status: string }>;
};
setOnlineNodes(
(data.nodes ?? [])
.filter((n) => n.status === "online")
.map((n) => ({ id: n.id, name: n.name })),
);
} catch {
// Non-fatal — user can still pick zeroclaw runtime without nodes.
}
})();
}, []);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -73,7 +96,8 @@ export function MissionWizard({
title.trim().length > 0 &&
(!preset.requiresRepo || repo !== null)) ||
step === 3 ||
step === 4;
(step === 4 &&
(runtimeKind === "zeroclaw" || targetNodeId !== ""));
async function submit() {
setError(null);
@@ -89,6 +113,9 @@ export function MissionWizard({
schedule,
description: description.trim() || undefined,
phases: preset.phases,
runtime_kind: runtimeKind,
target_node_id:
runtimeKind === "local_herdr" ? targetNodeId : undefined,
});
onCreated(created.id);
} catch (e) {
@@ -372,7 +399,63 @@ export function MissionWizard({
{step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Schedule</span>
<span style={labelStyle}>Runtime</span>
<label style={radioRowStyle(runtimeKind === "zeroclaw")}>
<input
type="radio"
checked={runtimeKind === "zeroclaw"}
onChange={() => {
setRuntimeKind("zeroclaw");
setTargetNodeId("");
}}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
Hosted (ZeroClaw)
</div>
<div style={hintStyle}>
Runs headlessly in the shared ZeroClaw daemon. Default. No
fleet node required.
</div>
</div>
</label>
<label style={radioRowStyle(runtimeKind === "local_herdr")}>
<input
type="radio"
checked={runtimeKind === "local_herdr"}
onChange={() => setRuntimeKind("local_herdr")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
On a fleet node (Herdr)
</div>
<div style={hintStyle}>
Executes in a Herdr pane on a fleet node, using that
node's local CLI (claude / kimi / codex). Operator-visible,
live pane view in the mission canvas.
</div>
{runtimeKind === "local_herdr" && (
<select
value={targetNodeId}
onChange={(e) => setTargetNodeId(e.target.value)}
style={{ ...fieldStyle, marginTop: 6 }}
>
<option value="">Pick a node</option>
{onlineNodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
)}
{runtimeKind === "local_herdr" && onlineNodes.length === 0 && (
<div style={{ ...hintStyle, color: "#ff8a7a", marginTop: 4 }}>
No online nodes. Connect one from the INFRA tier first.
</div>
)}
</div>
</label>
<span style={{ ...labelStyle, marginTop: 6 }}>Schedule</span>
<label style={radioRowStyle(scheduleKind === "one_shot")}>
<input
type="radio"
@@ -426,6 +509,14 @@ export function MissionWizard({
: "auto-provision from prompt"
}
/>
<ReviewRow
k="Runtime"
v={
runtimeKind === "local_herdr"
? `Herdr on ${onlineNodes.find((n) => n.id === targetNodeId)?.name ?? targetNodeId}`
: "Hosted (ZeroClaw)"
}
/>
<ReviewRow
k="Schedule"
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}