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