research: pipeline diagnostics + refuse publish without outcome
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user