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:
@@ -2,8 +2,8 @@
|
||||
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
|
||||
//! foundation that lets long-horizon topology runs survive worker restarts.
|
||||
|
||||
use cm_db::repo::{topology_runs, workspaces};
|
||||
use cm_domain::{Workspace, WorkspaceId};
|
||||
use cm_db::repo::{loops, topology_runs, users, workspaces};
|
||||
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -131,3 +131,111 @@ async fn cancel_transitions_only_active_runs() {
|
||||
.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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user