missions: multi-team model — pick research + development teams
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:
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
let team_id = mint_team_from_template(
|
||||||
pool,
|
pool,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
user_id,
|
user_id,
|
||||||
provisioner.as_ref(),
|
provisioner.as_ref(),
|
||||||
&template,
|
&template,
|
||||||
&mission.title,
|
&team_name,
|
||||||
"claude-sonnet-5",
|
"claude-sonnet-5",
|
||||||
)
|
)
|
||||||
.await?;
|
.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");
|
||||||
|
|
||||||
// Bind the team onto the mission.
|
// Bind the first team onto the mission for legacy single-team paths.
|
||||||
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,
|
||||||
|
|||||||
@@ -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,29 +84,30 @@ 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 }}>
|
||||||
|
{Object.entries(grouped).map(([purpose, purposeRows]) => (
|
||||||
|
<div key={purpose} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
@@ -95,18 +115,45 @@ export function MissionTeamTab({
|
|||||||
letterSpacing: ".14em",
|
letterSpacing: ".14em",
|
||||||
color: "#7cd6e0",
|
color: "#7cd6e0",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{team.name ?? "Team"} · {team.members.length} member
|
{purpose} · {purposeRows.length} team{purposeRows.length === 1 ? "" : "s"}
|
||||||
{team.members.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 ?? [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.team_id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid rgba(255,255,255,.07)",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "#101014",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13, color: "#f3f3f5", fontWeight: 600 }}>
|
||||||
|
{r.team_name}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: "auto", fontFamily: mono, fontSize: 10, color: "#8a8a92" }}>
|
||||||
|
{members.length} member{members.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div style={{ padding: 12, color: "#8a8a92", fontSize: 12 }}>
|
||||||
|
Materializing…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
team.members.map((m) => {
|
members.map((m) => {
|
||||||
const claw = claws[m.claw_id];
|
const claw = claws[m.claw_id];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -115,17 +162,15 @@ export function MissionTeamTab({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 10,
|
||||||
padding: "11px 12px",
|
padding: "9px 12px",
|
||||||
borderRadius: 10,
|
borderTop: "1px solid rgba(255,255,255,.04)",
|
||||||
border: "1px solid rgba(255,255,255,.07)",
|
|
||||||
background: "#101014",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
width: 30,
|
width: 24,
|
||||||
height: 30,
|
height: 24,
|
||||||
borderRadius: 8,
|
borderRadius: 6,
|
||||||
background: "rgba(255,111,97,.1)",
|
background: "rgba(255,111,97,.1)",
|
||||||
border: "1px solid rgba(255,111,97,.25)",
|
border: "1px solid rgba(255,111,97,.25)",
|
||||||
color: "#ff8a7a",
|
color: "#ff8a7a",
|
||||||
@@ -135,14 +180,13 @@ export function MissionTeamTab({
|
|||||||
flex: "none",
|
flex: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<User size={14} />
|
<User size={12} />
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 12.5,
|
||||||
color: "#f3f3f5",
|
color: "#f3f3f5",
|
||||||
fontWeight: 500,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
@@ -153,11 +197,10 @@ export function MissionTeamTab({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
fontSize: 10.5,
|
fontSize: 10,
|
||||||
letterSpacing: ".08em",
|
letterSpacing: ".08em",
|
||||||
color: "#8a8a92",
|
color: "#8a8a92",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
marginTop: 2,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{m.role}
|
{m.role}
|
||||||
@@ -169,7 +212,7 @@ export function MissionTeamTab({
|
|||||||
onClick={() => onOpenClaw(m.claw_id)}
|
onClick={() => onOpenClaw(m.claw_id)}
|
||||||
title="Open this claw in the AGENT tier"
|
title="Open this claw in the AGENT tier"
|
||||||
style={{
|
style={{
|
||||||
padding: "5px 10px",
|
padding: "4px 9px",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: "1px solid rgba(94,200,216,.4)",
|
border: "1px solid rgba(94,200,216,.4)",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
@@ -182,7 +225,7 @@ export function MissionTeamTab({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
<ArrowUpRight size={11} />
|
<ArrowUpRight size={10} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -191,4 +234,9 @@ export function MissionTeamTab({
|
|||||||
)}
|
)}
|
||||||
</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={{
|
|
||||||
fontFamily: mono,
|
|
||||||
fontSize: 9.5,
|
|
||||||
color: "#7cd6e0",
|
|
||||||
letterSpacing: ".1em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
BUILTIN
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span
|
{hasCodingPhase && (
|
||||||
style={{
|
<TeamMultiSelect
|
||||||
marginLeft: "auto",
|
label="Development teams *"
|
||||||
fontFamily: mono,
|
hint="Teams that run the coding phase — implement, review, commit. Pick one or more (e.g. backend + frontend for a full-stack change)."
|
||||||
fontSize: 10,
|
templates={teamTemplates}
|
||||||
color: "#8a8a92",
|
selected={devTeamIds}
|
||||||
}}
|
onToggle={(id) =>
|
||||||
>
|
setDevTeamIds((prev) => {
|
||||||
{t.default_topology} · {t.risk_profile}
|
const next = new Set(prev);
|
||||||
</span>
|
if (next.has(id)) next.delete(id);
|
||||||
</div>
|
else next.add(id);
|
||||||
{t.description && (
|
return next;
|
||||||
<span style={{ fontSize: 12, color: "#a0a0a8", lineHeight: 1.5 }}>
|
})
|
||||||
{t.description}
|
}
|
||||||
</span>
|
/>
|
||||||
|
)}
|
||||||
|
{!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 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>
|
</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}`} />}
|
||||||
|
{hasResearchPhase && researchTeamIds.size > 0 && (
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
k="Team"
|
k="Research teams"
|
||||||
v={
|
v={Array.from(researchTeamIds)
|
||||||
teamTemplateId
|
.map(
|
||||||
? (teamTemplates.find((t) => t.id === teamTemplateId)?.name ??
|
(id) => teamTemplates.find((t) => t.id === id)?.name ?? id,
|
||||||
teamTemplateId)
|
)
|
||||||
: "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
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user