missions: surface per-phase run errors on the phase card
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 37s
ci / rust (push) Successful in 3m2s
ci / e2e (push) Skipped
ci / publish (push) Successful in 4m3s

Adds inline failure debugging to the Phases tab. When you see a
phase card marked FAILED, click the collapsed error summary and the
full topology_run.error text expands under it — the exact stack
trace / provider error / whatever the worker recorded.

Backend:
  - TopologyRunSummary gains mission_phase_id + team_id + error
    fields. list_by_mission SELECT extended; other constructor
    (list_recent) explicitly passes None for the new fields.
  - GET /api/missions/{id}/runs response now carries all of the
    above so the frontend can attribute failures per phase.

Frontend:
  - MissionRunSummary type mirrors backend additions.
  - MissionCanvas fetches runs alongside mission on load +
    auto-refresh; indexes by mission_phase_id in a memoized Map.
  - Each phase card renders a per-run row: colored status pill
    (running / completed / failed), short run id, finished_at
    timestamp. For failed runs, a <details> collapses the error
    text — first line as summary, full 4kB in a monospace <pre> on
    expand.

Directly unblocks the "phase says Failed but there's no info to
debug" report. Both research and coding phases get this — the code
path is phase-kind-agnostic.
This commit is contained in:
Omar Sobh
2026-07-21 12:32:39 -07:00
parent 277189ea9b
commit f5bba67e38
3 changed files with 146 additions and 2 deletions
+24 -1
View File
@@ -22,6 +22,14 @@ pub struct TopologyRunSummary {
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")] #[serde(with = "time::serde::rfc3339::option")]
pub finished_at: Option<OffsetDateTime>, pub finished_at: Option<OffsetDateTime>,
/// FK to mission_phases when the run was enqueued by phase_runner.
/// Frontend uses this to attribute failures to the right phase card.
pub mission_phase_id: Option<Uuid>,
pub team_id: Option<Uuid>,
/// The last error message the worker recorded; only populated when
/// status = 'failed'. Trimmed here to first 4kB to keep the API
/// response small; the full text lives in topology_runs.error.
pub error: Option<String>,
} }
/// A full saved comparison run. /// A full saved comparison run.
@@ -365,6 +373,9 @@ pub async fn list_recent(
kind: r.kind, kind: r.kind,
created_at: r.created_at, created_at: r.created_at,
finished_at: r.finished_at, finished_at: r.finished_at,
mission_phase_id: None,
team_id: None,
error: None,
}) })
.collect()) .collect())
} }
@@ -379,7 +390,9 @@ pub async fn list_by_mission(
) -> Result<Vec<TopologyRunSummary>, DbError> { ) -> Result<Vec<TopologyRunSummary>, DbError> {
use sqlx::Row; use sqlx::Row;
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, task, status, kind, created_at, finished_at "SELECT id, task, status, kind, created_at, finished_at,
mission_phase_id, team_id,
LEFT(coalesce(error, ''), 4096) AS error
FROM topology_runs FROM topology_runs
WHERE mission_id = $1 ORDER BY created_at DESC LIMIT $2", WHERE mission_id = $1 ORDER BY created_at DESC LIMIT $2",
) )
@@ -396,6 +409,16 @@ pub async fn list_by_mission(
kind: r.get("kind"), kind: r.get("kind"),
created_at: r.get("created_at"), created_at: r.get("created_at"),
finished_at: r.try_get("finished_at").ok(), finished_at: r.try_get("finished_at").ok(),
mission_phase_id: r.try_get("mission_phase_id").ok().flatten(),
team_id: r.try_get("team_id").ok().flatten(),
error: {
let s: String = r.try_get("error").unwrap_or_default();
if s.is_empty() {
None
} else {
Some(s)
}
},
}) })
.collect()) .collect())
} }
@@ -14,12 +14,14 @@ import { FileText, Pencil, Play, Plus, RefreshCw, Sparkles, Trash2 } from "lucid
import { import {
deleteMission, deleteMission,
getMission, getMission,
listMissionRuns,
refineMission, refineMission,
setMissionDescription, setMissionDescription,
setMissionStatus, setMissionStatus,
triggerBenchmark, triggerBenchmark,
triggerSecurityScan, triggerSecurityScan,
type MissionDetail, type MissionDetail,
type MissionRunSummary,
type MissionStatus, type MissionStatus,
type PhaseKind, type PhaseKind,
type PhaseStatus, type PhaseStatus,
@@ -113,6 +115,7 @@ export function MissionCanvas({
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false);
const [runs, setRuns] = useState<MissionRunSummary[]>([]);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!selectedId) { if (!selectedId) {
@@ -122,8 +125,12 @@ export function MissionCanvas({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const m = await getMission(selectedId); const [m, r] = await Promise.all([
getMission(selectedId),
listMissionRuns(selectedId).catch(() => ({ runs: [] })),
]);
setMission(m); setMission(m);
setRuns(r.runs);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "load failed"); setError(e instanceof Error ? e.message : "load failed");
} finally { } finally {
@@ -246,6 +253,20 @@ export function MissionCanvas({
} }
}, [mission, onChanged, load]); }, [mission, onChanged, load]);
// Index runs by phase_id so the phase card can surface the run's
// status + error text inline. Newest-first ordering from the API
// means the first entry per phase is the most recent attempt.
const runsByPhase = useMemo(() => {
const idx = new Map<string, MissionRunSummary[]>();
for (const r of runs) {
if (!r.mission_phase_id) continue;
const list = idx.get(r.mission_phase_id) ?? [];
list.push(r);
idx.set(r.mission_phase_id, list);
}
return idx;
}, [runs]);
const orderedPhases = useMemo( const orderedPhases = useMemo(
() => () =>
mission mission
@@ -640,6 +661,102 @@ export function MissionCanvas({
: ""} : ""}
</span> </span>
)} )}
{(() => {
const phaseRuns = runsByPhase.get(p.id) ?? [];
if (phaseRuns.length === 0) return null;
return (
<div
style={{
marginTop: 6,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
{phaseRuns.map((r) => {
const isFailed = r.status === "failed";
const isTerminal = ["completed", "failed", "cancelled"].includes(r.status);
return (
<div
key={r.id}
style={{
padding: "6px 9px",
borderRadius: 6,
border: `1px solid ${
isFailed
? "rgba(255,138,122,.35)"
: "rgba(255,255,255,.06)"
}`,
background: isFailed
? "rgba(255,138,122,.06)"
: "rgba(255,255,255,.02)",
fontFamily: mono,
fontSize: 11,
color: "#cfcfd5",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
color:
r.status === "failed"
? "#ff8a7a"
: r.status === "completed"
? "#5fd08a"
: r.status === "running"
? "#5ec8d8"
: "#8a8a92",
textTransform: "uppercase",
letterSpacing: ".08em",
fontSize: 10,
}}
>
run · {r.status}
</span>
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{r.id.slice(0, 8)}
</span>
{isTerminal && r.finished_at && (
<span style={{ color: "#6a6a72", fontSize: 10 }}>
{new Date(r.finished_at).toLocaleTimeString()}
</span>
)}
</div>
{isFailed && r.error && (
<details style={{ marginTop: 4 }}>
<summary
style={{
cursor: "pointer",
color: "#ff8a7a",
fontSize: 11,
}}
>
{r.error.split("\n")[0].slice(0, 180) || "error"}
</summary>
<pre
style={{
margin: "6px 0 0",
padding: 8,
borderRadius: 5,
background: "rgba(0,0,0,.35)",
color: "#e0d0cf",
fontSize: 10.5,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 260,
overflow: "auto",
}}
>
{r.error}
</pre>
</details>
)}
</div>
);
})}
</div>
);
})()}
{mission.status === "running" || mission.status === "completed" ? ( {mission.status === "running" || mission.status === "completed" ? (
<div style={{ display: "flex", gap: 6, marginTop: 6 }}> <div style={{ display: "flex", gap: 6, marginTop: 6 }}>
{p.kind === "security_scan" && ( {p.kind === "security_scan" && (
+4
View File
@@ -216,6 +216,10 @@ export interface MissionRunSummary {
kind: string; kind: string;
created_at: string; created_at: string;
finished_at: string | null; finished_at: string | null;
mission_phase_id: string | null;
team_id: string | null;
/** First 4kB of the failure text; empty otherwise. */
error: string | null;
} }
export const listMissionRuns = (id: string) => export const listMissionRuns = (id: string) =>