loops: per-iteration live logs (Steps + Container tabs), collapse graph JSON
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 38s
ci / rust (push) Successful in 3m15s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m35s

The Loops canvas' IterationsTimeline was a flat status-pill list —
no way to see WHAT an iteration was actually doing. Meanwhile the
Research canvas had full LiveRunLogs with Steps + Container tabs
against the same underlying topology_run SSE endpoints. This
factors LiveRunLogs so both canvases share it.

Frontend
- LiveRunLogs gains a directRunId?: string prop. In this mode it
  skips the topic-scoped active-runs + pipeline-state polls and
  pins activeRun to the given id. topicId stays optional (topic
  mode unchanged from ResearchCanvas' perspective).
- LoopsCanvas IterationsTimeline: each row is now a click-to-
  expand card. On expand, renders <LiveRunLogs directRunId={...}/>
  right below the header — Steps + Container tabs, full SSE tail,
  same 320px terminal.
- Auto-opens the newest running/queued iteration so a click on
  'Run now' immediately exposes the live pane.
- New CollapsibleSection helper wraps the graph JSON block so it
  starts closed. Reference material stays one click away without
  cluttering the canvas.

Backend
- run_container_log_sse now resolves the tail target via
  loop.source_research_topic_id when the run itself has no
  research_topic_id. Paired coding loops (kind=exec with a
  source_research_topic_id) reuse the paired topic's team
  container, so we tail its docker logs. Pure loop runs still
  error with a clearer message.
This commit is contained in:
Omar Sobh
2026-07-16 17:30:07 -07:00
parent e3ef3fd056
commit 2ba40b03b7
3 changed files with 179 additions and 55 deletions
+39 -14
View File
@@ -482,22 +482,47 @@ pub async fn run_container_log_sse(
let ws = user.workspace_id;
let stream = async_stream::stream! {
use futures::StreamExt;
// 1) Workspace scope + topic id.
let topic_id = match cm_db::repo::topology_runs::status(&pool, id, ws).await {
Ok(_) => match cm_db::repo::topology_runs::research_topic_id(&pool, id).await {
Ok(Some(t)) => t,
_ => {
yield Ok::<Event, Infallible>(
Event::default().event("error").data(
"run has no bound research topic; container log unavailable",
),
);
return;
// 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;
}
let direct = cm_db::repo::topology_runs::research_topic_id(&pool, id).await.ok().flatten();
let via_loop = if direct.is_none() {
let loop_id = cm_db::repo::topology_runs::loop_id_for_run(&pool, id).await.ok().flatten();
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())
}
},
Err(_) => {
None => None,
}
} else { None };
let topic_id = match direct.or(via_loop) {
Some(t) => t,
None => {
yield Ok::<Event, Infallible>(
Event::default().event("error").data("run not found"),
Event::default().event("error").data(
"run has no bound research topic or paired-loop topic; container log unavailable",
),
);
return;
}