Files
clawmates/crates/cm-db/src/repo/structure_reify.rs
T
Omar Sobh acd2a0f287
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 25s
ci / rust (push) Successful in 3m54s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 3m4s
structure polish: post-reify nav + ensure-chain + TeamWizard auto-parent
Two small quality-of-life fixes on top of the reify commit:

Post-reify navigation
  OrphanMigrationDialog already returned team_id in its result;
  Dashboard now pushes /?team=<team_id> before router.refresh() so
  the user lands on the freshly-materialized team and sees exactly
  where their agents just moved. Previously they had to hunt for it
  in the newly-rebuilt sidebar.

Wizard auto-materialize (POST /api/structure/ensure-chain)
  cm-db: ensure_chain(pool, ws, fallback_org, fallback_company) —
    fast path returns coordinates of the first org+company already
    bound in this workspace (workspace's oldest org, oldest company
    under it). Slow path inserts a new org+company with the
    fallback names ("My Workspace" / "General") + binds them via
    org_companies. Returns { org_id, company_id, created }. Small
    txn — leaves the workspace consistent whether it was already
    wired or not.
  cm-api: POST /api/structure/ensure-chain accepts optional
    fallback_org_name and fallback_company_name in the body (trimmed,
    else default). Returns the ids.
  CreateTeamRequest gains an optional attach_to_company_id. When
    set, after build_team() completes, we look up the company
    (workspace ownership check enforced by companies::get), count
    its existing teams for a stable n_i node id, and insert a
    company_teams binding — so the team lands under the parent
    atomically instead of a follow-up round-trip.
  TeamWizard now calls ensure-chain before POST /api/teams and
    passes the returned company_id in attach_to_company_id. Both
    calls are best-effort — if ensure-chain fails (network etc.)
    we still try to create the team, and the migration dialog stays
    available as the fallback UX. Wizard flow now: fresh workspace's
    first team is fully wired from the moment it appears in the
    tree — no synthetic "My Workspace" scaffolding ever gets
    rendered around it.

The Team/Company create paths not touched here (create_team_from_claws,
company create, org create, MasterPlannerModal scaffold) still
work as before — they just won't auto-parent yet. Later commits
can wire them the same way.
2026-07-09 11:41:27 -07:00

386 lines
12 KiB
Rust

//! Orphan discovery + one-shot reification.
//!
//! When a workspace has agents that were never added to a team (or teams
//! that were never added to a company, or companies never added to an
//! org), the dashboard falls back to synthesized scaffolding — "My
//! Workspace", "Teams", "Direct", "Ungrouped" — so the tree renders. Those
//! placeholders confuse people. `reify_orphans` is the migration path:
//! create a real org + company + team in one transaction, then re-parent
//! every orphan into the new chain. After it lands, the dashboard has
//! no reason to synthesize anything.
//!
//! The three `orphan_*` functions also power the "you have N ungrouped
//! things" count the dialog shows before the user hits save.
use serde::Serialize;
use sqlx::{PgPool, Postgres, Transaction};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
use cm_domain::WorkspaceId;
/// Counts of orphans in the workspace (things the dashboard would have to
/// scaffold under a synthetic container). Zero for a fully-wired workspace.
#[derive(Debug, Clone, Serialize)]
pub struct OrphanCounts {
pub agents: i64,
pub teams: i64,
pub companies: i64,
}
/// Agent ids for claws in the workspace that don't appear in ANY
/// `team_members` row. The dashboard would park these under an
/// "Ungrouped" team inside a "Direct" company.
pub async fn orphan_agents(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT a.id AS id
FROM agents a
WHERE a.workspace_id = $1
AND a.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM team_members tm WHERE tm.claw_id = a.id
)",
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.id).collect())
}
/// Team ids for teams in the workspace that aren't bound to any company via
/// `company_teams`. The dashboard would park these under a synthetic
/// "Teams" company.
pub async fn orphan_teams(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT t.id AS id
FROM teams t
WHERE t.workspace_id = $1
AND NOT EXISTS (
SELECT 1 FROM company_teams ct WHERE ct.team_id = t.id
)",
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.id).collect())
}
/// Company ids for companies in the workspace that aren't bound to any org
/// via `org_companies`. The dashboard would park these directly under the
/// synthetic "My Workspace" org.
pub async fn orphan_companies(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<Vec<Uuid>, DbError> {
let rows = sqlx::query!(
"SELECT c.id AS id
FROM companies c
WHERE c.workspace_id = $1
AND NOT EXISTS (
SELECT 1 FROM org_companies oc WHERE oc.company_id = c.id
)",
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|r| r.id).collect())
}
/// Cheap combined counts for the dialog to show before the user commits.
pub async fn count_orphans(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<OrphanCounts, DbError> {
let row = sqlx::query!(
"SELECT
(SELECT COUNT(*)::BIGINT FROM agents a
WHERE a.workspace_id = $1 AND a.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM team_members tm WHERE tm.claw_id = a.id))
AS agents,
(SELECT COUNT(*)::BIGINT FROM teams t
WHERE t.workspace_id = $1
AND NOT EXISTS (SELECT 1 FROM company_teams ct WHERE ct.team_id = t.id))
AS teams,
(SELECT COUNT(*)::BIGINT FROM companies c
WHERE c.workspace_id = $1
AND NOT EXISTS (SELECT 1 FROM org_companies oc WHERE oc.company_id = c.id))
AS companies",
workspace_id.as_uuid(),
)
.fetch_one(pool)
.await?;
Ok(OrphanCounts {
agents: row.agents.unwrap_or(0),
teams: row.teams.unwrap_or(0),
companies: row.companies.unwrap_or(0),
})
}
/// Result of [`ensure_chain`]: the ids of the org + company the caller
/// should attach new teams under. `created` is `true` if either row was
/// freshly inserted by this call, `false` when we reused what already
/// existed. Wizards use this to guarantee a workspace has a parent chain
/// before creating a team, without asking the user for names — the two
/// created rows use the `fallback_*` names and can be renamed inline.
#[derive(Debug, Clone, Serialize)]
pub struct EnsuredChain {
pub org_id: Uuid,
pub company_id: Uuid,
pub created: bool,
}
/// Ensure the workspace has at least one org + one company; return
/// coordinates for the first available. Idempotent: reuses existing rows
/// when present, creates placeholder rows otherwise. Safe to call before
/// every `POST /api/teams` so a fresh workspace's first team lands under
/// a real parent chain instead of falling into synthesized scaffolding.
pub async fn ensure_chain(
pool: &PgPool,
workspace_id: WorkspaceId,
fallback_org_name: &str,
fallback_company_name: &str,
) -> Result<EnsuredChain, DbError> {
// Fast path: workspace already has an org with a company bound. Use
// the first one we find — the wizard doesn't need to be clever about
// which parent to pick; the user can rename or reparent later.
if let Some(row) = sqlx::query!(
"SELECT oc.org_id AS org_id, oc.company_id AS company_id
FROM org_companies oc
JOIN orgs o ON o.id = oc.org_id
WHERE o.workspace_id = $1
ORDER BY o.created_at
LIMIT 1",
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
{
return Ok(EnsuredChain {
org_id: row.org_id,
company_id: row.company_id,
created: false,
});
}
// Slow path: materialize both. Reuses the reify shape but skips the
// team + orphan moves — the caller is about to create its own team.
let mut tx = pool.begin().await?;
let now = OffsetDateTime::now_utc();
let org_id = Uuid::now_v7();
let company_id = Uuid::now_v7();
let empty_graph = serde_json::json!({"kind":"flat","nodes":[],"edges":[]});
sqlx::query!(
"INSERT INTO orgs (id, workspace_id, name, kind, graph, status, created_at)
VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
org_id,
workspace_id.as_uuid(),
fallback_org_name,
empty_graph,
now,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO companies (id, workspace_id, name, kind, graph, status, created_at)
VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
company_id,
workspace_id.as_uuid(),
fallback_company_name,
empty_graph,
now,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO org_companies (org_id, node_id, company_id, role)
VALUES ($1, 'n0', $2, 'company')",
org_id,
company_id,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(EnsuredChain {
org_id,
company_id,
created: true,
})
}
/// Result of a successful reify — ids of the freshly-created holder rows
/// so the frontend can select the new org/company/team after refresh.
#[derive(Debug, Clone, Serialize)]
pub struct Reified {
pub org_id: Uuid,
pub company_id: Uuid,
pub team_id: Uuid,
pub moved_agents: i64,
pub moved_teams: i64,
pub moved_companies: i64,
}
/// One-shot migration. Runs inside a single transaction so a mid-way
/// failure rolls back cleanly and the workspace stays in its prior
/// (synthesized) state.
///
/// Empty graphs are stored as `{"kind":"flat","nodes":[],"edges":[]}`.
/// Later PATCHes via the topology endpoints can rebuild them once the
/// user assigns real roles.
pub async fn reify_orphans(
pool: &PgPool,
workspace_id: WorkspaceId,
org_name: &str,
company_name: &str,
team_name: &str,
) -> Result<Reified, DbError> {
let mut tx: Transaction<'_, Postgres> = pool.begin().await?;
let now = OffsetDateTime::now_utc();
let org_id = Uuid::now_v7();
let company_id = Uuid::now_v7();
let team_id = Uuid::now_v7();
let empty_graph = serde_json::json!({"kind":"flat","nodes":[],"edges":[]});
// Create org, company, team (flat, empty graphs — user can shape later).
sqlx::query!(
"INSERT INTO orgs (id, workspace_id, name, kind, graph, status, created_at)
VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
org_id,
workspace_id.as_uuid(),
org_name,
empty_graph,
now,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO companies (id, workspace_id, name, kind, graph, status, created_at)
VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
company_id,
workspace_id.as_uuid(),
company_name,
empty_graph,
now,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO teams (id, workspace_id, name, kind, graph)
VALUES ($1, $2, $3, 'flat', $4)",
team_id,
workspace_id.as_uuid(),
team_name,
empty_graph,
)
.execute(&mut *tx)
.await?;
// Bind the new company under the new org at node n0.
sqlx::query!(
"INSERT INTO org_companies (org_id, node_id, company_id, role)
VALUES ($1, 'n0', $2, 'company')",
org_id,
company_id,
)
.execute(&mut *tx)
.await?;
// Bind the new team under the new company at n0.
sqlx::query!(
"INSERT INTO company_teams (company_id, node_id, team_id, role)
VALUES ($1, 'n0', $2, 'team')",
company_id,
team_id,
)
.execute(&mut *tx)
.await?;
// Move orphan agents into the new team, orphan teams into the new
// company, orphan companies under the new org. Each gets a distinct
// node_id ("n1", "n2", …) so downstream topology rebuilds have
// stable anchors.
let orph_agents = sqlx::query!(
"SELECT a.id AS id
FROM agents a
WHERE a.workspace_id = $1
AND a.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM team_members tm WHERE tm.claw_id = a.id)",
workspace_id.as_uuid(),
)
.fetch_all(&mut *tx)
.await?;
let moved_agents = orph_agents.len() as i64;
for (i, r) in orph_agents.into_iter().enumerate() {
let node_id = format!("n{}", i + 1);
sqlx::query!(
"INSERT INTO team_members (team_id, node_id, claw_id, role)
VALUES ($1, $2, $3, 'claw')",
team_id,
node_id,
r.id,
)
.execute(&mut *tx)
.await?;
}
let orph_teams = sqlx::query!(
"SELECT t.id AS id
FROM teams t
WHERE t.workspace_id = $1
AND t.id <> $2
AND NOT EXISTS (SELECT 1 FROM company_teams ct WHERE ct.team_id = t.id)",
workspace_id.as_uuid(),
team_id,
)
.fetch_all(&mut *tx)
.await?;
let moved_teams = orph_teams.len() as i64;
for (i, r) in orph_teams.into_iter().enumerate() {
let node_id = format!("n{}", i + 1);
sqlx::query!(
"INSERT INTO company_teams (company_id, node_id, team_id, role)
VALUES ($1, $2, $3, 'team')",
company_id,
node_id,
r.id,
)
.execute(&mut *tx)
.await?;
}
let orph_cos = sqlx::query!(
"SELECT c.id AS id
FROM companies c
WHERE c.workspace_id = $1
AND c.id <> $2
AND NOT EXISTS (SELECT 1 FROM org_companies oc WHERE oc.company_id = c.id)",
workspace_id.as_uuid(),
company_id,
)
.fetch_all(&mut *tx)
.await?;
let moved_companies = orph_cos.len() as i64;
for (i, r) in orph_cos.into_iter().enumerate() {
let node_id = format!("n{}", i + 1);
sqlx::query!(
"INSERT INTO org_companies (org_id, node_id, company_id, role)
VALUES ($1, $2, $3, 'company')",
org_id,
node_id,
r.id,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Reified {
org_id,
company_id,
team_id,
moved_agents,
moved_teams,
moved_companies,
})
}