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:
co-authored by
Claude Opus 5
parent
25f075a8be
commit
e4bddeb1ba
@@ -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'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't run commands — it only reads what the
|
</>
|
||||||
agents wrote. Phrase each condition so their own output proves
|
) : (
|
||||||
it: “cargo test was run and reported 0 failures”
|
<p style={hintStyle}>
|
||||||
works; “the code is well factored” 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'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'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'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>
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user