missions: phase-completion summary card (Claude Opus 4.8 synthesized)
New phase_summarizer background worker fires on any mission_phase
transition to a terminal state (completed/failed). Aggregates every
topology_runs.checkpoint.outputs[] + mission_tasks + mission_artifacts
bound to that phase and asks Claude Opus 4.8 to produce a structured
JSON card:
{ narrative, metrics, sources, tooling, next_actions }
Rendered inline on the mission page under each completed phase via
new PhaseSummaryCard component. Metrics grid is kind-specific:
research surfaces insights/sources/int_cards/artifacts, coding
surfaces cards_picked_up/commits/tests/issues, benchmark surfaces
regressions/improvements, security surfaces findings-by-severity.
New table: mission_phase_summaries (migration 0060), unique per
phase_id — regenerates on retry.
New endpoint: GET /api/missions/{id}/phases/{phase_id}/summary.
Model overridable via CLAWMATES_SUMMARIZER_MODEL. Reuses the
ANTHROPIC_API_KEY prod already carries for mission_refiner.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
// Post-phase completion card. Fetches the LLM-synthesized summary
|
||||
// (`GET /api/missions/{id}/phases/{phase_id}/summary`) for any phase
|
||||
// in a terminal state and renders: narrative, kind-specific metrics
|
||||
// grid, sources, tooling recommendations, artifacts, and next actions.
|
||||
//
|
||||
// 404 while `phase_summarizer` hasn't run yet — shows a "waiting"
|
||||
// pill in that case instead of an error.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPhaseSummary, type PhaseSummary } from "@/lib/api/missions";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
export function PhaseSummaryCard({
|
||||
missionId,
|
||||
phaseId,
|
||||
}: {
|
||||
missionId: string;
|
||||
phaseId: string;
|
||||
}) {
|
||||
const [summary, setSummary] = useState<PhaseSummary | null>(null);
|
||||
const [waiting, setWaiting] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let stop = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await getPhaseSummary(missionId, phaseId);
|
||||
if (!alive) return;
|
||||
setSummary(s);
|
||||
setWaiting(false);
|
||||
setErr(null);
|
||||
stop = true;
|
||||
} catch (e) {
|
||||
const msg = String(e);
|
||||
if (msg.includes("404")) {
|
||||
if (!alive) return;
|
||||
setWaiting(true);
|
||||
} else {
|
||||
if (!alive) return;
|
||||
setErr(msg);
|
||||
setWaiting(false);
|
||||
stop = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
// Poll for up to ~10 min while waiting for the summarizer.
|
||||
const t = setInterval(() => {
|
||||
if (stop) return;
|
||||
void load();
|
||||
}, 15_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [missionId, phaseId]);
|
||||
|
||||
if (waiting && !summary) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: "1px dashed rgba(255,255,255,.10)",
|
||||
background: "rgba(255,255,255,.02)",
|
||||
color: "#8a8a92",
|
||||
fontSize: 11,
|
||||
fontFamily: mono,
|
||||
}}
|
||||
>
|
||||
Summarizing this phase… (Claude Opus 4.8 fires on the next 30s tick after
|
||||
the phase reaches a terminal state)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (err) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
background: "rgba(255,138,122,.06)",
|
||||
border: "1px solid rgba(255,138,122,.35)",
|
||||
color: "#ff8a7a",
|
||||
fontSize: 11,
|
||||
fontFamily: mono,
|
||||
}}
|
||||
>
|
||||
summary failed: {err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!summary) return null;
|
||||
if (summary.error) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
background: "rgba(255,138,122,.06)",
|
||||
border: "1px solid rgba(255,138,122,.35)",
|
||||
fontSize: 11,
|
||||
fontFamily: mono,
|
||||
color: "#e0d0cf",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#ff8a7a", marginBottom: 4 }}>
|
||||
summary generation failed
|
||||
</div>
|
||||
{summary.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = Object.entries(summary.metrics ?? {});
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
background: "linear-gradient(180deg, rgba(94,200,216,.05), rgba(94,200,216,.02))",
|
||||
border: "1px solid rgba(94,200,216,.20)",
|
||||
fontFamily: mono,
|
||||
color: "#cfcfd5",
|
||||
fontSize: 11,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: ".1em",
|
||||
textTransform: "uppercase",
|
||||
color: "#5ec8d8",
|
||||
}}
|
||||
>
|
||||
phase summary · {summary.kind}
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: "#6a6a72" }}>
|
||||
{summary.model} · {new Date(summary.generated_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ whiteSpace: "pre-wrap", color: "#e0e0e5", lineHeight: 1.4 }}>
|
||||
{summary.narrative}
|
||||
</div>
|
||||
|
||||
{metrics.length > 0 && (
|
||||
<MetricsGrid entries={metrics} />
|
||||
)}
|
||||
|
||||
{summary.sources && summary.sources.length > 0 && (
|
||||
<Section
|
||||
label="sources consulted"
|
||||
items={summary.sources.map((s) => ({
|
||||
head: s.title ?? s.url ?? s.path ?? "source",
|
||||
body: [s.note, s.url ?? s.path].filter(Boolean).join(" · "),
|
||||
href: s.url,
|
||||
}))}
|
||||
accent="#c9a0ff"
|
||||
/>
|
||||
)}
|
||||
|
||||
{summary.tooling && summary.tooling.length > 0 && (
|
||||
<Section
|
||||
label="tooling recommendations"
|
||||
items={summary.tooling.map((t) => ({
|
||||
head: `${t.kind ? `[${t.kind}] ` : ""}${t.title ?? "recommendation"}`,
|
||||
body: t.why ?? t.note ?? "",
|
||||
}))}
|
||||
accent="#5fd08a"
|
||||
/>
|
||||
)}
|
||||
|
||||
{summary.artifacts && summary.artifacts.length > 0 && (
|
||||
<Section
|
||||
label="artifacts saved"
|
||||
items={summary.artifacts.map((a) => ({
|
||||
head: a.title ?? a.path,
|
||||
body: `[${a.kind}] ${a.path}`,
|
||||
}))}
|
||||
accent="#f0c060"
|
||||
/>
|
||||
)}
|
||||
|
||||
{summary.next_actions && summary.next_actions.length > 0 && (
|
||||
<Section
|
||||
label="next actions"
|
||||
items={summary.next_actions.map((n) => ({
|
||||
head: n.title ?? "action",
|
||||
body: n.note ?? "",
|
||||
}))}
|
||||
accent="#5ec8d8"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsGrid({ entries }: { entries: Array<[string, unknown]> }) {
|
||||
const flat: Array<[string, string]> = [];
|
||||
for (const [k, v] of entries) {
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
|
||||
flat.push([`${k}.${sk}`, formatVal(sv)]);
|
||||
}
|
||||
} else {
|
||||
flat.push([k, formatVal(v)]);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{flat.map(([k, v]) => (
|
||||
<div
|
||||
key={k}
|
||||
style={{
|
||||
padding: "5px 8px",
|
||||
borderRadius: 4,
|
||||
background: "rgba(0,0,0,.25)",
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#6a6a72", fontSize: 9, textTransform: "uppercase" }}>
|
||||
{k.replace(/_/g, " ")}
|
||||
</div>
|
||||
<div style={{ color: "#e0e0e5", fontSize: 13 }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatVal(v: unknown): string {
|
||||
if (v == null) return "—";
|
||||
if (typeof v === "number") return v.toLocaleString();
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function Section({
|
||||
label,
|
||||
items,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
items: Array<{ head: string; body: string; href?: string }>;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: accent,
|
||||
fontSize: 9,
|
||||
letterSpacing: ".08em",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{items.map((it, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
borderRadius: 3,
|
||||
background: "rgba(255,255,255,.02)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#e0e0e5" }}>
|
||||
{it.href ? (
|
||||
<a
|
||||
href={it.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: accent, textDecoration: "underline" }}
|
||||
>
|
||||
{it.head}
|
||||
</a>
|
||||
) : (
|
||||
it.head
|
||||
)}
|
||||
</div>
|
||||
{it.body && (
|
||||
<div style={{ color: "#8a8a92", fontSize: 10, marginTop: 1 }}>
|
||||
{it.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user