feat(api): topology endpoints (catalog / classify / build)
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Stateless, auth-gated routes backing the topology builder UI:
- GET  /api/topologies          — catalog of the 12 kinds + descriptions + role mix
- POST /api/topologies/classify — infer a kind + metrics from a posted graph
- POST /api/topologies/build    — build a canonical graph from {kind, roles}

Add Serialize to cm-topology Classification/GraphMetrics; add ApiError::BadRequest
(400) for invalid build input; add cm-topology dep. 3 integration tests
(catalog/build/auth) green against Postgres; offline build + clippy clean.

No DB or provider yet — running/comparing topologies is a later provider-backed
endpoint. Server redeploy will batch with the ReactFlow UI that consumes these.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-15 22:04:26 -07:00
co-authored by Claude Opus 4.8
parent b0c122b88d
commit fa7e5dec5f
8 changed files with 155 additions and 2 deletions
Generated
+1
View File
@@ -496,6 +496,7 @@ dependencies = [
"cm-scheduler", "cm-scheduler",
"cm-secrets", "cm-secrets",
"cm-testkit", "cm-testkit",
"cm-topology",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"hex", "hex",
+1
View File
@@ -26,6 +26,7 @@ cm-runtime = { path = "../cm-runtime" }
cm-safety = { path = "../cm-safety" } cm-safety = { path = "../cm-safety" }
cm-scheduler = { path = "../cm-scheduler" } cm-scheduler = { path = "../cm-scheduler" }
cm-secrets = { path = "../cm-secrets" } cm-secrets = { path = "../cm-secrets" }
cm-topology = { path = "../cm-topology" }
thiserror = { workspace = true } thiserror = { workspace = true }
tower-http = { version = "0.6", features = ["trace"] } tower-http = { version = "0.6", features = ["trace"] }
time = { workspace = true } time = { workspace = true }
+3
View File
@@ -7,6 +7,8 @@ use serde_json::json;
/// server-side, never echoed to clients. /// server-side, never echoed to clients.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ApiError { pub enum ApiError {
#[error("bad request")]
BadRequest,
#[error("unauthorized")] #[error("unauthorized")]
Unauthorized, Unauthorized,
#[error("forbidden")] #[error("forbidden")]
@@ -48,6 +50,7 @@ impl From<cm_auth::AuthError> for ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = match self { let status = match self {
ApiError::BadRequest => StatusCode::BAD_REQUEST,
ApiError::Unauthorized => StatusCode::UNAUTHORIZED, ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN, ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::NotFound => StatusCode::NOT_FOUND,
+3
View File
@@ -135,6 +135,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/billing/config", get(routes::billing::billing_config)) .route("/api/billing/config", get(routes::billing::billing_config))
.route("/api/billing/stripe", post(routes::billing::stripe_webhook)) .route("/api/billing/stripe", post(routes::billing::stripe_webhook))
.route("/api/team/permissions", get(routes::team::permissions)) .route("/api/team/permissions", get(routes::team::permissions))
.route("/api/topologies", get(routes::topology::catalog))
.route("/api/topologies/classify", post(routes::topology::classify_graph))
.route("/api/topologies/build", post(routes::topology::build_graph))
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
+1
View File
@@ -15,3 +15,4 @@ pub mod sessions;
pub mod skills; pub mod skills;
pub mod slack; pub mod slack;
pub mod team; pub mod team;
pub mod topology;
+72
View File
@@ -0,0 +1,72 @@
//! 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::Json;
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
use serde::{Deserialize, Serialize};
use crate::{ApiError, 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<RoleWeight>,
}
/// `GET /api/topologies` — the catalog of supported topology kinds.
pub async fn catalog(_auth: Authed) -> Json<Vec<CatalogEntry>> {
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<TopologyGraph>) -> Json<Classification> {
Json(classify(&graph))
}
/// Request body for building a canonical topology.
#[derive(Deserialize)]
pub struct BuildRequest {
pub kind: TopologyKind,
pub roles: Vec<String>,
}
/// `POST /api/topologies/build` — build a canonical graph from a kind + roles.
pub async fn build_graph(
_auth: Authed,
Json(req): Json<BuildRequest>,
) -> Result<Json<TopologyGraph>, 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))
}
+70
View File
@@ -278,3 +278,73 @@ async fn team_members_lists_workspace_users() {
assert_eq!(list[0]["role"], "owner"); assert_eq!(list[0]["role"], "owner");
assert_eq!(list[0]["display_name"], "Owner"); assert_eq!(list[0]["display_name"], "Owner");
} }
#[tokio::test]
async fn topology_catalog_lists_all_kinds() {
let pool = cm_testkit::test_pool().await;
seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let catalog: Value = server
.client
.get(format!("{}/api/topologies", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let kinds = catalog.as_array().unwrap();
assert_eq!(kinds.len(), 12);
assert!(kinds.iter().any(|k| k["kind"] == "hierarchical"));
assert!(!kinds[0]["role_distribution"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn topology_build_returns_a_canonical_graph() {
let pool = cm_testkit::test_pool().await;
seed(&pool).await;
let server = serve(pool).await;
let token = login(&server).await;
let graph: Value = server
.client
.post(format!("{}/api/topologies/build", server.base))
.bearer_auth(&token)
.json(&json!({ "kind": "pipeline", "roles": ["a", "b", "c"] }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(graph["kind"], "pipeline");
assert_eq!(graph["nodes"].as_array().unwrap().len(), 3);
assert_eq!(graph["edges"].as_array().unwrap().len(), 2);
// build rejects empty roles with 400.
let bad = server
.client
.post(format!("{}/api/topologies/build", server.base))
.bearer_auth(&token)
.json(&json!({ "kind": "pipeline", "roles": [] }))
.send()
.await
.unwrap();
assert_eq!(bad.status(), 400);
}
#[tokio::test]
async fn topology_endpoints_require_auth() {
let pool = cm_testkit::test_pool().await;
let server = serve(pool).await;
let res = server
.client
.get(format!("{}/api/topologies", server.base))
.send()
.await
.unwrap();
assert_eq!(res.status(), 401);
}
+4 -2
View File
@@ -8,12 +8,14 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use serde::Serialize;
use crate::graph::TopologyGraph; use crate::graph::TopologyGraph;
use crate::kind::TopologyKind; use crate::kind::TopologyKind;
/// Objective structural metrics for a topology graph (treated undirected for /// Objective structural metrics for a topology graph (treated undirected for
/// connectivity, directed for hierarchy). /// connectivity, directed for hierarchy).
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct GraphMetrics { pub struct GraphMetrics {
/// Number of nodes. /// Number of nodes.
pub order: usize, pub order: usize,
@@ -38,7 +40,7 @@ pub struct GraphMetrics {
} }
/// The result of [`classify`]. /// The result of [`classify`].
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct Classification { pub struct Classification {
/// Best-matching topology kind. /// Best-matching topology kind.
pub primary: TopologyKind, pub primary: TopologyKind,