research: pipeline diagnostics + refuse publish without outcome
ci / gates (push) Failing after 8s
ci / rust (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Two fixes surfaced by the first prod run of the pipeline:

1) Silent skip-to-published bug (R1 gap):
   approve_publish transitioned reviewing → publishing → published
   without checking that an outcome existed. Result: pipeline could
   fail silently (LLM auth error, network, etc), no outcome would be
   written, but state advanced to 'published' and the download endpoint
   returned 404 with no user-visible error. Now refuses with 409
   Conflict when no outcome exists so the frontend can surface WHY.

2) No end-to-end visibility:
   Users had no way to see where a run failed until they clicked
   Download and got nothing. Adds
   GET /api/research/:id/pipeline-state — a read-only per-stage report
   walking:
     - staffing (agents assigned)
     - repo (bound + cloned)
     - container (per-topic team runtime spawned)
     - runs (count + failed count + latest error text)
     - outcomes (count — the artifact rows get_artifact reads)
     - approval (pending flag)
   Each stage returns ok / warn / fail / skip plus optional detail text
   so the failure reason surfaces at the diagnostic level.

Frontend:
   ResearchCanvas shows a compact PIPELINE strip below the topic title,
   green/amber/red dots per stage, click to expand a full checklist
   with per-stage detail (including the LLM error from the last run
   attempt). Polls every 6s while the topic is processing/publishing.

Follow-up:
   - Root cause of the specific failure just observed: Claude CLI in
     the clawmates-runtime container isn't authenticated. Deploy-side
     config sweep (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY into
     the runtime image env), not a code fix.
   - Structured event stream on top of run_events for real per-step
     replay in the diagnostic panel.
This commit is contained in:
Omar Sobh
2026-07-09 16:59:59 -07:00
parent 44d6e95022
commit cca6e2be20
4 changed files with 365 additions and 0 deletions
@@ -9,12 +9,14 @@ import { useEffect, useState } from "react";
import type { Agent } from "@/lib/api/schemas";
import {
approvePublish,
getPipelineState,
getTopic,
listPendingApprovals,
rejectPublish,
requestPublish,
startTopic,
submitReview,
type PipelineStateResponse,
type PublishApproval,
type TopicDetail,
type TopicStatus,
@@ -91,6 +93,8 @@ export function ResearchCanvas({
}) {
const [topic, setTopic] = useState<TopicDetail | null>(null);
const [pendingApproval, setPendingApproval] = useState<PublishApproval | null>(null);
const [pipeline, setPipeline] = useState<PipelineStateResponse | null>(null);
const [diagOpen, setDiagOpen] = useState(false);
const [decidingApproval, setDecidingApproval] = useState<"approve" | "reject" | null>(null);
const [rejectFormOpen, setRejectFormOpen] = useState(false);
const [rejectNotes, setRejectNotes] = useState("");
@@ -152,6 +156,30 @@ export function ResearchCanvas({
// can offer inline Approve/Reject buttons — no separate inbox page needed.
// Only fetches when the backend flag says an approval exists to avoid
// hammering the endpoint on every topic view.
// Pipeline diagnostics — fetches per-stage status (staffing, repo, container,
// runs, outcomes, approval) so the user can see WHY a run failed instead of
// getting a silent 0-byte artifact. Runs when a topic is selected and any
// time refreshKey bumps; light polling while the topic is actively working.
useEffect(() => {
if (!selectedId) return;
let alive = true;
const load = async () => {
try {
const p = await getPipelineState(selectedId);
if (alive) setPipeline(p);
} catch {
if (alive) setPipeline(null);
}
};
load();
const active = topic?.status === "processing" || topic?.status === "publishing";
const handle = active ? setInterval(load, 6000) : null;
return () => {
alive = false;
if (handle) clearInterval(handle);
};
}, [selectedId, refreshKey, topic?.status]);
const wantsApprovalLookup =
!!topic && topic.has_pending_publish_request === true;
useEffect(() => {
@@ -318,6 +346,118 @@ export function ResearchCanvas({
</h1>
</div>
{/* Pipeline diagnostics — click to expand */}
{pipeline ? (
<div>
<button
type="button"
onClick={() => setDiagOpen((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${pipeline.stages.some((s) => s.status === "fail") ? "rgba(255,138,122,.4)" : "rgba(255,255,255,.08)"}`,
background: pipeline.stages.some((s) => s.status === "fail")
? "rgba(255,138,122,.06)"
: "#101014",
color: "#eaeaee",
cursor: "pointer",
textAlign: "left",
fontFamily: mono,
fontSize: 11.5,
}}
aria-expanded={diagOpen}
>
<span style={{ letterSpacing: ".08em", color: "#8a8a92" }}>PIPELINE</span>
<span style={{ display: "flex", gap: 4 }}>
{pipeline.stages.map((s) => (
<span
key={s.key}
title={`${s.key}: ${s.label}`}
style={{
width: 8,
height: 8,
borderRadius: "50%",
background:
s.status === "ok" ? "#5fd08a"
: s.status === "warn" ? "#ffb44a"
: s.status === "fail" ? "#ff8a7a"
: "#4a4a52",
}}
/>
))}
</span>
<span style={{ flex: 1, color: "#8a8a92" }}>
{pipeline.stages.filter((s) => s.status === "fail").length > 0
? `${pipeline.stages.filter((s) => s.status === "fail").length} failed stage(s) — click for details`
: pipeline.stages.filter((s) => s.status === "warn").length > 0
? `${pipeline.stages.filter((s) => s.status === "warn").length} warning(s)`
: "All stages ok"}
</span>
<span style={{ opacity: 0.7 }}>{diagOpen ? "▾" : "▸"}</span>
</button>
{diagOpen ? (
<div
style={{
marginTop: 6,
padding: 10,
borderRadius: 10,
background: "#0a0a0d",
border: "1px solid rgba(255,255,255,.06)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{pipeline.stages.map((s) => (
<div key={s.key} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background:
s.status === "ok" ? "#5fd08a"
: s.status === "warn" ? "#ffb44a"
: s.status === "fail" ? "#ff8a7a"
: "#4a4a52",
flex: "none",
}}
/>
<span style={{ fontFamily: mono, fontSize: 10, color: "#8a8a92", textTransform: "uppercase", minWidth: 90 }}>
{s.key}
</span>
<span style={{ flex: 1, color: "#eaeaee" }}>{s.label}</span>
</div>
{s.detail ? (
<div
style={{
marginLeft: 106,
padding: "6px 8px",
borderRadius: 6,
background: "rgba(255,138,122,.06)",
border: "1px solid rgba(255,138,122,.15)",
color: "#ffb0a5",
fontSize: 11,
fontFamily: mono,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{s.detail}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
{/* Agents */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={sectionHeader}>Agents ({topic.agents.length})</div>