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
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, task, created_at FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "task",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "8675cedae691e400bc935dde9b05b1ee71fe7494003dbd36c5a801c77c3d97ac"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, task, comparison, created_at FROM topology_runs\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "task",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "comparison",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "b4f2ebe304fa3b4722584bfe6c3df389b46d52724fc9b137dbe83b3d4fce2983"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO topology_runs (id, workspace_id, task, comparison)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "d0af8ed0859959bf080a23068c3fce9fd80c6a0646010d8682b9f37706ffdd00"
}
+2
View File
@@ -139,6 +139,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/topologies/classify", post(routes::topology::classify_graph)) .route("/api/topologies/classify", post(routes::topology::classify_graph))
.route("/api/topologies/build", post(routes::topology::build_graph)) .route("/api/topologies/build", post(routes::topology::build_graph))
.route("/api/topologies/compare", post(routes::topology::compare_topologies)) .route("/api/topologies/compare", post(routes::topology::compare_topologies))
.route("/api/topology-runs", get(routes::topology::list_runs))
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
+65 -2
View File
@@ -3,11 +3,13 @@
//! back the topology builder UI. Running/comparing topologies is a later, //! back the topology builder UI. Running/comparing topologies is a later,
//! provider-backed endpoint. //! provider-backed endpoint.
use axum::extract::State; use axum::extract::{Path, State};
use axum::Json; use axum::Json;
use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor}; use cm_orchestrator::{compare, Comparison, JudgeScorer, ProviderExecutor};
use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind}; use cm_topology::{build, classify, heuristics, Classification, TopologyGraph, TopologyKind};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use time::format_description::well_known::Rfc3339;
use uuid::Uuid;
use crate::{ApiError, AppState, Authed}; use crate::{ApiError, AppState, Authed};
@@ -85,7 +87,7 @@ pub struct CompareRequest {
/// provider for both execution (tool-free turns) and the LLM judge. /// provider for both execution (tool-free turns) and the LLM judge.
pub async fn compare_topologies( pub async fn compare_topologies(
State(state): State<AppState>, State(state): State<AppState>,
_auth: Authed, Authed(user): Authed,
Json(req): Json<CompareRequest>, Json(req): Json<CompareRequest>,
) -> Result<Json<Comparison>, ApiError> { ) -> Result<Json<Comparison>, ApiError> {
let provider = state.runtime.provider(); let provider = state.runtime.provider();
@@ -96,5 +98,66 @@ pub async fn compare_topologies(
let cmp = compare(&req.graphs, &req.task, &executor, &scorer) let cmp = compare(&req.graphs, &req.task, &executor, &scorer)
.await .await
.map_err(|_| ApiError::Internal)?; .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)) 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,
}))
}
+28
View File
@@ -369,6 +369,34 @@ async fn topology_compare_runs_across_topologies() {
let cmp: Value = res.json().await.unwrap(); let cmp: Value = res.json().await.unwrap();
assert_eq!(cmp["results"].as_array().unwrap().len(), 2); assert_eq!(cmp["results"].as_array().unwrap().len(), 2);
assert_eq!(cmp["leaderboard"].as_array().unwrap().len(), 2); assert_eq!(cmp["leaderboard"].as_array().unwrap().len(), 2);
// The run was persisted and is listable + fetchable.
let runs: Value = server
.client
.get(format!("{}/api/topology-runs", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let list = runs.as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["task"], "draft a launch plan");
let id = list[0]["id"].as_str().unwrap();
let detail: Value = server
.client
.get(format!("{}/api/topology-runs/{id}", server.base))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(detail["comparison"]["results"].as_array().unwrap().len(), 2);
} }
#[tokio::test] #[tokio::test]
+1
View File
@@ -11,5 +11,6 @@ pub mod sessions;
pub mod skills; pub mod skills;
pub mod steps; pub mod steps;
pub mod threads; pub mod threads;
pub mod topology_runs;
pub mod users; pub mod users;
pub mod workspaces; pub mod workspaces;
+92
View File
@@ -0,0 +1,92 @@
//! Persistence for saved multi-topology comparison runs.
use cm_domain::WorkspaceId;
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::DbError;
/// A row summary for the recent-runs list.
pub struct TopologyRunSummary {
pub id: Uuid,
pub task: String,
pub created_at: OffsetDateTime,
}
/// A full saved comparison run.
pub struct TopologyRun {
pub id: Uuid,
pub task: String,
pub comparison: Value,
pub created_at: OffsetDateTime,
}
/// Save a comparison run for a workspace.
pub async fn insert(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
task: &str,
comparison: &Value,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO topology_runs (id, workspace_id, task, comparison)
VALUES ($1, $2, $3, $4)",
id,
workspace_id.as_uuid(),
task,
comparison,
)
.execute(pool)
.await?;
Ok(())
}
/// The most recent runs for a workspace, newest first.
pub async fn list_recent(
pool: &PgPool,
workspace_id: WorkspaceId,
limit: i64,
) -> Result<Vec<TopologyRunSummary>, DbError> {
let rows = sqlx::query!(
"SELECT id, task, created_at FROM topology_runs
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
workspace_id.as_uuid(),
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| TopologyRunSummary {
id: r.id,
task: r.task,
created_at: r.created_at,
})
.collect())
}
/// A single saved run, scoped to its workspace.
pub async fn get(
pool: &PgPool,
id: Uuid,
workspace_id: WorkspaceId,
) -> Result<TopologyRun, DbError> {
let row = sqlx::query!(
"SELECT id, task, comparison, created_at FROM topology_runs
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id.as_uuid(),
)
.fetch_optional(pool)
.await?
.ok_or(DbError::NotFound)?;
Ok(TopologyRun {
id: row.id,
task: row.task,
comparison: row.comparison,
created_at: row.created_at,
})
}
@@ -1,9 +1,15 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology"; import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
interface RunSummary {
id: string;
task: string;
created_at: string;
}
interface CompareResult { interface CompareResult {
kind: string; kind: string;
quality: number; quality: number;
@@ -28,6 +34,33 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
const [cmp, setCmp] = useState<Comparison | null>(null); const [cmp, setCmp] = useState<Comparison | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [runs, setRuns] = useState<RunSummary[]>([]);
async function loadRuns() {
try {
const res = await fetch("/api/topology-runs");
if (res.ok) setRuns((await res.json()) as RunSummary[]);
} catch {
/* recent list is best-effort */
}
}
useEffect(() => {
void loadRuns();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function loadRun(id: string) {
try {
const res = await fetch(`/api/topology-runs/${id}`);
if (res.ok) {
const detail = (await res.json()) as { comparison: Comparison };
setCmp(detail.comparison);
}
} catch {
/* ignore */
}
}
function toggle(kind: string) { function toggle(kind: string) {
setSelected((s) => { setSelected((s) => {
@@ -63,6 +96,7 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
}); });
if (!res.ok) throw new Error(`Compare failed (${res.status})`); if (!res.ok) throw new Error(`Compare failed (${res.status})`);
setCmp((await res.json()) as Comparison); setCmp((await res.json()) as Comparison);
void loadRuns();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Compare failed"); setError(e instanceof Error ? e.message : "Compare failed");
} finally { } finally {
@@ -120,6 +154,22 @@ export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
{error && <p className="text-sm text-coral">{error}</p>} {error && <p className="text-sm text-coral">{error}</p>}
{cmp && <Results cmp={cmp} />} {cmp && <Results cmp={cmp} />}
{runs.length > 0 && (
<div className="flex flex-col gap-1 border-t border-border pt-4">
<p className="text-xs font-medium text-muted-foreground">Recent comparisons</p>
{runs.map((r) => (
<button
key={r.id}
type="button"
onClick={() => loadRun(r.id)}
className="rounded-lg px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-surface-warm/50"
>
{r.task}
</button>
))}
</div>
)}
</div> </div>
); );
} }
+13
View File
@@ -0,0 +1,13 @@
-- Saved multi-topology comparison runs (the Topologies "Compare" view). The
-- full result — per-topology metrics, leaderboard, and Pareto flags — is stored
-- as JSONB so the schema stays stable as the comparison shape evolves.
CREATE TABLE topology_runs (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
task TEXT NOT NULL,
comparison JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX topology_runs_workspace_idx
ON topology_runs (workspace_id, created_at DESC);