Files
clawmates/crates/cm-api/src/routes/topology.rs
T
Omar SobhandClaude Opus 4.8 6d77a0acc1
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
Topology P-zc 1A: ZeroClawDriveExecutor — real role-agents over /ws/chat
cm-orchestrator owns the topology graph; each turn now drives a real ZeroClaw
role-agent in a container via the proven gateway drive recipe, instead of a
tool-free cm-llm call.

- crates/cm-api/src/topology_exec.rs: ZeroClawDriveExecutor impl TurnExecutor —
  pair (POST /pair + X-Pairing-Code, token cached) -> ws /ws/chat?agent=<alias>
  -> send {type:message,content} -> drain chunk/done/approval_request/error.
  approval_request is recorded as a BLOCKED GatedAction, never auto-approved (§15).
  Role->alias via ZEROCLAW_AGENT_MAP, fallback ZEROCLAW_DEFAULT_AGENT (scout).
- POST /api/topologies/run {task,graph} -> execute() -> RunRecord, persisted
  best-effort to the existing topology_runs table (no migration). compare stays
  tool-free. from_env() is read in-handler so cm-api still boots unset.
- deploy/clawmates-runtime: example config now declares a tool-free multi-agent
  role-cast; README documents the ZEROCLAW_* knobs + run endpoint.

tokio-tungstenite 0.26 (already in lock) + dev axum `ws` for the hermetic test.
3 lib tests green, clippy clean, SQLX_OFFLINE build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 12:52:54 -07:00

200 lines
6.4 KiB
Rust

//! 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<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))
}
/// Request body for running a multi-topology comparison.
#[derive(Deserialize)]
pub struct CompareRequest {
pub task: String,
pub graphs: Vec<TopologyGraph>,
}
/// `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<AppState>,
Authed(user): Authed,
Json(req): Json<CompareRequest>,
) -> Result<Json<Comparison>, 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<AppState>,
Authed(user): Authed,
Json(req): Json<RunRequest>,
) -> Result<Json<RunRecord>, 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<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<RunSummary>>, 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<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<RunDetail>, 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,
}))
}