slice 9 cleanup: drop legacy research/loops backend + tables

Retires the legacy research/loops backend after the missions arc
(slices 1-9) fully replaced it. Frontend cutover was 4663348; this
commit finishes the job on the backend + database.

Migration:
  - 0053_drop_legacy_research_loops.sql — drops the 8 legacy tables
    (research_topics, research_topic_agents, research_outcomes,
    research_publish_approvals, loops, loop_agents, loop_orgs,
    loop_teams) and the 3 topology_runs FK columns
    (research_topic_id, loop_id, iteration). parent_run_id stays;
    recursive_exec still uses it.

Files deleted (11):
  - crates/cm-api/src/routes/{research,loops,research_setup,
    research_pipeline,wizard_repo,probe}.rs
  - crates/cm-api/src/research_container.rs
  - crates/cm-db/src/repo/{research_topics,research_outcomes,
    research_publish_approvals,loops}.rs
  - crates/cm-runtime/src/loops.rs
  - crates/cm-api/tests/research_publish_role.rs

Files edited:
  - crates/cm-api/src/lib.rs — dropped 20 legacy route registrations
    (all /api/research/* + /api/loops/* + /webhooks/loops + probe)
    and module decls
  - crates/cm-api/src/topology_worker.rs — deleted legacy dispatch
    (freeze_research_outcome, advance_loop_after_completion,
    continue_initial_burst, maybe_transition_research_topic,
    parse_reorder_rationale, per-topic/loop gateway resolver).
    reap_stuck_runs now keys on mission_id (not topic_id).
    Executor path unconditionally uses ZeroClawDriveExecutor::from_env
    — mission_orchestrator provisions each claw as an agent inside
    the shared runtime via RuntimeProvisioner, so per-team gateway
    resolution is no longer applicable.
  - crates/cm-api/src/routes/topology.rs — deleted container-log SSE
    endpoint (research/loop-specific), dropped loop_id filter and
    iteration field from ListRunsQuery/RunSummary
  - crates/cm-api/src/routes/world.rs — removed
    active_research_topics/active_loops/preseed_repo_paths;
    World SSE no longer emits repo:{topic}/loop:{id} landmark orbs
    (follow-up task #21 tracks adding mission:{id} equivalents)
  - crates/cm-api/src/runtime_provision.rs — removed now-unused
    mint_workspace_service_token
  - crates/cm-db/src/repo/topology_runs.rs — removed 9 legacy
    helpers (research_topic_id lookup, loop_id_for_run,
    iteration_for_run, active_runs_for_research_topic, etc.)
  - crates/cm-db/src/repo/teams.rs — removed 4 dead helpers
    (team_for_loop, team_for_research_topic + setters)
  - crates/cm-api/tests/topology_jobs.rs — removed loop/topic
    tests, dropped enqueue_run_with_topic helper
  - crates/bins/clawmates-server/src/main.rs — removed
    spawn_loop_scheduler call
  - crates/cm-api/src/routes/mod.rs, crates/cm-db/src/repo/mod.rs,
    crates/cm-runtime/src/lib.rs — module decls stripped

sqlx cache: regenerated against post-migration schema
  (71 files changed, ~+70 / -8896 net)

Test/build: SQLX_OFFLINE=true cargo check --workspace clean;
cargo test --workspace --no-run clean.

Follow-up (task #21): World view lost the in-flight-work landmarks
when repo:{topic} / loop:{id} orbs disappeared. Add mission:{id}
orbs as the missions-era replacement.
This commit is contained in:
Omar Sobh
2026-07-19 18:37:24 -07:00
parent 56201a6985
commit fdb8cfeecc
71 changed files with 70 additions and 8896 deletions
+1 -246
View File
@@ -2,9 +2,7 @@
//! (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, research_outcomes, research_topics, teams, topology_runs, users, workspaces,
};
use cm_db::repo::{teams, topology_runs, users, workspaces};
use cm_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
@@ -24,33 +22,6 @@ async fn seed_user(pool: &sqlx::PgPool, ws: WorkspaceId, email: &str) -> UserId
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(),
@@ -176,222 +147,6 @@ async fn cancel_transitions_only_active_runs() {
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_id = seed_user(&pool, ws, "[email protected]").await;
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");
}
#[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,
research_topics::NewTopic {
workspace_id: ws.as_uuid(),
title: "Topic",
description: "desc",
outcome_kind: "spec",
topology_kind: "hub_spoke",
repo_id: None,
created_by: 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();
// A completed run is not enough on its own: `notify_run_completed` only
// advances the topic to `reviewing` once at least one outcome exists,
// so mirror the worker order and persist the artifact first.
research_outcomes::insert(&pool, topic, "# body", Some(run_id))
.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,
research_topics::NewTopic {
workspace_id: ws.as_uuid(),
title: "Topic",
description: "desc",
outcome_kind: "spec",
topology_kind: "hub_spoke",
repo_id: None,
created_by: 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"
);
}
async fn seed_team(
pool: &sqlx::PgPool,
ws: WorkspaceId,