missions: multi-team model — pick research + development teams
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 37s
ci / rust (push) Failing after 1m41s
ci / e2e (push) Skipped
ci / publish (push) Skipped

Directly addresses "we want to pick one or more teams to assign to a
mission, first screen research teams, next screen dev teams." A
mission now materializes N teams, each tagged with a phase purpose.

Backend:
  - 0056_mission_teams.sql — new join table
    mission_teams(mission_id, team_id, purpose). team_id PK because a
    team belongs to one mission-purpose. missions.team_id kept as
    legacy pointer to the first minted team for single-team surfaces.
  - mission_orchestrator::on_launch — reads mission.config.phase_teams
    (JSONB shape { research: [tid,...], coding: [tid,...] }), mints
    one team per (purpose, template) pair, records each in
    mission_teams, binds the first to mission.team_id. Legacy fallback:
    if config.phase_teams is absent, uses missions.team_template_id.
    Hard error if both are absent.
  - GET /api/missions/{id}/teams — returns
    [{ team_id, purpose, team_name }], sorted by created_at asc.

Frontend wizard (step 3 rewrite):
  - researchTeamIds / devTeamIds — Set<string> multi-selects
  - Reusable TeamMultiSelect component (checkbox-style cards)
  - Panels rendered conditionally by preset:
    hasResearchPhase → "Research teams" panel
    hasCodingPhase → "Development teams" panel
    neither → "Teams" panel (bench/security-only missions)
  - canNext enforces at least one pick in every visible panel
  - submit builds config.phase_teams and passes it via CreateMissionRequest
  - Review step shows both selections by name

MissionTeamTab:
  - Fetches /api/missions/{id}/teams and groups by purpose
  - Each purpose renders a section with per-team cards
  - Falls back to a single "mission" pseudo-row for legacy missions
    that only have missions.team_id (no mission_teams rows)

CreateMissionRequest no longer sends team_template_id from the wizard
— the multi-team config.phase_teams path supersedes it. The backend
still accepts team_template_id for API callers.

Verified: cargo check --workspace + tsc + eslint --quiet all green.
This commit is contained in:
Omar Sobh
2026-07-20 19:25:07 -07:00
parent 0ee689f590
commit b8b8cb452e
7 changed files with 512 additions and 244 deletions
+4
View File
@@ -460,6 +460,10 @@ pub fn router(state: AppState) -> Router {
patch(routes::missions::set_description), patch(routes::missions::set_description),
) )
.route("/api/missions/{id}/runs", get(routes::missions::list_runs)) .route("/api/missions/{id}/runs", get(routes::missions::list_runs))
.route(
"/api/missions/{id}/teams",
get(routes::missions::list_teams),
)
.route( .route(
"/api/missions/{id}/benchmark", "/api/missions/{id}/benchmark",
post(routes::missions::trigger_benchmark), post(routes::missions::trigger_benchmark),
+78 -35
View File
@@ -50,37 +50,88 @@ pub async fn on_launch(
if mission.team_id.is_some() { if mission.team_id.is_some() {
return Ok(mission.team_id); return Ok(mission.team_id);
} }
let Some(template_id) = mission.team_template_id else {
// Hard fail — a mission with no team AND no template can't run: // New multi-team model: config.phase_teams = {
// there are no agents to execute phases. The wizard requires // "research": ["template-uuid", ...],
// a template pick; this branch guards against direct API // "coding": ["template-uuid", ...]
// callers or legacy rows. // }
// Mints one team per (phase-purpose, template) pair. The FIRST
// minted team gets bound to mission.team_id for backward-compat
// with the single-team surfaces (Team tab, legacy code).
//
// Fallback: if config.phase_teams is absent, use the legacy
// single team_template_id path so existing missions still work.
let phase_teams = mission
.config
.get("phase_teams")
.and_then(|v| v.as_object());
let picks: Vec<(String, Uuid)> = if let Some(pt) = phase_teams {
let mut out = Vec::new();
for (purpose, list) in pt.iter() {
if let Some(arr) = list.as_array() {
for item in arr {
if let Some(id_str) = item.as_str() {
if let Ok(id) = Uuid::parse_str(id_str) {
out.push((purpose.clone(), id));
}
}
}
}
}
out
} else if let Some(id) = mission.team_template_id {
vec![("mission".to_string(), id)]
} else {
return Err( return Err(
"mission has no team_id and no team_template_id — pick a template in the wizard \ "mission has no team_template_id and no config.phase_teams — pick teams in the wizard"
before launching, or attach an existing team via the API"
.to_string(), .to_string(),
); );
}; };
let template = cm_db::repo::team_templates::get(pool, template_id) if picks.is_empty() {
.await return Err(
.map_err(|e| format!("load template: {e}"))? "mission's config.phase_teams is empty — pick at least one team in the wizard".into(),
.ok_or_else(|| format!("template {template_id} not found"))?; );
}
let provisioner = RuntimeProvisioner::from_env(); let provisioner = RuntimeProvisioner::from_env();
let mut first_team_id: Option<Uuid> = None;
for (purpose, template_id) in &picks {
let template = cm_db::repo::team_templates::get(pool, *template_id)
.await
.map_err(|e| format!("load template {template_id}: {e}"))?
.ok_or_else(|| format!("template {template_id} not found"))?;
let team_name = format!(
"{} · {} · {}",
mission.title, purpose, template.template.name
);
let team_id = mint_team_from_template(
pool,
workspace_id,
user_id,
provisioner.as_ref(),
&template,
&team_name,
"claude-sonnet-5",
)
.await?;
// Record (mission, team, purpose) in mission_teams so the Team
// tab can group by phase purpose without parsing team names.
sqlx::query("INSERT INTO mission_teams (mission_id, team_id, purpose) VALUES ($1, $2, $3)")
.bind(mission_id)
.bind(team_id)
.bind(purpose)
.execute(pool)
.await
.map_err(|e| format!("record mission_team {team_id}: {e}"))?;
if first_team_id.is_none() {
first_team_id = Some(team_id);
}
}
let team_id = first_team_id.expect("picks non-empty guaranteed above");
let team_id = mint_team_from_template( // Bind the first team onto the mission for legacy single-team paths.
pool,
workspace_id,
user_id,
provisioner.as_ref(),
&template,
&mission.title,
"claude-sonnet-5",
)
.await?;
// Bind the team onto the mission.
sqlx::query("UPDATE missions SET team_id = $1, updated_at = now() WHERE id = $2") sqlx::query("UPDATE missions SET team_id = $1, updated_at = now() WHERE id = $2")
.bind(team_id) .bind(team_id)
.bind(mission_id) .bind(mission_id)
@@ -113,23 +164,15 @@ pub async fn on_launch(
if mission.runtime_kind == "local_herdr" { if mission.runtime_kind == "local_herdr" {
if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) { if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) {
let prompt = mission.description.clone().unwrap_or_default(); let prompt = mission.description.clone().unwrap_or_default();
// CLI selection precedence: // CLI selection: mission.config.cli overrides; else default.
// mission.config.cli → template.config.default_cli → "claude" // (Per-template default_cli fallback was in the single-team
// Templates encode which agent CLI fits their stack; missions can // path; the multi-team path doesn't have one canonical
// override per-run for A/B (kimi on morpheus vs claude on tank). // template to consult, so we keep the mission-level knob.)
let cli = mission let cli = mission
.config .config
.get("cli") .get("cli")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(str::to_string) .map(str::to_string)
.or_else(|| {
template
.template
.config
.get("default_cli")
.and_then(|v| v.as_str())
.map(str::to_string)
})
.unwrap_or_else(|| "claude".to_string()); .unwrap_or_else(|| "claude".to_string());
match crate::fleet_herdr::dispatch( match crate::fleet_herdr::dispatch(
hub, hub,
+37
View File
@@ -410,6 +410,43 @@ pub async fn herdr_dispatch(
})) }))
} }
/// GET /api/missions/{id}/teams — teams materialized for this mission,
/// grouped by purpose (research / coding / etc). Returns
/// [{ purpose, team_id, team_name }] so the Team tab can render
/// sections. The legacy single-team view falls back to
/// mission.team_id when this array is empty.
pub async fn list_teams(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
use sqlx::Row;
let rows = sqlx::query(
"SELECT mt.team_id::text AS team_id, mt.purpose, t.name AS team_name
FROM mission_teams mt
JOIN teams t ON t.id = mt.team_id
WHERE mt.mission_id = $1
ORDER BY mt.created_at ASC",
)
.bind(id)
.fetch_all(&state.pool)
.await?;
let teams: Vec<Value> = rows
.into_iter()
.map(|r| {
serde_json::json!({
"team_id": r.get::<String, _>("team_id"),
"purpose": r.get::<String, _>("purpose"),
"team_name": r.get::<String, _>("team_name"),
})
})
.collect();
Ok(Json(serde_json::json!({ "teams": teams })))
}
/// GET /api/missions/{id}/runs — topology_runs bound to this mission, /// GET /api/missions/{id}/runs — topology_runs bound to this mission,
/// newest first. Used by the Live tab to subscribe to per-run SSE. /// newest first. Used by the Live tab to subscribe to per-run SSE.
pub async fn list_runs( pub async fn list_runs(
@@ -750,7 +750,11 @@ export function MissionCanvas({
)} )}
{tab === "team" && ( {tab === "team" && (
<MissionTeamTab teamId={mission.team_id} onOpenClaw={onOpenClaw} /> <MissionTeamTab
missionId={mission.id}
teamId={mission.team_id}
onOpenClaw={onOpenClaw}
/>
)} )}
{tab === "live" && ( {tab === "live" && (
@@ -26,37 +26,56 @@ interface Claw {
job_title?: string | null; job_title?: string | null;
} }
interface MissionTeamRow {
team_id: string;
purpose: string;
team_name: string;
}
export function MissionTeamTab({ export function MissionTeamTab({
missionId,
teamId, teamId,
onOpenClaw, onOpenClaw,
}: { }: {
missionId: string;
teamId: string | null; teamId: string | null;
onOpenClaw?: (clawId: string) => void; onOpenClaw?: (clawId: string) => void;
}) { }) {
const [team, setTeam] = useState<TeamDetail | null>(null); const [rows, setRows] = useState<MissionTeamRow[]>([]);
const [teams, setTeams] = useState<Record<string, TeamDetail>>({});
const [claws, setClaws] = useState<Record<string, Claw>>({}); const [claws, setClaws] = useState<Record<string, Claw>>({});
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!teamId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTeam(null);
return;
}
let alive = true; let alive = true;
(async () => { (async () => {
try { try {
const [tRes, cRes] = await Promise.all([ const [mtRes, cRes] = await Promise.all([
fetch(`/api/teams/${teamId}`), fetch(`/api/missions/${missionId}/teams`),
fetch(`/api/team/claws`), fetch(`/api/team/claws`),
]); ]);
if (!tRes.ok) throw new Error(`team ${tRes.status}`); if (!mtRes.ok) throw new Error(`teams ${mtRes.status}`);
const detail = (await tRes.json()) as TeamDetail; const data = (await mtRes.json()) as { teams: MissionTeamRow[] };
const clawList = cRes.ok // Fallback for legacy single-team missions: mission_teams empty
? ((await cRes.json()) as Claw[]) // but mission.team_id is set — synthesize one "mission" row so
: []; // the UI doesn't look empty.
const list = data.teams.length > 0
? data.teams
: teamId
? [{ team_id: teamId, purpose: "mission", team_name: "Team" }]
: [];
const details = await Promise.all(
list.map(async (r) => {
const t = await fetch(`/api/teams/${r.team_id}`);
return t.ok
? ([r.team_id, (await t.json()) as TeamDetail] as const)
: null;
}),
);
const clawList = cRes.ok ? ((await cRes.json()) as Claw[]) : [];
if (!alive) return; if (!alive) return;
setTeam(detail); setRows(list);
setTeams(Object.fromEntries(details.filter(Boolean) as (readonly [string, TeamDetail])[]));
setClaws(Object.fromEntries(clawList.map((c) => [c.id, c]))); setClaws(Object.fromEntries(clawList.map((c) => [c.id, c])));
} catch (e) { } catch (e) {
if (alive) setError(e instanceof Error ? e.message : "load failed"); if (alive) setError(e instanceof Error ? e.message : "load failed");
@@ -65,130 +84,159 @@ export function MissionTeamTab({
return () => { return () => {
alive = false; alive = false;
}; };
}, [teamId]); }, [missionId, teamId]);
if (!teamId) { if (!teamId && rows.length === 0) {
return ( return (
<div style={{ padding: 24, color: "#8a8a92", fontSize: 13 }}> <div style={{ padding: 24, color: "#8a8a92", fontSize: 13 }}>
No team yet. Launch the mission to materialize a team from the picked No teams yet. Launch the mission to materialize teams from the picked
template. templates.
</div> </div>
); );
} }
if (error) { if (error) {
return <div style={{ padding: 24, color: "#ff8a7a", fontSize: 12 }}>{error}</div>; return <div style={{ padding: 24, color: "#ff8a7a", fontSize: 12 }}>{error}</div>;
} }
if (!team) {
return ( // Group rows by purpose so each section renders as one card.
<div style={{ padding: 24, color: "#5ec8d8", fontFamily: mono, fontSize: 12 }}> const grouped = rows.reduce<Record<string, MissionTeamRow[]>>((acc, r) => {
Loading (acc[r.purpose] = acc[r.purpose] ?? []).push(r);
</div> return acc;
); }, {});
}
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div {Object.entries(grouped).map(([purpose, purposeRows]) => (
style={{ <div key={purpose} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
fontFamily: mono, <div
fontSize: 10, style={{
letterSpacing: ".14em", fontFamily: mono,
color: "#7cd6e0", fontSize: 10,
textTransform: "uppercase", letterSpacing: ".14em",
marginBottom: 4, color: "#7cd6e0",
}} textTransform: "uppercase",
> }}
{team.name ?? "Team"} · {team.members.length} member >
{team.members.length === 1 ? "" : "s"} {purpose} · {purposeRows.length} team{purposeRows.length === 1 ? "" : "s"}
</div> </div>
{team.members.length === 0 ? ( {purposeRows.map((r) => {
<div style={{ padding: 16, color: "#8a8a92", fontSize: 12 }}> const detail = teams[r.team_id];
Team has no members yet (orchestrator may still be materializing). const members = detail?.members ?? [];
</div> return (
) : ( <div
team.members.map((m) => { key={r.team_id}
const claw = claws[m.claw_id];
return (
<div
key={m.claw_id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "11px 12px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.07)",
background: "#101014",
}}
>
<span
style={{ style={{
width: 30, border: "1px solid rgba(255,255,255,.07)",
height: 30, borderRadius: 10,
borderRadius: 8, background: "#101014",
background: "rgba(255,111,97,.1)", overflow: "hidden",
border: "1px solid rgba(255,111,97,.25)",
color: "#ff8a7a",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "none",
}} }}
> >
<User size={14} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div <div
style={{ style={{
fontSize: 13, padding: "8px 12px",
color: "#f3f3f5", borderBottom: "1px solid rgba(255,255,255,.05)",
fontWeight: 500, display: "flex",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{claw?.name ?? `claw ${m.claw_id.slice(0, 8)}`}
</div>
<div
style={{
fontFamily: mono,
fontSize: 10.5,
letterSpacing: ".08em",
color: "#8a8a92",
textTransform: "uppercase",
marginTop: 2,
}}
>
{m.role}
</div>
</div>
{onOpenClaw && (
<button
type="button"
onClick={() => onOpenClaw(m.claw_id)}
title="Open this claw in the AGENT tier"
style={{
padding: "5px 10px",
borderRadius: 6,
border: "1px solid rgba(94,200,216,.4)",
background: "transparent",
color: "#5ec8d8",
fontSize: 11,
cursor: "pointer",
display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 4, gap: 8,
}} }}
> >
Open <span style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 600 }}>
<ArrowUpRight size={11} /> {r.team_name}
</button> </span>
)} <span style={{ marginLeft: "auto", fontFamily: mono, fontSize: 10, color: "#8a8a92" }}>
</div> {members.length} member{members.length === 1 ? "" : "s"}
); </span>
}) </div>
)} {members.length === 0 ? (
<div style={{ padding: 12, color: "#8a8a92", fontSize: 12 }}>
Materializing
</div>
) : (
members.map((m) => {
const claw = claws[m.claw_id];
return (
<div
key={m.claw_id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 12px",
borderTop: "1px solid rgba(255,255,255,.04)",
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: 6,
background: "rgba(255,111,97,.1)",
border: "1px solid rgba(255,111,97,.25)",
color: "#ff8a7a",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flex: "none",
}}
>
<User size={12} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12.5,
color: "#f3f3f5",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{claw?.name ?? `claw ${m.claw_id.slice(0, 8)}`}
</div>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".08em",
color: "#8a8a92",
textTransform: "uppercase",
}}
>
{m.role}
</div>
</div>
{onOpenClaw && (
<button
type="button"
onClick={() => onOpenClaw(m.claw_id)}
title="Open this claw in the AGENT tier"
style={{
padding: "4px 9px",
borderRadius: 6,
border: "1px solid rgba(94,200,216,.4)",
background: "transparent",
color: "#5ec8d8",
fontSize: 11,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
Open
<ArrowUpRight size={10} />
</button>
)}
</div>
);
})
)}
</div>
);
})}
</div>
))}
</div> </div>
); );
} }
@@ -46,19 +46,14 @@ export function MissionWizard({
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null); const [repo, setRepo] = useState<PickedRepo | null>(null);
const [teamTemplateId, setTeamTemplateId] = useState<string>(""); const [researchTeamIds, setResearchTeamIds] = useState<Set<string>>(new Set());
const [devTeamIds, setDevTeamIds] = useState<Set<string>>(new Set());
const [teamTemplates, setTeamTemplates] = useState<TeamTemplate[]>([]); const [teamTemplates, setTeamTemplates] = useState<TeamTemplate[]>([]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const list = await listTeamTemplates(); const list = await listTeamTemplates();
setTeamTemplates(list); setTeamTemplates(list);
// Pre-select the first template so the wizard has a
// valid selection by default — a mission without a team
// can't run, and the backend now rejects that transition
// hard, so the empty default was actively wrong.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (list.length > 0) setTeamTemplateId((prev) => prev || list[0].id);
} catch { } catch {
// Non-fatal: user is stuck on step 3 until templates load. // Non-fatal: user is stuck on step 3 until templates load.
} }
@@ -97,12 +92,21 @@ export function MissionWizard({
[templateKind], [templateKind],
); );
// Which panels to show on step 3 (research / dev) depends on which
// phases the picked workflow includes. A benchmark-only mission
// needs neither; a research_and_code mission needs both.
const hasResearchPhase = preset.phases.some((p) => p.kind === "research");
const hasCodingPhase = preset.phases.some((p) => p.kind === "coding");
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 && teamTemplateId !== "") || (step === 3 &&
(!hasResearchPhase || researchTeamIds.size > 0) &&
(!hasCodingPhase || devTeamIds.size > 0) &&
// If neither panel applies, require at least one dev team.
(hasResearchPhase || hasCodingPhase || devTeamIds.size > 0)) ||
(step === 4 && (step === 4 &&
(runtimeKind === "zeroclaw" || targetNodeId !== "")); (runtimeKind === "zeroclaw" || targetNodeId !== ""));
@@ -112,17 +116,31 @@ 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" };
const phase_teams: Record<string, string[]> = {};
if (hasResearchPhase && researchTeamIds.size > 0)
phase_teams.research = Array.from(researchTeamIds);
if (hasCodingPhase && devTeamIds.size > 0)
phase_teams.coding = Array.from(devTeamIds);
// Missions with only ambient phases (bench / security) still get
// their dev-team picks recorded so at least one team exists.
if (
!hasResearchPhase &&
!hasCodingPhase &&
devTeamIds.size > 0
) {
phase_teams.mission = Array.from(devTeamIds);
}
const created = await createMission({ const created = await createMission({
title: title.trim(), title: title.trim(),
template_kind: templateKind, template_kind: templateKind,
repo_id: repo?.repo_id, repo_id: repo?.repo_id,
team_template_id: teamTemplateId || undefined,
schedule, schedule,
description: description.trim() || undefined, description: description.trim() || undefined,
phases: preset.phases, phases: preset.phases,
runtime_kind: runtimeKind, runtime_kind: runtimeKind,
target_node_id: target_node_id:
runtimeKind === "local_herdr" ? targetNodeId : undefined, runtimeKind === "local_herdr" ? targetNodeId : undefined,
config: { phase_teams },
}); });
onCreated(created.id); onCreated(created.id);
} catch (e) { } catch (e) {
@@ -315,14 +333,7 @@ export function MissionWizard({
)} )}
{step === 3 && ( {step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<span style={labelStyle}>Team template *</span>
<p style={hintStyle}>
Pick a canonical roster. On mission launch the team is
materialized roles, prompts, MCP bundles, skills, brain
seeds. Required: a mission with no team has no agents to
run it. If none of these fit, extend the templates catalog.
</p>
{teamTemplates.length === 0 && ( {teamTemplates.length === 0 && (
<div <div
style={{ style={{
@@ -339,69 +350,54 @@ export function MissionWizard({
say `team_template_loader: upserted builtin ...`. say `team_template_loader: upserted builtin ...`.
</div> </div>
)} )}
<div style={{ display: "grid", gap: 8 }}> {hasResearchPhase && (
{teamTemplates.map((t) => { <TeamMultiSelect
const active = teamTemplateId === t.id; label="Research teams *"
return ( hint="Teams that run the research phase — investigate, gather sources, write the brief. Pick one or more."
<button templates={teamTemplates}
key={t.id} selected={researchTeamIds}
type="button" onToggle={(id) =>
onClick={() => setTeamTemplateId(t.id)} setResearchTeamIds((prev) => {
style={templateCardStyle(active)} const next = new Set(prev);
> if (next.has(id)) next.delete(id);
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> else next.add(id);
<span style={{ fontWeight: 700, color: "#f3f3f5", fontSize: 13.5 }}> return next;
{t.name} })
</span> }
{t.source === "builtin" && ( />
<span )}
style={{ {hasCodingPhase && (
fontFamily: mono, <TeamMultiSelect
fontSize: 9.5, label="Development teams *"
color: "#7cd6e0", hint="Teams that run the coding phase — implement, review, commit. Pick one or more (e.g. backend + frontend for a full-stack change)."
letterSpacing: ".1em", templates={teamTemplates}
}} selected={devTeamIds}
> onToggle={(id) =>
BUILTIN setDevTeamIds((prev) => {
</span> const next = new Set(prev);
)} if (next.has(id)) next.delete(id);
<span else next.add(id);
style={{ return next;
marginLeft: "auto", })
fontFamily: mono, }
fontSize: 10, />
color: "#8a8a92", )}
}} {!hasResearchPhase && !hasCodingPhase && (
> <TeamMultiSelect
{t.default_topology} · {t.risk_profile} label="Teams *"
</span> hint="Teams that run this mission's phases. Pick one or more."
</div> templates={teamTemplates}
{t.description && ( selected={devTeamIds}
<span style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}> onToggle={(id) =>
{t.description} setDevTeamIds((prev) => {
</span> const next = new Set(prev);
)} if (next.has(id)) next.delete(id);
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}> else next.add(id);
{t.stack.map((s) => ( return next;
<span })
key={s} }
style={{ />
fontFamily: mono, )}
fontSize: 10,
padding: "2px 7px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,.1)",
color: "#cfcfd5",
}}
>
{s}
</span>
))}
</div>
</button>
);
})}
</div>
</div> </div>
)} )}
@@ -508,15 +504,27 @@ export function MissionWizard({
<ReviewRow k="Title" v={title} /> <ReviewRow k="Title" v={title} />
{description && <ReviewRow k="Description" v={description} />} {description && <ReviewRow k="Description" v={description} />}
{repo && <ReviewRow k="Repo" v={`${repo.owner}/${repo.name}`} />} {repo && <ReviewRow k="Repo" v={`${repo.owner}/${repo.name}`} />}
<ReviewRow {hasResearchPhase && researchTeamIds.size > 0 && (
k="Team" <ReviewRow
v={ k="Research teams"
teamTemplateId v={Array.from(researchTeamIds)
? (teamTemplates.find((t) => t.id === teamTemplateId)?.name ?? .map(
teamTemplateId) (id) => teamTemplates.find((t) => t.id === id)?.name ?? id,
: "auto-provision from prompt" )
} .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 <ReviewRow
k="Runtime" k="Runtime"
v={ v={
@@ -580,6 +588,108 @@ export function MissionWizard({
); );
} }
function TeamMultiSelect({
label,
hint,
templates,
selected,
onToggle,
}: {
label: string;
hint: string;
templates: TeamTemplate[];
selected: Set<string>;
onToggle: (id: string) => void;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={labelStyle}>{label}</span>
<p style={hintStyle}>{hint}</p>
<div style={{ display: "grid", gap: 8 }}>
{templates.map((t) => {
const active = selected.has(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => onToggle(t.id)}
style={templateCardStyle(active)}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
aria-hidden
style={{
width: 13,
height: 13,
borderRadius: 3,
border: `1px solid ${active ? "#ff8a7a" : "rgba(255,255,255,.25)"}`,
background: active ? "#ff8a7a" : "transparent",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "#101013",
fontSize: 10,
flex: "none",
}}
>
{active ? "✓" : ""}
</span>
<span style={{ fontWeight: 700, color: "#f3f3f5", fontSize: 13.5 }}>
{t.name}
</span>
{t.source === "builtin" && (
<span
style={{
fontFamily: mono,
fontSize: 9.5,
color: "#7cd6e0",
letterSpacing: ".1em",
}}
>
BUILTIN
</span>
)}
<span
style={{
marginLeft: "auto",
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
}}
>
{t.default_topology} · {t.risk_profile}
</span>
</div>
{t.description && (
<span style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
{t.description}
</span>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{t.stack.map((s) => (
<span
key={s}
style={{
fontFamily: mono,
fontSize: 10,
padding: "2px 7px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,.1)",
color: "#cfcfd5",
}}
>
{s}
</span>
))}
</div>
</button>
);
})}
</div>
</div>
);
}
function ReviewRow({ k, v }: { k: string; v: string }) { function ReviewRow({ k, v }: { k: string; v: string }) {
return ( return (
<div <div
+22
View File
@@ -0,0 +1,22 @@
-- Multi-team missions: a mission can have N teams, each tagged with a
-- purpose (research / coding / security / benchmark / etc). The wizard
-- picks templates per purpose; on_launch mints one team per (purpose,
-- template) pick and records the link here.
--
-- missions.team_id stays around as a legacy pointer to the FIRST minted
-- team for single-team surfaces (Team tab default view, etc). It is
-- redundant with (mission_teams WHERE mission_id = X LIMIT 1) but
-- preserving it avoids a broader refactor in this slice.
BEGIN;
CREATE TABLE mission_teams (
mission_id UUID NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
team_id UUID PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX mission_teams_mission_idx ON mission_teams (mission_id);
COMMIT;