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.
252 lines
7.4 KiB
Rust
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(),
|
|
}),
|
|
))
|
|
}
|