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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user