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:
@@ -50,37 +50,88 @@ pub async fn on_launch(
|
||||
if mission.team_id.is_some() {
|
||||
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:
|
||||
// there are no agents to execute phases. The wizard requires
|
||||
// a template pick; this branch guards against direct API
|
||||
// callers or legacy rows.
|
||||
|
||||
// New multi-team model: config.phase_teams = {
|
||||
// "research": ["template-uuid", ...],
|
||||
// "coding": ["template-uuid", ...]
|
||||
// }
|
||||
// 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(
|
||||
"mission has no team_id and no team_template_id — pick a template in the wizard \
|
||||
before launching, or attach an existing team via the API"
|
||||
"mission has no team_template_id and no config.phase_teams — pick teams in the wizard"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let template = cm_db::repo::team_templates::get(pool, template_id)
|
||||
.await
|
||||
.map_err(|e| format!("load template: {e}"))?
|
||||
.ok_or_else(|| format!("template {template_id} not found"))?;
|
||||
if picks.is_empty() {
|
||||
return Err(
|
||||
"mission's config.phase_teams is empty — pick at least one team in the wizard".into(),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
pool,
|
||||
workspace_id,
|
||||
user_id,
|
||||
provisioner.as_ref(),
|
||||
&template,
|
||||
&mission.title,
|
||||
"claude-sonnet-5",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 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")
|
||||
.bind(team_id)
|
||||
.bind(mission_id)
|
||||
@@ -113,23 +164,15 @@ pub async fn on_launch(
|
||||
if mission.runtime_kind == "local_herdr" {
|
||||
if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) {
|
||||
let prompt = mission.description.clone().unwrap_or_default();
|
||||
// CLI selection precedence:
|
||||
// mission.config.cli → template.config.default_cli → "claude"
|
||||
// Templates encode which agent CLI fits their stack; missions can
|
||||
// override per-run for A/B (kimi on morpheus vs claude on tank).
|
||||
// CLI selection: mission.config.cli overrides; else default.
|
||||
// (Per-template default_cli fallback was in the single-team
|
||||
// path; the multi-team path doesn't have one canonical
|
||||
// template to consult, so we keep the mission-level knob.)
|
||||
let cli = mission
|
||||
.config
|
||||
.get("cli")
|
||||
.and_then(|v| v.as_str())
|
||||
.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());
|
||||
match crate::fleet_herdr::dispatch(
|
||||
hub,
|
||||
|
||||
Reference in New Issue
Block a user