Skip to main content

ralph/
session.rs

1use crate::config::Config;
2use crate::errors::{RalphError, Result};
3use crate::providers::Message;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "snake_case")]
12pub enum SessionStatus {
13    InProgress,
14    Done,
15    Failed,
16    Interrupted,
17}
18
19impl std::fmt::Display for SessionStatus {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            SessionStatus::InProgress => write!(f, "in_progress"),
23            SessionStatus::Done => write!(f, "done"),
24            SessionStatus::Failed => write!(f, "failed"),
25            SessionStatus::Interrupted => write!(f, "interrupted"),
26        }
27    }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct SessionMeta {
32    pub session_id: String,
33    pub workspace: String,
34    pub provider: String,
35    pub model: String,
36    pub created_at: DateTime<Utc>,
37    pub last_active: DateTime<Utc>,
38    pub turn_count: u32,
39    pub status: SessionStatus,
40}
41
42#[derive(Debug)]
43pub struct Session {
44    pub meta: SessionMeta,
45    pub messages: Vec<Message>,
46    pub session_dir: PathBuf,
47    /// Files Ralph has modified in this session (for checkpoint snapshotting).
48    pub modified_files: Vec<PathBuf>,
49}
50
51impl Session {
52    /// Create a fresh session.
53    pub fn create(workspace: &Path, provider: &str, model: &str, config: &Config) -> Result<Self> {
54        let now = Utc::now();
55        let date_str = now.format("%Y-%m-%d").to_string();
56        let short_id = Uuid::new_v4().to_string()[..6].to_string();
57        let session_id = format!("{}_{}", date_str, short_id);
58
59        let session_dir = config.sessions_dir().join(&session_id);
60        std::fs::create_dir_all(&session_dir)?;
61        std::fs::create_dir_all(session_dir.join("checkpoints"))?;
62
63        let meta = SessionMeta {
64            session_id: session_id.clone(),
65            workspace: workspace.display().to_string(),
66            provider: provider.to_string(),
67            model: model.to_string(),
68            created_at: now,
69            last_active: now,
70            turn_count: 0,
71            status: SessionStatus::InProgress,
72        };
73
74        let session = Self {
75            meta,
76            messages: Vec::new(),
77            session_dir,
78            modified_files: Vec::new(),
79        };
80        session.save_meta()?;
81        Ok(session)
82    }
83
84    /// Load an existing session by ID.
85    pub fn load(session_id: &str, config: &Config) -> Result<Self> {
86        let session_dir = config.sessions_dir().join(session_id);
87        if !session_dir.exists() {
88            return Err(RalphError::SessionNotFound(session_id.to_string()));
89        }
90
91        let meta = load_json::<SessionMeta>(&session_dir.join("session.json"))
92            .map_err(|e| RalphError::SessionCorrupt(e.to_string()))?;
93
94        let messages =
95            load_json::<Vec<Message>>(&session_dir.join("messages.json")).unwrap_or_default();
96
97        Ok(Self {
98            meta,
99            messages,
100            session_dir,
101            modified_files: Vec::new(),
102        })
103    }
104
105    /// Find the most recent session for the given workspace.
106    pub fn find_for_workspace(workspace: &Path, config: &Config) -> Option<Self> {
107        let sessions_dir = config.sessions_dir();
108        if !sessions_dir.exists() {
109            return None;
110        }
111
112        let workspace_str = workspace.display().to_string();
113
114        let mut candidates: Vec<(SessionMeta, PathBuf)> = std::fs::read_dir(&sessions_dir)
115            .ok()?
116            .flatten()
117            .filter(|e| e.path().is_dir())
118            .filter_map(|e| {
119                let meta_path = e.path().join("session.json");
120                let meta: SessionMeta = load_json(&meta_path).ok()?;
121                if meta.workspace == workspace_str
122                    && (meta.status == SessionStatus::Interrupted
123                        || meta.status == SessionStatus::InProgress)
124                {
125                    Some((meta, e.path()))
126                } else {
127                    None
128                }
129            })
130            .collect();
131
132        candidates.sort_by(|a, b| b.0.last_active.cmp(&a.0.last_active));
133
134        let (meta, session_dir) = candidates.into_iter().next()?;
135        let messages =
136            load_json::<Vec<Message>>(&session_dir.join("messages.json")).unwrap_or_default();
137
138        Some(Self {
139            meta,
140            messages,
141            session_dir,
142            modified_files: Vec::new(),
143        })
144    }
145
146    /// List all sessions, sorted by last_active descending.
147    pub fn list_all(config: &Config) -> Vec<SessionMeta> {
148        let sessions_dir = config.sessions_dir();
149        if !sessions_dir.exists() {
150            return Vec::new();
151        }
152
153        let mut sessions: Vec<SessionMeta> = std::fs::read_dir(&sessions_dir)
154            .unwrap_or_else(|_| std::fs::read_dir(".").unwrap())
155            .flatten()
156            .filter(|e| e.path().is_dir())
157            .filter_map(|e| load_json::<SessionMeta>(&e.path().join("session.json")).ok())
158            .collect();
159
160        sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active));
161        sessions
162    }
163
164    /// Append messages and persist.
165    pub fn save_turn(&mut self, new_messages: &[Message], turn_count: u32) -> Result<()> {
166        for msg in new_messages {
167            self.messages.push(msg.clone());
168        }
169        self.meta.turn_count = turn_count;
170        self.meta.last_active = Utc::now();
171        self.flush()
172    }
173
174    pub fn set_status(&mut self, status: SessionStatus) -> Result<()> {
175        self.meta.status = status;
176        self.meta.last_active = Utc::now();
177        self.save_meta()
178    }
179
180    pub fn flush(&self) -> Result<()> {
181        self.save_meta()?;
182        save_json(&self.session_dir.join("messages.json"), &self.messages)
183    }
184
185    fn save_meta(&self) -> Result<()> {
186        save_json(&self.session_dir.join("session.json"), &self.meta)
187    }
188
189    pub fn checkpoints_dir(&self) -> PathBuf {
190        self.session_dir.join("checkpoints")
191    }
192
193    /// Append a line to the human-readable session log in ~/.ralph/logs/.
194    pub fn log_event(&self, event: &str) {
195        if let Some(log_path) = self.log_path() {
196            if let Some(parent) = log_path.parent() {
197                let _ = std::fs::create_dir_all(parent);
198            }
199            let line = format!("[{}] {}\n", Utc::now().format("%Y-%m-%dT%H:%M:%SZ"), event);
200            if let Ok(mut f) = std::fs::OpenOptions::new()
201                .create(true)
202                .append(true)
203                .open(&log_path)
204            {
205                let _ = f.write_all(line.as_bytes());
206            }
207        }
208    }
209
210    fn log_path(&self) -> Option<PathBuf> {
211        let date = Utc::now().format("%Y-%m-%d").to_string();
212        let home = dirs::home_dir()?;
213        Some(
214            home.join(".ralph")
215                .join("logs")
216                .join(format!("{}_{}.log", date, self.meta.session_id)),
217        )
218    }
219
220    /// Delete sessions older than `days`.
221    pub fn clean_old(config: &Config, days: u32) {
222        let sessions_dir = config.sessions_dir();
223        if !sessions_dir.exists() {
224            return;
225        }
226        let cutoff = Utc::now() - chrono::Duration::days(days as i64);
227
228        if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
229            for entry in entries.flatten() {
230                if !entry.path().is_dir() {
231                    continue;
232                }
233                let meta_path = entry.path().join("session.json");
234                if let Ok(meta) = load_json::<SessionMeta>(&meta_path) {
235                    if meta.last_active < cutoff {
236                        let _ = std::fs::remove_dir_all(entry.path());
237                    }
238                }
239            }
240        }
241    }
242}
243
244fn load_json<T: for<'de> Deserialize<'de>>(path: &Path) -> anyhow::Result<T> {
245    let content = std::fs::read_to_string(path)?;
246    Ok(serde_json::from_str(&content)?)
247}
248
249fn save_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
250    let content = serde_json::to_string_pretty(value)?;
251    std::fs::write(path, content)?;
252    Ok(())
253}