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:
@@ -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,
|
||||
/// newest first. Used by the Live tab to subscribe to per-run SSE.
|
||||
pub async fn list_runs(
|
||||
|
||||
Reference in New Issue
Block a user