loops: iteration timeline — GET /api/topology-runs?loop_id=X

Extend the topology-runs list route with an optional loop_id filter that
returns iterations for a single loop, newest-iteration-first. Adds the
iteration and finished_at columns to the summary (skip-null on the JSON
so compares stay compact). Backed by list_by_loop in the repo, which uses
the existing topology_runs_loop_idx partial index.

LoopsCanvas fetches the runs in parallel with the loop detail and renders
an iteration timeline card (iteration #, status pill, start time, duration,
run id prefix) between the graph section and the actions row.
This commit is contained in:
Omar Sobh
2026-07-06 12:28:34 -07:00
parent af98a79071
commit 6d1dda6197
7 changed files with 414 additions and 14 deletions
+32 -3
View File
@@ -6,7 +6,7 @@
use std::convert::Infallible;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
@@ -216,6 +216,8 @@ pub async fn run_swarm(
}
/// A saved/queued run, summarized (now includes lifecycle status + kind).
/// `iteration` + `finished_at` populate for loop iterations / terminal runs;
/// they're skipped from the JSON when null to keep the compares path compact.
#[derive(Serialize)]
pub struct RunSummary {
pub id: String,
@@ -223,15 +225,40 @@ pub struct RunSummary {
pub status: String,
pub kind: String,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub iteration: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finished_at: Option<String>,
}
/// Query params for `GET /api/topology-runs`. `loop_id` filters to a single
/// loop's iterations, ordered newest-iteration-first.
#[derive(Deserialize)]
pub struct ListRunsQuery {
#[serde(default)]
pub loop_id: Option<Uuid>,
#[serde(default)]
pub limit: Option<i64>,
}
/// `GET /api/topology-runs` — recent runs for the workspace (compares + durable
/// run jobs), newest first.
/// run jobs), newest first. `?loop_id=X` filters to iterations of one loop,
/// ordered by iteration DESC (uses `topology_runs_loop_idx`).
pub async fn list_runs(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ListRunsQuery>,
) -> Result<Json<Vec<RunSummary>>, ApiError> {
let rows = cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, 20).await?;
let limit = q.limit.filter(|n| *n > 0 && *n <= 200).unwrap_or(20);
let rows = match q.loop_id {
Some(loop_id) => {
cm_db::repo::topology_runs::list_by_loop(&state.pool, user.workspace_id, loop_id, limit)
.await?
}
None => {
cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, limit).await?
}
};
let out = rows
.into_iter()
.map(|r| RunSummary {
@@ -240,6 +267,8 @@ pub async fn list_runs(
status: r.status,
kind: r.kind,
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(),
iteration: r.iteration,
finished_at: r.finished_at.and_then(|t| t.format(&Rfc3339).ok()),
})
.collect();
Ok(Json(out))