feat(topology): persist comparison runs + history
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:
co-authored by
Claude Opus 4.8
parent
654ec0f511
commit
7baf2082d0
+35
@@ -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"
|
||||||
|
}
|
||||||
+41
@@ -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"
|
||||||
|
}
|
||||||
+17
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user