//! Org endpoints — deploy a baseline topology whose nodes are real *companies*, //! then run it on the durable recursive runner. The top rung of the deploy //! ladder (single → team → company → org). Creating an org composes companies //! that already own their teams (which own their 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 company in the org (becomes a topology node). #[derive(Deserialize)] pub struct OrgMemberInput { pub company_id: Uuid, #[serde(default)] pub role: String, } #[derive(Deserialize)] pub struct CreateOrgRequest { pub name: String, /// TopologyKind (snake_case), e.g. "hierarchical", "pipeline". pub kind: String, pub members: Vec, } #[derive(Serialize)] pub struct OrgCreated { pub org_id: String, } fn parse_kind(s: &str) -> Result { serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest) } /// `POST /api/orgs` — build the baseline topology over the chosen companies, /// bind each node to its company, and persist. Validates ownership first. pub async fn create_org( 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 company is real + in this workspace. for m in &body.members { cm_db::repo::companies::get(&state.pool, m.company_id, user.workspace_id).await?; } // 2. Build the topology and bind each node to its company (attrs["company_id"] // + attrs["agent"], so the orchestrator forwards it through TurnRequest). let roles: Vec<&str> = body .members .iter() .map(|m| { if m.role.is_empty() { "company" } 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("company_id".into(), m.company_id.to_string()); node.attrs.insert("agent".into(), m.company_id.to_string()); } } // 3. Persist org + node→company bindings. let org_id = Uuid::now_v7(); let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?; cm_db::repo::orgs::insert_org( &state.pool, org_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::orgs::add_company(&state.pool, org_id, &node.id, m.company_id, &node.role) .await?; } } Ok(( StatusCode::CREATED, Json(OrgCreated { org_id: org_id.to_string(), }), )) } #[derive(Serialize)] pub struct OrgSummaryOut { pub id: String, pub name: String, pub kind: String, pub status: String, pub created_at: String, } /// `GET /api/orgs` — recent orgs for the workspace. pub async fn list_orgs( State(state): State, Authed(user): Authed, ) -> Result>, ApiError> { let rows = cm_db::repo::orgs::list_for_workspace(&state.pool, user.workspace_id, 50).await?; Ok(Json( rows.into_iter() .map(|o| OrgSummaryOut { id: o.id.to_string(), name: o.name, kind: o.kind, status: o.status, created_at: o.created_at.format(&Rfc3339).unwrap_or_default(), }) .collect(), )) } #[derive(Serialize)] pub struct OrgCompanyOut { pub node_id: String, pub company_id: String, pub role: String, } #[derive(Serialize)] pub struct OrgDetail { pub id: String, pub name: String, pub kind: String, pub status: String, pub created_at: String, pub graph: Value, pub members: Vec, } /// `GET /api/orgs/{id}` — an org's graph + node→company bindings. /// `DELETE /api/orgs/{id}` — remove the org (structural: companies survive, just /// ungrouped from it). pub async fn delete_org( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result { cm_db::repo::orgs::delete_org(&state.pool, id, user.workspace_id).await?; Ok(axum::http::StatusCode::NO_CONTENT) } pub async fn get_org( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let org = cm_db::repo::orgs::get(&state.pool, id, user.workspace_id).await?; let members = cm_db::repo::orgs::companies_for_org(&state.pool, id).await?; Ok(Json(OrgDetail { id: org.id.to_string(), name: org.name, kind: org.kind, status: org.status, created_at: org.created_at.format(&Rfc3339).unwrap_or_default(), graph: org.graph, members: members .into_iter() .map(|m| OrgCompanyOut { node_id: m.node_id, company_id: m.company_id.to_string(), role: m.role, }) .collect(), })) } #[derive(Deserialize)] pub struct RunOrgRequest { pub task: String, } #[derive(Serialize)] pub struct RunAccepted { pub run_id: String, pub status: String, } /// `POST /api/orgs/{id}/run` — enqueue a durable `org`-tier run; the worker /// drives the recursive executor (each node runs its company's topology, which /// runs its teams, which run their claws). pub async fn run_org( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let org = cm_db::repo::orgs::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, &org.graph, "org", ) .await?; Ok(( StatusCode::ACCEPTED, Json(RunAccepted { run_id: run_id.to_string(), status: "queued".into(), }), )) }