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:
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user