use crate::config::Config;
use crate::errors::{RalphError, Result};
use crate::providers::Message;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SessionStatus {
InProgress,
Done,
Failed,
Interrupted,
}
impl std::fmt::Display for SessionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SessionStatus::InProgress => write!(f, "in_progress"),
SessionStatus::Done => write!(f, "done"),
SessionStatus::Failed => write!(f, "failed"),
SessionStatus::Interrupted => write!(f, "interrupted"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub session_id: String,
pub workspace: String,
pub provider: String,
pub model: String,
pub created_at: DateTime<Utc>,
pub last_active: DateTime<Utc>,
pub turn_count: u32,
pub status: SessionStatus,
}
#[derive(Debug)]
pub struct Session {
pub meta: SessionMeta,
pub messages: Vec<Message>,
pub session_dir: PathBuf,
pub modified_files: Vec<PathBuf>,
}
impl Session {
pub fn create(workspace: &Path, provider: &str, model: &str, config: &Config) -> Result<Self> {
let now = Utc::now();
let date_str = now.format("%Y-%m-%d").to_string();
let short_id = Uuid::new_v4().to_string()[..6].to_string();
let session_id = format!("{}_{}", date_str, short_id);
let session_dir = config.sessions_dir().join(&session_id);
std::fs::create_dir_all(&session_dir)?;
std::fs::create_dir_all(session_dir.join("checkpoints"))?;
let meta = SessionMeta {
session_id: session_id.clone(),
workspace: workspace.display().to_string(),
provider: provider.to_string(),
model: model.to_string(),
created_at: now,
last_active: now,
turn_count: 0,
status: SessionStatus::InProgress,
};
let session = Self {
meta,
messages: Vec::new(),
session_dir,
modified_files: Vec::new(),
};
session.save_meta()?;
Ok(session)
}
pub fn load(session_id: &str, config: &Config) -> Result<Self> {
let session_dir = config.sessions_dir().join(session_id);
if !session_dir.exists() {
return Err(RalphError::SessionNotFound(session_id.to_string()));
}
let meta = load_json::<SessionMeta>(&session_dir.join("session.json"))
.map_err(|e| RalphError::SessionCorrupt(e.to_string()))?;
let messages =
load_json::<Vec<Message>>(&session_dir.join("messages.json")).unwrap_or_default();
Ok(Self {
meta,
messages,
session_dir,
modified_files: Vec::new(),
})
}
pub fn find_for_workspace(workspace: &Path, config: &Config) -> Option<Self> {
let sessions_dir = config.sessions_dir();
if !sessions_dir.exists() {
return None;
}
let workspace_str = workspace.display().to_string();
let mut candidates: Vec<(SessionMeta, PathBuf)> = std::fs::read_dir(&sessions_dir)
.ok()?
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let meta_path = e.path().join("session.json");
let meta: SessionMeta = load_json(&meta_path).ok()?;
if meta.workspace == workspace_str
&& (meta.status == SessionStatus::Interrupted
|| meta.status == SessionStatus::InProgress)
{
Some((meta, e.path()))
} else {
None
}
})
.collect();
candidates.sort_by(|a, b| b.0.last_active.cmp(&a.0.last_active));
let (meta, session_dir) = candidates.into_iter().next()?;
let messages =
load_json::<Vec<Message>>(&session_dir.join("messages.json")).unwrap_or_default();
Some(Self {
meta,
messages,
session_dir,
modified_files: Vec::new(),
})
}
pub fn list_all(config: &Config) -> Vec<SessionMeta> {
let sessions_dir = config.sessions_dir();
if !sessions_dir.exists() {
return Vec::new();
}
let mut sessions: Vec<SessionMeta> = std::fs::read_dir(&sessions_dir)
.unwrap_or_else(|_| std::fs::read_dir(".").unwrap())
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| load_json::<SessionMeta>(&e.path().join("session.json")).ok())
.collect();
sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active));
sessions
}
pub fn save_turn(&mut self, new_messages: &[Message], turn_count: u32) -> Result<()> {
for msg in new_messages {
self.messages.push(msg.clone());
}
self.meta.turn_count = turn_count;
self.meta.last_active = Utc::now();
self.flush()
}
pub fn set_status(&mut self, status: SessionStatus) -> Result<()> {
self.meta.status = status;
self.meta.last_active = Utc::now();
self.save_meta()
}
pub fn flush(&self) -> Result<()> {
self.save_meta()?;
save_json(&self.session_dir.join("messages.json"), &self.messages)
}
fn save_meta(&self) -> Result<()> {
save_json(&self.session_dir.join("session.json"), &self.meta)
}
pub fn checkpoints_dir(&self) -> PathBuf {
self.session_dir.join("checkpoints")
}
pub fn log_event(&self, event: &str) {
if let Some(log_path) = self.log_path() {
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = format!("[{}] {}\n", Utc::now().format("%Y-%m-%dT%H:%M:%SZ"), event);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = f.write_all(line.as_bytes());
}
}
}
fn log_path(&self) -> Option<PathBuf> {
let date = Utc::now().format("%Y-%m-%d").to_string();
let home = dirs::home_dir()?;
Some(
home.join(".ralph")
.join("logs")
.join(format!("{}_{}.log", date, self.meta.session_id)),
)
}
pub fn clean_old(config: &Config, days: u32) {
let sessions_dir = config.sessions_dir();
if !sessions_dir.exists() {
return;
}
let cutoff = Utc::now() - chrono::Duration::days(days as i64);
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let meta_path = entry.path().join("session.json");
if let Ok(meta) = load_json::<SessionMeta>(&meta_path) {
if meta.last_active < cutoff {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
}
}
}
fn load_json<T: for<'de> Deserialize<'de>>(path: &Path) -> anyhow::Result<T> {
let content = std::fs::read_to_string(path)?;
Ok(serde_json::from_str(&content)?)
}
fn save_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let content = serde_json::to_string_pretty(value)?;
std::fs::write(path, content)?;
Ok(())
}