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
+4 -228
View File
@@ -226,39 +226,26 @@ pub struct RunSummary {
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.
/// Query params for `GET /api/topology-runs`.
#[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. `?loop_id=X` filters to iterations of one loop,
/// ordered by iteration DESC (uses `topology_runs_loop_idx`).
/// run jobs), newest first.
pub async fn list_runs(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ListRunsQuery>,
) -> Result<Json<Vec<RunSummary>>, ApiError> {
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 rows =
cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, limit).await?;
let out = rows
.into_iter()
.map(|r| RunSummary {
@@ -267,7 +254,6 @@ 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();
@@ -388,213 +374,3 @@ pub async fn get_run(
checkpoint: run.checkpoint,
}))
}
// ── Phase: live container log tail ─────────────────────────────────
/// Strip ANSI escape sequences from a line so the browser terminal
/// renders it cleanly. Cheap and allocation-only when a match hits.
fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
// Skip until final byte in @-~ range.
i += 2;
while i < bytes.len() && !(bytes[i] >= 0x40 && bytes[i] <= 0x7e) {
i += 1;
}
i += 1;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
/// Squeeze a zeroclaw daemon log line into `[bracket] action outcome
/// · trailing message`. Falls back to the ANSI-stripped raw line when
/// the shape isn't recognised so we never lose an interesting line.
fn compact_container_log(line: &str) -> Option<String> {
let stripped = strip_ansi(line);
let trimmed = stripped.trim_end();
if trimmed.is_empty() {
return None;
}
// Drop pure framing noise: `zeroclaw_scope{...}` continuations
// that carry no zc_action.
let has_action = trimmed.contains("zc_action=");
if !has_action {
// Non-daemon lines (bash echoes, container startup banners,
// panic backtraces) — keep as-is; those are useful too.
if trimmed.contains("zc_") {
return None; // structural framing without action, drop
}
return Some(trimmed.to_string());
}
let bracket = trimmed
.split_once(']')
.and_then(|(before, _)| before.strip_prefix('['))
.unwrap_or("");
let action = trimmed
.split("zc_action=")
.nth(1)
.and_then(|s| s.split_whitespace().next())
.unwrap_or("?");
let outcome = trimmed
.split("zc_outcome=")
.nth(1)
.and_then(|s| s.split_whitespace().next())
.unwrap_or("");
let msg = trimmed
.rsplit(':')
.next()
.map(str::trim)
.unwrap_or("")
.to_string();
let tag = if bracket.is_empty() {
"system"
} else {
bracket
};
Some(if outcome.is_empty() || outcome == "unknown" {
format!("[{tag}] {action} · {msg}")
} else {
format!("[{tag}] {action} ({outcome}) · {msg}")
})
}
/// `GET /api/topology-runs/{id}/container-log` — SSE stream of the
/// per-topic team container's daemon log, filtered from the ZeroClaw
/// structural noise into `[actor] action (outcome) · message` lines.
/// Emits a `line` event per surviving line, plus periodic keep-alives.
/// Ends when the container's log stream closes or the client
/// disconnects. Auth: workspace-scoped like `run_events_sse`.
pub async fn run_container_log_sse(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
// All early exits + the live tail funnel through one stream! so
// Sse::new sees a single concrete stream type.
let pool = state.pool.clone();
let ws = user.workspace_id;
let stream = async_stream::stream! {
use futures::StreamExt;
// 1) Workspace scope + resolve the topic id whose container we'll
// tail. Two paths:
// a) run.research_topic_id set → research pipeline; use it
// directly (existing behavior).
// b) research_topic_id NULL + run belongs to a loop whose
// source_research_topic_id is set → paired coding loop;
// the loop reuses the research topic's team container.
// Anything else (raw topology runs, pure loop with no paired
// topic) errors out with a clear message.
if cm_db::repo::topology_runs::status(&pool, id, ws).await.is_err() {
yield Ok::<Event, Infallible>(
Event::default().event("error").data("run not found"),
);
return;
}
// Precedence — must mirror topology_worker::try_team_gateway_url,
// which is what actually spawns the container:
// a) run's loop has team_id set → the team runtime spawned
// `team-<team_id>-container` (matches spawn_team). This is
// the paired-coding-loop path when the wizard picked
// "fresh coding team". Loops with a team_id do NOT reuse
// the research topic's container.
// b) run.research_topic_id set → per-topic research container
// `research-<topic_id>-team` (matches spawn).
// c) run's loop has source_research_topic_id (legacy paired
// flow, no team_id) → same as (b) via the topic.
// d) anything else → error with a clear message.
let loop_id = cm_db::repo::topology_runs::loop_id_for_run(&pool, id).await.ok().flatten();
let team_id = match loop_id {
Some(lid) => cm_db::repo::teams::team_for_loop(&pool, lid).await.ok().flatten(),
None => None,
};
let direct = cm_db::repo::topology_runs::research_topic_id(&pool, id).await.ok().flatten();
let via_loop = if team_id.is_none() && direct.is_none() {
match loop_id {
Some(lid) => {
use sqlx::Row;
sqlx::query(
"SELECT source_research_topic_id FROM loops WHERE id = $1"
)
.bind(lid)
.fetch_optional(&pool)
.await
.ok()
.flatten()
.and_then(|r| r.try_get::<Option<Uuid>, _>("source_research_topic_id").ok().flatten())
}
None => None,
}
} else { None };
let container = if let Some(tid) = team_id {
crate::research_container::team_container_name_for(tid)
} else {
match direct.or(via_loop) {
Some(t) => crate::research_container::container_name_for(t),
None => {
yield Ok::<Event, Infallible>(
Event::default().event("error").data(
"run has no bound team, research topic, or paired-loop topic; container log unavailable",
),
);
return;
}
}
};
// 2) Docker handle.
let docker = match crate::research_container::connect() {
Ok(d) => d,
Err(e) => {
yield Ok(Event::default()
.event("error")
.data(format!("docker connect failed: {e}")));
return;
}
};
// 3) Tail.
let opts = bollard::query_parameters::LogsOptionsBuilder::default()
.stdout(true)
.stderr(true)
.follow(true)
.tail("200")
.timestamps(false)
.build();
yield Ok(Event::default()
.event("info")
.data(format!("tailing {container}")));
let mut log_stream = docker.logs(&container, Some(opts));
// Line-accumulator so partial chunks don't truncate a log line.
let mut buf = String::new();
while let Some(chunk) = log_stream.next().await {
let bytes = match chunk {
Ok(bollard::container::LogOutput::StdOut { message })
| Ok(bollard::container::LogOutput::StdErr { message })
| Ok(bollard::container::LogOutput::Console { message }) => message,
Ok(_) => continue,
Err(e) => {
yield Ok(Event::default().event("error").data(e.to_string()));
break;
}
};
let s = String::from_utf8_lossy(&bytes);
buf.push_str(&s);
while let Some(nl) = buf.find('\n') {
let line: String = buf.drain(..=nl).collect();
if let Some(compact) = compact_container_log(&line) {
yield Ok(Event::default().event("line").data(compact));
}
}
}
yield Ok(Event::default().event("done").data("stream closed"));
};
Sse::new(stream).keep_alive(KeepAlive::default())
}