Skip to main content

phi_agent/
session.rs

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