missions: phase-completion summary card (Claude Opus 4.8 synthesized)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 9s
ci / frontend (push) Successful in 26s
ci / e2e (push) Skipped
ci / publish (push) Skipped

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:
Omar Sobh
2026-07-23 16:54:38 -07:00
parent 1be3430bf2
commit 5c63ef0ed3
8 changed files with 1022 additions and 0 deletions
+4
View File
@@ -293,6 +293,10 @@ async fn run() -> Result<(), String> {
// runtime containers 30 min after the mission reaches a terminal // runtime containers 30 min after the mission reaches a terminal
// state so operators have a window to pull final artifacts. // state so operators have a window to pull final artifacts.
cm_api::mission_runtime::spawn_sweeper(pool.clone(), std::time::Duration::from_secs(30 * 60)); cm_api::mission_runtime::spawn_sweeper(pool.clone(), std::time::Duration::from_secs(30 * 60));
// Phase completion summarizer: reads terminal-state phases and
// asks Claude Opus 4.8 to synthesize a "what got done" card that
// the UI renders under the phase.
cm_api::phase_summarizer::spawn(pool.clone());
// PDF renderer worker (Slice 6): watches mission_artifacts for // PDF renderer worker (Slice 6): watches mission_artifacts for
// MD entries with render_pdf_status='pending', calls the // MD entries with render_pdf_status='pending', calls the
// configured LLM (default Gemini 2.5 Flash) for styled HTML, // configured LLM (default Gemini 2.5 Flash) for styled HTML,
+5
View File
@@ -18,6 +18,7 @@ pub mod mission_workspace;
pub mod node_rules; pub mod node_rules;
pub mod pdf_renderer; pub mod pdf_renderer;
pub mod phase_runner; pub mod phase_runner;
pub mod phase_summarizer;
pub mod quota; pub mod quota;
mod recursive_exec; mod recursive_exec;
mod routes; mod routes;
@@ -466,6 +467,10 @@ pub fn router(state: AppState) -> Router {
"/api/missions/{id}/phases/{phase_id}/retry", "/api/missions/{id}/phases/{phase_id}/retry",
post(routes::missions::retry_phase), post(routes::missions::retry_phase),
) )
.route(
"/api/missions/{id}/phases/{phase_id}/summary",
get(routes::missions::get_phase_summary),
)
.route( .route(
"/api/missions/{id}/teams", "/api/missions/{id}/teams",
get(routes::missions::list_teams), get(routes::missions::list_teams),
+574
View File
@@ -0,0 +1,574 @@
//! Post-phase summarization worker.
//!
//! Watches `mission_phases` for terminal-state transitions and, for
//! each one that hasn't been summarized yet, aggregates every
//! `topology_runs.checkpoint.outputs[]` bound to that phase plus the
//! phase's `mission_tasks` + `mission_artifacts` and asks Claude Opus
//! 4.8 to produce a structured completion card:
//!
//! { narrative, metrics, sources, tooling, next_actions }
//!
//! The output lands in `mission_phase_summaries` (one row per
//! phase_id, upserted). The mission phase card in the UI renders it
//! below the phase's other detail so the operator sees "what did this
//! phase actually accomplish, what did it produce, and what's next".
//!
//! Runs on a slow tick (30s) — summarization is cheap to defer, and
//! the LLM call is the expensive part.
use serde_json::{json, Value};
use sqlx::PgPool;
use sqlx::Row;
use std::time::Duration;
use uuid::Uuid;
const DEFAULT_MODEL: &str = "claude-opus-4-8";
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
const POLL_INTERVAL: Duration = Duration::from_secs(30);
/// Cap the raw material we send to the model. Missions can produce
/// hundreds of KB of agent output; we slice by turn and by phase
/// artifact but still bound the total prompt.
const MAX_OUTPUT_BYTES: usize = 60_000;
fn model_name() -> String {
std::env::var("CLAWMATES_SUMMARIZER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
}
pub fn spawn(pool: PgPool) {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(45)).await;
let mut ticker = tokio::time::interval(POLL_INTERVAL);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = sweep_once(&pool).await {
eprintln!("phase_summarizer: sweep failed: {e}");
}
}
});
}
async fn sweep_once(pool: &PgPool) -> Result<(), String> {
// Terminal phases with no summary yet.
let rows = sqlx::query(
"SELECT mp.id, mp.mission_id, mp.kind
FROM mission_phases mp
LEFT JOIN mission_phase_summaries mps ON mps.phase_id = mp.id
WHERE mp.status IN ('completed', 'failed')
AND mps.id IS NULL
LIMIT 10",
)
.fetch_all(pool)
.await
.map_err(|e| format!("scan phases: {e}"))?;
for row in rows {
let phase_id: Uuid = row.get("id");
let mission_id: Uuid = row.get("mission_id");
let kind: String = row.get("kind");
if let Err(e) = summarize_one(pool, mission_id, phase_id, &kind).await {
// Persist an error row so we don't infinite-retry a broken
// phase — the UI can surface "summary unavailable: <e>".
eprintln!("phase_summarizer: {phase_id} ({kind}) failed: {e}");
let _ = record_error(pool, mission_id, phase_id, &kind, &e).await;
}
}
Ok(())
}
async fn summarize_one(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
kind: &str,
) -> Result<(), String> {
let material = collect_material(pool, mission_id, phase_id).await?;
if material.outputs.is_empty()
&& material.tasks_created == 0
&& material.artifacts.is_empty()
{
// Nothing to summarize. Write a placeholder so we don't retry
// this phase every 30s.
return upsert_summary(
pool,
mission_id,
phase_id,
kind,
"claude-opus-4-8",
"This phase produced no recorded output. The agents may have failed \
to reach their working directory or found nothing to act on.",
&json!({
"outputs": 0,
"tasks": 0,
"artifacts": 0,
}),
&json!([]),
&json!([]),
&json!([]),
&json!([]),
)
.await;
}
let (narrative, structured) = call_anthropic(kind, &material).await?;
let metrics = structured
.get("metrics")
.cloned()
.unwrap_or_else(|| json!({}));
let sources = structured
.get("sources")
.cloned()
.unwrap_or_else(|| json!([]));
let tooling = structured
.get("tooling")
.cloned()
.unwrap_or_else(|| json!([]));
let next_actions = structured
.get("next_actions")
.cloned()
.unwrap_or_else(|| json!([]));
let artifacts = serde_json::to_value(&material.artifacts).unwrap_or(json!([]));
upsert_summary(
pool,
mission_id,
phase_id,
kind,
&model_name(),
&narrative,
&metrics,
&sources,
&artifacts,
&tooling,
&next_actions,
)
.await
}
struct PhaseMaterial {
/// Concatenated per-turn outputs across every topology_run bound to
/// this phase, trimmed to `MAX_OUTPUT_BYTES`.
outputs: String,
/// Original count (pre-trim) — helps the LLM understand the scale
/// even when we truncated.
output_count: usize,
/// Total tokens across runs (from checkpoint.totals.tokens).
tokens: u64,
turns: u64,
tasks_created: usize,
tasks_completed: usize,
tasks_failed: usize,
artifacts: Vec<ArtifactRef>,
task_summaries: Vec<TaskRef>,
}
#[derive(serde::Serialize)]
struct ArtifactRef {
path: String,
kind: String,
title: Option<String>,
}
#[derive(serde::Serialize)]
struct TaskRef {
external_id: Option<String>,
title: String,
status: String,
}
async fn collect_material(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
) -> Result<PhaseMaterial, String> {
// Runs — checkpoint.outputs + totals aggregated.
let run_rows = sqlx::query(
"SELECT checkpoint FROM topology_runs
WHERE mission_id = $1 AND mission_phase_id = $2",
)
.bind(mission_id)
.bind(phase_id)
.fetch_all(pool)
.await
.map_err(|e| format!("load runs: {e}"))?;
let mut concat = String::new();
let mut output_count = 0usize;
let mut tokens = 0u64;
let mut turns = 0u64;
for row in &run_rows {
let cp: Option<Value> = row.get("checkpoint");
let Some(cp) = cp else { continue };
if let Some(t) = cp.get("totals") {
tokens += t.get("tokens").and_then(|v| v.as_u64()).unwrap_or(0);
turns += t.get("turns").and_then(|v| v.as_u64()).unwrap_or(0);
}
if let Some(arr) = cp.get("outputs").and_then(|v| v.as_array()) {
for (i, item) in arr.iter().enumerate() {
output_count += 1;
if concat.len() >= MAX_OUTPUT_BYTES {
continue;
}
let s = match item {
Value::String(s) => s.clone(),
other => other.to_string(),
};
concat.push_str(&format!("\n\n── turn {} ──\n", i + 1));
let remaining = MAX_OUTPUT_BYTES.saturating_sub(concat.len());
if s.len() > remaining {
concat.push_str(&s[..remaining]);
concat.push_str("\n… (truncated)");
} else {
concat.push_str(&s);
}
}
}
}
// Tasks — count by status, keep small summaries.
let task_rows = sqlx::query(
"SELECT external_id, title, status
FROM mission_tasks
WHERE mission_id = $1 AND phase_id = $2
ORDER BY created_at
LIMIT 40",
)
.bind(mission_id)
.bind(phase_id)
.fetch_all(pool)
.await
.map_err(|e| format!("load tasks: {e}"))?;
let mut task_summaries = Vec::new();
let mut tasks_completed = 0usize;
let mut tasks_failed = 0usize;
for row in &task_rows {
let status: String = row.get("status");
match status.as_str() {
"complete" => tasks_completed += 1,
"failed" => tasks_failed += 1,
_ => {}
}
task_summaries.push(TaskRef {
external_id: row.get("external_id"),
title: row.get("title"),
status,
});
}
// Artifacts.
let artifact_rows = sqlx::query(
"SELECT path, kind, title
FROM mission_artifacts
WHERE mission_id = $1 AND phase_id = $2
ORDER BY created_at
LIMIT 40",
)
.bind(mission_id)
.bind(phase_id)
.fetch_all(pool)
.await
.map_err(|e| format!("load artifacts: {e}"))?;
let artifacts: Vec<ArtifactRef> = artifact_rows
.into_iter()
.map(|r| ArtifactRef {
path: r.get("path"),
kind: r.get("kind"),
title: r.get("title"),
})
.collect();
Ok(PhaseMaterial {
outputs: concat,
output_count,
tokens,
turns,
tasks_created: task_rows.len(),
tasks_completed,
tasks_failed,
artifacts,
task_summaries,
})
}
async fn call_anthropic(
kind: &str,
material: &PhaseMaterial,
) -> Result<(String, Value), String> {
let api_key =
std::env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY unset".to_string())?;
let model = model_name();
let system = system_prompt(kind);
let user = user_prompt(kind, material);
let body = json!({
"model": model,
"max_tokens": 4096,
"system": system,
"messages": [ { "role": "user", "content": user } ]
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &api_key)
.header("anthropic-version", ANTHROPIC_API_VERSION)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("anthropic call: {e}"))?;
if !resp.status().is_success() {
let code = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!(
"anthropic {code}: {}",
&body[..body.len().min(500)]
));
}
let json: Value = resp
.json()
.await
.map_err(|e| format!("anthropic json: {e}"))?;
let raw = json
.get("content")
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter()
.find(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
})
.and_then(|b| b.get("text"))
.and_then(|t| t.as_str())
.ok_or_else(|| "anthropic response missing text block".to_string())?
.trim()
.to_string();
if raw.is_empty() {
return Err("anthropic returned empty text".into());
}
// Model returns a JSON object; extract narrative + rest.
let parsed: Value = serde_json::from_str(&strip_code_fence(&raw)).map_err(|e| {
format!(
"summarizer JSON parse failed: {e}. Raw head: {}",
&raw[..raw.len().min(400)]
)
})?;
let narrative = parsed
.get("narrative")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if narrative.is_empty() {
return Err("summarizer response missing narrative".into());
}
Ok((narrative, parsed))
}
/// Trim a leading/trailing ```json … ``` fence the model sometimes wraps
/// around its output despite being asked for raw JSON.
fn strip_code_fence(s: &str) -> String {
let t = s.trim();
let stripped = t
.strip_prefix("```json")
.or_else(|| t.strip_prefix("```"))
.unwrap_or(t);
let stripped = stripped.trim_start_matches('\n');
stripped
.strip_suffix("```")
.map(|s| s.trim_end_matches('\n'))
.unwrap_or(stripped)
.to_string()
}
fn system_prompt(kind: &str) -> String {
let base = "You are the mission phase summarizer. Read the agent-produced \
material below and produce a compact JSON object that the operator \
UI will render as a completion card. Extract concrete facts from the \
outputs — never invent findings, PRs, files, or counts that the \
source material does not support.\n\
\n\
Return raw JSON (no code fence, no preamble). The shape MUST be:\n\
\n\
{\n \
\"narrative\": string, // 2–5 sentences: what this phase actually accomplished\n \
\"metrics\": { ... }, // kind-specific counts, see below\n \
\"sources\": [ ... ], // things the agents CONSULTED (URLs, files, docs)\n \
\"tooling\": [ ... ], // concrete recommendations — new skills / scripts / MCP tools worth wiring into the platform\n \
\"next_actions\": [ ... ] // what should happen next — cards to file, follow-ups for the next phase\n\
}\n\
\n\
Every array element is an object with at least a `title` and a short `note`. \
Sources also include a `url` or `path` when identifiable. Tooling \
entries include a `kind` ('skill' | 'script' | 'mcp' | 'workflow') and a \
`why` (what problem it solves that surfaced in the phase).\n\
\n\
Keep it tight — the card is small. If a section has nothing to say, \
return an empty array.";
let kind_hint = match kind {
"research" => "\n\nMetrics shape for RESEARCH:\n\
{ \"insights\": <int>, \"sources_gathered\": <int>, \"int_cards\": <int>, \
\"artifacts_saved\": <int>, \"handoffs_to_coding\": <int> }\n\
Focus the narrative on WHAT WAS LEARNED and WHAT THE CODING PHASE \
NEEDS TO DO next. INT-XX markers in the raw outputs are the count of \
concrete follow-up cards produced.",
"coding" => "\n\nMetrics shape for CODING:\n\
{ \"cards_picked_up\": <int>, \"cards_closed\": <int>, \"commits\": <int>, \
\"tests_added\": <int>, \"tests_passing\": <int>, \"tests_failing\": <int>, \
\"issues_found\": <int>, \"issues_fixed\": <int> }\n\
Focus the narrative on WHAT WAS BUILT, WHAT PASSED VALIDATION, and \
WHAT'S STILL OPEN. Commit hashes and PR/branch names are useful in \
`sources` when visible.",
"benchmark" => "\n\nMetrics shape for BENCHMARK:\n\
{ \"baselines\": <int>, \"comparisons\": <int>, \"regressions\": <int>, \
\"improvements\": <int> }\n\
Report the deltas the agents actually measured.",
"security_scan" => "\n\nMetrics shape for SECURITY:\n\
{ \"findings\": <int>, \"by_severity\": { \"crit\": <int>, \"high\": <int>, \"med\": <int>, \"low\": <int> }, \
\"patches_proposed\": <int> }",
_ => "",
};
format!("{base}{kind_hint}")
}
fn user_prompt(kind: &str, m: &PhaseMaterial) -> String {
let task_head: Vec<String> = m
.task_summaries
.iter()
.take(30)
.map(|t| {
format!(
"- [{}] {} — {}",
t.status,
t.external_id.as_deref().unwrap_or("--"),
t.title,
)
})
.collect();
let artifact_head: Vec<String> = m
.artifacts
.iter()
.take(30)
.map(|a| {
format!(
"- [{}] {}{}",
a.kind,
a.path,
a.title
.as_deref()
.map(|t| format!(" — {t}"))
.unwrap_or_default(),
)
})
.collect();
format!(
"Phase kind: {kind}\n\
Aggregate stats:\n\
- runs.checkpoint.outputs total (pre-truncate): {output_count}\n\
- total turns across runs: {turns}\n\
- total tokens across runs: {tokens}\n\
- mission_tasks in this phase: {tasks_created} (completed: {tasks_completed}, failed: {tasks_failed})\n\
- mission_artifacts in this phase: {artifact_count}\n\
\n\
Mission tasks in this phase (up to 30):\n\
{tasks}\n\
\n\
Mission artifacts in this phase (up to 30):\n\
{arts}\n\
\n\
Concatenated per-turn agent outputs (up to {max_bytes} bytes):\n\
{outputs}",
kind = kind,
output_count = m.output_count,
turns = m.turns,
tokens = m.tokens,
tasks_created = m.tasks_created,
tasks_completed = m.tasks_completed,
tasks_failed = m.tasks_failed,
artifact_count = m.artifacts.len(),
tasks = if task_head.is_empty() {
"(none)".to_string()
} else {
task_head.join("\n")
},
arts = if artifact_head.is_empty() {
"(none)".to_string()
} else {
artifact_head.join("\n")
},
max_bytes = MAX_OUTPUT_BYTES,
outputs = if m.outputs.is_empty() {
"(no outputs)".to_string()
} else {
m.outputs.clone()
},
)
}
#[allow(clippy::too_many_arguments)]
async fn upsert_summary(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
kind: &str,
model: &str,
narrative: &str,
metrics: &Value,
sources: &Value,
artifacts: &Value,
tooling: &Value,
next_actions: &Value,
) -> Result<(), String> {
sqlx::query(
"INSERT INTO mission_phase_summaries
(mission_id, phase_id, kind, model, narrative, metrics,
sources, artifacts, tooling, next_actions, generated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now())
ON CONFLICT (phase_id) DO UPDATE
SET kind = EXCLUDED.kind,
model = EXCLUDED.model,
narrative = EXCLUDED.narrative,
metrics = EXCLUDED.metrics,
sources = EXCLUDED.sources,
artifacts = EXCLUDED.artifacts,
tooling = EXCLUDED.tooling,
next_actions = EXCLUDED.next_actions,
generated_at = now(),
error = NULL",
)
.bind(mission_id)
.bind(phase_id)
.bind(kind)
.bind(model)
.bind(narrative)
.bind(metrics)
.bind(sources)
.bind(artifacts)
.bind(tooling)
.bind(next_actions)
.execute(pool)
.await
.map_err(|e| format!("upsert summary: {e}"))?;
Ok(())
}
async fn record_error(
pool: &PgPool,
mission_id: Uuid,
phase_id: Uuid,
kind: &str,
err: &str,
) -> Result<(), String> {
sqlx::query(
"INSERT INTO mission_phase_summaries
(mission_id, phase_id, kind, model, narrative, error)
VALUES ($1, $2, $3, 'error', 'Summary generation failed.', $4)
ON CONFLICT (phase_id) DO UPDATE
SET error = EXCLUDED.error,
generated_at = now()",
)
.bind(mission_id)
.bind(phase_id)
.bind(kind)
.bind(err)
.execute(pool)
.await
.map_err(|e| format!("record error: {e}"))?;
Ok(())
}
+45
View File
@@ -480,6 +480,51 @@ pub async fn retry_phase(
Ok(Json(serde_json::json!({ "reset": true }))) Ok(Json(serde_json::json!({ "reset": true })))
} }
/// GET /api/missions/{id}/phases/{phase_id}/summary — the completion
/// card produced by `phase_summarizer` for a terminal-state phase.
/// Returns 404 while the phase is still running / hasn't been
/// summarized yet.
pub async fn get_phase_summary(
State(state): State<AppState>,
Authed(user): Authed,
Path((id, phase_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Value>, ApiError> {
// Scope check.
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
.await?
.ok_or(ApiError::NotFound)?;
use sqlx::Row;
let row = sqlx::query(
"SELECT kind, model, narrative, metrics, sources, artifacts,
tooling, next_actions, generated_at, error
FROM mission_phase_summaries
WHERE mission_id = $1 AND phase_id = $2",
)
.bind(id)
.bind(phase_id)
.fetch_optional(&state.pool)
.await?;
let Some(r) = row else {
return Err(ApiError::NotFound);
};
let generated_at: time::OffsetDateTime = r.get("generated_at");
let payload = serde_json::json!({
"kind": r.get::<String, _>("kind"),
"model": r.get::<String, _>("model"),
"narrative": r.get::<String, _>("narrative"),
"metrics": r.get::<Value, _>("metrics"),
"sources": r.get::<Value, _>("sources"),
"artifacts": r.get::<Value, _>("artifacts"),
"tooling": r.get::<Value, _>("tooling"),
"next_actions": r.get::<Value, _>("next_actions"),
"generated_at": generated_at
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
"error": r.get::<Option<String>, _>("error"),
});
Ok(Json(payload))
}
/// GET /api/missions/{id}/runs — topology_runs bound to this mission, /// GET /api/missions/{id}/runs — topology_runs bound to this mission,
/// newest first. Used by the Live tab to subscribe to per-run SSE. /// newest first. Used by the Live tab to subscribe to per-run SSE.
pub async fn list_runs( pub async fn list_runs(
@@ -37,6 +37,7 @@ import { MissionLivePane } from "./MissionLivePane";
import { MissionTeamTab } from "./MissionTeamTab"; import { MissionTeamTab } from "./MissionTeamTab";
import { MissionWizard } from "./MissionWizard"; import { MissionWizard } from "./MissionWizard";
import { PhaseRunsList } from "./PhaseRunsList"; import { PhaseRunsList } from "./PhaseRunsList";
import { PhaseSummaryCard } from "./PhaseSummaryCard";
import { RefineDiffModal } from "./RefineDiffModal"; import { RefineDiffModal } from "./RefineDiffModal";
const mono = const mono =
@@ -681,6 +682,9 @@ export function MissionCanvas({
</span> </span>
)} )}
<PhaseRunsList runs={runsByPhase.get(p.id) ?? []} /> <PhaseRunsList runs={runsByPhase.get(p.id) ?? []} />
{(p.status === "completed" || p.status === "failed") && (
<PhaseSummaryCard missionId={mission.id} phaseId={p.id} />
)}
{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.status === "failed" && mission.status === "running" && ( {p.status === "failed" && mission.status === "running" && (
@@ -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>
);
}
+35
View File
@@ -250,6 +250,41 @@ export interface RunOutput {
export const getRunOutput = (runId: string) => export const getRunOutput = (runId: string) =>
api<RunOutput>(`/api/topology-runs/${runId}/output`); api<RunOutput>(`/api/topology-runs/${runId}/output`);
export interface PhaseSummarySource {
title?: string;
note?: string;
url?: string;
path?: string;
}
export interface PhaseSummaryTooling {
title?: string;
kind?: string;
why?: string;
note?: string;
}
export interface PhaseSummaryNextAction {
title?: string;
note?: string;
}
export interface PhaseSummary {
kind: string;
model: string;
narrative: string;
metrics: Record<string, unknown>;
sources: PhaseSummarySource[];
artifacts: Array<{ path: string; kind: string; title?: string | null }>;
tooling: PhaseSummaryTooling[];
next_actions: PhaseSummaryNextAction[];
generated_at: string;
error: string | null;
}
export const getPhaseSummary = (missionId: string, phaseId: string) =>
api<PhaseSummary>(`/api/missions/${missionId}/phases/${phaseId}/summary`);
export const retryMissionPhase = (id: string, phaseId: string) => export const retryMissionPhase = (id: string, phaseId: string) =>
api<{ reset: boolean }>( api<{ reset: boolean }>(
`/api/missions/${id}/phases/${phaseId}/retry`, `/api/missions/${id}/phases/${phaseId}/retry`,
@@ -0,0 +1,41 @@
-- Phase completion summaries.
--
-- When a mission_phase reaches terminal state, `phase_summarizer` calls
-- Claude Opus 4.8 with all the phase's checkpoint outputs + task-card
-- rows + artifact rows and gets back a structured JSON payload that
-- fuels the UI's phase-completion card (narrative + metrics + sources
-- + tooling recommendations + next actions).
--
-- ONE row per (mission_phase). Re-generation is allowed (retry) and
-- overwrites via the unique index below.
--
-- `metrics` shape varies by kind:
-- research → { insights, sources, int_cards, artifacts, tokens, turns }
-- coding → { cards_picked_up, cards_closed, commits, tests_added,
-- tests_passing, tests_failing, issues_found, issues_fixed }
-- benchmark → { snapshots, deltas, regressions }
-- security → { findings_by_severity, patches_applied }
--
-- `sources`, `artifacts`, `next_actions` are arrays of small objects.
CREATE TABLE mission_phase_summaries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mission_id UUID NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
phase_id UUID NOT NULL REFERENCES mission_phases(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- research | coding | benchmark | security_scan
model TEXT NOT NULL, -- e.g. "claude-opus-4-8"
narrative TEXT NOT NULL,
metrics JSONB NOT NULL DEFAULT '{}'::jsonb,
sources JSONB NOT NULL DEFAULT '[]'::jsonb,
artifacts JSONB NOT NULL DEFAULT '[]'::jsonb,
tooling JSONB NOT NULL DEFAULT '[]'::jsonb,
next_actions JSONB NOT NULL DEFAULT '[]'::jsonb,
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
error TEXT
);
CREATE UNIQUE INDEX mission_phase_summaries_phase_id_uidx
ON mission_phase_summaries(phase_id);
CREATE INDEX mission_phase_summaries_mission_id_idx
ON mission_phase_summaries(mission_id);