Files
clawmates/crates/cm-api/src/routes/structure.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

315 lines
11 KiB
Rust

//! Unified structure endpoint for the recursive zoom canvas.
//!
//! `GET /api/structure/{level}/{id}` returns one level of the
//! `org ▸ company ▸ team ▸ claw` hierarchy in a single polymorphic shape: the
//! level's own topology graph plus its children (the tier below) with their
//! drill targets. The canvas fetches depth-1 lazily — drilling into a child
//! issues another request for that child's level — so a large org never serializes
//! its whole subtree at once.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_domain::AgentId;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// One child node (the tier below) with where it drills to.
#[derive(Serialize)]
pub struct StructureChild {
/// The graph node id this child binds to (matches a node in `graph`).
pub node_id: String,
pub role: String,
/// The child's level: "company" | "team" | "claw".
pub child_level: String,
/// The child's id — the drill target (`/{child_level}s/{child_id}` or, for
/// a claw, its chat at `/claws/{child_id}`).
pub child_id: String,
pub child_name: String,
}
/// One level of the hierarchy: its graph + the children to drill into.
#[derive(Serialize)]
pub struct StructureNode {
/// "org" | "company" | "team" | "claw".
pub level: String,
pub id: String,
pub name: String,
/// Topology kind at this level (None for a claw leaf).
pub kind: Option<String>,
/// This level's TopologyGraph (None for a claw leaf).
pub graph: Option<Value>,
pub children: Vec<StructureChild>,
}
/// Workspace-wide hierarchy counts for the breadcrumb + status bar.
#[derive(Serialize)]
pub struct StructureStats {
pub org_count: usize,
pub company_count: usize,
pub team_count: usize,
pub claw_count: usize,
pub running_now: i64,
}
/// `GET /api/structure/stats` — counts across the whole workspace hierarchy.
pub async fn stats(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<StructureStats>, ApiError> {
let ws = user.workspace_id;
let orgs = cm_db::repo::orgs::list_for_workspace(&state.pool, ws, 1000).await?;
let companies = cm_db::repo::companies::list_for_workspace(&state.pool, ws, 1000).await?;
let teams = cm_db::repo::teams::list_for_workspace(&state.pool, ws, 1000).await?;
let claws = cm_db::repo::agents::roster(&state.pool, ws).await?;
let running_now = cm_db::repo::topology_runs::count_active(&state.pool, ws).await?;
Ok(Json(StructureStats {
org_count: orgs.len(),
company_count: companies.len(),
team_count: teams.len(),
claw_count: claws.len(),
running_now,
}))
}
/// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy.
pub async fn node(
State(state): State<AppState>,
Authed(user): Authed,
Path((level, id)): Path<(String, Uuid)>,
) -> Result<Json<StructureNode>, ApiError> {
let ws = user.workspace_id;
match level.as_str() {
"org" => {
let org = cm_db::repo::orgs::get(&state.pool, id, ws).await?;
let bindings = cm_db::repo::orgs::companies_for_org(&state.pool, id).await?;
let mut children = Vec::with_capacity(bindings.len());
for b in bindings {
let name = cm_db::repo::companies::get(&state.pool, b.company_id, ws)
.await
.map(|c| c.name)
.unwrap_or_default();
children.push(StructureChild {
node_id: b.node_id,
role: b.role,
child_level: "company".into(),
child_id: b.company_id.to_string(),
child_name: name,
});
}
Ok(Json(StructureNode {
level: "org".into(),
id: org.id.to_string(),
name: org.name,
kind: Some(org.kind),
graph: Some(org.graph),
children,
}))
}
"company" => {
let company = cm_db::repo::companies::get(&state.pool, id, ws).await?;
let bindings = cm_db::repo::companies::teams_for_company(&state.pool, id).await?;
let mut children = Vec::with_capacity(bindings.len());
for b in bindings {
let name = cm_db::repo::teams::get_team(&state.pool, b.team_id, ws)
.await
.map(|t| t.name)
.unwrap_or_default();
children.push(StructureChild {
node_id: b.node_id,
role: b.role,
child_level: "team".into(),
child_id: b.team_id.to_string(),
child_name: name,
});
}
Ok(Json(StructureNode {
level: "company".into(),
id: company.id.to_string(),
name: company.name,
kind: Some(company.kind),
graph: Some(company.graph),
children,
}))
}
"team" => {
let team = cm_db::repo::teams::get_team(&state.pool, id, ws).await?;
let members = cm_db::repo::teams::members_for_team(&state.pool, id).await?;
let mut children = Vec::with_capacity(members.len());
for m in members {
let name = cm_db::repo::agents::get(&state.pool, AgentId::from(m.claw_id))
.await
.ok()
.filter(|a| a.workspace_id == ws)
.map(|a| a.name)
.unwrap_or_default();
children.push(StructureChild {
node_id: m.node_id,
role: m.role,
child_level: "claw".into(),
child_id: m.claw_id.to_string(),
child_name: name,
});
}
Ok(Json(StructureNode {
level: "team".into(),
id: team.id.to_string(),
name: team.name,
kind: Some(team.kind),
graph: Some(team.graph),
children,
}))
}
"claw" => {
let agent = cm_db::repo::agents::get(&state.pool, AgentId::from(id)).await?;
if agent.workspace_id != ws {
return Err(ApiError::NotFound);
}
Ok(Json(StructureNode {
level: "claw".into(),
id: agent.id.to_string(),
name: agent.name,
kind: None,
graph: None,
children: vec![],
}))
}
_ => 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/ensure-chain` — guarantee the workspace has an
/// org + company chain and return coordinates for it. Idempotent: reuses
/// existing rows when present, creates placeholder ones otherwise.
/// Wizards call this before creating a team so first-team-in-a-fresh-
/// workspace lands under real parents instead of synthesized scaffolding.
#[derive(Deserialize, Default)]
pub struct EnsureChainRequest {
/// Placeholder name used only if we have to CREATE the org.
#[serde(default)]
pub fallback_org_name: Option<String>,
/// Placeholder name used only if we have to CREATE the company.
#[serde(default)]
pub fallback_company_name: Option<String>,
}
#[derive(Serialize)]
pub struct EnsuredChainOut {
pub org_id: String,
pub company_id: String,
/// `true` when either row was freshly created by this call.
pub created: bool,
}
pub async fn ensure_chain(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<EnsureChainRequest>,
) -> Result<Json<EnsuredChainOut>, ApiError> {
let org_name = body
.fallback_org_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("My Workspace");
let company_name = body
.fallback_company_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("General");
let r = cm_db::repo::structure_reify::ensure_chain(
&state.pool,
user.workspace_id,
org_name,
company_name,
)
.await?;
Ok(Json(EnsuredChainOut {
org_id: r.org_id.to_string(),
company_id: r.company_id.to_string(),
created: r.created,
}))
}
/// `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,
}),
))
}