//! Team endpoints — deploy a baseline topology staffed with real claws, then run //! it on the durable topology runner. The first rung of the deploy ladder. use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus}; use cm_topology::{build, TopologyKind}; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::format_description::well_known::Rfc3339; use uuid::Uuid; use crate::runtime_provision::{claw_alias, RuntimeProvisioner}; use crate::{ApiError, AppState, Authed}; /// One staffed role in the team (becomes a claw + a topology node). #[derive(Deserialize)] pub struct TeamMemberInput { pub role: String, pub name: String, /// Model selector: claude | glm | glm-5.2 | kimi | gemini | groq. #[serde(default)] pub model: String, #[serde(default)] pub system_prompt: String, #[serde(default)] pub accent: String, } #[derive(Deserialize)] pub struct CreateTeamRequest { pub name: String, /// TopologyKind (snake_case), e.g. "hierarchical", "pipeline". pub kind: String, pub members: Vec, } #[derive(Serialize)] pub struct TeamCreated { pub team_id: String, } fn parse_kind(s: &str) -> Result { serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest) } /// Create a team end-to-end: for each member create a claw + provision a runtime /// agent, build the baseline topology, bind node→claw, persist. Returns the team /// id + the created claw ids (in member order) so callers (e.g. the Master /// Planner scaffold) can attach brains afterward. pub(crate) async fn build_team( state: &AppState, workspace_id: cm_domain::WorkspaceId, user_id: cm_domain::UserId, name: &str, kind_str: &str, members: &[TeamMemberInput], ) -> Result<(Uuid, Vec), ApiError> { if members.is_empty() { return Err(ApiError::BadRequest); } let kind = parse_kind(kind_str)?; let provisioner = RuntimeProvisioner::from_env().ok_or(ApiError::Internal)?; let mut claw_ids: Vec = Vec::with_capacity(members.len()); for m in members { let agent = Agent { id: AgentId::new(), workspace_id, name: m.name.clone(), job_title: m.role.clone(), system_prompt: m.system_prompt.clone(), avatar: String::new(), accent: m.accent.clone(), wallpaper: String::new(), managed_by: user_id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?; let claw_id = agent.id.as_uuid(); provisioner.provision_claw(claw_id, &m.model).await.map_err(|e| { eprintln!("teams: provision claw {claw_id} failed: {e}"); ApiError::Internal })?; cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?; claw_ids.push(claw_id); } let roles: Vec<&str> = members.iter().map(|m| m.role.as_str()).collect(); let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?; for (i, node) in graph.nodes.iter_mut().enumerate() { if let Some(cid) = claw_ids.get(i) { node.attrs.insert("agent".into(), claw_alias(*cid)); node.attrs.insert("claw_id".into(), cid.to_string()); } } let team_id = Uuid::now_v7(); let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; cm_db::repo::teams::insert_team(&state.pool, team_id, workspace_id, name, kind.as_str(), &graph_json).await?; for (i, node) in graph.nodes.iter().enumerate() { if let Some(cid) = claw_ids.get(i) { cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role).await?; } } Ok((team_id, claw_ids)) } /// `POST /api/teams` — create a team from explicit members. pub async fn create_team( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let (team_id, _) = build_team( &state, user.workspace_id, user.user_id, &body.name, &body.kind, &body.members, ) .await?; Ok((StatusCode::CREATED, Json(TeamCreated { team_id: team_id.to_string() }))) } #[derive(Deserialize)] pub struct ComposeTeamRequest { pub name: String, /// TopologyKind (snake_case); defaults to `hub_spoke` when omitted. #[serde(default)] pub kind: String, /// Existing claws (agents) to group into the new team. pub claw_ids: Vec, } /// `POST /api/teams/from-claws` — create a team from EXISTING claws (no new /// provisioning): verify each claw is in the caller's workspace, build the /// baseline topology over their roles, bind node→claw, persist. pub async fn create_team_from_claws( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { if body.claw_ids.is_empty() { return Err(ApiError::BadRequest); } let kind = parse_kind(if body.kind.is_empty() { "hub_spoke" } else { &body.kind })?; // Resolve + authorize each claw, collecting its role for the topology. let mut roles: Vec = Vec::with_capacity(body.claw_ids.len()); for cid in &body.claw_ids { let agent = crate::routes::claws::workspace_agent(&state, &user, AgentId::from(*cid)).await?; roles.push(if agent.job_title.is_empty() { "claw".into() } else { agent.job_title }); } let role_refs: Vec<&str> = roles.iter().map(|s| s.as_str()).collect(); let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?; for (i, node) in graph.nodes.iter_mut().enumerate() { if let Some(cid) = body.claw_ids.get(i) { node.attrs.insert("agent".into(), claw_alias(*cid)); node.attrs.insert("claw_id".into(), cid.to_string()); } } let team_id = Uuid::now_v7(); let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; cm_db::repo::teams::insert_team( &state.pool, team_id, user.workspace_id, &body.name, kind.as_str(), &graph_json, ) .await?; for (i, node) in graph.nodes.iter().enumerate() { if let Some(cid) = body.claw_ids.get(i) { cm_db::repo::teams::add_member(&state.pool, team_id, &node.id, *cid, &node.role) .await?; } } Ok(( StatusCode::CREATED, Json(TeamCreated { team_id: team_id.to_string(), }), )) } #[derive(Serialize)] pub struct TeamSummaryOut { pub id: String, pub name: String, pub kind: String, pub status: String, pub created_at: String, } /// `GET /api/teams` — recent teams for the workspace. pub async fn list_teams( State(state): State, Authed(user): Authed, ) -> Result>, ApiError> { let rows = cm_db::repo::teams::list_for_workspace(&state.pool, user.workspace_id, 50).await?; Ok(Json( rows.into_iter() .map(|t| TeamSummaryOut { id: t.id.to_string(), name: t.name, kind: t.kind, status: t.status, created_at: t.created_at.format(&Rfc3339).unwrap_or_default(), }) .collect(), )) } #[derive(Serialize)] pub struct TeamMemberOut { pub node_id: String, pub claw_id: String, pub role: String, } #[derive(Serialize)] pub struct TeamDetail { pub id: String, pub name: String, pub kind: String, pub status: String, pub created_at: String, pub graph: Value, pub members: Vec, } /// `GET /api/teams/{id}` — a team's graph + node→claw bindings. pub async fn get_team( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?; let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?; Ok(Json(TeamDetail { id: team.id.to_string(), name: team.name, kind: team.kind, status: team.status, created_at: team.created_at.format(&Rfc3339).unwrap_or_default(), graph: team.graph, members: members .into_iter() .map(|m| TeamMemberOut { node_id: m.node_id, claw_id: m.claw_id.to_string(), role: m.role, }) .collect(), })) } #[derive(Deserialize)] pub struct PatchTeamRequest { /// New TopologyKind (snake_case), e.g. "hierarchical", "hub_spoke". pub kind: String, } /// `PATCH /api/teams/{id}` — change a team's topology: rebuild the graph over the /// existing members' roles (stable node ids keep the node→claw bindings valid) /// and persist the new kind + graph. pub async fn patch_team( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?; let kind = parse_kind(&body.kind)?; let members = cm_db::repo::teams::members_for_team(&state.pool, team.id).await?; let by_node: std::collections::HashMap = members .into_iter() .map(|m| (m.node_id, (m.claw_id, m.role))) .collect(); let n = by_node.len(); let roles: Vec = (0..n) .map(|i| by_node.get(&format!("n{i}")).map(|(_, r)| r.clone()).unwrap_or_else(|| "claw".into())) .collect(); let role_refs: Vec<&str> = roles.iter().map(String::as_str).collect(); let mut graph = build(kind, &role_refs).map_err(|_| ApiError::BadRequest)?; for node in graph.nodes.iter_mut() { if let Some((cid, _)) = by_node.get(&node.id) { node.attrs.insert("agent".into(), claw_alias(*cid)); node.attrs.insert("claw_id".into(), cid.to_string()); } } let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; cm_db::repo::teams::set_topology(&state.pool, team.id, user.workspace_id, kind.as_str(), &graph_json).await?; Ok(StatusCode::NO_CONTENT) } /// `DELETE /api/teams/{id}` — remove a team and its node→claw bindings (the claws /// themselves remain in the workspace). pub async fn delete_team( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result { cm_db::repo::teams::delete_team(&state.pool, id, user.workspace_id).await?; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] pub struct RunTeamRequest { pub task: String, } #[derive(Serialize)] pub struct RunAccepted { pub run_id: String, pub status: String, } /// `POST /api/teams/{id}/run` — enqueue a durable run of the team's topology /// (drives the bound claws). Poll/stream via `/api/topology-runs/{run_id}`. pub async fn run_team( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let team = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?; let run_id = Uuid::now_v7(); cm_db::repo::topology_runs::enqueue_run( &state.pool, run_id, user.workspace_id, &body.task, &team.graph, ) .await?; Ok(( StatusCode::ACCEPTED, Json(RunAccepted { run_id: run_id.to_string(), status: "queued".into(), }), )) }