research: auto-transition processing → reviewing on last run
Hooks the topology_worker's post-terminal path into a new notify_run_completed repo helper that atomically transitions the topic processing → reviewing when the completed run has research_topic_id set AND no siblings for that topic are still queued or running. Guarded on status='processing' so a retry, a re-fire, or a topic already past processing are all no-ops. Best-effort at the worker; DB hiccups are logged and never fail the run. The manual /submit-review endpoint stays as an escape hatch for topics that end up parked in processing with nothing to complete (updated the doc comment).
This commit is contained in:
@@ -2,11 +2,51 @@
|
||||
//! (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::{loops, topology_runs, users, workspaces};
|
||||
use cm_db::repo::{loops, research_topics, topology_runs, users, workspaces};
|
||||
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId, email: &str) -> UserId {
|
||||
let user = User {
|
||||
id: UserId::new(),
|
||||
workspace_id: ws,
|
||||
email: email.into(),
|
||||
role: Role::Owner,
|
||||
display_name: "Owner".into(),
|
||||
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||
};
|
||||
users::insert(pool, &user).await.unwrap();
|
||||
user.id
|
||||
}
|
||||
|
||||
/// Enqueue a durable topology run with `research_topic_id` set. Kept in the
|
||||
/// test file to avoid a production repo helper for the topic-scoped enqueue
|
||||
/// path (nothing else in the codebase writes this column yet).
|
||||
async fn enqueue_run_with_topic(
|
||||
pool: &sqlx::PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
task: &str,
|
||||
topic_id: Uuid,
|
||||
) -> Uuid {
|
||||
let id = Uuid::now_v7();
|
||||
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n","role":"r"}], "edges": []});
|
||||
sqlx::query!(
|
||||
"INSERT INTO topology_runs
|
||||
(id, workspace_id, task, kind, status, graph, tier, research_topic_id)
|
||||
VALUES ($1, $2, $3, 'run', 'queued', $4, 'team', $5)",
|
||||
id,
|
||||
workspace_id.as_uuid(),
|
||||
task,
|
||||
graph,
|
||||
topic_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
async fn seed_workspace(pool: &sqlx::PgPool) -> WorkspaceId {
|
||||
let ws = Workspace {
|
||||
id: WorkspaceId::new(),
|
||||
@@ -138,15 +178,7 @@ async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
|
||||
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 user_id = seed_user(&pool, ws, "[email protected]").await;
|
||||
|
||||
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n1","role":"drafter"}], "edges": []});
|
||||
let triggers = json!({});
|
||||
@@ -165,7 +197,7 @@ async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
|
||||
next_fire_at: None,
|
||||
webhook_token: None,
|
||||
webhook_signing_key: None,
|
||||
created_by: user.id.as_uuid(),
|
||||
created_by: user_id.as_uuid(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -199,22 +231,15 @@ async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
|
||||
next_fire_at: None,
|
||||
webhook_token: None,
|
||||
webhook_signing_key: None,
|
||||
created_by: user.id.as_uuid(),
|
||||
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 _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
|
||||
@@ -239,3 +264,112 @@ async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
|
||||
.unwrap();
|
||||
assert!(cross.is_empty(), "loops are scoped by workspace");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_run_completed_transitions_topic_when_no_siblings_in_flight() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = seed_workspace(&pool).await;
|
||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
||||
|
||||
let topic = research_topics::create(
|
||||
&pool,
|
||||
ws.as_uuid(),
|
||||
"Topic",
|
||||
"desc",
|
||||
"spec",
|
||||
user_id.as_uuid(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// The auto-transition only fires while the topic is `processing`.
|
||||
research_topics::set_status(&pool, topic, ws.as_uuid(), "processing")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let run_id = enqueue_run_with_topic(&pool, ws, "task", topic).await;
|
||||
// Flip to a terminal state before calling — mirrors the worker order.
|
||||
let result = json!({"final_output": "done"});
|
||||
topology_runs::complete(&pool, run_id, &result)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let transitioned = topology_runs::notify_run_completed(&pool, run_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(transitioned, "no siblings in flight → topic transitions");
|
||||
let t = research_topics::get(&pool, topic, ws.as_uuid())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(t.status, "reviewing");
|
||||
|
||||
// Idempotent: a second call after the topic has already left `processing`
|
||||
// is a no-op.
|
||||
let again = topology_runs::notify_run_completed(&pool, run_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!again,
|
||||
"second call is a no-op — topic is no longer processing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_run_completed_leaves_topic_processing_when_siblings_in_flight() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = seed_workspace(&pool).await;
|
||||
let user_id = seed_user(&pool, ws, "[email protected]").await;
|
||||
|
||||
let topic = research_topics::create(
|
||||
&pool,
|
||||
ws.as_uuid(),
|
||||
"Topic",
|
||||
"desc",
|
||||
"spec",
|
||||
user_id.as_uuid(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
research_topics::set_status(&pool, topic, ws.as_uuid(), "processing")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let done = enqueue_run_with_topic(&pool, ws, "first", topic).await;
|
||||
let _still_queued = enqueue_run_with_topic(&pool, ws, "second", topic).await;
|
||||
topology_runs::complete(&pool, done, &json!({"final_output": "x"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let transitioned = topology_runs::notify_run_completed(&pool, done)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!transitioned, "sibling still queued → hold");
|
||||
let t = research_topics::get(&pool, topic, ws.as_uuid())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(t.status, "processing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_run_completed_ignores_runs_with_no_research_topic() {
|
||||
let pool = cm_testkit::test_pool().await;
|
||||
let ws = seed_workspace(&pool).await;
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n","role":"r"}], "edges": []});
|
||||
topology_runs::enqueue_run(&pool, id, ws, "task", &graph)
|
||||
.await
|
||||
.unwrap();
|
||||
topology_runs::complete(&pool, id, &json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let transitioned = topology_runs::notify_run_completed(&pool, id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!transitioned,
|
||||
"no research_topic_id → nothing to transition"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user