Recursive deploy ladder: Company + Org tiers, mesh mark, two-tier rail

Completes the scale ladder (single → team → company → org). Every tier is a
topology whose nodes are the tier below; running a parent recursively runs each
child's sub-topology down to the leaf claws.

Backend:
- migration 0011: companies/company_teams, orgs/org_companies, topology_runs.tier
- cm-db repos for companies + orgs (mirror teams)
- TurnRequest.attrs (forwarded from node.attrs) for child-id binding
- SubTopologyExecutor (recursive_exec.rs): a parent "turn" runs the child's
  sub-topology; durability via parent updated_at keepalive + cancel propagation
  + depth cap; boxed future breaks the org→company recursion
- topology_worker selects executor by job.tier
- routes: /api/companies, /api/orgs (create/list/get/run) + unified
  /api/structure/{level}/{id} for the zoom canvas

Frontend:
- MeshMark: node-mesh brand glyph (replaces the claw PNG), tier variants
- TopologyGraphView: optional onNodeClick/nodeMeta + dark-token theming
- StructureCanvas + Breadcrumb: one recursive zoom view for every tier
  (drill down on node click, breadcrumb up); TeamRunPanel extracted + shared
- two-tier Discord-style rail: StructureRail (mesh mark + org/company/team
  glyphs + tools popover + deploy + user) | RosterColumn (selected group's
  children, or your claws); SecondaryNav for cross-cutting tools
- ComposeWizard (company/org) wired into DeployWizard; /companies + /orgs pages

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-18 14:25:06 -07:00
co-authored by Claude Opus 4.8
parent bba18a4687
commit 3eca4ed70c
58 changed files with 3221 additions and 229 deletions
+151
View File
@@ -0,0 +1,151 @@
//! 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::Json;
use cm_domain::AgentId;
use serde::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>,
}
/// `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),
}
}