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 ws = user.workspace_id;
let stream = async_stream::stream! { let stream = async_stream::stream! {
use futures::StreamExt; use futures::StreamExt;
// 1) Workspace scope + topic id. // 1) Workspace scope + resolve the topic id whose container we'll
let topic_id = match cm_db::repo::topology_runs::status(&pool, id, ws).await { // tail. Two paths:
Ok(_) => match cm_db::repo::topology_runs::research_topic_id(&pool, id).await { // a) run.research_topic_id set → research pipeline; use it
Ok(Some(t)) => t, // directly (existing behavior).
_ => { // b) research_topic_id NULL + run belongs to a loop whose
yield Ok::<Event, Infallible>( // source_research_topic_id is set → paired coding loop;
Event::default().event("error").data( // the loop reuses the research topic's team container.
"run has no bound research topic; container log unavailable", // 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() {
return; 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())
} }
}, None => None,
Err(_) => { }
} else { None };
let topic_id = match direct.or(via_loop) {
Some(t) => t,
None => {
yield Ok::<Event, Infallible>( 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; return;
} }
@@ -193,27 +193,40 @@ export type StepPulse = {
export function LiveRunLogs({ export function LiveRunLogs({
topicId, topicId,
directRunId,
onStep, onStep,
defaultOpen = true,
}: { }: {
topicId: string; /** Topic-scoped mode: polls /active-runs + /pipeline-state, discovers
* active runs. Used from ResearchCanvas. Ignored when directRunId is
* set. */
topicId?: string;
/** Run-scoped mode: pin to a single known topology_run id. Skips both
* polls, subscribes straight to SSE. Used from LoopsCanvas for a
* drilled-into iteration. */
directRunId?: string;
onStep?: (pulse: StepPulse) => void; onStep?: (pulse: StepPulse) => void;
defaultOpen?: boolean;
}) { }) {
const [runs, setRuns] = useState<string[]>([]); const [runs, setRuns] = useState<string[]>(directRunId ? [directRunId] : []);
const [activeRun, setActiveRun] = useState<string | null>(null); const [activeRun, setActiveRun] = useState<string | null>(directRunId ?? null);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(defaultOpen);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true); // auto-scroll to bottom const [pinned, setPinned] = useState(true); // auto-scroll to bottom
const [pipeline, setPipeline] = useState<PipelineStateResponse | null>(null); const [pipeline, setPipeline] = useState<PipelineStateResponse | null>(null);
// Poll pipeline-state so we can surface the pre-first-step setup // Topic-mode only: poll pipeline-state so we can surface the pre-first-
// phases (repo materialize, container start, container populate) as // step setup phases (repo materialize, container start, container
// pseudo-log lines. Real step events overtake this once they arrive. // populate) as pseudo-log lines. Real step events overtake this once
// they arrive. Run-mode skips this — a single iteration doesn't have
// a topic pipeline to poll.
useEffect(() => { useEffect(() => {
if (!topicId || directRunId) return;
let live = true; let live = true;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() { async function tick() {
try { try {
const p = await getPipelineState(topicId); const p = await getPipelineState(topicId!);
if (live) setPipeline(p); if (live) setPipeline(p);
} catch { } catch {
/* ignore */ /* ignore */
@@ -225,16 +238,17 @@ export function LiveRunLogs({
live = false; live = false;
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
}, [topicId]); }, [topicId, directRunId]);
// Poll active-runs so the list refreshes when new runs kick off or old // Topic-mode only: poll active-runs so the list refreshes when new
// ones finish. Cheap query — a single indexed count on the topic. // runs kick off or old ones finish.
useEffect(() => { useEffect(() => {
if (!topicId || directRunId) return;
let live = true; let live = true;
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() { async function tick() {
try { try {
const r = await getActiveRuns(topicId); const r = await getActiveRuns(topicId!);
if (!live) return; if (!live) return;
const ids = r.runs.map((x) => x.id); const ids = r.runs.map((x) => x.id);
setRuns(ids); setRuns(ids);
@@ -253,7 +267,11 @@ export function LiveRunLogs({
live = false; live = false;
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
}; };
}, [topicId]); }, [topicId, directRunId]);
// Run-mode: nothing to null-check — activeRun is pinned to directRunId
// at the useState initializer above. The SSE hooks below fire whenever
// activeRun is non-null.
const { events, status } = useRunEvents(activeRun, onStep); const { events, status } = useRunEvents(activeRun, onStep);
const [tab, setTab] = useState<"steps" | "container">("steps"); const [tab, setTab] = useState<"steps" | "container">("steps");
@@ -277,7 +295,9 @@ export function LiveRunLogs({
// Nothing active → render nothing (matches the "Pipeline in flight" // Nothing active → render nothing (matches the "Pipeline in flight"
// parent state which is what gates this whole panel). // parent state which is what gates this whole panel).
if (runs.length === 0) return null; // Topic-mode: hide entirely when no runs. Run-mode: always render
// (parent already decided to drill in).
if (runs.length === 0 && !directRunId) return null;
return ( return (
<div> <div>
+106 -27
View File
@@ -17,6 +17,7 @@ import {
type LoopRepeatPolicy, type LoopRepeatPolicy,
type RunSummary, type RunSummary,
} from "@/lib/api/loops"; } from "@/lib/api/loops";
import { LiveRunLogs } from "./LiveRunLogs";
function formatRepeatPolicy(policy: LoopRepeatPolicy | undefined): string { function formatRepeatPolicy(policy: LoopRepeatPolicy | undefined): string {
if (!policy) return "infinite"; if (!policy) return "infinite";
@@ -236,8 +237,9 @@ export function LoopsCanvas({
</pre> </pre>
</Section> </Section>
{/* Graph */} {/* Graph — collapsed by default; the topology overview is
<Section header="Topology (graph JSON)"> reflected in the iteration steps and rarely needs inspection. */}
<CollapsibleSection header="Topology (graph JSON)">
<pre <pre
style={{ style={{
margin: 0, margin: 0,
@@ -255,7 +257,7 @@ export function LoopsCanvas({
> >
{JSON.stringify(loop.graph, null, 2)} {JSON.stringify(loop.graph, null, 2)}
</pre> </pre>
</Section> </CollapsibleSection>
{/* Iterations timeline */} {/* Iterations timeline */}
<Section header={`Iterations${runs.length ? ` · ${runs.length}` : ""}`}> <Section header={`Iterations${runs.length ? ` · ${runs.length}` : ""}`}>
@@ -325,6 +327,15 @@ function IterationsTimeline({
runs: RunSummary[]; runs: RunSummary[];
loading: boolean; loading: boolean;
}) { }) {
// One expanded iteration at a time; auto-open the newest running one.
const [expanded, setExpanded] = useState<string | null>(null);
const runningId = runs.find((r) => r.status === "running" || r.status === "queued")?.id ?? null;
const [seenRunning, setSeenRunning] = useState<string | null>(runningId);
if (seenRunning !== runningId) {
setSeenRunning(runningId);
if (runningId) setExpanded(runningId);
}
if (loading && runs.length === 0) { if (loading && runs.length === 0) {
return <p style={hintStyle}>Loading iterations…</p>; return <p style={hintStyle}>Loading iterations…</p>;
} }
@@ -348,44 +359,71 @@ function IterationsTimeline({
const durationSec = finished const durationSec = finished
? Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000)) ? Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
: null; : null;
const isOpen = expanded === r.id;
return ( return (
<li <li
key={r.id} key={r.id}
style={{ style={{
display: "grid", display: "flex",
gridTemplateColumns: "48px 90px 1fr auto", flexDirection: "column",
alignItems: "center", gap: 8,
gap: 12,
padding: "8px 12px", padding: "8px 12px",
borderRadius: 8, borderRadius: 8,
background: "rgba(255,255,255,.04)", background: isOpen ? "rgba(94,200,216,.06)" : "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.05)", border: `1px solid ${isOpen ? "rgba(94,200,216,.25)" : "rgba(255,255,255,.05)"}`,
fontFamily: mono, fontFamily: mono,
fontSize: 12, fontSize: 12,
color: "#eaeaee", color: "#eaeaee",
}} }}
> >
<span <button
type="button"
onClick={() => setExpanded(isOpen ? null : r.id)}
aria-expanded={isOpen}
style={{ style={{
color: "#5a5a62", display: "grid",
fontVariantNumeric: "tabular-nums", gridTemplateColumns: "16px 48px 90px 1fr auto",
alignItems: "center",
gap: 12,
padding: 0,
background: "transparent",
border: 0,
color: "inherit",
fontFamily: "inherit",
fontSize: "inherit",
textAlign: "left",
cursor: "pointer",
width: "100%",
}} }}
> >
#{r.iteration ?? "—"} <span style={{ opacity: 0.7 }}>{isOpen ? "▾" : "▸"}</span>
</span> <span
<StatusPill status={r.status} /> style={{
<span style={{ color: "#b5b5bd" }}> color: "#5a5a62",
{started.toLocaleString()} fontVariantNumeric: "tabular-nums",
{durationSec !== null && ( }}
<span style={{ color: "#5a5a62" }}> · {formatDuration(durationSec)}</span> >
)} #{r.iteration ?? "—"}
</span> </span>
<span <StatusPill status={r.status} />
style={{ color: "#5a5a62", fontSize: 10.5, letterSpacing: ".05em" }} <span style={{ color: "#b5b5bd" }}>
title={r.id} {started.toLocaleString()}
> {durationSec !== null && (
{r.id.slice(0, 8)} <span style={{ color: "#5a5a62" }}> · {formatDuration(durationSec)}</span>
</span> )}
</span>
<span
style={{ color: "#5a5a62", fontSize: 10.5, letterSpacing: ".05em" }}
title={r.id}
>
{r.id.slice(0, 8)}
</span>
</button>
{isOpen ? (
<div style={{ marginTop: 6 }}>
<LiveRunLogs directRunId={r.id} defaultOpen={true} />
</div>
) : null}
</li> </li>
); );
})} })}
@@ -454,6 +492,47 @@ function Section({
); );
} }
/** Section wrapper whose body collapses behind a chevron. Body starts
* closed. Used for reference blocks (graph JSON, raw task template)
* that clutter the canvas but should still be a click away. */
function CollapsibleSection({
header,
children,
}: {
header: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 0,
background: "transparent",
border: 0,
cursor: "pointer",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
textTransform: "uppercase",
textAlign: "left",
}}
>
<span style={{ opacity: 0.7 }}>{open ? "▾" : "▸"}</span>
{header}
</button>
{open ? children : null}
</div>
);
}
function Placeholder() { function Placeholder() {
return ( return (
<div <div