missions: surface per-phase run errors on the phase card
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:
@@ -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" && (
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user