TeamWizard already ran ensure-chain before /api/teams; the two "AddTo"
modals didn't, so teams/companies created from them landed as
structural orphans (no company/org parent). Same treatment now applied
to both modals + their backend endpoints.
Backend — two symmetric `attach_to_*_id` fields (mirrors what
create_team already exposes):
- ComposeTeamRequest gains `attach_to_company_id`. After team insert,
create_team_from_claws binds it via companies::add_team with a fresh
`n{count}` node id.
- CreateCompanyRequest gains `attach_to_org_id`. After company insert,
create_company binds it via orgs::add_company the same way.
Both bindings are optional — plain POSTs from tools/tests still work.
Ownership is re-checked via `<parent>::get(pool, id, workspace_id)` so
the endpoints can't be tricked into parenting into another workspace.
Frontend — both modals now:
1. POST /api/structure/ensure-chain (empty body → server picks
"My Workspace" / "General" fallbacks when nothing exists yet).
2. Include the returned parent id in the create request.
AddToOrgModal untouched — orgs are top-level, no parent needed.
MasterPlannerModal untouched — it posts to /webhooks, doesn't create
structural rows.
Follow-up already queued in the original list: same treatment for the
Company/Org "wizard"-flavored surfaces (as opposed to the compose
modals). Currently those don't exist as distinct wizards.
326 lines
10 KiB
Rust
326 lines
10 KiB
Rust
//! 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<CompanyMemberInput>,
|
|
/// 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<Uuid>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct CompanyCreated {
|
|
pub company_id: String,
|
|
}
|
|
|
|
fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
|
|
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<AppState>,
|
|
Authed(user): Authed,
|
|
Json(body): Json<CreateCompanyRequest>,
|
|
) -> Result<(StatusCode, Json<CompanyCreated>), 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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<PatchCompanyRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
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<String, (Uuid, String)> = bindings
|
|
.into_iter()
|
|
.map(|b| (b.node_id, (b.team_id, b.role)))
|
|
.collect();
|
|
let n = by_node.len();
|
|
let roles: Vec<String> = (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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<RenameCompanyRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
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<AppState>,
|
|
Authed(user): Authed,
|
|
) -> Result<Json<Vec<CompanySummaryOut>>, 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<CompanyTeamOut>,
|
|
}
|
|
|
|
/// `GET /api/companies/{id}` — a company's graph + node→team bindings.
|
|
pub async fn get_company(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<CompanyDetail>, 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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<RunCompanyRequest>,
|
|
) -> Result<(StatusCode, Json<RunAccepted>), 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(),
|
|
}),
|
|
))
|
|
}
|