Skip to main content

phi_agent/
session.rs

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
11/// Session context — holds the session ID, directory, and file lock.
12///
13/// Created via [`resolve_session`]. The lock is released when the struct is dropped.
14pub 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    /// Path to the session ID marker file.
23    #[allow(dead_code)]
24    pub fn session_id_path(&self) -> PathBuf {
25        self.session_dir.join("session_id")
26    }
27
28    /// Path to the session metadata JSON file.
29    #[allow(dead_code)]
30    pub fn metadata_path(&self) -> PathBuf {
31        self.session_dir.join("session_meta.json")
32    }
33
34    /// Path to the session log file (human-readable).
35    pub fn log_path(&self) -> PathBuf {
36        self.session_dir.join("session.log")
37    }
38
39    /// Path to the per-turn JSONL event log. `turn` is 1-indexed.
40    pub fn turn_path(&self, turn: usize) -> PathBuf {
41        self.session_dir.join(format!("turn_{:03}.jsonl", turn))
42    }
43}
44
45/// Validate session ID format.
46///
47/// Allowed: alphanumerics, hyphens, underscores. 1–128 characters.
48pub 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
60/// Resolve session ID (priority: CLI arg → `PHI_SESSION_ID` env var → auto-generate).
61pub 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
77/// Generate a new session_id (format: YYYYMMDD_first8ofUuid)
78pub 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
85/// Get or create a session directory under `base_dir/sessions/<session_id>`.
86///
87/// Returns the directory path and whether it was newly created.
88pub 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    // Write session_id file
100    std::fs::write(session_dir.join("session_id"), session_id)?;
101
102    // Update session_meta.json
103    update_session_meta(&session_dir, session_id)?;
104
105    Ok((session_dir, is_new))
106}
107
108/// Acquire an exclusive file lock on the session directory.
109///
110/// Prevents concurrent access from other processes. Returns an error if the
111/// session is already in use.
112pub 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
126/// Update session_meta.json
127fn 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
146/// Clean up expired sessions.
147///
148/// Sessions inactive for more than `max_age_days` are removed from disk.
149/// Active (locked) sessions are skipped.
150pub 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; // locked, skip
173        }
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
204/// Resolve and create a session context — session ID, directory, and file lock.
205///
206/// This is the primary entry point for session setup. It combines ID resolution,
207/// directory creation, and lock acquisition into a single call.
208pub 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}