structure: reify-orphans endpoint + "give these a home" dialog
Turns the four synthetic tree containers into a real migration path.
Clicking any of them ("My Workspace", "Teams", "Direct",
"Ungrouped") opens a dialog that creates a real
org → company → team chain and re-parents every orphan into it, all
in one DB transaction.
cm-db (new module structure_reify)
- orphan_agents / orphan_teams / orphan_companies: workspace-scoped
SELECTs of entities without a parent binding in team_members /
company_teams / org_companies. Used both by the dialog's counter
and internally by the migration.
- count_orphans: cheap combined-count via three subqueries in a
single SELECT so the dialog only round-trips once for the header.
- reify_orphans(pool, ws, org_name, company_name, team_name):
1. begins a tx
2. inserts a new org + company + team (all `flat`, empty graphs
— user can shape them later via the existing PATCH endpoints)
3. binds company under org (org_companies "n0")
4. binds team under company (company_teams "n0")
5. inserts team_members rows for every orphan agent (n1, n2, …)
6. inserts company_teams rows for every orphan team
7. inserts org_companies rows for every orphan company
8. commits, returns the created ids + moved counts
cm-api (routes/structure)
- GET /api/structure/orphan-counts → { agents, teams, companies }
- POST /api/structure/reify-orphans → { org_id, company_id, team_id,
moved_* }. Trims + rejects any empty name; validates before
starting the transaction so a 400 never rolls anything back.
Frontend
- New OrphanMigrationDialog: fetches counts on open, three name
fields (defaults: Organization "My Workspace", Company "General",
Team "Everyone"), POSTs on save. "Nothing to migrate" state
disables the save button when the workspace is already fully
wired. Copy explicitly notes that everything is renameable in the
sidebar afterward.
- Dashboard: onTreeSelect now branches on SYNTHETIC_TREE_IDS —
clicking a synthetic node opens the dialog instead of falling
through to the (nonexistent) selection. On successful reify,
router.refresh() so the sidebar + world viz reflect the new real
chain.
What this doesn't do yet (next commit)
- Wizard auto-materialize: when creating a team/company via wizard,
auto-create parent placeholders if they don't exist. Deferred so
this commit stays focused.
This commit is contained in:
@@ -28,6 +28,7 @@ pub mod runs;
|
||||
pub mod sessions;
|
||||
pub mod skills;
|
||||
pub mod steps;
|
||||
pub mod structure_reify;
|
||||
pub mod teams;
|
||||
pub mod terminal_tabs;
|
||||
pub mod threads;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! 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 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user