//! 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_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent}; 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, /// Whether this member needs write access (file edits, git, shell) rather /// than read-only research tools. /// /// `None` falls back to guessing from the role name, which is what we used /// to do unconditionally — see `resolve_risk_profile`. #[serde(default)] pub needs_write: Option, } #[derive(Deserialize)] pub struct CreateTeamRequest { pub name: String, /// TopologyKind (snake_case), e.g. "hierarchical", "pipeline". pub kind: String, pub members: Vec, /// Optional parent company — when provided, the freshly-created team /// gets bound to it via `company_teams` so it never lands orphaned. /// Wizards typically fetch this from `POST /api/structure/ensure-chain` /// so the workspace always has a valid parent before team creation. #[serde(default)] pub attach_to_company_id: Option, } #[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> { build_team_with_lifecycle( state, workspace_id, user_id, name, kind_str, members, "permanent", ) .await } /// Same as `build_team` but with an explicit `lifecycle` (`permanent` | /// `ephemeral`). Ephemeral teams are torn down by the topology_worker after /// their last run terminates — used by the Scheduled + Triggered planner modes. pub(crate) async fn build_team_with_lifecycle( state: &AppState, workspace_id: cm_domain::WorkspaceId, user_id: cm_domain::UserId, name: &str, kind_str: &str, members: &[TeamMemberInput], lifecycle: &str, ) -> 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 { // Parity with the individual `POST /api/claws` handler — each // claw counts against the workspace's agent quota + emits an // audit row. Without these the auto-provision + team-wizard // paths silently bypassed both governance rails. crate::quota::enforce_new_agent(state, workspace_id).await?; 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?; cm_db::repo::audit::append( &state.pool, workspace_id, cm_db::repo::audit::Actor::User(user_id), "agent.created", "agent", &agent.id.to_string(), serde_json::json!({ "name": agent.name, "job_title": agent.job_title, "source": "team_wizard", }), ) .await?; let claw_id = agent.id.as_uuid(); let risk = RuntimeProvisioner::resolve_risk_profile(&m.role, m.needs_write); // Ad-hoc team-wizard teams aren't mission-bound, so they use the // default per-agent workspace under /agents//workspace/. provisioner .provision_claw(claw_id, &m.model, risk) .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_with_lifecycle( &state.pool, team_id, workspace_id, name, kind.as_str(), &graph_json, lifecycle, ) .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?; // Auto-parent the new team when the wizard fetched a company via // `POST /api/structure/ensure-chain`. Ownership check + node_id // allocation happen inline so the team never lands orphaned mid-turn. if let Some(company_id) = body.attach_to_company_id { let company = cm_db::repo::companies::get(&state.pool, company_id, user.workspace_id) .await .map_err(|_| ApiError::NotFound)?; let existing = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?; let node_id = format!("n{}", existing.len()); cm_db::repo::companies::add_team(&state.pool, company.id, &node_id, team_id, "team") .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, /// Optional parent company id. When set, the new team is bound under /// this company via `companies::add_team` inside the same handler so /// the team never lands orphaned. Mirrors `create_team`'s field — /// wizards fetch this via `POST /api/structure/ensure-chain`. #[serde(default)] pub attach_to_company_id: Option, } /// `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?; } } // Same auto-parent path as create_team — if the caller preflighted // ensure-chain to get a company_id, bind the new team under it here. if let Some(company_id) = body.attach_to_company_id { let company = cm_db::repo::companies::get(&state.pool, company_id, user.workspace_id) .await .map_err(|_| ApiError::NotFound)?; let existing = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?; let node_id = format!("n{}", existing.len()); cm_db::repo::companies::add_team(&state.pool, company.id, &node_id, team_id, "team") .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, /// Per-team runtime posture (0045). `None` risk_profile ⇒ /// container inherits the template default at spawn time. #[serde(skip_serializing_if = "Option::is_none")] pub risk_profile: Option, #[serde(default)] pub mcp_bundles: Vec, } /// `PATCH /api/teams/{id}/runtime-config` — set the per-team /// risk_profile + mcp_bundles. Wizards call this after creating a team /// to bind it as either "read-heavy research" or "write-capable coding" /// without weakening the sibling team's posture. #[derive(Deserialize)] pub struct RuntimeConfigRequest { #[serde(default)] pub risk_profile: Option, #[serde(default)] pub mcp_bundles: Vec, } pub async fn set_runtime_config( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { // Workspace-scope check via the existing get_team. let _ = cm_db::repo::teams::get_team(&state.pool, id, user.workspace_id).await?; cm_db::repo::teams::set_team_runtime_config( &state.pool, id, user.workspace_id, &cm_db::repo::teams::TeamRuntimeConfig { risk_profile: body.risk_profile, mcp_bundles: body.mcp_bundles, }, ) .await?; Ok(StatusCode::NO_CONTENT) } /// `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?; let runtime = cm_db::repo::teams::get_team_runtime_config(&state.pool, id, user.workspace_id) .await .ok() .flatten() .unwrap_or_default(); 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(), risk_profile: runtime.risk_profile, mcp_bundles: runtime.mcp_bundles, })) } #[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) } /// `PATCH /api/teams/{id}/name` — inline rename from the sidebar. #[derive(Deserialize)] pub struct RenameTeamRequest { pub name: String, } pub async fn rename_team( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { let name = body.name.trim(); if name.is_empty() { return Err(ApiError::BadRequest); } cm_db::repo::teams::rename_team(&state.pool, id, user.workspace_id, name).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?; crate::quota::enforce_new_run(&state, user.workspace_id).await?; let run_id = Uuid::now_v7(); cm_db::repo::topology_runs::enqueue_run_for_team( &state.pool, run_id, user.workspace_id, &body.task, &team.graph, id, ) .await?; Ok(( StatusCode::ACCEPTED, Json(RunAccepted { run_id: run_id.to_string(), status: "queued".into(), }), )) } // ── auto-provision (0047 fold): LLM-derived team ───────────────────── #[derive(Deserialize)] pub struct AutoProvisionRequest { /// Topic title (short — used to name the team). pub title: String, /// Topic description (drives the LLM roster derivation). pub description: String, /// Outcome kind (spec / prod_plan / roadmap / paper / integrations) /// — steers the roster + topology. pub outcome_kind: String, /// Optional user hint for topology. When absent, derived from /// outcome_kind (integrations → pipeline; everything else → /// hub_spoke). Accepts the same strings as the wizard. #[serde(default)] pub topology_kind: Option, /// Optional preferred model for every provisioned agent. Falls back /// to `claude-sonnet-5` when omitted. Kept overridable so the same /// endpoint serves cost-conscious topics too. #[serde(default)] pub model: Option, /// Optional `["file_read","web_search",...]` risk-profile hint — /// when omitted the endpoint picks by outcome_kind (research → /// research_web_readonly; coding-adjacent → coding_readwrite). #[serde(default)] pub risk_profile: Option, /// MCP bundle aliases — same fall-back rule applies (always /// clawmates_door; gitea_forge when a repo is bound; deep-research /// skill for research profiles). #[serde(default)] pub mcp_bundles: Vec, } #[derive(Serialize)] pub struct AutoProvisionedAgent { pub agent_id: String, pub name: String, pub role_slot: String, pub model: String, } #[derive(Serialize)] pub struct AutoProvisionResponse { pub team_id: String, pub topology_kind: String, pub agents: Vec, /// Echo of the runtime-config applied to the team row (0045 fields). #[serde(skip_serializing_if = "Option::is_none")] pub risk_profile: Option, pub mcp_bundles: Vec, } const AUTOPROVISION_SYSTEM: &str = "You are a team-composition assistant. \ Given a research/coding topic prompt + outcome kind, produce a compact roster of \ 3 to 5 agents that could execute the pipeline end to end. For each agent output:\n\ - role_slot: short lowercase snake_case slot name (e.g. \"harvester\", \"tdd_implementer\"). \ Must be unique within the roster.\n\ - name: short human-friendly display name (1-2 words, ASCII, distinct per agent).\n\ - system_prompt: 2-4 sentence system prompt describing this agent's job in the \ topology. Should be actionable and reference the topic where relevant.\n\ Return a SINGLE JSON object with no code fences and no additional keys:\n\ {\"roles\": [ {\"role_slot\": \"...\", \"name\": \"...\", \"system_prompt\": \"...\"}, ... ]}\n\ Order matters: it dictates the pipeline / hub_spoke position. First role is the \ coordinator or first stage."; /// `POST /api/teams/auto-provision` — LLM-derive a roster then materialize /// a team + all claws + node→claw bindings, and stamp the runtime posture /// (risk_profile + mcp_bundles) so it's ready for wizard step-5 to bind. /// Returns the shape the wizard's `agents[]` submit expects, so the caller /// can flow straight into `POST /api/research` (or `/api/loops`) with /// `agents: response.agents` and no user-visible detour to the roster page. pub async fn auto_provision( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { if body.title.trim().is_empty() || body.description.trim().is_empty() { return Err(ApiError::BadRequest); } // Derive topology + defaults from outcome_kind if the caller didn't override. let outcome_kind = body.outcome_kind.trim().to_string(); let topology_kind = body.topology_kind.clone().unwrap_or_else(|| { if outcome_kind == "integrations" { "pipeline".to_string() } else { "hub_spoke".to_string() } }); let model = body .model .clone() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "claude-sonnet-5".to_string()); // Auto-pick runtime posture when the caller didn't. Research topics // default to the read-only + web-fetch profile so agents can fetch // papers; coding-adjacent kinds don't apply here (loops.wizard picks // them from a different path). let risk_profile = body.risk_profile.clone().or_else(|| { Some(match outcome_kind.as_str() { "integrations" | "paper" | "spec" | "prod_plan" | "roadmap" => { "research_web_readonly".to_string() } _ => "research_web_readonly".to_string(), }) }); let mut mcp_bundles = body.mcp_bundles.clone(); if mcp_bundles.is_empty() { mcp_bundles.push("clawmates_door".to_string()); // gitea_forge is scoped to teams that will touch repos; the // wizard's downstream repo-binding step is what earns it. // Always safe to add now — the MCP layer no-ops when the token // isn't present in the container env. mcp_bundles.push("gitea_forge".to_string()); } // 1) LLM plan pass → roster JSON. let user_message = format!( "Outcome kind: {outcome_kind}\nTopology: {topology_kind}\n\nTopic title: {}\n\nTopic description:\n{}", body.title.trim(), body.description.trim(), ); let request = ChatRequest { system: AUTOPROVISION_SYSTEM.into(), messages: vec![ChatMessage { role: ChatRole::User, parts: vec![ContentPart::Text { text: user_message }], }], tools: Vec::new(), model: state.runtime.model().to_string(), max_tokens: 2048, web_search: false, }; let provider = state.runtime.provider(); let mut stream = provider .stream(request) .await .map_err(|_| ApiError::Internal)?; let mut buf = String::new(); use futures::StreamExt; while let Some(event) = stream.next().await { match event.map_err(|_| ApiError::Internal)? { LlmEvent::TextDelta(delta) => buf.push_str(&delta), LlmEvent::Stop(_) => break, _ => {} } } #[derive(Deserialize)] struct DerivedRole { role_slot: String, name: String, system_prompt: String, } #[derive(Deserialize)] struct DerivedRoster { roles: Vec, } let roster: DerivedRoster = serde_json::from_str(buf.trim()).map_err(|_| ApiError::Internal)?; if roster.roles.is_empty() || roster.roles.len() > 8 { return Err(ApiError::Internal); } // 2) Materialize the team + all claws via the existing build_team pipeline. let members: Vec = roster .roles .iter() .map(|r| TeamMemberInput { role: r.role_slot.trim().to_string(), name: r.name.trim().to_string(), model: model.clone(), system_prompt: r.system_prompt.trim().to_string(), accent: String::new(), // The autoprovision roster schema doesn't declare access yet, so // this path keeps the role-name guess rather than silently // changing what it grants. needs_write: None, }) .collect(); let team_name = format!("Auto · {}", body.title.trim()); let (team_id, claw_ids) = build_team( &state, user.workspace_id, user.user_id, &team_name, &topology_kind, &members, ) .await?; // 3) Stamp the runtime posture so the container spawn slice (3b) has // the right risk_profile + bundles when it fires. if let Err(e) = cm_db::repo::teams::set_team_runtime_config( &state.pool, team_id, user.workspace_id, &cm_db::repo::teams::TeamRuntimeConfig { risk_profile: risk_profile.clone(), mcp_bundles: mcp_bundles.clone(), }, ) .await { eprintln!("auto_provision({team_id}): runtime-config write failed: {e:?}"); } // 4) Shape the response for the wizard: agent_id + role_slot in the // exact form the /api/research submit body expects. let agents: Vec = claw_ids .iter() .zip(members.iter()) .map(|(cid, m)| AutoProvisionedAgent { agent_id: cid.to_string(), name: m.name.clone(), role_slot: m.role.clone(), model: model.clone(), }) .collect(); Ok(( StatusCode::CREATED, Json(AutoProvisionResponse { team_id: team_id.to_string(), topology_kind, agents, risk_profile, mcp_bundles, }), )) }