//! 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, 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, 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, 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 { 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 { // 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 { 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, }) }