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
+225
View File
@@ -0,0 +1,225 @@
//! Company endpoints — deploy a baseline topology staffed with real *teams*,
//! then run it on the durable recursive runner. The 3rd rung of the deploy
//! ladder (single → team → company → org). Unlike teams, creating a company
//! provisions nothing new: it composes teams that already own provisioned claws.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use cm_topology::{build, TopologyKind};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// One bound team in the company (becomes a topology node).
#[derive(Deserialize)]
pub struct CompanyMemberInput {
pub team_id: Uuid,
#[serde(default)]
pub role: String,
}
#[derive(Deserialize)]
pub struct CreateCompanyRequest {
pub name: String,
/// TopologyKind (snake_case), e.g. "hierarchical", "pipeline".
pub kind: String,
pub members: Vec<CompanyMemberInput>,
}
#[derive(Serialize)]
pub struct CompanyCreated {
pub company_id: String,
}
fn parse_kind(s: &str) -> Result<TopologyKind, ApiError> {
serde_json::from_value(Value::String(s.to_string())).map_err(|_| ApiError::BadRequest)
}
/// `POST /api/companies` — build the baseline topology over the chosen teams,
/// bind each node to its team, and persist. Validates every team belongs to the
/// workspace first (so a company can only compose teams the caller owns).
pub async fn create_company(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateCompanyRequest>,
) -> Result<(StatusCode, Json<CompanyCreated>), ApiError> {
if body.members.is_empty() {
return Err(ApiError::BadRequest);
}
let kind = parse_kind(&body.kind)?;
// 1. Validate each bound team is real + in this workspace.
for m in &body.members {
cm_db::repo::teams::get_team(&state.pool, m.team_id, user.workspace_id).await?;
}
// 2. Build the topology and bind each node to its team. We write the team id
// into BOTH attrs["team_id"] (descriptive) and attrs["agent"] (so the
// orchestrator forwards it through TurnRequest exactly like a claw alias).
let roles: Vec<&str> = body
.members
.iter()
.map(|m| {
if m.role.is_empty() {
"team"
} else {
m.role.as_str()
}
})
.collect();
let mut graph = build(kind, &roles).map_err(|_| ApiError::BadRequest)?;
for (i, node) in graph.nodes.iter_mut().enumerate() {
if let Some(m) = body.members.get(i) {
node.attrs.insert("team_id".into(), m.team_id.to_string());
node.attrs.insert("agent".into(), m.team_id.to_string());
}
}
// 3. Persist company + node→team bindings.
let company_id = Uuid::now_v7();
let graph_json = serde_json::to_value(&graph).map_err(|_| ApiError::Internal)?;
cm_db::repo::companies::insert_company(
&state.pool,
company_id,
user.workspace_id,
&body.name,
kind.as_str(),
&graph_json,
)
.await?;
for (i, node) in graph.nodes.iter().enumerate() {
if let Some(m) = body.members.get(i) {
cm_db::repo::companies::add_team(
&state.pool,
company_id,
&node.id,
m.team_id,
&node.role,
)
.await?;
}
}
Ok((
StatusCode::CREATED,
Json(CompanyCreated {
company_id: company_id.to_string(),
}),
))
}
#[derive(Serialize)]
pub struct CompanySummaryOut {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
}
/// `GET /api/companies` — recent companies for the workspace.
pub async fn list_companies(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<CompanySummaryOut>>, ApiError> {
let rows =
cm_db::repo::companies::list_for_workspace(&state.pool, user.workspace_id, 50).await?;
Ok(Json(
rows.into_iter()
.map(|c| CompanySummaryOut {
id: c.id.to_string(),
name: c.name,
kind: c.kind,
status: c.status,
created_at: c.created_at.format(&Rfc3339).unwrap_or_default(),
})
.collect(),
))
}
#[derive(Serialize)]
pub struct CompanyTeamOut {
pub node_id: String,
pub team_id: String,
pub role: String,
}
#[derive(Serialize)]
pub struct CompanyDetail {
pub id: String,
pub name: String,
pub kind: String,
pub status: String,
pub created_at: String,
pub graph: Value,
pub members: Vec<CompanyTeamOut>,
}
/// `GET /api/companies/{id}` — a company's graph + node→team bindings.
pub async fn get_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<CompanyDetail>, ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
let members = cm_db::repo::companies::teams_for_company(&state.pool, id).await?;
Ok(Json(CompanyDetail {
id: company.id.to_string(),
name: company.name,
kind: company.kind,
status: company.status,
created_at: company.created_at.format(&Rfc3339).unwrap_or_default(),
graph: company.graph,
members: members
.into_iter()
.map(|m| CompanyTeamOut {
node_id: m.node_id,
team_id: m.team_id.to_string(),
role: m.role,
})
.collect(),
}))
}
#[derive(Deserialize)]
pub struct RunCompanyRequest {
pub task: String,
}
#[derive(Serialize)]
pub struct RunAccepted {
pub run_id: String,
pub status: String,
}
/// `POST /api/companies/{id}/run` — enqueue a durable `company`-tier run; the
/// worker drives the recursive executor (each node runs its team's topology).
pub async fn run_company(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
Json(body): Json<RunCompanyRequest>,
) -> Result<(StatusCode, Json<RunAccepted>), ApiError> {
let company = cm_db::repo::companies::get(&state.pool, id, user.workspace_id).await?;
let run_id = Uuid::now_v7();
cm_db::repo::topology_runs::enqueue_run_tier(
&state.pool,
run_id,
user.workspace_id,
&body.task,
&company.graph,
"company",
)
.await?;
Ok((
StatusCode::ACCEPTED,
Json(RunAccepted {
run_id: run_id.to_string(),
status: "queued".into(),
}),
))
}