feat(topology): persist comparison runs + history
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

Save each comparison and let users reload past ones.
- migration 0008: topology_runs (workspace-scoped; full comparison as JSONB).
- cm-db repo::topology_runs (insert / list_recent / get) + regenerated .sqlx.
- cm-api: compare persists best-effort (never loses the LLM result on a DB
  hiccup); GET /api/topology-runs (recent) + GET /api/topology-runs/{id}.
  Integration test asserts persist → list → get.
- frontend: "Recent comparisons" list on the Compare tab; click to reload a
  saved run. e2e p8 green (39 suite); offline build + clippy clean.

Server self-migrates at boot (cm_db::MIGRATOR), so 0008 applies on deploy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 05:11:52 -07:00
co-authored by Claude Opus 4.8
parent 654ec0f511
commit 7baf2082d0
10 changed files with 345 additions and 3 deletions
+65 -2
View File
@@ -3,11 +3,13 @@
//! back the topology builder UI. Running/comparing topologies is a later,
//! provider-backed endpoint.
use axum::extract::State;
use axum::extract::{Path, State};
use axum::Json;
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
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};
@@ -85,7 +87,7 @@ pub struct CompareRequest {
/// provider for both execution (tool-free turns) and the LLM judge.
pub async fn compare_topologies(
State(state): State<AppState>,
_auth: Authed,
Authed(user): Authed,
Json(req): Json<CompareRequest>,
) -> Result<Json<Comparison>, ApiError> {
let provider = state.runtime.provider();
@@ -96,5 +98,66 @@ pub async fn compare_topologies(
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))
}
/// 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,
}))
}