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]>
200 lines
6.4 KiB
Rust
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,
|
|
}))
|
|
}
|