Files
clawmates/crates/cm-db/src/repo/companies.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

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())
}