Adds a Quota.max_active_runs ceiling (queued + running topology runs at once, per workspace) to stop a single workspace flooding the shared queue. Free tier: 10, Pro: 25, Team: 100. Enforced at every /run enqueue site: run_org, run_company, run_team, and the webhook trigger. Webhooks return 429 rather than 402 so external callers can back off — the guard is what stops a leaked webhook token from being weaponized into a queue flood. A single team run also spawns a tier-tree of children, so the practical cap grows with the topology — this counts the outer runs, not every step.
231 lines
6.7 KiB
Rust
231 lines
6.7 KiB
Rust
//! 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<OrgMemberInput>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct OrgCreated {
|
|
pub org_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/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<AppState>,
|
|
Authed(user): Authed,
|
|
Json(body): Json<CreateOrgRequest>,
|
|
) -> Result<(StatusCode, Json<OrgCreated>), 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<AppState>,
|
|
Authed(user): Authed,
|
|
) -> Result<Json<Vec<OrgSummaryOut>>, 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<OrgCompanyOut>,
|
|
}
|
|
|
|
/// `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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<axum::http::StatusCode, ApiError> {
|
|
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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<OrgDetail>, 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<AppState>,
|
|
Authed(user): Authed,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<RunOrgRequest>,
|
|
) -> Result<(StatusCode, Json<RunAccepted>), 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(),
|
|
}),
|
|
))
|
|
}
|