A phase used to complete when its topology_runs reached a terminal state --
purely structural. It marked itself done whether the agents produced the
artifact or wrote nothing at all, and it ran exactly once: execute_resumable's
skip(start) is resume, not repeat, and the only re-run path was a human
hitting the retry endpoint.
A phase can now carry `done_when`, a completion condition judged after each
pass against the evidence the agents actually surfaced. Not met and passes
remain -> the phase goes back to pending with iteration bumped, and the
verdict's reason is appended to the next pass's task text. That feedback is
what makes iteration converge rather than repeat -- the same mechanism /goal
uses, and that swarm.rs already uses for rejected work.
The evaluator runs on the SUBSCRIPTION model. CLAWMATES_EVALUATOR_MODEL
defaults to judge_model(), and a `runtime:<alias>` spec routes through
ZeroClawDriveExecutor -- a container agent on claude_cli, i.e. Claude Code on
the OAuth subscription, needing no platform API key. Same routing the door
governor uses.
Two deliberate departures from the governor's contract, both required:
- FAIL-CLOSED. Runtime::judge is fail-open and reads a verdict by
!contains("DENY"), so a model explaining why it *would* deny reads as
approval and an empty reply reads as approval. For completion that is
backwards: unsure must mean not done. The contract is swarm.rs's strict
JSON {"met","reason"} with .unwrap_or(false). Six tests cover the closed
paths -- prose, empty, missing field, non-boolean, transport error.
- judge_raw returns the raw reply; judge collapses to a bool too early to
carry a structured verdict.
Iteration scoping is the subtle part and has its own test: on pass 2 the
phase's own iteration is 1 but pass 1's completed run is still in the table,
so "are this phase's runs all finished?" must ask about the CURRENT pass or
that stale row closes out pass 2 the instant it is enqueued.
Evidence comes from phase_summarizer::collect_evidence, extracted from the
existing collect_material so the evaluator and the summary card cannot
disagree about what a phase produced.
done_when/max_iterations are promoted from phase config into columns (the
sweep filters on them every tick) and max_iterations is clamped to 20 at
insert -- the UI limits it too, but a runaway loop must not be one crafted
request away.
A phase with no condition completes exactly as before; that regression guard
is the first test in the file.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
609 lines
20 KiB
Rust
609 lines
20 KiB
Rust
//! 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,
|
||
}
|
||
|
||
/// Render this phase's material as plain evidence text.
|
||
///
|
||
/// Shared with the completion evaluator (`crate::evaluator`), which judges a
|
||
/// `done_when` condition against exactly the same material the summarizer
|
||
/// writes its card from — turn outputs, task counts, artifacts. Reusing this
|
||
/// keeps the two from disagreeing about what the phase actually produced, and
|
||
/// the truncation/aggregation logic only has to be right once.
|
||
pub async fn collect_evidence(
|
||
pool: &PgPool,
|
||
mission_id: Uuid,
|
||
phase_id: Uuid,
|
||
) -> Result<String, String> {
|
||
let m = collect_material(pool, mission_id, phase_id).await?;
|
||
let mut s = String::with_capacity(m.outputs.len() + 512);
|
||
s.push_str(&format!(
|
||
"turns: {}\ntokens: {}\nagent outputs: {}\ntasks: {} created, {} completed, {} failed\n",
|
||
m.turns, m.tokens, m.output_count, m.tasks_created, m.tasks_completed, m.tasks_failed,
|
||
));
|
||
if !m.artifacts.is_empty() {
|
||
s.push_str("\nartifacts written:\n");
|
||
for a in m.artifacts.iter().take(40) {
|
||
s.push_str(&format!("- {} ({})\n", a.path, a.kind));
|
||
}
|
||
}
|
||
if !m.task_summaries.is_empty() {
|
||
s.push_str("\ntask states:\n");
|
||
for t in m.task_summaries.iter().take(40) {
|
||
s.push_str(&format!(
|
||
"- {} [{}] {}\n",
|
||
t.external_id.as_deref().unwrap_or("-"),
|
||
t.status,
|
||
t.title
|
||
));
|
||
}
|
||
}
|
||
s.push_str("\nagent turn output:\n");
|
||
s.push_str(&m.outputs);
|
||
Ok(s)
|
||
}
|
||
|
||
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(())
|
||
}
|