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.
229 lines
5.9 KiB
Rust
229 lines
5.9 KiB
Rust
//! Persistence for companies — a baseline topology staffed with real workspace
|
|
//! teams (the 3rd rung of the deploy ladder). `companies` holds the topology
|
|
//! graph; `company_teams` is the durable node→team binding. Mirrors `teams`
|
|
//! one tier up: where a team binds nodes to claws, a company binds nodes to
|
|
//! whole teams, and the recursive executor runs each bound team's sub-topology.
|
|
|
|
use cm_domain::WorkspaceId;
|
|
use serde_json::Value;
|
|
use sqlx::PgPool;
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
use crate::DbError;
|
|
|
|
/// A company row summary (list view).
|
|
pub struct CompanySummary {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub status: String,
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// A full company (graph + metadata).
|
|
pub struct Company {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub kind: String,
|
|
pub graph: Value,
|
|
pub status: String,
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// A node→team binding within a company.
|
|
pub struct CompanyTeam {
|
|
pub node_id: String,
|
|
pub team_id: Uuid,
|
|
pub role: String,
|
|
}
|
|
|
|
/// Insert a company (the topology graph). Members are added separately.
|
|
pub async fn insert_company(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
kind: &str,
|
|
graph: &Value,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query!(
|
|
"INSERT INTO companies (id, workspace_id, name, kind, graph)
|
|
VALUES ($1, $2, $3, $4, $5)",
|
|
id,
|
|
workspace_id.as_uuid(),
|
|
name,
|
|
kind,
|
|
graph,
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Rebuild a company's topology (kind + graph) in place; node→team bindings
|
|
/// (keyed off stable node ids `n0..`) are left untouched. Non-macro query.
|
|
pub async fn set_topology(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
kind: &str,
|
|
graph: &Value,
|
|
) -> Result<(), DbError> {
|
|
let res = sqlx::query(
|
|
"UPDATE companies SET kind = $3, graph = $4 WHERE id = $1 AND workspace_id = $2",
|
|
)
|
|
.bind(id)
|
|
.bind(workspace_id.as_uuid())
|
|
.bind(kind)
|
|
.bind(graph)
|
|
.execute(pool)
|
|
.await?;
|
|
if res.rows_affected() == 0 {
|
|
return Err(DbError::NotFound);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a company and its node→team bindings (teams themselves remain).
|
|
/// Rename a company in-place. Only the display name changes; the graph +
|
|
/// bindings are untouched.
|
|
pub async fn rename_company(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
name: &str,
|
|
) -> Result<(), DbError> {
|
|
let res = sqlx::query("UPDATE companies 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_company(
|
|
pool: &PgPool,
|
|
id: Uuid,
|
|
workspace_id: WorkspaceId,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query("DELETE FROM company_teams WHERE company_id = $1")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await?;
|
|
let res = sqlx::query("DELETE FROM companies 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(())
|
|
}
|
|
|
|
/// Bind a team to a topology node within a company.
|
|
pub async fn add_team(
|
|
pool: &PgPool,
|
|
company_id: Uuid,
|
|
node_id: &str,
|
|
team_id: Uuid,
|
|
role: &str,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query!(
|
|
"INSERT INTO company_teams (company_id, node_id, team_id, role)
|
|
VALUES ($1, $2, $3, $4)",
|
|
company_id,
|
|
node_id,
|
|
team_id,
|
|
role,
|
|
)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// The most recent companies for a workspace, newest first.
|
|
pub async fn list_for_workspace(
|
|
pool: &PgPool,
|
|
workspace_id: WorkspaceId,
|
|
limit: i64,
|
|
) -> Result<Vec<CompanySummary>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT id, name, kind, status, created_at FROM companies
|
|
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| CompanySummary {
|
|
id: r.id,
|
|
name: r.name,
|
|
kind: r.kind,
|
|
status: r.status,
|
|
created_at: r.created_at,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// A single company, workspace-scoped.
|
|
pub async fn get(pool: &PgPool, id: Uuid, workspace_id: WorkspaceId) -> Result<Company, DbError> {
|
|
let row = sqlx::query!(
|
|
"SELECT id, name, kind, graph, status, created_at FROM companies
|
|
WHERE id = $1 AND workspace_id = $2",
|
|
id,
|
|
workspace_id.as_uuid(),
|
|
)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or(DbError::NotFound)?;
|
|
Ok(Company {
|
|
id: row.id,
|
|
name: row.name,
|
|
kind: row.kind,
|
|
graph: row.graph,
|
|
status: row.status,
|
|
created_at: row.created_at,
|
|
})
|
|
}
|
|
|
|
/// The node→team bindings for a company.
|
|
pub async fn teams_for_company(
|
|
pool: &PgPool,
|
|
company_id: Uuid,
|
|
) -> Result<Vec<CompanyTeam>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT node_id, team_id, role FROM company_teams WHERE company_id = $1 ORDER BY node_id",
|
|
company_id,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| CompanyTeam {
|
|
node_id: r.node_id,
|
|
team_id: r.team_id,
|
|
role: r.role,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Distinct team ids bound to a company via `company_teams`. The
|
|
/// cascade-reap path drills through here to collect agents.
|
|
pub async fn teams_of_company(pool: &PgPool, company_id: Uuid) -> Result<Vec<Uuid>, DbError> {
|
|
let rows = sqlx::query!(
|
|
"SELECT DISTINCT team_id FROM company_teams WHERE company_id = $1",
|
|
company_id,
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows.into_iter().map(|r| r.team_id).collect())
|
|
}
|