//! Checkpoint og recovery — lagre og gjenoppta agent-sesjoner. //! //! Skriver mellomtilstand til JSON-fil slik at en krasjet sesjon //! kan gjenopptas med `--resume`. Inkluderer: //! - Meldingshistorikk //! - Token-forbruk per modell //! - Pågående oppgave //! - Tidsstempel og sesjons-ID //! //! Checkpoint skrives periodisk (etter hvert N-te tool-kall) og //! ved sesjonsslutt. Ved `--resume` lastes siste checkpoint og //! agenten fortsetter der den slapp. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use uuid::Uuid; use crate::provider::{Message, TokenUsage}; /// Serialiserbar sesjonsstatus for checkpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Checkpoint { /// Unik sesjons-ID. pub session_id: String, /// Modellspesifikasjon brukt. pub model_spec: String, /// Meldingshistorikk. pub messages: Vec, /// Token-forbruk per modell. pub usage: HashMap, /// Total kostnad i USD. pub total_cost: f64, /// Antall iterasjoner utført. pub total_iterations: usize, /// Pågående oppgave (task-tekst). pub task: Option, /// Task-node-ID (hvis hentet fra grafen). pub task_node_id: Option, /// Arbeidsmappe. pub working_dir: String, /// Filer endret i sesjonen (fra git diff). pub files_changed: Vec, /// Tidsstempel for checkpoint. pub timestamp: DateTime, /// Tidsstempel for sesjonsstart. pub started_at: DateTime, /// Status: "running", "completed", "failed", "interrupted". pub status: String, /// Avsluttende oppsummering (fylles ved sesjonsslutt). pub summary: Option, } /// Token-forbruk (serialiserbar versjon). #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TokenUsageData { pub input_tokens: u64, pub output_tokens: u64, } impl From<&TokenUsage> for TokenUsageData { fn from(u: &TokenUsage) -> Self { Self { input_tokens: u.input_tokens, output_tokens: u.output_tokens, } } } impl From<&TokenUsageData> for TokenUsage { fn from(d: &TokenUsageData) -> Self { Self { input_tokens: d.input_tokens, output_tokens: d.output_tokens, } } } /// Standard checkpoint-mappe. pub fn checkpoint_dir() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join(".synops").join("checkpoints") } /// Filsti for en gitt sesjons-ID. pub fn checkpoint_path(session_id: &str) -> PathBuf { checkpoint_dir().join(format!("{}.json", session_id)) } /// Sti til "latest" symlink/fil som peker på siste checkpoint. fn latest_path() -> PathBuf { checkpoint_dir().join("latest") } /// Generer en unik sesjons-ID. pub fn new_session_id() -> String { let now = Utc::now(); format!( "{}-{}", now.format("%Y%m%d-%H%M%S"), &Uuid::now_v7().to_string()[..8] ) } /// Lagre checkpoint til disk. pub fn save(checkpoint: &Checkpoint) -> Result<(), String> { let dir = checkpoint_dir(); std::fs::create_dir_all(&dir) .map_err(|e| format!("Kunne ikke opprette checkpoint-mappe: {e}"))?; let path = checkpoint_path(&checkpoint.session_id); let json = serde_json::to_string_pretty(checkpoint) .map_err(|e| format!("Serialisering feilet: {e}"))?; // Atomisk skriving via temp-fil let tmp = path.with_extension("tmp"); std::fs::write(&tmp, &json) .map_err(|e| format!("Skriving feilet: {e}"))?; std::fs::rename(&tmp, &path) .map_err(|e| format!("Rename feilet: {e}"))?; // Oppdater "latest"-peker let latest = latest_path(); let _ = std::fs::write(&latest, &checkpoint.session_id); tracing::debug!( session_id = checkpoint.session_id.as_str(), path = %path.display(), "Checkpoint lagret" ); Ok(()) } /// Last inn checkpoint fra disk. pub fn load(session_id: &str) -> Result { let path = checkpoint_path(session_id); let json = std::fs::read_to_string(&path) .map_err(|e| format!("Kunne ikke lese checkpoint {}: {e}", path.display()))?; let checkpoint: Checkpoint = serde_json::from_str(&json) .map_err(|e| format!("Ugyldig checkpoint JSON: {e}"))?; Ok(checkpoint) } /// Last inn siste checkpoint (fra "latest"-peker). pub fn load_latest() -> Result { let latest = latest_path(); let session_id = std::fs::read_to_string(&latest) .map_err(|e| format!("Ingen siste checkpoint funnet: {e}"))?; let session_id = session_id.trim(); load(session_id) } /// Rydd opp gamle checkpoints (behold de siste N). pub fn cleanup(keep: usize) -> Result { let dir = checkpoint_dir(); if !dir.exists() { return Ok(0); } let mut entries: Vec<_> = std::fs::read_dir(&dir) .map_err(|e| format!("Kunne ikke lese checkpoint-mappe: {e}"))? .filter_map(|e| e.ok()) .filter(|e| { e.path() .extension() .is_some_and(|ext| ext == "json") }) .collect(); if entries.len() <= keep { return Ok(0); } // Sorter etter modifiseringstid (nyeste først) entries.sort_by(|a, b| { let t_a = a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH); let t_b = b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH); t_b.cmp(&t_a) }); let mut removed = 0; for entry in entries.iter().skip(keep) { if std::fs::remove_file(entry.path()).is_ok() { removed += 1; } } Ok(removed) } /// Hent liste over endrede filer fra git diff (for sesjonsrapport). pub async fn changed_files(working_dir: &Path) -> Vec { let output = tokio::process::Command::new("git") .args(["diff", "--name-only", "HEAD"]) .current_dir(working_dir) .output() .await; match output { Ok(o) if o.status.success() => { String::from_utf8_lossy(&o.stdout) .lines() .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect() } _ => vec![], } } /// Generer en sesjonsrapport-streng. pub fn session_report(checkpoint: &Checkpoint) -> String { let mut report = String::new(); report.push_str(&format!("# Sesjonsrapport: {}\n\n", checkpoint.session_id)); report.push_str(&format!("Modell: {}\n", checkpoint.model_spec)); report.push_str(&format!("Status: {}\n", checkpoint.status)); report.push_str(&format!( "Varighet: {} → {}\n", checkpoint.started_at.format("%Y-%m-%d %H:%M:%S"), checkpoint.timestamp.format("%Y-%m-%d %H:%M:%S"), )); report.push_str(&format!("Iterasjoner: {}\n\n", checkpoint.total_iterations)); // Token-forbruk report.push_str("## Token-forbruk\n\n"); report.push_str("| Modell | Input | Output | Kostnad |\n"); report.push_str("|--------|-------|--------|---------|\n"); let mut total_input = 0u64; let mut total_output = 0u64; for (model, usage) in &checkpoint.usage { let cost = crate::provider::calculate_cost( model, &TokenUsage { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, }, ); report.push_str(&format!( "| {} | {} | {} | ${:.4} |\n", model, usage.input_tokens, usage.output_tokens, cost )); total_input += usage.input_tokens; total_output += usage.output_tokens; } report.push_str(&format!( "| **Totalt** | **{}** | **{}** | **${:.4}** |\n\n", total_input, total_output, checkpoint.total_cost )); // Oppgave if let Some(ref task) = checkpoint.task { report.push_str("## Oppgave\n\n"); report.push_str(task); report.push_str("\n\n"); } // Filer endret if !checkpoint.files_changed.is_empty() { report.push_str("## Filer endret\n\n"); for f in &checkpoint.files_changed { report.push_str(&format!("- {}\n", f)); } report.push('\n'); } // Oppsummering if let Some(ref summary) = checkpoint.summary { report.push_str("## Oppsummering\n\n"); report.push_str(summary); report.push('\n'); } report } /// Logg token-forbruk til ai_usage_log i PG. pub async fn log_usage_to_pg( pool: &sqlx::PgPool, checkpoint: &Checkpoint, ) -> Result<(), String> { let agent_id = Uuid::parse_str(crate::graph::AGENT_NODE_ID) .map_err(|e| format!("Ugyldig agent UUID: {e}"))?; let task_node_id = checkpoint .task_node_id .as_deref() .and_then(|s| Uuid::parse_str(s).ok()); for (model, usage) in &checkpoint.usage { let total = usage.input_tokens + usage.output_tokens; let cost = crate::provider::calculate_cost( model, &TokenUsage { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, }, ); sqlx::query( r#" INSERT INTO ai_usage_log (agent_node_id, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, estimated_cost, job_type) VALUES ($1, $2, $2, $3, $4, $5, $6, $7) "#, ) .bind(agent_id) .bind(model) .bind(usage.input_tokens as i32) .bind(usage.output_tokens as i32) .bind(total as i32) .bind(cost) .bind( if task_node_id.is_some() { "agent_task" } else { "agent_session" }, ) .execute(pool) .await .map_err(|e| format!("ai_usage_log insert feilet: {e}"))?; } tracing::info!( session_id = checkpoint.session_id.as_str(), models = checkpoint.usage.len(), total_cost = format!("${:.4}", checkpoint.total_cost).as_str(), "Kostnadslogging til PG fullført" ); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_new_session_id() { let id = new_session_id(); assert!(id.len() > 15); assert!(id.contains('-')); } #[test] fn test_checkpoint_roundtrip() { let cp = Checkpoint { session_id: "test-123".into(), model_spec: "openrouter/anthropic/claude-sonnet-4".into(), messages: vec![Message { role: "system".into(), content: Some("Hello".into()), tool_calls: None, tool_call_id: None, }], usage: { let mut m = HashMap::new(); m.insert("claude-sonnet-4".into(), TokenUsageData { input_tokens: 1000, output_tokens: 500, }); m }, total_cost: 0.0225, total_iterations: 5, task: Some("Fiks buggen".into()), task_node_id: None, working_dir: "/home/vegard/synops".into(), files_changed: vec!["src/main.rs".into()], timestamp: Utc::now(), started_at: Utc::now(), status: "completed".into(), summary: Some("Buggen ble fikset.".into()), }; let json = serde_json::to_string_pretty(&cp).unwrap(); let loaded: Checkpoint = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.session_id, "test-123"); assert_eq!(loaded.messages.len(), 1); assert_eq!(loaded.total_cost, 0.0225); assert_eq!(loaded.files_changed.len(), 1); } #[test] fn test_session_report() { let cp = Checkpoint { session_id: "test-report".into(), model_spec: "test-model".into(), messages: vec![], usage: { let mut m = HashMap::new(); m.insert("test-model".into(), TokenUsageData { input_tokens: 2000, output_tokens: 1000, }); m }, total_cost: 0.05, total_iterations: 10, task: Some("Test-oppgave".into()), task_node_id: None, working_dir: "/tmp".into(), files_changed: vec!["a.rs".into(), "b.rs".into()], timestamp: Utc::now(), started_at: Utc::now(), status: "completed".into(), summary: Some("Alt gikk bra.".into()), }; let report = session_report(&cp); assert!(report.contains("Sesjonsrapport")); assert!(report.contains("test-model")); assert!(report.contains("a.rs")); assert!(report.contains("Alt gikk bra")); } }