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 pub modified_files: Vec<PathBuf>,
49}
50
51impl Session {
52 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 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 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 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 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 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 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}