loops: per-iteration live logs (Steps + Container tabs), collapse graph JSON
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:
@@ -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
|
||||||
|
// 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>(
|
yield Ok::<Event, Infallible>(
|
||||||
Event::default().event("error").data(
|
Event::default().event("error").data("run not found"),
|
||||||
"run has no bound research topic; container log unavailable",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
let direct = cm_db::repo::topology_runs::research_topic_id(&pool, id).await.ok().flatten();
|
||||||
Err(_) => {
|
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,
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
|
|||||||
@@ -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,23 +359,44 @@ 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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(isOpen ? null : r.id)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
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%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ opacity: 0.7 }}>{isOpen ? "▾" : "▸"}</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: "#5a5a62",
|
color: "#5a5a62",
|
||||||
@@ -386,6 +418,12 @@ function IterationsTimeline({
|
|||||||
>
|
>
|
||||||
{r.id.slice(0, 8)}
|
{r.id.slice(0, 8)}
|
||||||
</span>
|
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user