//! Topology endpoints: the catalog, structural classification, and building a //! canonical graph from a kind + roles. Stateless (no DB, no provider) — these //! back the topology builder UI. Running/comparing topologies is a later, //! provider-backed endpoint. use axum::extract::{Path, State}; use axum::Json; use cm_orchestrator::{compare, execute, Comparison, JudgeScorer, ProviderExecutor, RunRecord}; use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind}; use serde::{Deserialize, Serialize}; use time::format_description::well_known::Rfc3339; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; /// A role and its suggested share of the team. #[derive(Serialize)] pub struct RoleWeight { pub role: String, pub weight: f32, } /// One supported topology kind, with its description and default role mix. #[derive(Serialize)] pub struct CatalogEntry { pub kind: TopologyKind, pub name: String, pub description: String, pub role_distribution: Vec, } /// `GET /api/topologies` — the catalog of supported topology kinds. pub async fn catalog(_auth: Authed) -> Json> { let entries = TopologyKind::ALL .iter() .map(|&kind| { let h = heuristics(kind); CatalogEntry { kind, name: kind.as_str().to_string(), description: kind.description().to_string(), role_distribution: h .role_distribution .iter() .map(|(role, weight)| RoleWeight { role: (*role).to_string(), weight: *weight, }) .collect(), } }) .collect(); Json(entries) } /// `POST /api/topologies/classify` — infer a topology kind from a graph. pub async fn classify_graph(_auth: Authed, Json(graph): Json) -> Json { Json(classify(&graph)) } /// Request body for building a canonical topology. #[derive(Deserialize)] pub struct BuildRequest { pub kind: TopologyKind, pub roles: Vec, } /// `POST /api/topologies/build` — build a canonical graph from a kind + roles. pub async fn build_graph( _auth: Authed, Json(req): Json, ) -> Result, ApiError> { let roles: Vec<&str> = req.roles.iter().map(String::as_str).collect(); let graph = build(req.kind, &roles).map_err(|_| ApiError::BadRequest)?; Ok(Json(graph)) } /// Request body for running a multi-topology comparison. #[derive(Deserialize)] pub struct CompareRequest { pub task: String, pub graphs: Vec, } /// `POST /api/topologies/compare` — run a task across the given topologies and /// return a leaderboard + quality/cost Pareto front. Uses the configured /// provider for both execution (tool-free turns) and the LLM judge. pub async fn compare_topologies( State(state): State, Authed(user): Authed, Json(req): Json, ) -> Result, ApiError> { let provider = state.runtime.provider(); let model = state.runtime.model().to_string(); let executor = ProviderExecutor::new(provider.clone(), model.clone(), state.runtime.max_tokens()); let scorer = JudgeScorer::new(provider, model, 16); let cmp = compare(&req.graphs, &req.task, &executor, &scorer) .await .map_err(|_| ApiError::Internal)?; // Best-effort persistence: never lose the (expensive) result on a DB hiccup. if let Ok(value) = serde_json::to_value(&cmp) { let _ = cm_db::repo::topology_runs::insert( &state.pool, Uuid::now_v7(), user.workspace_id, &req.task, &value, ) .await; } Ok(Json(cmp)) } /// Request body for executing a single topology on a real agent container. #[derive(Deserialize)] pub struct RunRequest { pub task: String, pub graph: TopologyGraph, } /// `POST /api/topologies/run` — execute one topology by driving real ZeroClaw /// role-agents (in a container) for each turn, returning the run journal. The /// orchestrator owns the graph; agents are tool-free behind the Clawmates MCP /// door, so §15 holds by construction. Result is persisted best-effort to the /// existing `topology_runs` table. pub async fn run_topology( State(state): State, Authed(user): Authed, Json(req): Json, ) -> Result, ApiError> { let executor = crate::topology_exec::ZeroClawDriveExecutor::from_env().map_err(|_| ApiError::Internal)?; let record = execute(&req.graph, &req.task, &executor) .await .map_err(|_| ApiError::Internal)?; if let Ok(value) = serde_json::to_value(&record) { let _ = cm_db::repo::topology_runs::insert( &state.pool, Uuid::now_v7(), user.workspace_id, &req.task, &value, ) .await; } Ok(Json(record)) } /// A saved comparison run, summarized. #[derive(Serialize)] pub struct RunSummary { pub id: String, pub task: String, pub created_at: String, } /// `GET /api/topology-runs` — recent saved comparison runs for the workspace. pub async fn list_runs( State(state): State, Authed(user): Authed, ) -> Result>, ApiError> { let rows = cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, 20).await?; let out = rows .into_iter() .map(|r| RunSummary { id: r.id.to_string(), task: r.task, created_at: r.created_at.format(&Rfc3339).unwrap_or_default(), }) .collect(); Ok(Json(out)) } /// A full saved comparison run. #[derive(Serialize)] pub struct RunDetail { pub id: String, pub task: String, pub created_at: String, pub comparison: serde_json::Value, } /// `GET /api/topology-runs/{id}` — a single saved comparison run. pub async fn get_run( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let run = cm_db::repo::topology_runs::get(&state.pool, id, user.workspace_id).await?; Ok(Json(RunDetail { id: run.id.to_string(), task: run.task, created_at: run.created_at.format(&Rfc3339).unwrap_or_default(), comparison: run.comparison, })) }