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.
199 lines
5.3 KiB
Rust
199 lines
5.3 KiB
Rust
//! Persistence for orgs — a baseline topology whose nodes are whole companies
|
|
//! (the top rung of the deploy ladder). `orgs` holds the topology graph;
|
|
//! `org_companies` is the durable node→company binding. Mirrors `companies`
|
|
//! one tier up; the recursive executor runs each bound company's sub-topology,
|
|
//! which in turn runs its teams, which run their claws.
|
|
|
|
use cm_domain::WorkspaceId;
|
|
use serde_json::Value;
|
|
use sqlx::PgPool;
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
use crate::DbError;
|
|
|
|
/// An org row summary (list view).
|
|
pub struct OrgSummary {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub status: String,
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// A full org (graph + metadata).
|
|
pub struct Org {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub graph: Value,
|
|
pub status: String,
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// A node→company binding within an org.
|
|
pub struct OrgCompany {
|
|
pub node_id: String,
|
|
pub company_id: Uuid,
|
|
pub role: String,
|
|
}
|
|
|
|
/// Insert an org (the topology graph). Members are added separately.
|
|
pub async fn insert_org(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
kind: &str,
|
|
graph: &Value,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query!(
|
|
"INSERT INTO orgs (id, workspace_id, name, kind, graph)
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
id,
|
|
workspace_id.as_uuid(),
|
|
name,
|
|
kind,
|
|
graph,
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Bind a company to a topology node within an org.
|
|
pub async fn add_company(
|
|
pool: &PgPool,
|
|
org_id: Uuid,
|
|
node_id: &str,
|
|
company_id: Uuid,
|
|
role: &str,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query!(
|
|
"INSERT INTO org_companies (org_id, node_id, company_id, role)
|
|
VALUES ($1, $2, $3, $4)",
|
|
org_id,
|
|
node_id,
|
|
company_id,
|
|
role,
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The most recent orgs for a workspace, newest first.
|
|
pub async fn list_for_workspace(
|
|
pool: &PgPool,
|
|
workspace_id: WorkspaceId,
|
|
limit: i64,
|
|
) -> Result<Vec<OrgSummary>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT id, name, kind, status, created_at FROM orgs
|
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
|
workspace_id.as_uuid(),
|
|
limit,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| OrgSummary {
|
|
id: r.id,
|
|
name: r.name,
|
|
kind: r.kind,
|
|
status: r.status,
|
|
created_at: r.created_at,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// A single org, workspace-scoped.
|
|
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Org, DbError> {
|
|
let row = sqlx::query!(
|
|
"SELECT id, name, kind, graph, status, created_at FROM orgs
|
|
WHERE id = $1 AND workspace_id = $2",
|
|
id,
|
|
workspace_id.as_uuid(),
|
|
)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or(DbError::NotFound)?;
|
|
Ok(Org {
|
|
id: row.id,
|
|
name: row.name,
|
|
kind: row.kind,
|
|
graph: row.graph,
|
|
status: row.status,
|
|
created_at: row.created_at,
|
|
})
|
|
}
|
|
|
|
/// The node→company bindings for an org.
|
|
pub async fn companies_for_org(pool: &PgPool, org_id: Uuid) -> Result<Vec<OrgCompany>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT node_id, company_id, role FROM org_companies WHERE org_id = $1 ORDER BY node_id",
|
|
org_id,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| OrgCompany {
|
|
node_id: r.node_id,
|
|
company_id: r.company_id,
|
|
role: r.role,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Delete an org. Structural only: the `org_companies` links are removed
|
|
/// (cascade) so the companies survive, just ungrouped from this org.
|
|
/// Rename an org in-place. The name is the only mutable identity field.
|
|
/// Returns NotFound when the id isn't visible in the caller's workspace.
|
|
pub async fn rename_org(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
) -> Result<(), DbError> {
|
|
let res = sqlx::query("UPDATE orgs SET name = $3 WHERE id = $1 AND workspace_id = $2")
|
|
.bind(id)
|
|
.bind(workspace_id.as_uuid())
|
|
.bind(name)
|
|
.execute(pool)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(DbError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_org(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<(), DbError> {
|
|
sqlx::query("DELETE FROM org_companies WHERE org_id = $1")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await?;
|
|
let res = sqlx::query("DELETE FROM orgs WHERE id = $1 AND workspace_id = $2")
|
|
.bind(id)
|
|
.bind(workspace_id.as_uuid())
|
|
.execute(pool)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(DbError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Distinct company ids bound to an org via `org_companies`. The
|
|
/// cascade-reap path drills through here to collect teams → agents.
|
|
pub async fn companies_of_org(pool: &PgPool, org_id: Uuid) -> Result<Vec<Uuid>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT DISTINCT company_id FROM org_companies WHERE org_id = $1",
|
|
org_id,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(|r| r.company_id).collect())
|
|
}
|