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.
315 lines
11 KiB
Rust
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,
|
|
}),
|
|
))
|
|
}
|