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
@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, task, status, kind, created_at, iteration, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 AND loop_id = $2\n ORDER BY iteration DESC NULLS LAST, created_at DESC\n LIMIT $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "task",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "iteration",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "finished_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "a552e2bdbbcf567e1ce057acfe7e5395fd58dad9887dc0c26b87fb993ce2b769"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, task, status, kind, created_at FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2", "query": "SELECT id, task, status, kind, created_at, iteration, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -27,6 +27,16 @@
"ordinal": 4, "ordinal": 4,
"name": "created_at", "name": "created_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "iteration",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "finished_at",
"type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -40,8 +50,10 @@
false, false,
false, false,
false, false,
false false,
true,
true
] ]
}, },
"hash": "c2ea3efe8d13800dc95fc21984a770281dce7d518cdc05941736e3bf628c4876" "hash": "e7a8b969ddd7fa1e1cc72082e6c39c3295f60b30e274a02cb1d2e6c7aed3da8b"
} }
+32 -3
View File
@@ -6,7 +6,7 @@
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use axum::extract::{Path, State}; use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse; use axum::response::IntoResponse;
@@ -216,6 +216,8 @@ pub async fn run_swarm(
} }
/// A saved/queued run, summarized (now includes lifecycle status + kind). /// 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)] #[derive(Serialize)]
pub struct RunSummary { pub struct RunSummary {
pub id: String, pub id: String,
@@ -223,15 +225,40 @@ pub struct RunSummary {
pub status: String, pub status: String,
pub kind: String, pub kind: String,
pub created_at: 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 /// `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( pub async fn list_runs(
State(state): State<AppState>, State(state): State<AppState>,
Authed(user): Authed, Authed(user): Authed,
Query(q): Query<ListRunsQuery>,
) -> Result<Json<Vec<RunSummary>>, ApiError> { ) -> 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 let out = rows
.into_iter() .into_iter()
.map(|r| RunSummary { .map(|r| RunSummary {
@@ -240,6 +267,8 @@ pub async fn list_runs(
status: r.status, status: r.status,
kind: r.kind, kind: r.kind,
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(), 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(); .collect();
Ok(Json(out)) Ok(Json(out))
+110 -2
View File
@@ -2,8 +2,8 @@
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the //! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
//! foundation that lets long-horizon topology runs survive worker restarts. //! foundation that lets long-horizon topology runs survive worker restarts.
use cm_db::repo::{topology_runs, workspaces}; use cm_db::repo::{loops, topology_runs, users, workspaces};
use cm_domain::{Workspace, WorkspaceId}; use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
@@ -131,3 +131,111 @@ async fn cancel_transitions_only_active_runs() {
.unwrap(); .unwrap();
assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap()); assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap());
} }
#[tokio::test]
async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
let pool = cm_testkit::test_pool().await;
let ws = seed_workspace(&pool).await;
// A loop needs a valid `created_by` user in the same workspace.
let user = User {
id: UserId::new(),
workspace_id: ws,
email: "[email protected]".into(),
role: Role::Owner,
display_name: "Hop".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
users::insert(&pool, &user).await.unwrap();
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n1","role":"drafter"}], "edges": []});
let triggers = json!({});
let repeat = json!({"kind": "infinite"});
let loop_id = loops::create(
&pool,
loops::NewLoop {
workspace_id: ws.as_uuid(),
title: "L",
description: "d",
graph: &graph,
task_template: "task",
triggers: &triggers,
repeat_policy: &repeat,
enabled: true,
next_fire_at: None,
webhook_token: None,
webhook_signing_key: None,
created_by: user.id.as_uuid(),
},
)
.await
.unwrap();
// Three iterations of the target loop, plus a naked run + another loop's
// iteration that should be filtered out.
let it1 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t1", &graph, 1, None)
.await
.unwrap();
let it2 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t2", &graph, 2, Some(it1))
.await
.unwrap();
let it3 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t3", &graph, 3, Some(it2))
.await
.unwrap();
topology_runs::enqueue_run(&pool, Uuid::now_v7(), ws, "naked", &graph)
.await
.unwrap();
let other_loop = loops::create(
&pool,
loops::NewLoop {
workspace_id: ws.as_uuid(),
title: "L2",
description: "d2",
graph: &graph,
task_template: "task2",
triggers: &triggers,
repeat_policy: &repeat,
enabled: true,
next_fire_at: None,
webhook_token: None,
webhook_signing_key: None,
created_by: user.id.as_uuid(),
},
)
.await
.unwrap();
let _other_it = loops::enqueue_iteration(
&pool,
other_loop,
ws.as_uuid(),
"other",
&graph,
1,
None,
)
.await
.unwrap();
let rows = topology_runs::list_by_loop(&pool, ws, loop_id, 20)
.await
.unwrap();
assert_eq!(rows.len(), 3, "only the target loop's iterations");
// Newest iteration first.
assert_eq!(rows[0].iteration, Some(3));
assert_eq!(rows[0].id, it3);
assert_eq!(rows[1].iteration, Some(2));
assert_eq!(rows[1].id, it2);
assert_eq!(rows[2].iteration, Some(1));
assert_eq!(rows[2].id, it1);
// Each iteration is still a durable run — finished_at is None until completion.
assert!(rows.iter().all(|r| r.finished_at.is_none()));
assert!(rows.iter().all(|r| r.kind == "run"));
// Wrong workspace: nothing.
let other_ws = seed_workspace(&pool).await;
let cross = topology_runs::list_by_loop(&pool, other_ws, loop_id, 20)
.await
.unwrap();
assert!(cross.is_empty(), "loops are scoped by workspace");
}
+43 -2
View File
@@ -10,13 +10,17 @@ use uuid::Uuid;
use crate::DbError; use crate::DbError;
/// A row summary for the recent-runs list. /// A row summary for the recent-runs list. `iteration` and `finished_at`
/// are populated for loop iterations and for terminal runs respectively;
/// `None` for compares or still-in-flight runs.
pub struct TopologyRunSummary { pub struct TopologyRunSummary {
pub id: Uuid, pub id: Uuid,
pub task: String, pub task: String,
pub status: String, pub status: String,
pub kind: String, pub kind: String,
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
pub iteration: Option<i32>,
pub finished_at: Option<OffsetDateTime>,
} }
/// A full saved comparison run. /// A full saved comparison run.
@@ -271,7 +275,8 @@ pub async fn list_recent(
limit: i64, limit: i64,
) -> Result<Vec<TopologyRunSummary>, DbError> { ) -> Result<Vec<TopologyRunSummary>, DbError> {
let rows = sqlx::query!( let rows = sqlx::query!(
"SELECT id, task, status, kind, created_at FROM topology_runs "SELECT id, task, status, kind, created_at, iteration, finished_at
FROM topology_runs
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2", WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
workspace_id.as_uuid(), workspace_id.as_uuid(),
limit, limit,
@@ -286,6 +291,42 @@ pub async fn list_recent(
status: r.status, status: r.status,
kind: r.kind, kind: r.kind,
created_at: r.created_at, created_at: r.created_at,
iteration: r.iteration,
finished_at: r.finished_at,
})
.collect())
}
/// Iterations of a loop, newest first. Uses the partial index
/// `topology_runs_loop_idx` on `(loop_id, iteration DESC)`.
pub async fn list_by_loop(
pool: &PgPool,
workspace_id: WorkspaceId,
loop_id: Uuid,
limit: i64,
) -> Result<Vec<TopologyRunSummary>, DbError> {
let rows = sqlx::query!(
"SELECT id, task, status, kind, created_at, iteration, finished_at
FROM topology_runs
WHERE workspace_id = $1 AND loop_id = $2
ORDER BY iteration DESC NULLS LAST, created_at DESC
LIMIT $3",
workspace_id.as_uuid(),
loop_id,
limit,
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| TopologyRunSummary {
id: r.id,
task: r.task,
status: r.status,
kind: r.kind,
created_at: r.created_at,
iteration: r.iteration,
finished_at: r.finished_at,
}) })
.collect()) .collect())
} }
@@ -11,8 +11,10 @@ import {
disableLoop, disableLoop,
enableLoop, enableLoop,
getLoop, getLoop,
listLoopRuns,
runLoopNow, runLoopNow,
type Loop, type Loop,
type RunSummary,
} from "@/lib/api/loops"; } from "@/lib/api/loops";
const mono = const mono =
@@ -30,6 +32,8 @@ export function LoopsCanvas({
onDeleted: () => void; onDeleted: () => void;
}) { }) {
const [loop, setLoop] = useState<Loop | null>(null); const [loop, setLoop] = useState<Loop | null>(null);
const [runs, setRuns] = useState<RunSummary[]>([]);
const [runsLoading, setRunsLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -39,18 +43,31 @@ export function LoopsCanvas({
let alive = true; let alive = true;
const load = async () => { const load = async () => {
if (!selectedId) { if (!selectedId) {
if (alive) setLoop(null); if (alive) {
setLoop(null);
setRuns([]);
}
return; return;
} }
setLoading(true); setLoading(true);
setRunsLoading(true);
setError(null); setError(null);
try { try {
const l = await getLoop(selectedId); const [l, r] = await Promise.all([
if (alive) setLoop(l); getLoop(selectedId),
listLoopRuns(selectedId).catch(() => [] as RunSummary[]),
]);
if (alive) {
setLoop(l);
setRuns(r);
}
} catch (e) { } catch (e) {
if (alive) setError(e instanceof Error ? e.message : "load failed"); if (alive) setError(e instanceof Error ? e.message : "load failed");
} finally { } finally {
if (alive) setLoading(false); if (alive) {
setLoading(false);
setRunsLoading(false);
}
} }
}; };
load(); load();
@@ -233,6 +250,11 @@ export function LoopsCanvas({
</pre> </pre>
</Section> </Section>
{/* Iterations timeline */}
<Section header={`Iterations${runs.length ? ` · ${runs.length}` : ""}`}>
<IterationsTimeline runs={runs} loading={runsLoading} />
</Section>
{/* Actions */} {/* Actions */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<button <button
@@ -289,6 +311,117 @@ export function LoopsCanvas({
); );
} }
function IterationsTimeline({
runs,
loading,
}: {
runs: RunSummary[];
loading: boolean;
}) {
if (loading && runs.length === 0) {
return <p style={hintStyle}>Loading iterations…</p>;
}
if (runs.length === 0) {
return <p style={hintStyle}>No iterations yet — hit &quot;Run now&quot; to fire one.</p>;
}
return (
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{runs.map((r) => {
const started = new Date(r.created_at);
const finished = r.finished_at ? new Date(r.finished_at) : null;
const durationSec = finished
? Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
: null;
return (
<li
key={r.id}
style={{
display: "grid",
gridTemplateColumns: "48px 90px 1fr auto",
alignItems: "center",
gap: 12,
padding: "8px 12px",
borderRadius: 8,
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.05)",
fontFamily: mono,
fontSize: 12,
color: "#eaeaee",
}}
>
<span
style={{
color: "#5a5a62",
fontVariantNumeric: "tabular-nums",
}}
>
#{r.iteration ?? "—"}
</span>
<StatusPill status={r.status} />
<span style={{ color: "#b5b5bd" }}>
{started.toLocaleString()}
{durationSec !== null && (
<span style={{ color: "#5a5a62" }}> · {formatDuration(durationSec)}</span>
)}
</span>
<span
style={{ color: "#5a5a62", fontSize: 10.5, letterSpacing: ".05em" }}
title={r.id}
>
{r.id.slice(0, 8)}
</span>
</li>
);
})}
</ul>
);
}
function StatusPill({ status }: { status: string }) {
const color =
status === "completed"
? "#5fd08a"
: status === "failed"
? "#ff8a7a"
: status === "cancelled"
? "#c8a464"
: status === "running"
? "#7fbcff"
: "#8a8a92";
return (
<span
style={{
color,
fontSize: 10.5,
letterSpacing: ".1em",
textTransform: "uppercase",
fontWeight: 700,
}}
>
{status}
</span>
);
}
function formatDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m < 60) return s ? `${m}m ${s}s` : `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm ? `${h}h ${rm}m` : `${h}h`;
}
function Section({ function Section({
header, header,
children, children,
+17
View File
@@ -93,3 +93,20 @@ export const enableLoop = (id: string) =>
export const disableLoop = (id: string) => export const disableLoop = (id: string) =>
api<void>(`/api/loops/${id}/disable`, { method: "POST" }); api<void>(`/api/loops/${id}/disable`, { method: "POST" });
// One iteration/run of a loop as summarized by /api/topology-runs.
// `iteration`/`finished_at` are populated for loop iterations / terminal runs.
export interface RunSummary {
id: string;
task: string;
status: string;
kind: string;
created_at: string;
iteration?: number;
finished_at?: string;
}
export const listLoopRuns = (loopId: string, limit = 20) =>
api<RunSummary[]>(
`/api/topology-runs?loop_id=${encodeURIComponent(loopId)}&limit=${limit}`,
);