Files
clawmates/crates/cm-api/src/routes/orgs.rs
T
Omar Sobh 99e5207e69
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 25s
ci / rust (push) Successful in 3m51s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m43s
sidebar: click-to-rename org/company/team + strip synthetics from world viz
Two related pieces of the "kill My Workspace" cleanup, landed
together because they share the same file:

Backend

- Three tiny inline-rename endpoints:
    PATCH /api/orgs/{id}/name
    PATCH /api/companies/{id}/name
    PATCH /api/teams/{id}/name
  Each takes { name: string }, trims + rejects empty, returns 204.
  Backed by rename_org / rename_company / rename_team in cm-db —
  single-row UPDATEs scoped to the caller's workspace, NotFound if
  the id isn't visible.
- Registered next to the existing PATCH /:id (topology) routes so
  they don't collide.

Frontend

- StructureTree accepts an optional onRename and canRename.
  TreeRow: click on the label text of a renamable node → the span
  becomes an <input>, focus + select-all, save on Enter or blur,
  cancel on Escape. The rest of the row (row chevron / row body)
  still navigates + selects as before, so single-click behaviour
  is preserved for everything except the name text itself.
  react-hooks/set-state-in-effect avoided by resetting the draft
  in the enterEdit() click handler instead of inside a useEffect.

- Dashboard passes canRename={item.level !== "claw" && !synthetic}
  (claws don't have a rename endpoint yet; synthetic scaffolding
  gets reified into real rows in the next commit — the wizard
  auto-materialize + orphan-migration dialog).
  onRename fires the corresponding PATCH and calls router.refresh()
  so the label lands in every consumer of the tree.

- World viz seed: new stripSynthetics(roots) helper walks the tree
  and lifts children of any synthetic container up to their
  grandparent's level. worldCanvasRoots feeds through this before
  narrowRoots(). Result: the Live viz no longer shows "My Workspace"
  or "Teams" nodes — real agents orbit the world root directly
  (which is what you were asking for). Sidebar tree still shows
  them so orphaned agents remain visible until the migration lands.
2026-07-09 11:18:52 -07:00

252 lines
7.4 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.
/// `PATCH /api/orgs/{id}/name` — inline rename from the sidebar. Trims the
/// input and rejects empty; returns 204 on success, 404 if the id isn't
/// visible in the caller's workspace.
#[derive(Deserialize)]
pub struct RenameOrgRequest {
pub name: String,
}
pub async fn rename_org(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<RenameOrgRequest>,
) -> Result<StatusCode, ApiError> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::BadRequest);
}
cm_db::repo::orgs::rename_org(&state.pool, id, user.workspace_id, name).await?;
Ok(StatusCode::NO_CONTENT)
}
/// `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(),
}),
))
}