feat(ui): three-screen mission wizard that asks only what the type needs

Five fixed steps for every mission type, and getting a research document out of
it meant naming a team, choosing a runtime, and writing per-phase completion
conditions under a paragraph explaining what a model checker can and cannot
prove. Two of those steps asked for things the mission does not use, and one of
them blocked outright.

  1  What do you want to do?
  2  Title, a description with a Polish button, repo ONLY if the type needs one
  3  Review -> Launch, plus one collapsed Advanced section

Two hard defects fixed on the way:

- The microVM runtime could not be selected AT ALL. Step 4 gated Next on
  `targetNodeId`, which microVM deliberately never sets because placement picks
  the node per phase. Everything shipped today, the local-GPU backend included,
  was unreachable from the UI.
- Step 3 required a team while every workflow TOML already names one in
  `default_team_template` — which this file ignored. The answer was always
  available and the question was always asked. It is now resolved by key, with a
  category fallback, and shown under Advanced so an operator can see WHICH
  default rather than having to supply one.

A failed `/api/team-templates` request and a genuinely empty list rendered the
identical red banner, which sends the reader looking for missing template files
when the request had 401'd. They now say different things.

`phases[]` is no longer sent unless someone set a completion condition.
`recipeToPreset` strips each phase's `config`, so posting the stripped list
overrode the recipe's real settings — tools, commit policy, loop mode — with
nothing. Omitting it lets `phases_for_create` use the recipe, which is both
simpler and more correct.

Launch keeps its own gate, since Advanced can still produce an unlaunchable
combination — but it names what is missing instead of greying out in silence.

Artifacts get a Download link. Deliberately a plain link to the streaming route
rather than a Blob built from what "Read" already fetched: that content is
capped at 2 MiB and UTF-8-decoded, so reusing it would silently produce a
truncated or undownloadable file for exactly the artifacts worth downloading.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-09 18:50:13 -07:00
co-authored by Claude Opus 5
parent 25f075a8be
commit e4bddeb1ba
3 changed files with 568 additions and 404 deletions
@@ -11,7 +11,7 @@
// research phase can leave dozens of documents. // research phase can leave dozens of documents.
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { FileText, GitMerge } from "lucide-react"; import { Download, FileText, GitMerge } from "lucide-react";
import { import {
getArtifactContent, getArtifactContent,
@@ -172,6 +172,28 @@ export function MissionArtifacts({
> >
{isOpen ? "Hide" : "Read"} {isOpen ? "Hide" : "Read"}
</button> </button>
{/* A plain link, not a fetch-and-Blob.
The read endpoint caps at 2 MiB and decodes as UTF-8, so
building the download from what "Read" already fetched would
inherit both limits and silently produce a truncated or
undownloadable file for exactly the artifacts worth
downloading. This hits the streaming route instead. */}
<a
href={`/api/missions/${missionId}/artifacts/${a.id}/download`}
download
style={{
...secondaryBtn,
padding: "5px 10px",
fontSize: 11,
textDecoration: "none",
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<Download aria-hidden size={12} />
Download
</a>
</div> </div>
{isOpen && ( {isOpen && (
<div <div
@@ -194,7 +216,8 @@ export function MissionArtifacts({
</div> </div>
) : text?.truncated ? ( ) : text?.truncated ? (
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}> <div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92" }}>
too large to display inline ({text.bytes.toLocaleString()} bytes) too large to display inline ({text.bytes.toLocaleString()}{" "}
bytes) — use Download
</div> </div>
) : text ? ( ) : text ? (
<MarkdownBlock source={text.content} /> <MarkdownBlock source={text.content} />
@@ -1,19 +1,28 @@
"use client"; "use client";
// MissionWizard — unified 5-step wizard replacing ResearchWizard + // MissionWizard — three screens, and each one asks only what the chosen
// LoopsWizard. // mission type actually needs.
// 1. Template — pick one of the 5 workflow templates (or custom)
// 2. Target — repo picker (when template requires) + subject/prompt
// 3. Team — bind existing team (later slices: team template + auto-provision)
// 4. Schedule — one-shot / cron / on-event
// 5. Review — plan preview + launch
// //
// Slice 2 ships a minimal implementation that creates a mission with // 1. What do you want to do? — the workflow
// the template's canned phase composition; Slice 4 threads the TOML // 2. The essentials — title, description (+ polish), repo IF needed
// recipe engine through here for real template dispatch. // 3. Review → Launch — plus one collapsed Advanced section
//
// It was five fixed steps for every type, and getting a research document out
// of it meant naming a team, choosing a runtime, and writing per-phase
// completion conditions under a paragraph explaining what a model checker can
// and cannot prove. Two of those steps asked for things the mission does not
// use, and one of them BLOCKED:
//
// - step 4 gated Next on `targetNodeId`, which microVM never sets, so the
// microVM runtime could not be selected at all;
// - step 3 required a team while every workflow TOML already names one in
// `default_team_template`, which this file ignored.
//
// Everything still configurable is behind Advanced with a working default, so
// opening it is a choice rather than a toll.
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react"; import { ChevronRight, Wand2, X } from "lucide-react";
import { import {
createMission, createMission,
@@ -55,7 +64,7 @@ const PHASE_CONDITION_PLACEHOLDER: Record<PhaseKind, string> = {
"e.g. every finding was triaged, each with either a patch or a stated reason for accepting it", "e.g. every finding was triaged, each with either a patch or a stated reason for accepting it",
}; };
type Step = 1 | 2 | 3 | 4 | 5; type Step = 1 | 2 | 3;
export function MissionWizard({ export function MissionWizard({
onClose, onClose,
@@ -72,13 +81,17 @@ export function MissionWizard({
const [researchTeamIds, setResearchTeamIds] = useState<Set<string>>(new Set()); const [researchTeamIds, setResearchTeamIds] = useState<Set<string>>(new Set());
const [devTeamIds, setDevTeamIds] = useState<Set<string>>(new Set()); const [devTeamIds, setDevTeamIds] = useState<Set<string>>(new Set());
const [teamTemplates, setTeamTemplates] = useState<TeamTemplate[]>([]); const [teamTemplates, setTeamTemplates] = useState<TeamTemplate[]>([]);
// A FAILED request and a genuinely empty list used to render the identical
// red banner, which sent the reader looking for missing template files when
// the request had 401'd. They are different problems with different fixes.
const [teamError, setTeamError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const list = await listTeamTemplates(); setTeamTemplates(await listTeamTemplates());
setTeamTemplates(list); setTeamError(null);
} catch { } catch (e) {
// Non-fatal: user is stuck on step 3 until templates load. setTeamError(e instanceof Error ? e.message : "request failed");
} }
})(); })();
}, []); }, []);
@@ -156,6 +169,11 @@ export function MissionWizard({
}, []); }, []);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [polishing, setPolishing] = useState(false);
// The text as it was before polishing, so the button is undoable. A rewrite
// the user cannot back out of is one they have to be brave to try.
const [prePolish, setPrePolish] = useState<string | null>(null);
// Workflow recipes come from the server (templates/workflows/*.toml) so a // Workflow recipes come from the server (templates/workflows/*.toml) so a
// new TOML shows up here without a frontend change. TEMPLATE_PRESETS is the // new TOML shows up here without a frontend change. TEMPLATE_PRESETS is the
@@ -182,23 +200,97 @@ export function MissionWizard({
[templates, templateKind], [templates, templateKind],
); );
// Which panels to show on step 3 (research / dev) depends on which // Which team panels Advanced shows depends on the phases the picked workflow
// phases the picked workflow includes. A benchmark-only mission // includes. A benchmark-only mission needs neither; research_and_code needs
// needs neither; a research_and_code mission needs both. // both.
const hasResearchPhase = preset.phases.some((p) => p.kind === "research"); const hasResearchPhase = preset.phases.some((p) => p.kind === "research");
const hasCodingPhase = preset.phases.some((p) => p.kind === "coding"); const hasCodingPhase = preset.phases.some((p) => p.kind === "coding");
// The team the RECIPE already chose.
//
// Every workflow TOML declares `default_team_template`, and this wizard used
// to ignore it and make the user pick one — while the server requires a team
// for zeroclaw/local_herdr through three separate gates. So the answer was
// always available and the question was always asked.
//
// Resolved by key, falling back to the first template whose category suits
// the phase mix, so a fleet whose templates were renamed still launches.
const defaultTeam = useMemo(() => {
if (teamTemplates.length === 0) return null;
const byKey = preset.defaultTeamTemplate
? teamTemplates.find((t) => t.key === preset.defaultTeamTemplate)
: undefined;
if (byKey) return byKey;
const wanted = hasCodingPhase ? "development" : "research";
return (
teamTemplates.find((t) => t.category === wanted) ?? teamTemplates[0] ?? null
);
}, [teamTemplates, preset.defaultTeamTemplate, hasCodingPhase]);
// Effective picks: whatever Advanced chose, else the recipe's default. Kept
// separate from the Set state so opening Advanced and closing it again
// without touching anything does not silently change the mission.
const effectiveResearchTeams =
researchTeamIds.size > 0
? Array.from(researchTeamIds)
: defaultTeam
? [defaultTeam.id]
: [];
const effectiveDevTeams =
devTeamIds.size > 0
? Array.from(devTeamIds)
: defaultTeam
? [defaultTeam.id]
: [];
async function polish() {
if (!description.trim() || polishing) return;
setPolishing(true);
setError(null);
try {
const r = await fetch("/api/missions/refine-draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description,
template_kind: templateKind,
}),
});
if (!r.ok) {
const body = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `polish failed (${r.status})`);
}
const data = (await r.json()) as { original: string; refined: string };
setPrePolish(data.original);
setDescription(data.refined);
} catch (e) {
setError(e instanceof Error ? e.message : "polish failed");
} finally {
setPolishing(false);
}
}
// Only the two things the user must supply. The team is defaulted, the
// runtime has a working default, and microVM chooses its own node — the old
// gate demanded a `targetNodeId` microVM never sets, which made that runtime
// unselectable end to end.
const canNext = const canNext =
(step === 1 && !!templateKind) || (step === 1 && !!templateKind) ||
(step === 2 && (step === 2 &&
title.trim().length > 0 && title.trim().length > 0 &&
(!preset.requiresRepo || repo !== null)) || (!preset.requiresRepo || repo !== null));
(step === 3 &&
(!hasResearchPhase || researchTeamIds.size > 0) && // Advanced can still produce an unlaunchable combination, so the LAUNCH
(!hasCodingPhase || devTeamIds.size > 0) && // button has its own gate — and says which one is missing rather than
// If neither panel applies, require at least one dev team. // greying out silently.
(hasResearchPhase || hasCodingPhase || devTeamIds.size > 0)) || const blocker =
(step === 4 && runtimeKind === "local_herdr" && !targetNodeId
(runtimeKind === "zeroclaw" || targetNodeId !== "")); ? "Pick a node for the Herdr runtime, under Advanced."
: runtimeKind !== "microvm" && effectiveDevTeams.length === 0 && effectiveResearchTeams.length === 0
? teamError
? `Teams could not be loaded (${teamError}). This runtime needs one.`
: "No team template is available, and this runtime needs one."
: null;
async function submit() { async function submit() {
setError(null); setError(null);
@@ -206,33 +298,27 @@ export function MissionWizard({
try { try {
const schedule: Schedule = const schedule: Schedule =
scheduleKind === "cron" ? { kind: "cron", cron } : { kind: "one_shot" }; scheduleKind === "cron" ? { kind: "cron", cron } : { kind: "one_shot" };
// Teams: the recipe's default unless Advanced overrode it. microVM needs
// none (`mission_orchestrator` materialises no team for it), but sending
// one is harmless and keeps the composed path able to derive node shape.
const phase_teams: Record<string, string[]> = {}; const phase_teams: Record<string, string[]> = {};
if (hasResearchPhase && researchTeamIds.size > 0) if (hasResearchPhase && effectiveResearchTeams.length > 0)
phase_teams.research = Array.from(researchTeamIds); phase_teams.research = effectiveResearchTeams;
if (hasCodingPhase && devTeamIds.size > 0) if (hasCodingPhase && effectiveDevTeams.length > 0)
phase_teams.coding = Array.from(devTeamIds); phase_teams.coding = effectiveDevTeams;
// Missions with only ambient phases (bench / security) still get if (!hasResearchPhase && !hasCodingPhase && effectiveDevTeams.length > 0)
// their dev-team picks recorded so at least one team exists. phase_teams.mission = effectiveDevTeams;
if (
!hasResearchPhase && // Phases are sent ONLY to carry a completion condition someone set under
!hasCodingPhase && // Advanced. Otherwise they are omitted entirely and the server uses the
devTeamIds.size > 0 // recipe — which is strictly better: `recipeToPreset` strips each phase's
) { // `config`, so posting the stripped list overrode the recipe's real
phase_teams.mission = Array.from(devTeamIds); // settings (tools, commit policy, loop mode) with nothing.
} const conditioned = preset.phases
const created = await createMission({ .filter((p) => conditionFor(p.order_idx).doneWhen.trim())
title: title.trim(), .map((p) => {
template_kind: templateKind,
repo_id: repo?.repo_id,
schedule,
description: description.trim() || undefined,
// Conditions ride in each phase's config; the server merges them over
// the recipe's config, promotes done_when / max_iterations into
// columns, and clamps the cap. Phases without a condition are sent
// unchanged so they keep the recipe's settings and finish in one pass.
phases: preset.phases.map((p) => {
const c = conditionFor(p.order_idx); const c = conditionFor(p.order_idx);
if (!c.doneWhen.trim()) return p;
return { return {
...p, ...p,
config: { config: {
@@ -241,13 +327,18 @@ export function MissionWizard({
max_iterations: c.maxIterations, max_iterations: c.maxIterations,
}, },
}; };
}), });
const created = await createMission({
title: title.trim(),
template_kind: templateKind,
repo_id: repo?.repo_id,
schedule,
description: description.trim() || undefined,
...(conditioned.length > 0 ? { phases: conditioned } : {}),
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, backend: runtimeKind === "microvm" ? backend : undefined,
config: { phase_teams }, config: { phase_teams },
}); });
@@ -259,6 +350,7 @@ export function MissionWizard({
} }
} }
return ( return (
<div <div
onClick={onClose} onClick={onClose}
@@ -313,7 +405,7 @@ export function MissionWizard({
color: "#5ec8d8", color: "#5ec8d8",
}} }}
> >
STEP {step} / 5 STEP {step} / 3
</span> </span>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}> <span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5" }}>
New mission New mission
@@ -340,10 +432,10 @@ export function MissionWizard({
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}> <div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && ( {step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Template</span> <span style={labelStyle}>What do you want to do?</span>
<p style={hintStyle}> <p style={hintStyle}>
Pick the workflow shape. This decides which phases run and what Everything else is filled in for you. You can change it on the
the coding/research agents are asked to do. last screen if you want to.
</p> </p>
<div <div
style={{ style={{
@@ -395,8 +487,12 @@ export function MissionWizard({
marginTop: 4, marginTop: 4,
}} }}
> >
{p.phases.map((ph) => ph.kind).join(" → ")} {p.phases
{p.requiresRepo ? " · needs repo" : ""} .map((ph) => PHASE_LABEL[ph.kind] ?? ph.kind)
.join(" → ")}
{p.requiresRepo
? " · needs a repository"
: " · produces a document"}
</span> </span>
</button> </button>
); );
@@ -414,49 +510,343 @@ export function MissionWizard({
id="mission-title" id="mission-title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. ClawHDF5 spec — memory engine v3" placeholder="e.g. How other teams do agent sandboxing"
style={fieldStyle} style={fieldStyle}
/> />
<label style={labelStyle} htmlFor="mission-desc">
Description / prompt <div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 2,
}}
>
<label style={{ ...labelStyle, marginBottom: 0 }} htmlFor="mission-desc">
{preset.requiresRepo
? "What should it do?"
: "What should it look into?"}
</label> </label>
<button
type="button"
onClick={polish}
disabled={!description.trim() || polishing}
title="Rewrite this into a brief the agents can follow"
style={{
...secondaryBtn,
marginLeft: "auto",
padding: "4px 10px",
fontSize: 12,
display: "inline-flex",
alignItems: "center",
gap: 6,
opacity: !description.trim() || polishing ? 0.4 : 1,
}}
>
<Wand2 aria-hidden size={13} />
{polishing ? "Polishing…" : "Polish"}
</button>
{prePolish !== null && (
<button
type="button"
onClick={() => {
setDescription(prePolish);
setPrePolish(null);
}}
style={{ ...secondaryBtn, padding: "4px 10px", fontSize: 12 }}
>
Undo
</button>
)}
</div>
<textarea <textarea
id="mission-desc" id="mission-desc"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={5} rows={8}
placeholder="What should the mission accomplish? The template's agents will use this as their driving prompt." placeholder="Say it however you like — Polish turns it into a brief the agents can follow."
style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }} style={{ ...fieldStyle, resize: "vertical", fontFamily: "inherit" }}
/> />
<span style={labelStyle}> {error && step === 2 && (
Completion conditions{" "} <p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
<span style={{ color: "#6a6a72" }}>(optional)</span> )}
</span>
{preset.requiresRepo ? (
<>
<span style={labelStyle}>Repository</span>
<p style={hintStyle}> <p style={hintStyle}>
Set per phase. After each pass a model checks the condition and, {preset.title} reads and writes code, so it needs a repo.
if it doesn&apos;t hold, that phase runs again with the reason as
guidance. Leave a phase empty to finish it in one pass.
</p> </p>
<p style={{ ...hintStyle, color: "#e8b465" }}> <RepoPicker value={repo} onChange={setRepo} />
The checker can&apos;t run commands — it only reads what the </>
agents wrote. Phrase each condition so their own output proves ) : (
it: &ldquo;cargo test was run and reported 0 failures&rdquo; <p style={hintStyle}>
works; &ldquo;the code is well factored&rdquo; does not. No repository needed. This produces a document you can read
and download when the mission finishes.
</p>
)}
</div>
)}
{step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Review</span>
<ReviewRow k="Mission" v={preset.title} />
<ReviewRow k="Title" v={title} />
{description && (
<ReviewRow
k="Brief"
v={
description.length > 220
? `${description.slice(0, 220)}…`
: description
}
/>
)}
<ReviewRow
k="Repository"
v={repo ? `${repo.owner}/${repo.name}` : "none — produces a document"}
/>
<ReviewRow k="Steps" v={preset.phases.map((p) => PHASE_LABEL[p.kind] ?? p.kind).join(" → ")} />
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
style={{
...secondaryBtn,
marginTop: 6,
display: "flex",
alignItems: "center",
gap: 6,
justifyContent: "flex-start",
}}
>
<ChevronRight
aria-hidden
size={13}
style={{
transform: advancedOpen ? "rotate(90deg)" : "none",
transition: "transform .12s",
}}
/>
Advanced
<span style={{ ...hintStyle, margin: 0, marginLeft: 6 }}>
team, runtime, schedule, completion checks — all defaulted
</span>
</button>
{advancedOpen && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
padding: 14,
borderRadius: 10,
border: "1px solid #1c1c22",
background: "#0d0d10",
}}
>
{/* Teams. Defaulted from the recipe; shown so an operator can
see WHICH default, not so a user has to choose one. */}
{teamError ? (
<p style={{ ...hintStyle, color: "#ff8a7a", margin: 0 }}>
Team templates could not be loaded: {teamError}. Missions on
the hosted runtime need one.
</p>
) : teamTemplates.length === 0 ? (
<p style={{ ...hintStyle, color: "#e8b465", margin: 0 }}>
The server returned no team templates. Check that
`templates/teams/` is present — the log should say
`team_template_loader: upserted builtin ...`.
</p>
) : (
<>
<p style={{ ...hintStyle, margin: 0 }}>
Using{" "}
<strong style={{ color: "#f3f3f5" }}>
{defaultTeam?.name ?? "—"}
</strong>{" "}
by default (from this workflow&apos;s recipe). Override:
</p>
{hasResearchPhase && (
<TeamMultiSelect
label="Research team"
hint="Runs the research phase."
templates={teamTemplates.filter(
(t) => t.category === "research",
)}
selected={researchTeamIds}
onToggle={(id) =>
setResearchTeamIds((s2) => {
const n = new Set(s2);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
/>
)}
{hasCodingPhase && (
<TeamMultiSelect
label="Development team"
hint="Runs the coding phase."
templates={teamTemplates.filter(
(t) => t.category === "development",
)}
selected={devTeamIds}
onToggle={(id) =>
setDevTeamIds((s2) => {
const n = new Set(s2);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
})
}
/>
)}
</>
)}
{/* Runtime */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<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 (default)
</div>
<div style={hintStyle}>
Runs in the shared runtime. No fleet node required.
</div>
</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 throwaway VM per step on a fleet node. Placement
picks the node, so none 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.
</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}>
Runs in a visible pane using that node&apos;s local CLI.
</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>
)}
</div>
</label>
</div>
{/* Schedule */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={labelStyle}>Schedule</span>
<label style={radioRowStyle(scheduleKind === "one_shot")}>
<input
type="radio"
checked={scheduleKind === "one_shot"}
onChange={() => setScheduleKind("one_shot")}
/>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
Run once
</div>
</label>
<label style={radioRowStyle(scheduleKind === "cron")}>
<input
type="radio"
checked={scheduleKind === "cron"}
onChange={() => setScheduleKind("cron")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
On a schedule
</div>
{scheduleKind === "cron" && (
<input
value={cron}
onChange={(e) => setCron(e.target.value)}
style={{ ...fieldStyle, marginTop: 6 }}
/>
)}
</div>
</label>
</div>
{/* Completion checks — off by default; one pass, done. */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={labelStyle}>Completion checks</span>
<p style={{ ...hintStyle, margin: 0 }}>
Leave blank and each step finishes in one pass. Fill one in
and a model re-reads the work after each pass and sends the
step back if it does not hold — it can only read what the
agents wrote, so phrase it as something their own output
proves.
</p> </p>
{preset.phases.map((p) => { {preset.phases.map((p) => {
const c = conditionFor(p.order_idx); const c = conditionFor(p.order_idx);
return ( return (
<div <div
key={p.order_idx} key={p.order_idx}
style={{ style={{ display: "flex", flexDirection: "column", gap: 6 }}
display: "flex",
flexDirection: "column",
gap: 6,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid #1c1c22",
background: "#0d0d10",
}}
> >
<label <label
style={{ ...labelStyle, marginBottom: 0 }} style={{ ...labelStyle, marginBottom: 0 }}
@@ -479,9 +869,7 @@ export function MissionWizard({
}} }}
/> />
{c.doneWhen.trim() && ( {c.doneWhen.trim() && (
<div <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<label <label
style={{ ...hintStyle, margin: 0 }} style={{ ...hintStyle, margin: 0 }}
htmlFor={`max-iter-${p.order_idx}`} htmlFor={`max-iter-${p.order_idx}`}
@@ -504,279 +892,19 @@ export function MissionWizard({
} }
style={{ ...fieldStyle, width: 80 }} style={{ ...fieldStyle, width: 80 }}
/> />
<span style={{ ...hintStyle, margin: 0 }}>
each pass is a full team run
</span>
</div> </div>
)} )}
</div> </div>
); );
})} })}
{preset.requiresRepo && ( </div>
<>
<span style={labelStyle}>Repository</span>
<p style={hintStyle}>
{preset.title} needs a repo bound so agents can read + write
/workspace/repo.
</p>
<RepoPicker value={repo} onChange={setRepo} />
</>
)}
</div> </div>
)} )}
{step === 3 && ( {blocker && (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}> <p style={{ ...hintStyle, color: "#e8b465" }}>{blocker}</p>
{teamTemplates.length === 0 && (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px solid rgba(255,138,122,.4)",
background: "rgba(255,138,122,.08)",
color: "#ff8a7a",
fontSize: 12.5,
}}
>
No team templates available. Check that the server has
loaded templates from templates/teams/ — logs should
say `team_template_loader: upserted builtin ...`.
</div>
)}
{hasResearchPhase && (
<TeamMultiSelect
label="Research teams *"
hint="Teams that run the research phase — investigate, gather sources, write the brief. Pick one or more."
templates={teamTemplates.filter(
(t) => t.category === "research",
)}
selected={researchTeamIds}
onToggle={(id) =>
setResearchTeamIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
})
}
/>
)}
{hasCodingPhase && (
<TeamMultiSelect
label="Development teams *"
hint="Teams that run the coding phase — implement, review, commit. Pick one or more (e.g. backend + frontend for a full-stack change)."
templates={teamTemplates.filter(
(t) => t.category === "development",
)}
selected={devTeamIds}
onToggle={(id) =>
setDevTeamIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
})
}
/>
)}
{!hasResearchPhase && !hasCodingPhase && (
<TeamMultiSelect
label="Teams *"
hint="Teams that run this mission's phases. Pick one or more."
templates={teamTemplates}
selected={devTeamIds}
onToggle={(id) =>
setDevTeamIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
})
}
/>
)}
</div>
)}
{step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<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&apos;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>
<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
type="radio"
checked={scheduleKind === "one_shot"}
onChange={() => setScheduleKind("one_shot")}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>One-shot</div>
<div style={hintStyle}>Runs once on launch. Simplest.</div>
</div>
</label>
<label style={radioRowStyle(scheduleKind === "cron")}>
<input
type="radio"
checked={scheduleKind === "cron"}
onChange={() => setScheduleKind("cron")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Cron</div>
<div style={hintStyle}>
Fires on schedule. Coding phases re-check the repo and pick
up where they left off.
</div>
{scheduleKind === "cron" && (
<input
type="text"
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 */6 * * *"
style={{ ...fieldStyle, marginTop: 6 }}
/>
)}
</div>
</label>
</div>
)}
{step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Review</span>
<ReviewRow k="Template" v={preset.title} />
<ReviewRow k="Title" v={title} />
{description && <ReviewRow k="Description" v={description} />}
{repo && <ReviewRow k="Repo" v={`${repo.owner}/${repo.name}`} />}
{hasResearchPhase && researchTeamIds.size > 0 && (
<ReviewRow
k="Research teams"
v={Array.from(researchTeamIds)
.map(
(id) => teamTemplates.find((t) => t.id === id)?.name ?? id,
)
.join(", ")}
/>
)}
{(hasCodingPhase || (!hasResearchPhase && !hasCodingPhase)) &&
devTeamIds.size > 0 && (
<ReviewRow
k={hasCodingPhase ? "Development teams" : "Teams"}
v={Array.from(devTeamIds)
.map(
(id) => teamTemplates.find((t) => t.id === id)?.name ?? id,
)
.join(", ")}
/>
)}
<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"}
/>
<ReviewRow k="Phases" v={preset.phases.map((p) => p.kind).join(" → ")} />
{error && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
)} )}
{error && <p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>}
</div> </div>
)} )}
</div> </div>
@@ -792,16 +920,16 @@ export function MissionWizard({
> >
<button <button
type="button" type="button"
onClick={() => setStep((s) => Math.max(1, s - 1) as Step)} onClick={() => setStep((s2) => Math.max(1, s2 - 1) as Step)}
disabled={step === 1} disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }} style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
> >
Back Back
</button> </button>
{step < 5 ? ( {step < 3 ? (
<button <button
type="button" type="button"
onClick={() => setStep((s) => Math.min(5, s + 1) as Step)} onClick={() => setStep((s2) => Math.min(3, s2 + 1) as Step)}
disabled={!canNext} disabled={!canNext}
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }} style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
> >
@@ -811,8 +939,11 @@ export function MissionWizard({
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={submitting} disabled={submitting || blocker !== null}
style={{ ...primaryBtn, opacity: submitting ? 0.5 : 1 }} style={{
...primaryBtn,
opacity: submitting || blocker !== null ? 0.4 : 1,
}}
> >
{submitting ? "Creating…" : "Launch mission"} {submitting ? "Creating…" : "Launch mission"}
</button> </button>
+10
View File
@@ -465,11 +465,16 @@ export interface TemplatePreset {
blurb: string; blurb: string;
requiresRepo: boolean; requiresRepo: boolean;
phases: PhaseSpec[]; phases: PhaseSpec[];
/** The recipe's own team choice. Every workflow TOML declares one and the
* wizard used to ignore it, which is why step 3 asked the user to pick a
* team the mission had already chosen. */
defaultTeamTemplate?: string | null;
} }
export const TEMPLATE_PRESETS: TemplatePreset[] = [ export const TEMPLATE_PRESETS: TemplatePreset[] = [
{ {
kind: "research_only", kind: "research_only",
defaultTeamTemplate: "rust_sdlc",
title: "Research only", title: "Research only",
blurb: blurb:
"Produce a styled MD + PDF artifact in the workspace. One-shot or scheduled.", "Produce a styled MD + PDF artifact in the workspace. One-shot or scheduled.",
@@ -478,6 +483,7 @@ export const TEMPLATE_PRESETS: TemplatePreset[] = [
}, },
{ {
kind: "research_and_code", kind: "research_and_code",
defaultTeamTemplate: "rust_sdlc",
title: "Research + Coding Loop", title: "Research + Coding Loop",
blurb: blurb:
"Research a topic against a repo, then loop the coding team through the produced INT-XX items until done.", "Research a topic against a repo, then loop the coding team through the produced INT-XX items until done.",
@@ -489,6 +495,7 @@ export const TEMPLATE_PRESETS: TemplatePreset[] = [
}, },
{ {
kind: "security_hardening", kind: "security_hardening",
defaultTeamTemplate: "rust_sdlc",
title: "Security Hardening", title: "Security Hardening",
blurb: blurb:
"Scan the repo for vulnerabilities, research patches, then apply + verify.", "Scan the repo for vulnerabilities, research patches, then apply + verify.",
@@ -501,6 +508,7 @@ export const TEMPLATE_PRESETS: TemplatePreset[] = [
}, },
{ {
kind: "refactor", kind: "refactor",
defaultTeamTemplate: "rust_sdlc",
title: "Refactor", title: "Refactor",
blurb: blurb:
"Audit dependencies + versions, propose API/SDK adaptations, apply the changes.", "Audit dependencies + versions, propose API/SDK adaptations, apply the changes.",
@@ -509,6 +517,7 @@ export const TEMPLATE_PRESETS: TemplatePreset[] = [
}, },
{ {
kind: "benchmark", kind: "benchmark",
defaultTeamTemplate: "rust_sdlc",
title: "Benchmark", title: "Benchmark",
blurb: blurb:
"Author + baseline benchmarks so subsequent refactors can be measured before/after.", "Author + baseline benchmarks so subsequent refactors can be measured before/after.",
@@ -557,4 +566,5 @@ export const recipeToPreset = (r: WorkflowRecipe): TemplatePreset => ({
.slice() .slice()
.sort((a, b) => a.order_idx - b.order_idx) .sort((a, b) => a.order_idx - b.order_idx)
.map((p) => ({ kind: p.kind, order_idx: p.order_idx })), .map((p) => ({ kind: p.kind, order_idx: p.order_idx })),
defaultTeamTemplate: r.default_team_template ?? null,
}); });