//! Company endpoints — deploy a baseline topology staffed with real *teams*, //! then run it on the durable recursive runner. The 3rd rung of the deploy //! ladder (single → team → company → org). Unlike teams, creating a company //! provisions nothing new: it composes teams that already own provisioned claws. use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; 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::{ApiError, AppState, Authed}; /// One bound team in the company (becomes a topology node). #[derive(Deserialize)] pub struct CompanyMemberInput { pub team_id: Uuid, #[serde(default)] pub role: String, } #[derive(Deserialize)] pub struct CreateCompanyRequest { pub name: String, /// TopologyKind (snake_case), e.g. "hierarchical", "pipeline". pub kind: String, pub members: Vec, /// Optional parent org id. When set, the new company is bound under /// this org via `orgs::add_company` inside the same handler so the /// company never lands orphaned. Wizards fetch this via /// `POST /api/structure/ensure-chain`. #[serde(default)] pub attach_to_org_id: Option, } #[derive(Serialize)] pub struct CompanyCreated { pub company_id: String, } fn parse_kind(s: &str) -> Result { serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest) } /// `POST /api/companies` — build the baseline topology over the chosen teams, /// bind each node to its team, and persist. Validates every team belongs to the /// workspace first (so a company can only compose teams the caller owns). pub async fn create_company( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { if body.members.is_empty() { return Err(ApiError::BadRequest); } let kind = parse_kind(&body.kind)?; // 1. Validate each bound team is real + in this workspace. for m in &body.members { cm_db::repo::teams::get_team(&state.pool, m.team_id, user.workspace_id).await?; } // 2. Build the topology and bind each node to its team. We write the team id // into BOTH attrs["team_id"] (descriptive) and attrs["agent"] (so the // orchestrator forwards it through TurnRequest exactly like a claw alias). let roles: Vec<&str> = body .members .iter() .map(|m| { if m.role.is_empty() { "team" } else { 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(m) = body.members.get(i) { node.attrs.insert("team_id".into(), m.team_id.to_string()); node.attrs.insert("agent".into(), m.team_id.to_string()); } } // 3. Persist company + node→team bindings. let company_id = Uuid::now_v7(); let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; cm_db::repo::companies::insert_company( &state.pool, company_id, user.workspace_id, &body.name, kind.as_str(), &graph_json, ) .await?; for (i, node) in graph.nodes.iter().enumerate() { if let Some(m) = body.members.get(i) { cm_db::repo::companies::add_team( &state.pool, company_id, &node.id, m.team_id, &node.role, ) .await?; } } // Auto-parent under the caller's ensured org, mirroring create_team's // attach_to_company_id pattern. Ownership-checked; skipped when the // caller didn't run ensure-chain. if let Some(org_id) = body.attach_to_org_id { let org = cm_db::repo::orgs::get(&state.pool, org_id, user.workspace_id) .await .map_err(|_| ApiError::NotFound)?; let existing = cm_db::repo::orgs::companies_for_org(&state.pool, org.id).await?; let node_id = format!("n{}", existing.len()); cm_db::repo::orgs::add_company(&state.pool, org.id, &node_id, company_id, "company") .await?; } Ok(( StatusCode::CREATED, Json(CompanyCreated { company_id: company_id.to_string(), }), )) } #[derive(Deserialize)] pub struct PatchCompanyRequest { /// New TopologyKind (snake_case). pub kind: String, } /// `PATCH /api/companies/{id}` — change a company's topology: rebuild the graph /// over its bound teams (stable node ids keep the node→team bindings) + persist. pub async fn patch_company( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?; let kind = parse_kind(&body.kind)?; let bindings = cm_db::repo::companies::teams_for_company(&state.pool, company.id).await?; let by_node: std::collections::HashMap = bindings .into_iter() .map(|b| (b.node_id, (b.team_id, b.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(|| "team".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((tid, _)) = by_node.get(&node.id) { node.attrs.insert("team_id".into(), tid.to_string()); node.attrs.insert("agent".into(), tid.to_string()); } } let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; cm_db::repo::companies::set_topology( &state.pool, company.id, user.workspace_id, kind.as_str(), &graph_json, ) .await?; Ok(StatusCode::NO_CONTENT) } /// `PATCH /api/companies/{id}/name` — inline rename from the sidebar. #[derive(Deserialize)] pub struct RenameCompanyRequest { pub name: String, } pub async fn rename_company( 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::companies::rename_company(&state.pool, id, user.workspace_id, name).await?; Ok(StatusCode::NO_CONTENT) } /// `DELETE /api/companies/{id}` — remove a company (its teams remain). pub async fn delete_company( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result { cm_db::repo::companies::delete_company(&state.pool, id, user.workspace_id).await?; Ok(StatusCode::NO_CONTENT) } #[derive(Serialize)] pub struct CompanySummaryOut { pub id: String, pub name: String, pub kind: String, pub status: String, pub created_at: String, } /// `GET /api/companies` — recent companies for the workspace. pub async fn list_companies( State(state): State, Authed(user): Authed, ) -> Result>, ApiError> { let rows = cm_db::repo::companies::list_for_workspace(&state.pool, user.workspace_id, 50).await?; Ok(Json( rows.into_iter() .map(|c| CompanySummaryOut { id: c.id.to_string(), name: c.name, kind: c.kind, status: c.status, created_at: c.created_at.format(&Rfc3339).unwrap_or_default(), }) .collect(), )) } #[derive(Serialize)] pub struct CompanyTeamOut { pub node_id: String, pub team_id: String, pub role: String, } #[derive(Serialize)] pub struct CompanyDetail { pub id: String, pub name: String, pub kind: String, pub status: String, pub created_at: String, pub graph: Value, pub members: Vec, } /// `GET /api/companies/{id}` — a company's graph + node→team bindings. pub async fn get_company( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?; let members = cm_db::repo::companies::teams_for_company(&state.pool, id).await?; Ok(Json(CompanyDetail { id: company.id.to_string(), name: company.name, kind: company.kind, status: company.status, created_at: company.created_at.format(&Rfc3339).unwrap_or_default(), graph: company.graph, members: members .into_iter() .map(|m| CompanyTeamOut { node_id: m.node_id, team_id: m.team_id.to_string(), role: m.role, }) .collect(), })) } #[derive(Deserialize)] pub struct RunCompanyRequest { pub task: String, } #[derive(Serialize)] pub struct RunAccepted { pub run_id: String, pub status: String, } /// `POST /api/companies/{id}/run` — enqueue a durable `company`-tier run; the /// worker drives the recursive executor (each node runs its team's topology). pub async fn run_company( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let company = cm_db::repo::companies::get(&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_tier( &state.pool, run_id, user.workspace_id, &body.task, &company.graph, "company", ) .await?; Ok(( StatusCode::ACCEPTED, Json(RunAccepted { run_id: run_id.to_string(), status: "queued".into(), }), )) }