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:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user