live-run-logs: fix step-record parser + surface pre-first-step phases
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 29s
ci / rust (push) Successful in 3m3s
ci / e2e (push) Skipped
ci / publish (push) Successful in 3m1s

Two fixes based on live smoke test:

- StepRecord parser: the SSE step payload is {node_id, role, phase,
  output, gated, tokens} (orchestrator::StepRecord), not the made-up
  shape my first pass looked for. Render as '[role] phase · <first
  line of output> · Nt · N gated'.

- Pre-first-step visibility: the checkpoint only journals AFTER each
  turn completes, so the container-startup + agent-boot window (often
  30-90s for the first step) was completely dark ('waiting for first
  step…'). Poll pipeline-state every 2s and render its stages as
  pseudo-log lines (setup 🟢 staffing · 3 agents assigned / setup 🔵
  container · starting…) until real step events arrive.
This commit is contained in:
Omar Sobh
2026-07-15 10:37:33 -07:00
parent 2ef50bb30b
commit 573956e840
@@ -13,7 +13,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { getActiveRuns } from "@/lib/api/research"; import { getActiveRuns, getPipelineState } from "@/lib/api/research";
import type { PipelineStateResponse } from "@/lib/api/research";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -69,40 +70,79 @@ function useRunEvents(runId: string | null) {
return { events, status }; return { events, status };
} }
function ansiStripSummary(raw: string): string { function summarizeStep(raw: string): string {
// Try to parse the step JSON and surface a compact human line; fall // The SSE `step` payload is a serialized orchestrator StepRecord:
// back to the raw payload if it isn't JSON. // {node_id, role, phase, output, gated, tokens}. Render as
// [role] phase · <first-line-of-output> · Nt
try { try {
const j = JSON.parse(raw) as { const j = JSON.parse(raw) as {
step?: string; node_id?: string;
kind?: string; role?: string;
status?: string; phase?: string | { kind?: string };
message?: string;
output?: string; output?: string;
error?: string; tokens?: number;
agent?: string; gated?: unknown[];
}; };
const parts: string[] = []; const parts: string[] = [];
if (j.step) parts.push(j.step); if (j.role) parts.push(`[${j.role}]`);
else if (j.kind) parts.push(j.kind); const phase =
if (j.agent) parts.push(`@${j.agent}`); typeof j.phase === "string"
if (j.status) parts.push(j.status); ? j.phase
if (j.error) parts.push(`ERR: ${j.error}`); : j.phase && typeof j.phase === "object" && "kind" in j.phase
else if (j.message) parts.push(j.message); ? (j.phase.kind as string)
else if (j.output) parts.push(j.output.slice(0, 200)); : undefined;
if (parts.length > 0) return parts.join(" · "); if (phase) parts.push(phase);
if (j.node_id && !j.role) parts.push(j.node_id);
if (typeof j.tokens === "number" && j.tokens > 0) parts.push(`${j.tokens}t`);
if (Array.isArray(j.gated) && j.gated.length > 0) {
parts.push(`${j.gated.length} gated`);
}
const summary = parts.join(" ");
const output = (j.output ?? "").split("\n")[0]?.slice(0, 220);
return output ? `${summary} · ${output}` : summary || raw;
} catch { } catch {
/* not JSON */ /* not JSON */
} }
return raw.length > 240 ? raw.slice(0, 240) + "…" : raw; return raw.length > 240 ? raw.slice(0, 240) + "…" : raw;
} }
const STAGE_DOT: Record<string, string> = {
ok: "🟢",
waiting: "🔵",
warn: "🟡",
fail: "🔴",
skip: "◯",
};
export function LiveRunLogs({ topicId }: { topicId: string }) { export function LiveRunLogs({ topicId }: { topicId: string }) {
const [runs, setRuns] = useState<string[]>([]); const [runs, setRuns] = useState<string[]>([]);
const [activeRun, setActiveRun] = useState<string | null>(null); const [activeRun, setActiveRun] = useState<string | null>(null);
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
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);
// Poll pipeline-state so we can surface the pre-first-step setup
// phases (repo materialize, container start, container populate) as
// pseudo-log lines. Real step events overtake this once they arrive.
useEffect(() => {
let live = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() {
try {
const p = await getPipelineState(topicId);
if (live) setPipeline(p);
} catch {
/* ignore */
}
if (live) timer = setTimeout(tick, 2000);
}
void tick();
return () => {
live = false;
if (timer) clearTimeout(timer);
};
}, [topicId]);
// Poll active-runs so the list refreshes when new runs kick off or old // Poll active-runs so the list refreshes when new runs kick off or old
// ones finish. Cheap query — a single indexed count on the topic. // ones finish. Cheap query — a single indexed count on the topic.
@@ -247,16 +287,35 @@ export function LiveRunLogs({ topicId }: { topicId: string }) {
wordBreak: "break-word", wordBreak: "break-word",
}} }}
> >
{events.length === 0 ? ( {/* Pre-step setup phases from pipeline-state. Rendered until
<div style={{ color: "#6a6a72" }}> real step events arrive, then hidden so the terminal
doesn't scroll past the actual step timeline. */}
{events.length === 0 && pipeline ? (
<>
<div style={{ color: "#6a6a72", marginBottom: 4 }}>
{status === "connecting" {status === "connecting"
? "connecting…" ? "connecting…"
: status === "streaming" : "worker booting — steps journal after each turn completes"}
? "waiting for first step…"
: "no events"}
</div> </div>
) : ( {pipeline.stages.map((s) => (
events.map((e, i) => ( <div key={s.key} style={{ color: "#cfcfd5" }}>
<span style={{ color: "#5a5a62" }}>
setup {STAGE_DOT[s.status] ?? "◯"} {s.key.padEnd(9)}
</span>{" "}
{s.label}
{s.detail ? (
<span style={{ color: "#ffb0a5" }}> — {s.detail}</span>
) : null}
</div>
))}
</>
) : null}
{events.length === 0 && !pipeline ? (
<div style={{ color: "#6a6a72" }}>
{status === "connecting" ? "connecting…" : "waiting for pipeline state…"}
</div>
) : null}
{events.map((e, i) => (
<div <div
key={`${i}-${e.ts}`} key={`${i}-${e.ts}`}
style={{ style={{
@@ -267,10 +326,9 @@ export function LiveRunLogs({ topicId }: { topicId: string }) {
{new Date(e.ts).toLocaleTimeString()}{" "} {new Date(e.ts).toLocaleTimeString()}{" "}
{e.kind === "step" ? `#${e.index}` : "done"} {e.kind === "step" ? `#${e.index}` : "done"}
</span>{" "} </span>{" "}
{ansiStripSummary(e.raw)} {summarizeStep(e.raw)}
</div> </div>
)) ))}
)}
</div> </div>
{!pinned ? ( {!pinned ? (