1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use anyhow::{Result, bail};
6use fs2::FileExt;
7use regex::Regex;
8
9static SESSION_ID_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
10
11pub struct SessionContext {
15 pub session_id: String,
16 pub session_dir: PathBuf,
17 pub is_new_session: bool,
18 _lock: Option<File>,
19}
20
21impl SessionContext {
22 #[allow(dead_code)]
24 pub fn session_id_path(&self) -> PathBuf {
25 self.session_dir.join("session_id")
26 }
27
28 #[allow(dead_code)]
30 pub fn metadata_path(&self) -> PathBuf {
31 self.session_dir.join("session_meta.json")
32 }
33
34 pub fn log_path(&self) -> PathBuf {
36 self.session_dir.join("session.log")
37 }
38
39 pub fn turn_path(&self, turn: usize) -> PathBuf {
41 self.session_dir.join(format!("turn_{:03}.jsonl", turn))
42 }
43}
44
45pub fn validate_session_id(session_id: &str) -> Result<()> {
49 if session_id.is_empty() || session_id.len() > 128 {
50 bail!("Session ID must be 1-128 characters, got {}", session_id.len());
51 }
52
53 if !SESSION_ID_RE.is_match(session_id) {
54 bail!("Invalid session_id format '{}'. Only alphanumeric, hyphens, and underscores allowed.", session_id);
55 }
56
57 Ok(())
58}
59
60pub fn resolve_session_id(cli_session_id: Option<&str>) -> Result<String> {
62 if let Some(id) = cli_session_id {
63 validate_session_id(id)?;
64 return Ok(id.to_string());
65 }
66
67 if let Ok(id) = std::env::var("PHI_SESSION_ID")
68 && !id.is_empty()
69 {
70 validate_session_id(&id)?;
71 return Ok(id);
72 }
73
74 Ok(generate_session_id())
75}
76
77pub fn generate_session_id() -> String {
79 let now = chrono::Local::now();
80 let uuid = uuid::Uuid::new_v4().to_string();
81 let uuid_short = &uuid[..8.min(uuid.len())];
82 format!("{}_{}", now.format("%Y%m%d"), uuid_short)
83}
84
85pub fn get_or_create_session_dir(session_id: &str, base_dir: &Path) -> Result<(PathBuf, bool)> {
89 let session_dir = base_dir.join("sessions").join(session_id);
90 let is_new = !session_dir.exists();
91
92 if is_new {
93 std::fs::create_dir_all(&session_dir)?;
94 tracing::info!(session_id = %session_id, path = %session_dir.display(), "created new session directory");
95 } else {
96 tracing::info!(session_id = %session_id, path = %session_dir.display(), "reusing existing session directory");
97 }
98
99 std::fs::write(session_dir.join("session_id"), session_id)?;
101
102 update_session_meta(&session_dir, session_id)?;
104
105 Ok((session_dir, is_new))
106}
107
108pub fn acquire_session_lock(session_dir: &Path) -> Result<File> {
113 let lock_path = session_dir.join("session.lock");
114 let file = File::create(&lock_path)?;
115
116 file.try_lock_exclusive().map_err(|_| {
117 anyhow::anyhow!(
118 "Session '{}' is currently in use by another process",
119 session_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
120 )
121 })?;
122
123 Ok(file)
124}
125
126fn update_session_meta(session_dir: &Path, session_id: &str) -> Result<()> {
128 let meta_path = session_dir.join("session_meta.json");
129
130 let mut meta = if meta_path.exists() {
131 let content = std::fs::read_to_string(&meta_path)?;
132 serde_json::from_str::<serde_json::Value>(&content)?
133 } else {
134 serde_json::json!({
135 "session_id": session_id,
136 "created_at": chrono::Utc::now().to_rfc3339(),
137 })
138 };
139
140 meta["last_active_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
141
142 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
143 Ok(())
144}
145
146pub fn cleanup_expired_sessions(base_dir: &Path, max_age_days: i64) -> Result<u32> {
151 let sessions_dir = base_dir.join("sessions");
152 if !sessions_dir.exists() {
153 return Ok(0);
154 }
155
156 let now = chrono::Utc::now();
157 let mut cleaned = 0;
158
159 for entry in std::fs::read_dir(&sessions_dir)? {
160 let entry = entry?;
161 let path = entry.path();
162
163 if !path.is_dir() {
164 continue;
165 }
166
167 let lock_path = path.join("session.lock");
168 if lock_path.exists()
169 && let Ok(file) = File::open(&lock_path)
170 && file.try_lock_shared().is_err()
171 {
172 continue; }
174
175 let meta_path = path.join("session_meta.json");
176 if !meta_path.exists() {
177 std::fs::remove_dir_all(&path)?;
178 cleaned += 1;
179 continue;
180 }
181
182 let content = std::fs::read_to_string(&meta_path)?;
183 let meta: serde_json::Value = serde_json::from_str(&content)?;
184
185 if let Some(last_active) = meta["last_active_at"].as_str()
186 && let Ok(last_active) = chrono::DateTime::parse_from_rfc3339(last_active)
187 {
188 let age = now - last_active.with_timezone(&chrono::Utc);
189 if age.num_days() > max_age_days {
190 tracing::info!(path = %path.display(), age_days = age.num_days(), "removing expired session");
191 std::fs::remove_dir_all(&path)?;
192 cleaned += 1;
193 }
194 }
195 }
196
197 if cleaned > 0 {
198 tracing::info!(count = cleaned, "cleaned up expired sessions");
199 }
200
201 Ok(cleaned)
202}
203
204pub fn resolve_session(cli_session_id: Option<&str>, base_dir: &Path) -> Result<SessionContext> {
209 let session_id = resolve_session_id(cli_session_id)?;
210 let (session_dir, is_new) = get_or_create_session_dir(&session_id, base_dir)?;
211 let lock = acquire_session_lock(&session_dir)?;
212
213 Ok(SessionContext { session_id, session_dir, is_new_session: is_new, _lock: Some(lock) })
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use tempfile::TempDir;
220
221 #[test]
222 fn test_validate_session_id_valid() {
223 assert!(validate_session_id("my-session-123").is_ok());
224 assert!(validate_session_id("test_456").is_ok());
225 assert!(validate_session_id("a").is_ok());
226 }
227
228 #[test]
229 fn test_validate_session_id_invalid() {
230 assert!(validate_session_id("").is_err());
231 assert!(validate_session_id("my session").is_err());
232 assert!(validate_session_id("../etc").is_err());
233 assert!(validate_session_id("path/traversal").is_err());
234 }
235
236 #[test]
237 fn test_generate_session_id() {
238 let id = generate_session_id();
239 assert!(id.contains('_'));
240 let parts: Vec<&str> = id.split('_').collect();
241 assert_eq!(parts.len(), 2);
242 assert_eq!(parts[0].len(), 8);
243 assert_eq!(parts[1].len(), 8);
244 }
245
246 #[test]
247 fn test_session_context_methods() {
248 let tmp = TempDir::new().unwrap();
249 let ctx = resolve_session(Some("test-ctx"), tmp.path()).unwrap();
250
251 assert_eq!(ctx.session_id, "test-ctx");
252 assert!(ctx.session_id_path().exists());
253 assert!(ctx.metadata_path().exists());
254 assert_eq!(ctx.log_path(), ctx.session_dir.join("session.log"));
255 assert_eq!(ctx.turn_path(1), ctx.session_dir.join("turn_001.jsonl"));
256 }
257
258 #[test]
259 fn test_cleanup_expired_sessions() {
260 let tmp = TempDir::new().unwrap();
261 let (dir, _) = get_or_create_session_dir("old-session", tmp.path()).unwrap();
262
263 let meta_path = dir.join("session_meta.json");
264 let mut meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
265 let old = (chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339();
266 meta["last_active_at"] = serde_json::json!(old);
267 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
268
269 get_or_create_session_dir("new-session", tmp.path()).unwrap();
270 let cleaned = cleanup_expired_sessions(tmp.path(), 7).unwrap();
271 assert_eq!(cleaned, 1);
272 assert!(!dir.exists());
273 }
274}