structure: reify-orphans endpoint + "give these a home" dialog
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 27s
ci / rust (push) Successful in 3m57s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 4m3s

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:
Omar Sobh
2026-07-09 11:26:07 -07:00
parent 99e5207e69
commit 8b789beec0
21 changed files with 980 additions and 1 deletions
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT c.id AS id\n FROM companies c\n WHERE c.workspace_id = $1\n AND c.id <> $2\n AND NOT EXISTS (SELECT 1 FROM org_companies oc WHERE oc.company_id = c.id)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "162c078e49ddbbde6c55b8621a017aaaac3542e5b8a473f3f5131f13746babf0"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO teams (id, workspace_id, name, kind, graph)\n VALUES ($1, $2, $3, 'flat', $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "1635469f04a2f69a0c9224b92174cb05194d1d72fc736f54d22ef7f8120464c0"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id AS id\n FROM agents a\n WHERE a.workspace_id = $1\n AND a.deleted_at IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM team_members tm WHERE tm.claw_id = a.id\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "273897449471d98617cb2bc6c66c2ffed7e09a2ae89175ba016ed82bb93c12be"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id AS id\n FROM agents a\n WHERE a.workspace_id = $1\n AND a.deleted_at IS NULL\n AND NOT EXISTS (SELECT 1 FROM team_members tm WHERE tm.claw_id = a.id)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "55c02f024dbee63480d187f171cc0a61a2d176c2090b67068ce53c8e9f8c35af"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n (SELECT COUNT(*)::BIGINT FROM agents a\n WHERE a.workspace_id = $1 AND a.deleted_at IS NULL\n AND NOT EXISTS (SELECT 1 FROM team_members tm WHERE tm.claw_id = a.id))\n AS agents,\n (SELECT COUNT(*)::BIGINT FROM teams t\n WHERE t.workspace_id = $1\n AND NOT EXISTS (SELECT 1 FROM company_teams ct WHERE ct.team_id = t.id))\n AS teams,\n (SELECT COUNT(*)::BIGINT FROM companies c\n WHERE c.workspace_id = $1\n AND NOT EXISTS (SELECT 1 FROM org_companies oc WHERE oc.company_id = c.id))\n AS companies",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "agents",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "teams",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "companies",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "5fc405e41af76cfcf98f1924fceec773767a67613b5bb3f46da8f3a597966e7b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO org_companies (org_id, node_id, company_id, role)\n VALUES ($1, 'n0', $2, 'company')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "6070158f191f534e29b547aa1cda66a4c8d921689d4600daaf24c0b13b3daa4a"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO org_companies (org_id, node_id, company_id, role)\n VALUES ($1, $2, $3, 'company')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "768923a5bf41be60f4c4c1815f23dc09f9d57261ced0812f827b04787d82420c"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO orgs (id, workspace_id, name, kind, graph, status, created_at)\n VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Jsonb",
"Timestamptz"
]
},
"nullable": []
},
"hash": "a2c6f161c5799b8b80cf33b6bb788eda94d3465ca8520338e0e253188d8c8949"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.id AS id\n FROM teams t\n WHERE t.workspace_id = $1\n AND t.id <> $2\n AND NOT EXISTS (SELECT 1 FROM company_teams ct WHERE ct.team_id = t.id)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "a42a7972134892fca08c567355ece7bd4e0f163d476e058d09f399149af5a0f4"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO company_teams (company_id, node_id, team_id, role)\n VALUES ($1, 'n0', $2, 'team')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "a6c649fa01ac020b8fe5cb68a3c130fc40afd4b73f68e65b9e284bc7689a172e"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.id AS id\n FROM teams t\n WHERE t.workspace_id = $1\n AND NOT EXISTS (\n SELECT 1 FROM company_teams ct WHERE ct.team_id = t.id\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "c95ebd4e0ec40dc3eac259adc1c14248fd88ade8d2f7752ffc2e58b98dd266d8"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO companies (id, workspace_id, name, kind, graph, status, created_at)\n VALUES ($1, $2, $3, 'flat', $4, 'active', $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Jsonb",
"Timestamptz"
]
},
"nullable": []
},
"hash": "c95fa0fcfa258298941d184adea792040f4a8706daa6694e93596c80ee8d5708"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO team_members (team_id, node_id, claw_id, role)\n VALUES ($1, $2, $3, 'claw')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "d8146b94f0ac89e8d2e5140c92091fd3dbaef23df80a230a5f80dff6090866fc"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT c.id AS id\n FROM companies c\n WHERE c.workspace_id = $1\n AND NOT EXISTS (\n SELECT 1 FROM org_companies oc WHERE oc.company_id = c.id\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "ded7245c1201a3df1a90f7b01cc9109731a1ff57ccf753ff8d258dfa592b3da1"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO company_teams (company_id, node_id, team_id, role)\n VALUES ($1, $2, $3, 'team')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "e499eb5ba804df40d3c575b84bde5b1625df72c1065d2c03d8fb1bad85746573"
}
+8
View File
@@ -456,6 +456,14 @@ pub fn router(state: AppState) -> Router {
post(routes::loops::webhook_receive), post(routes::loops::webhook_receive),
) )
.route("/api/structure/stats", get(routes::structure::stats)) .route("/api/structure/stats", get(routes::structure::stats))
.route(
"/api/structure/orphan-counts",
get(routes::structure::orphan_counts),
)
.route(
"/api/structure/reify-orphans",
post(routes::structure::reify_orphans),
)
.route("/api/structure/{level}/{id}", get(routes::structure::node)) .route("/api/structure/{level}/{id}", get(routes::structure::node))
.route("/api/topology-runs", get(routes::topology::list_runs)) .route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run)) .route("/api/topology-runs/{id}", get(routes::topology::get_run))
+82 -1
View File
@@ -8,9 +8,10 @@
//! its whole subtree at once. //! its whole subtree at once.
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json; use axum::Json;
use cm_domain::AgentId; use cm_domain::AgentId;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
@@ -179,3 +180,83 @@ pub async fn node(
_ => Err(ApiError::BadRequest), _ => Err(ApiError::BadRequest),
} }
} }
// ── Orphan discovery + reification ──────────────────────────────────────
//
// The dashboard synthesizes "My Workspace / Direct / Ungrouped" containers
// whenever the workspace has entities that aren't fully wired into the
// org → company → team chain. These endpoints replace those synthetics
// with real DB rows so the sidebar and the world viz stop showing
// placeholder scaffolding.
/// `GET /api/structure/orphan-counts` — how many agents/teams/companies
/// would be scaffolded under a synthetic container. Zero on all three
/// means the workspace is fully wired.
#[derive(Serialize)]
pub struct OrphanCountsOut {
pub agents: i64,
pub teams: i64,
pub companies: i64,
}
pub async fn orphan_counts(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<OrphanCountsOut>, ApiError> {
let c = cm_db::repo::structure_reify::count_orphans(&state.pool, user.workspace_id).await?;
Ok(Json(OrphanCountsOut {
agents: c.agents,
teams: c.teams,
companies: c.companies,
}))
}
/// `POST /api/structure/reify-orphans` — create a real org+company+team
/// chain with the provided names and re-parent every orphan into it, all
/// in one transaction. Returns the freshly-created ids so the client can
/// select them after refresh.
#[derive(Deserialize)]
pub struct ReifyRequest {
pub org_name: String,
pub company_name: String,
pub team_name: String,
}
#[derive(Serialize)]
pub struct ReifiedOut {
pub org_id: String,
pub company_id: String,
pub team_id: String,
pub moved_agents: i64,
pub moved_teams: i64,
pub moved_companies: i64,
}
pub async fn reify_orphans(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<ReifyRequest>,
) -> Result<(StatusCode, Json<ReifiedOut>), ApiError> {
let org = body.org_name.trim();
let company = body.company_name.trim();
let team = body.team_name.trim();
if org.is_empty() || company.is_empty() || team.is_empty() {
return Err(ApiError::BadRequest);
}
let r = cm_db::repo::structure_reify::reify_orphans(
&state.pool,
user.workspace_id,
org,
company,
team,
)
.await?;
Ok((
StatusCode::CREATED,
Json(ReifiedOut {
org_id: r.org_id.to_string(),
company_id: r.company_id.to_string(),
team_id: r.team_id.to_string(),
moved_agents: r.moved_agents,
moved_teams: r.moved_teams,
moved_companies: r.moved_companies,
}),
))
}
+1
View File
@@ -28,6 +28,7 @@ pub mod runs;
pub mod sessions; pub mod sessions;
pub mod skills; pub mod skills;
pub mod steps; pub mod steps;
pub mod structure_reify;
pub mod teams; pub mod teams;
pub mod terminal_tabs; pub mod terminal_tabs;
pub mod threads; pub mod threads;
+294
View File
@@ -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,
})
}
@@ -39,6 +39,7 @@ import { ConnectHostWizard } from "./ConnectHostWizard";
import { INFRA_CATALOG } from "@/components/computer/catalogs/infra"; import { INFRA_CATALOG } from "@/components/computer/catalogs/infra";
import { ClawCommandCenter } from "./ClawCommandCenter"; import { ClawCommandCenter } from "./ClawCommandCenter";
import { MasterPlannerModal } from "./MasterPlannerModal"; import { MasterPlannerModal } from "./MasterPlannerModal";
import { OrphanMigrationDialog } from "./OrphanMigrationDialog";
import { BrainRegistryPanel } from "./BrainRegistryPanel"; import { BrainRegistryPanel } from "./BrainRegistryPanel";
import { BrainHistoryModal } from "./BrainHistoryModal"; import { BrainHistoryModal } from "./BrainHistoryModal";
import { TeamRunsModal } from "./TeamRunsModal"; import { TeamRunsModal } from "./TeamRunsModal";
@@ -423,9 +424,16 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
else if (lv === "company") selectCompany(id); else if (lv === "company") selectCompany(id);
else if (lv === "team") selectTeam(id); else if (lv === "team") selectTeam(id);
}; };
// Clicking a synthetic scaffolding node ("my-workspace" / "ws-teams" /
// "ungrouped-co" / "ungrouped-team") opens the migration dialog instead of
// navigating — those nodes have no real DB row to select. Real nodes fall
// through to the normal selection path below.
const [orphanDialogOpen, setOrphanDialogOpen] = useState(false);
// The tree's unified node handler (world tree + the flat agents list). On the // The tree's unified node handler (world tree + the flat agents list). On the
// flat agents page a claw click opens the agent; in the World tree it selects. // flat agents page a claw click opens the agent; in the World tree it selects.
const onTreeSelect = (item: TreeItem) => { const onTreeSelect = (item: TreeItem) => {
if (SYNTHETIC_TREE_IDS.has(item.id)) { setOrphanDialogOpen(true); return; }
if (isClaw && item.level === "claw") { openClaw(item.id); return; } if (isClaw && item.level === "claw") { openClaw(item.id); return; }
onWorldSelect(item.id); onWorldSelect(item.id);
expandPathTo(item.id); expandPathTo(item.id);
@@ -950,6 +958,15 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
{/* MASTER PLANNER — the "+" opens a chat with Opus 4.8 that proposes and {/* MASTER PLANNER — the "+" opens a chat with Opus 4.8 that proposes and
scaffolds a whole team of agents. */} scaffolds a whole team of agents. */}
{deployOpen ? <MasterPlannerModal onClose={() => setDeployOpen(false)} /> : null} {deployOpen ? <MasterPlannerModal onClose={() => setDeployOpen(false)} /> : null}
{orphanDialogOpen ? (
<OrphanMigrationDialog
onClose={() => setOrphanDialogOpen(false)}
onReified={() => {
setOrphanDialogOpen(false);
router.refresh();
}}
/>
) : null}
{repoWizardOpen ? ( {repoWizardOpen ? (
<RepoConnectionWizardStub <RepoConnectionWizardStub
onClose={() => setRepoWizardOpen(false)} onClose={() => setRepoWizardOpen(false)}
@@ -0,0 +1,279 @@
"use client";
// The "give these a home" migration dialog.
//
// Opens when the user clicks one of the synthetic tree scaffolding nodes
// ("my-workspace", "ws-teams", "ungrouped-co", "ungrouped-team"). Fetches
// the workspace's orphan counts so the user sees exactly what's about to
// be reified, then collects three names (Org / Company / Team) and POSTs
// to /api/structure/reify-orphans, which does the whole migration in a
// single DB transaction. All three fields default to something sensible
// so hitting the button with no edits still moves things forward.
import { useEffect, useState } from "react";
const mono = "'JetBrains Mono', ui-monospace, monospace";
type OrphanCounts = { agents: number; teams: number; companies: number };
export function OrphanMigrationDialog({
onClose,
onReified,
}: {
onClose: () => void;
/** Fired after a successful reify. Parent should refresh the workspace tree. */
onReified: (result: {
org_id: string;
company_id: string;
team_id: string;
moved_agents: number;
moved_teams: number;
moved_companies: number;
}) => void;
}) {
const [counts, setCounts] = useState<OrphanCounts | null>(null);
const [orgName, setOrgName] = useState("My Workspace");
const [companyName, setCompanyName] = useState("General");
const [teamName, setTeamName] = useState("Everyone");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
const r = await fetch("/api/structure/orphan-counts");
if (!r.ok) throw new Error(`${r.status}`);
const data = (await r.json()) as OrphanCounts;
if (alive) setCounts(data);
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : "count failed");
}
})();
return () => {
alive = false;
};
}, []);
async function save() {
if (saving) return;
const org = orgName.trim();
const company = companyName.trim();
const team = teamName.trim();
if (!org || !company || !team) {
setError("All three names are required.");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch("/api/structure/reify-orphans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_name: org,
company_name: company,
team_name: team,
}),
});
if (!res.ok) throw new Error(`POST reify-orphans → ${res.status}`);
const result = await res.json();
onReified(result);
} catch (e) {
setError(e instanceof Error ? e.message : "migration failed");
} finally {
setSaving(false);
}
}
const nothingToMigrate =
counts !== null &&
counts.agents === 0 &&
counts.teams === 0 &&
counts.companies === 0;
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 130,
background: "rgba(0,0,0,.62)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Give these a home"
style={{
width: "100%",
maxWidth: 520,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
borderRadius: 14,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div style={{ padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,.07)" }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".14em", color: "#5a5a62" }}>
STRUCTURE
</div>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5", marginTop: 4 }}>
Give these a home
</div>
<div style={{ fontFamily: mono, fontSize: 11.5, color: "#8a8a92", marginTop: 6, lineHeight: 1.55 }}>
You have some entities that never got parented into a real org
company team chain. Naming the three below will materialize the
chain and move everything under it in one transaction. You can
click any of them in the sidebar later to rename.
</div>
</div>
{/* Counts */}
<div
style={{
padding: "12px 20px",
display: "flex",
gap: 20,
fontFamily: mono,
fontSize: 11.5,
color: "#cfcfd5",
background: "rgba(255,255,255,.02)",
}}
>
{counts === null ? (
<span style={{ color: "#6a6a72" }}>Counting</span>
) : nothingToMigrate ? (
<span style={{ color: "#7fd0a0" }}>
Nothing to migrate the workspace is already fully wired.
</span>
) : (
<>
<span>
<span style={{ color: "#ffb44a" }}>{counts.agents}</span> agent
{counts.agents === 1 ? "" : "s"}
</span>
<span>
<span style={{ color: "#ffb44a" }}>{counts.teams}</span> team
{counts.teams === 1 ? "" : "s"}
</span>
<span>
<span style={{ color: "#ffb44a" }}>{counts.companies}</span> compan
{counts.companies === 1 ? "y" : "ies"}
</span>
</>
)}
</div>
{/* Inputs */}
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
<NameField label="Organization" value={orgName} onChange={setOrgName} disabled={saving} />
<NameField label="Company" value={companyName} onChange={setCompanyName} disabled={saving} />
<NameField
label={counts && counts.agents > 0 ? "Team (holds the ungrouped agents)" : "Team"}
value={teamName}
onChange={setTeamName}
disabled={saving}
/>
</div>
{error ? (
<div style={{ padding: "0 20px 4px", fontFamily: mono, fontSize: 11.5, color: "#ff8a7a" }}>
{error}
</div>
) : null}
{/* Footer */}
<div
style={{
padding: 14,
borderTop: "1px solid rgba(255,255,255,.07)",
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={onClose}
disabled={saving}
style={{
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 13,
fontWeight: 600,
cursor: saving ? "default" : "pointer",
}}
>
Later
</button>
<button
type="button"
onClick={save}
disabled={saving || nothingToMigrate}
title={nothingToMigrate ? "There's nothing to migrate." : undefined}
style={{
padding: "9px 18px",
borderRadius: 8,
border: 0,
background: saving || nothingToMigrate ? "rgba(94,200,216,.3)" : "#5ec8d8",
color: "#04181c",
fontSize: 13,
fontWeight: 700,
cursor: saving || nothingToMigrate ? "default" : "pointer",
}}
>
{saving ? "Migrating…" : "Save & migrate"}
</button>
</div>
</div>
</div>
);
}
function NameField({
label,
value,
onChange,
disabled,
}: {
label: string;
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{ fontFamily: mono, fontSize: 10.5, letterSpacing: ".08em", color: "#8a8a92", textTransform: "uppercase" }}>
{label}
</span>
<input
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
style={{
padding: "9px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "#141417",
color: "#f3f3f5",
fontSize: 13,
outline: "none",
}}
/>
</label>
);
}