live-run-logs: fix step-record parser + surface pre-first-step phases
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:
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user