use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
const SNAPSHOT_FILENAME: &str = "session_snapshot.json";
const SNAPSHOT_SUBDIR: &str = "recovery";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppStateSnapshot {
pub session_id: String,
pub message_count: usize,
pub last_tool_results: Vec<ToolResultEntry>,
pub snapshot_timestamp_ms: u64,
pub completed: bool,
pub project_dir: String,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolResultEntry {
pub tool_name: String,
pub call_id: String,
pub output_preview: String,
pub success: bool,
}
impl AppStateSnapshot {
pub fn new(session_id: impl Into<String>, project_dir: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
message_count: 0,
last_tool_results: Vec::new(),
snapshot_timestamp_ms: crate::event_bus::now_ms(),
completed: false,
project_dir: project_dir.into(),
cost_usd: 0.0,
}
}
pub fn record_tool_result(&mut self, entry: ToolResultEntry, max_entries: usize) {
self.last_tool_results.push(entry);
if self.last_tool_results.len() > max_entries {
let excess = self.last_tool_results.len() - max_entries;
self.last_tool_results.drain(..excess);
}
}
pub fn mark_completed(&mut self) {
self.completed = true;
self.snapshot_timestamp_ms = crate::event_bus::now_ms();
}
}
#[derive(Debug, Clone)]
pub struct SnapshotPersistence {
snapshot_dir: PathBuf,
}
impl SnapshotPersistence {
pub fn new() -> Self {
let snapshot_dir = dirs_next::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".opendev")
.join("data")
.join(SNAPSHOT_SUBDIR);
Self { snapshot_dir }
}
pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
Self {
snapshot_dir: dir.into(),
}
}
pub fn snapshot_path(&self, session_id: &str) -> PathBuf {
self.snapshot_dir
.join(format!("{session_id}_{SNAPSHOT_FILENAME}"))
}
pub fn save(&self, snapshot: &AppStateSnapshot) -> Result<(), String> {
std::fs::create_dir_all(&self.snapshot_dir)
.map_err(|e| format!("Failed to create snapshot dir: {e}"))?;
let path = self.snapshot_path(&snapshot.session_id);
let tmp_path = path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(snapshot)
.map_err(|e| format!("Failed to serialize snapshot: {e}"))?;
std::fs::write(&tmp_path, &json)
.map_err(|e| format!("Failed to write snapshot tmp: {e}"))?;
std::fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to rename snapshot: {e}"))?;
debug!("Saved snapshot for session {}", snapshot.session_id);
Ok(())
}
pub fn load(&self, session_id: &str) -> Option<AppStateSnapshot> {
let path = self.snapshot_path(session_id);
self.load_from_path(&path)
}
fn load_from_path(&self, path: &Path) -> Option<AppStateSnapshot> {
let contents = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
pub fn find_incomplete_sessions(&self) -> Vec<AppStateSnapshot> {
let mut snapshots = Vec::new();
let entries = match std::fs::read_dir(&self.snapshot_dir) {
Ok(e) => e,
Err(_) => return snapshots,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& let Some(snapshot) = self.load_from_path(&path)
&& !snapshot.completed
{
snapshots.push(snapshot);
}
}
snapshots.sort_by(|a, b| b.snapshot_timestamp_ms.cmp(&a.snapshot_timestamp_ms));
snapshots
}
pub fn remove(&self, session_id: &str) -> bool {
let path = self.snapshot_path(session_id);
match std::fs::remove_file(&path) {
Ok(()) => {
debug!("Removed snapshot for session {session_id}");
true
}
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
warn!("Failed to remove snapshot {}: {e}", path.display());
}
false
}
}
}
pub fn cleanup_old(&self, max_age: Duration) -> usize {
let cutoff_ms = crate::event_bus::now_ms().saturating_sub(max_age.as_millis() as u64);
let mut removed = 0;
let entries = match std::fs::read_dir(&self.snapshot_dir) {
Ok(e) => e,
Err(_) => return 0,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& let Some(snapshot) = self.load_from_path(&path)
&& snapshot.snapshot_timestamp_ms < cutoff_ms
&& std::fs::remove_file(&path).is_ok()
{
removed += 1;
}
}
removed
}
}
impl Default for SnapshotPersistence {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "state_snapshot_tests.rs"]
mod tests;