use crate::api::models::Message;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub session_id: String,
pub working_dir: String,
pub system_prompt: String,
pub messages: Vec<Message>,
#[serde(default)]
pub last_reasoning: Option<String>,
pub timestamp: String,
pub completed: bool,
#[serde(default)]
pub user_task: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub ui_state: Option<serde_json::Value>,
}
pub struct SessionStore {
session_dir: PathBuf,
}
impl SessionStore {
pub fn new(working_dir: &str) -> Self {
let session_dir = crate::config::project_data_dir(working_dir).join("sessions");
if let Err(e) = std::fs::create_dir_all(&session_dir) {
tracing::warn!("Failed to create session directory {:?}: {e}", session_dir);
}
Self { session_dir }
}
pub async fn save(&self, snapshot: &SessionSnapshot) -> Result<()> {
let filename = format!("{}.json", snapshot.session_id);
let path = self.session_dir.join(&filename);
let json = serde_json::to_string_pretty(snapshot)?;
let tmp_path = path.with_extension("json.tmp");
tokio::fs::write(&tmp_path, &json).await?;
tokio::fs::rename(&tmp_path, &path).await?;
let latest_path = self.session_dir.join("latest.json");
let latest_tmp = latest_path.with_extension("json.tmp");
let latest_content = serde_json::json!({
"session_id": snapshot.session_id,
"timestamp": snapshot.timestamp,
"completed": snapshot.completed,
});
tokio::fs::write(&latest_tmp, latest_content.to_string()).await?;
tokio::fs::rename(&latest_tmp, &latest_path).await?;
tracing::debug!(
session_id = %snapshot.session_id,
completed = snapshot.completed,
messages = snapshot.messages.len(),
"Session saved",
);
Ok(())
}
pub async fn load(&self, session_id: &str) -> Result<SessionSnapshot> {
let filename = format!("{session_id}.json");
let path = self.session_dir.join(&filename);
let content = tokio::fs::read_to_string(&path).await?;
let snapshot: SessionSnapshot = serde_json::from_str(&content)?;
Ok(snapshot)
}
pub async fn find_incomplete(&self) -> Option<SessionSnapshot> {
let latest_path = self.session_dir.join("latest.json");
let content = tokio::fs::read_to_string(&latest_path).await.ok()?;
let meta: serde_json::Value = serde_json::from_str(&content).ok()?;
let completed = meta.get("completed")?.as_bool()?;
if completed {
return None;
}
let session_id = meta.get("session_id")?.as_str()?;
self.load(session_id).await.ok()
}
pub async fn list(&self) -> Vec<(String, String, bool)> {
let mut sessions = Vec::new();
let mut entries = match tokio::fs::read_dir(&self.session_dir).await {
Ok(e) => e,
Err(_) => return sessions,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
continue;
}
if let Ok(content) = tokio::fs::read_to_string(&path).await
&& let Ok(snap) = serde_json::from_str::<SessionSnapshot>(&content)
{
sessions.push((snap.session_id, snap.timestamp, snap.completed));
}
}
sessions.sort_by(|a, b| b.1.cmp(&a.1));
sessions
}
pub async fn delete(&self, session_id: &str) -> bool {
let filename = format!("{session_id}.json");
let path = self.session_dir.join(&filename);
match tokio::fs::remove_file(&path).await {
Ok(_) => {
tracing::info!(session_id, "Session deleted");
let latest_path = self.session_dir.join("latest.json");
if let Ok(content) = tokio::fs::read_to_string(&latest_path).await
&& let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content)
&& meta.get("session_id").and_then(|v| v.as_str()) == Some(session_id)
{
let _ = tokio::fs::remove_file(&latest_path).await;
}
true
}
Err(_) => false,
}
}
pub async fn cleanup(&self, keep: usize) -> Result<usize> {
let sessions = self.list().await;
let completed: Vec<_> = sessions.iter().filter(|(_, _, c)| *c).collect();
let mut removed = 0;
if completed.len() > keep {
for (id, _, _) in &completed[keep..] {
let path = self.session_dir.join(format!("{id}.json"));
if tokio::fs::remove_file(&path).await.is_ok() {
removed += 1;
}
}
}
Ok(removed)
}
}
pub async fn detect_incomplete_journal(working_dir: &str) -> Option<String> {
let status_path = crate::config::project_data_dir(working_dir).join("STATUS.md");
let content = tokio::fs::read_to_string(&status_path).await.ok()?;
if content.contains("| Phase | `completed` |") {
return None;
}
for line in content.lines() {
if line.contains("| Session |") {
let id = line.split('`').nth(1)?;
return Some(id.to_string());
}
}
None
}