1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use agent_base::{AgentError, AgentResult};
6use fs2::FileExt;
7use regex::Regex;
8
9static SESSION_ID_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
10static SNAPSHOT_NAME_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
11
12#[derive(Debug)]
16pub struct SessionContext {
17 pub session_id: String,
19 pub session_dir: PathBuf,
21 pub base_dir: PathBuf,
23 pub is_new_session: bool,
25 _lock: Option<File>,
26}
27
28impl SessionContext {
29 #[allow(dead_code)]
31 pub fn session_id_path(&self) -> PathBuf {
32 self.session_dir.join("session_id")
33 }
34
35 pub fn metadata_path(&self) -> PathBuf {
37 self.session_dir.join("session_meta.json")
38 }
39
40 pub fn log_path(&self) -> PathBuf {
42 self.session_dir.join("session.log")
43 }
44
45 pub fn turn_path(&self, turn: usize) -> PathBuf {
47 self.session_dir.join(format!("turn_{:03}.jsonl", turn))
48 }
49}
50
51pub fn validate_session_id(session_id: &str) -> AgentResult<()> {
55 if session_id.is_empty() || session_id.len() > 128 {
56 return Err(AgentError::config_error(format!("Session ID must be 1-128 characters, got {}", session_id.len())));
57 }
58
59 if !SESSION_ID_RE.is_match(session_id) {
60 return Err(AgentError::config_error(format!(
61 "Invalid session_id format '{}'. Only alphanumeric, hyphens, and underscores allowed.",
62 session_id
63 )));
64 }
65
66 Ok(())
67}
68
69pub fn validate_snapshot_name(name: &str) -> AgentResult<()> {
74 if name.is_empty() || name.len() > 64 {
75 return Err(AgentError::config_error(format!("Snapshot name must be 1-64 characters, got {}", name.len())));
76 }
77
78 if name.contains('/') || name.contains('\\') || name.contains("..") {
79 return Err(AgentError::config_error(format!(
80 "Invalid snapshot name '{}': path separators and '..' are not allowed",
81 name
82 )));
83 }
84
85 if !SNAPSHOT_NAME_RE.is_match(name) {
86 return Err(AgentError::config_error(format!(
87 "Invalid snapshot name '{}'. Only alphanumeric, hyphens, and underscores allowed.",
88 name
89 )));
90 }
91
92 Ok(())
93}
94
95pub fn resolve_session_id(cli_session_id: Option<&str>) -> AgentResult<String> {
97 if let Some(id) = cli_session_id {
98 validate_session_id(id)?;
99 return Ok(id.to_string());
100 }
101
102 if let Ok(id) = std::env::var("PHI_SESSION_ID")
103 && !id.is_empty()
104 {
105 validate_session_id(&id)?;
106 return Ok(id);
107 }
108
109 Ok(generate_session_id())
110}
111
112pub fn generate_session_id() -> String {
114 let now = chrono::Local::now();
115 let uuid = uuid::Uuid::new_v4().to_string();
116 let uuid_short = &uuid[..8.min(uuid.len())];
117 format!("{}_{}", now.format("%Y%m%d"), uuid_short)
118}
119
120pub fn get_or_create_session_dir(session_id: &str, base_dir: &Path) -> AgentResult<(PathBuf, bool)> {
124 let session_dir = base_dir.join("sessions").join(session_id);
125 let is_new = !session_dir.exists();
126
127 if is_new {
128 std::fs::create_dir_all(&session_dir)?;
129 tracing::info!(session_id = %session_id, path = %session_dir.display(), "created new session directory");
130 } else {
131 tracing::info!(session_id = %session_id, path = %session_dir.display(), "reusing existing session directory");
132 }
133
134 std::fs::write(session_dir.join("session_id"), session_id)?;
136
137 update_session_meta(&session_dir, session_id)?;
139
140 Ok((session_dir, is_new))
141}
142
143pub fn acquire_session_lock(session_dir: &Path) -> AgentResult<File> {
148 let lock_path = session_dir.join("session.lock");
149 let file = File::create(&lock_path)?;
150
151 file.try_lock_exclusive().map_err(|_| {
152 AgentError::resource_unavailable(format!(
153 "Session '{}' is currently in use by another process",
154 session_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
155 ))
156 })?;
157
158 Ok(file)
159}
160
161fn update_session_meta(session_dir: &Path, session_id: &str) -> AgentResult<()> {
163 let meta_path = session_dir.join("session_meta.json");
164
165 let mut meta = if meta_path.exists() {
166 let content = std::fs::read_to_string(&meta_path)?;
167 serde_json::from_str::<serde_json::Value>(&content)?
168 } else {
169 serde_json::json!({
170 "session_id": session_id,
171 "created_at": chrono::Utc::now().to_rfc3339(),
172 })
173 };
174
175 meta["last_active_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
176
177 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
178 Ok(())
179}
180
181pub fn cleanup_expired_sessions(base_dir: &Path, max_age_days: i64) -> AgentResult<u32> {
186 let sessions_dir = base_dir.join("sessions");
187 if !sessions_dir.exists() {
188 return Ok(0);
189 }
190
191 let now = chrono::Utc::now();
192 let mut cleaned = 0;
193
194 for entry in std::fs::read_dir(&sessions_dir)? {
195 let entry = entry?;
196 let path = entry.path();
197
198 if !path.is_dir() {
199 continue;
200 }
201
202 let lock_path = path.join("session.lock");
203 if lock_path.exists()
204 && let Ok(file) = File::open(&lock_path)
205 && file.try_lock_shared().is_err()
206 {
207 continue; }
209
210 let meta_path = path.join("session_meta.json");
211 if !meta_path.exists() {
212 std::fs::remove_dir_all(&path)?;
213 cleaned += 1;
214 continue;
215 }
216
217 let content = std::fs::read_to_string(&meta_path)?;
218 let meta: serde_json::Value = serde_json::from_str(&content)?;
219
220 if let Some(last_active) = meta["last_active_at"].as_str()
221 && let Ok(last_active) = chrono::DateTime::parse_from_rfc3339(last_active)
222 {
223 let age = now - last_active.with_timezone(&chrono::Utc);
224 if age.num_days() > max_age_days {
225 tracing::info!(path = %path.display(), age_days = age.num_days(), "removing expired session");
226 std::fs::remove_dir_all(&path)?;
227 cleaned += 1;
228 }
229 }
230 }
231
232 if cleaned > 0 {
233 tracing::info!(count = cleaned, "cleaned up expired sessions");
234 }
235
236 Ok(cleaned)
237}
238
239pub fn create_snapshot(session_ctx: &SessionContext, name: &str, base_dir: &Path) -> AgentResult<PathBuf> {
246 validate_snapshot_name(name)?;
247 let snapshot_dir = base_dir.join("snapshots").join(name);
248
249 if snapshot_dir.exists() {
250 std::fs::remove_dir_all(&snapshot_dir)?;
251 }
252 std::fs::create_dir_all(&snapshot_dir)?;
253
254 let meta_path = session_ctx.metadata_path();
256 if meta_path.exists() {
257 std::fs::copy(&meta_path, snapshot_dir.join("session_meta.json"))?;
258 }
259
260 let mut turn_count = 0u32;
262 for entry in std::fs::read_dir(&session_ctx.session_dir)? {
263 let entry = entry?;
264 let name = entry.file_name();
265 let name_str = name.to_string_lossy();
266 if name_str.starts_with("turn_") && name_str.ends_with(".jsonl") {
267 std::fs::copy(entry.path(), snapshot_dir.join(&*name_str))?;
268 turn_count += 1;
269 }
270 }
271
272 let info = serde_json::json!({
274 "session_id": session_ctx.session_id,
275 "snapshot_name": name,
276 "created_at": chrono::Utc::now().to_rfc3339(),
277 "turn_count": turn_count,
278 });
279 std::fs::write(snapshot_dir.join("snapshot_info.json"), serde_json::to_string_pretty(&info)?)?;
280
281 tracing::info!(
282 session_id = %session_ctx.session_id,
283 name = name,
284 turns = turn_count,
285 "session snapshot created"
286 );
287
288 Ok(snapshot_dir)
289}
290
291pub fn list_snapshots(base_dir: &Path) -> AgentResult<Vec<SnapshotInfo>> {
295 let snapshots_dir = base_dir.join("snapshots");
296 if !snapshots_dir.exists() {
297 return Ok(Vec::new());
298 }
299
300 let mut snapshots = Vec::new();
301 for entry in std::fs::read_dir(&snapshots_dir)? {
302 let entry = entry?;
303 let path = entry.path();
304 if !path.is_dir() {
305 continue;
306 }
307
308 let info_path = path.join("snapshot_info.json");
309 if !info_path.exists() {
310 continue;
311 }
312
313 let content = std::fs::read_to_string(&info_path)?;
314 let info: serde_json::Value = serde_json::from_str(&content)?;
315
316 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
317 let session_id = info["session_id"].as_str().unwrap_or("-").to_string();
318 let created_at = info["created_at"].as_str().unwrap_or("-").to_string();
319 let turn_count = info["turn_count"].as_u64().unwrap_or(0) as u32;
320
321 snapshots.push(SnapshotInfo { name, session_id, created_at, turn_count, path });
322 }
323
324 snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
325 Ok(snapshots)
326}
327
328pub struct SnapshotInfo {
330 pub name: String,
332 pub session_id: String,
334 pub created_at: String,
336 pub turn_count: u32,
338 pub path: PathBuf,
340}
341
342pub fn restore_snapshot(name: &str, base_dir: &Path) -> AgentResult<SessionContext> {
346 validate_snapshot_name(name)?;
347 let snapshot_dir = base_dir.join("snapshots").join(name);
348 if !snapshot_dir.exists() {
349 return Err(AgentError::config_error(format!("Snapshot '{}' not found at {}", name, snapshot_dir.display())));
350 }
351
352 let new_session_id = generate_session_id();
354 let (new_session_dir, _) = get_or_create_session_dir(&new_session_id, base_dir)?;
355
356 for entry in std::fs::read_dir(&snapshot_dir)? {
358 let entry = entry?;
359 let fname = entry.file_name();
360 let name_str = fname.to_string_lossy();
361 if name_str.starts_with("turn_") && name_str.ends_with(".jsonl") {
362 std::fs::copy(entry.path(), new_session_dir.join(&*name_str))?;
363 }
364 }
365
366 let lock = acquire_session_lock(&new_session_dir)?;
367 tracing::info!(from = name, to = %new_session_id, "session restored from snapshot");
368
369 Ok(SessionContext {
370 session_id: new_session_id,
371 session_dir: new_session_dir,
372 base_dir: base_dir.to_path_buf(),
373 is_new_session: false,
374 _lock: Some(lock),
375 })
376}
377
378pub fn delete_snapshot(name: &str, base_dir: &Path) -> AgentResult<()> {
380 validate_snapshot_name(name)?;
381 let snapshot_dir = base_dir.join("snapshots").join(name);
382 if !snapshot_dir.exists() {
383 return Err(AgentError::config_error(format!("Snapshot '{}' not found", name)));
384 }
385 std::fs::remove_dir_all(&snapshot_dir)?;
386 tracing::info!(name = name, "snapshot deleted");
387 Ok(())
388}
389
390pub fn resolve_session(cli_session_id: Option<&str>, base_dir: &Path) -> AgentResult<SessionContext> {
395 let session_id = resolve_session_id(cli_session_id)?;
396 let (session_dir, is_new) = get_or_create_session_dir(&session_id, base_dir)?;
397 let lock = acquire_session_lock(&session_dir)?;
398
399 Ok(SessionContext {
400 session_id,
401 session_dir,
402 base_dir: base_dir.to_path_buf(),
403 is_new_session: is_new,
404 _lock: Some(lock),
405 })
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use tempfile::TempDir;
412
413 #[test]
414 fn test_validate_session_id_valid() {
415 assert!(validate_session_id("my-session-123").is_ok());
416 assert!(validate_session_id("test_456").is_ok());
417 assert!(validate_session_id("a").is_ok());
418 }
419
420 #[test]
421 fn test_validate_session_id_invalid() {
422 assert!(validate_session_id("").is_err());
423 assert!(validate_session_id("my session").is_err());
424 assert!(validate_session_id("../etc").is_err());
425 assert!(validate_session_id("path/traversal").is_err());
426 }
427
428 #[test]
429 fn test_generate_session_id() {
430 let id = generate_session_id();
431 assert!(id.contains('_'));
432 let parts: Vec<&str> = id.split('_').collect();
433 assert_eq!(parts.len(), 2);
434 assert_eq!(parts[0].len(), 8);
435 assert_eq!(parts[1].len(), 8);
436 }
437
438 #[test]
439 fn test_session_context_methods() {
440 let tmp = TempDir::new().unwrap();
441 let ctx = resolve_session(Some("test-ctx"), tmp.path()).unwrap();
442
443 assert_eq!(ctx.session_id, "test-ctx");
444 assert!(ctx.session_id_path().exists());
445 assert!(ctx.metadata_path().exists());
446 assert_eq!(ctx.log_path(), ctx.session_dir.join("session.log"));
447 assert_eq!(ctx.turn_path(1), ctx.session_dir.join("turn_001.jsonl"));
448 }
449
450 #[test]
451 fn test_cleanup_expired_sessions() {
452 let tmp = TempDir::new().unwrap();
453 let (dir, _) = get_or_create_session_dir("old-session", tmp.path()).unwrap();
454
455 let meta_path = dir.join("session_meta.json");
456 let mut meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
457 let old = (chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339();
458 meta["last_active_at"] = serde_json::json!(old);
459 std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
460
461 get_or_create_session_dir("new-session", tmp.path()).unwrap();
462 let cleaned = cleanup_expired_sessions(tmp.path(), 7).unwrap();
463 assert_eq!(cleaned, 1);
464 assert!(!dir.exists());
465 }
466
467 #[test]
470 fn test_validate_session_id_returns_config_error() {
471 let err = validate_session_id("").unwrap_err();
473 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for empty session ID, got {:?}", err);
474
475 let err = validate_session_id("../etc").unwrap_err();
477 assert!(
478 matches!(err, AgentError::ConfigError(_)),
479 "expected ConfigError for invalid session ID, got {:?}",
480 err
481 );
482 }
483
484 #[test]
485 fn test_resolve_session_returns_config_error_for_invalid_id() {
486 let tmp = TempDir::new().unwrap();
487 let result = resolve_session(Some("bad id with spaces"), tmp.path());
488 assert!(result.is_err());
489 let err = result.err().unwrap();
490 assert!(
491 matches!(err, AgentError::ConfigError(_)),
492 "expected ConfigError for invalid session ID, got {:?}",
493 err
494 );
495 }
496
497 #[test]
498 fn test_acquire_session_lock_returns_resource_unavailable_when_locked() {
499 let tmp = TempDir::new().unwrap();
500 let (_dir, _) = get_or_create_session_dir("lock-test", tmp.path()).unwrap();
501 let session_dir = tmp.path().join("sessions").join("lock-test");
502
503 let _lock1 = acquire_session_lock(&session_dir).unwrap();
505
506 let err = acquire_session_lock(&session_dir).unwrap_err();
508 assert!(
509 matches!(err, AgentError::ResourceUnavailable(_)),
510 "expected ResourceUnavailable for locked session, got {:?}",
511 err
512 );
513 }
514
515 #[test]
518 fn test_validate_snapshot_name_valid() {
519 assert!(validate_snapshot_name("my-snapshot").is_ok());
520 assert!(validate_snapshot_name("test_123").is_ok());
521 assert!(validate_snapshot_name("a").is_ok());
522 assert!(validate_snapshot_name("snapshot-2024-01-01").is_ok());
523 }
524
525 #[test]
526 fn test_validate_snapshot_name_invalid() {
527 assert!(validate_snapshot_name("").is_err());
528 assert!(validate_snapshot_name("../etc").is_err());
529 assert!(validate_snapshot_name("path/traversal").is_err());
530 assert!(validate_snapshot_name("back\\slash").is_err());
531 assert!(validate_snapshot_name("dot..dot").is_err());
532 assert!(validate_snapshot_name("has space").is_err());
533 assert!(validate_snapshot_name(&"x".repeat(65)).is_err()); }
535
536 #[test]
537 fn test_validate_snapshot_name_returns_config_error() {
538 let err = validate_snapshot_name("").unwrap_err();
539 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError, got {:?}", err);
540
541 let err = validate_snapshot_name("../etc").unwrap_err();
542 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for path traversal, got {:?}", err);
543 }
544
545 #[test]
548 fn test_create_snapshot_and_list() {
549 let tmp = TempDir::new().unwrap();
550 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
551
552 let turn_path = ctx.turn_path(1);
554 std::fs::write(&turn_path, "{}").unwrap();
555
556 let snap_path = create_snapshot(&ctx, "test-snap", tmp.path()).unwrap();
558 assert!(snap_path.exists());
559 assert!(snap_path.join("snapshot_info.json").exists());
560 assert!(snap_path.join("turn_001.jsonl").exists());
561
562 let snaps = list_snapshots(tmp.path()).unwrap();
564 assert_eq!(snaps.len(), 1);
565 assert_eq!(snaps[0].name, "test-snap");
566 assert_eq!(snaps[0].session_id, "snap-session");
567 assert_eq!(snaps[0].turn_count, 1);
568 }
569
570 #[test]
571 fn test_create_snapshot_overwrites_existing() {
572 let tmp = TempDir::new().unwrap();
573 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
574
575 create_snapshot(&ctx, "dup-snap", tmp.path()).unwrap();
576 let result = create_snapshot(&ctx, "dup-snap", tmp.path());
578 assert!(result.is_ok());
579
580 let snaps = list_snapshots(tmp.path()).unwrap();
581 assert_eq!(snaps.len(), 1);
582 }
583
584 #[test]
585 fn test_list_snapshots_empty_dir() {
586 let tmp = TempDir::new().unwrap();
587 let snaps = list_snapshots(tmp.path()).unwrap();
588 assert!(snaps.is_empty());
589 }
590
591 #[test]
592 fn test_list_snapshots_sorted_by_date_desc() {
593 let tmp = TempDir::new().unwrap();
594 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
595
596 create_snapshot(&ctx, "first", tmp.path()).unwrap();
597 std::thread::sleep(std::time::Duration::from_millis(10));
598 create_snapshot(&ctx, "second", tmp.path()).unwrap();
599
600 let snaps = list_snapshots(tmp.path()).unwrap();
601 assert_eq!(snaps.len(), 2);
602 assert_eq!(snaps[0].name, "second");
604 assert_eq!(snaps[1].name, "first");
605 }
606
607 #[test]
610 fn test_restore_snapshot_success() {
611 let tmp = TempDir::new().unwrap();
612 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
613
614 std::fs::write(ctx.turn_path(1), r#"{"type":"text_delta","text":"hello"}"#).unwrap();
616 create_snapshot(&ctx, "restore-me", tmp.path()).unwrap();
617
618 let restored = restore_snapshot("restore-me", tmp.path()).unwrap();
620 assert_ne!(restored.session_id, "snap-session");
621 assert!(!restored.is_new_session);
622 assert!(restored.turn_path(1).exists());
623
624 let content = std::fs::read_to_string(restored.turn_path(1)).unwrap();
626 assert!(content.contains("hello"));
627 }
628
629 #[test]
630 fn test_restore_snapshot_not_found() {
631 let tmp = TempDir::new().unwrap();
632 let err = restore_snapshot("no-such-snapshot", tmp.path()).unwrap_err();
633 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for missing snapshot, got {:?}", err);
634 }
635
636 #[test]
639 fn test_delete_snapshot_success() {
640 let tmp = TempDir::new().unwrap();
641 let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
642
643 create_snapshot(&ctx, "del-me", tmp.path()).unwrap();
644 assert_eq!(list_snapshots(tmp.path()).unwrap().len(), 1);
645
646 delete_snapshot("del-me", tmp.path()).unwrap();
647 assert_eq!(list_snapshots(tmp.path()).unwrap().len(), 0);
648 }
649
650 #[test]
651 fn test_delete_snapshot_not_found() {
652 let tmp = TempDir::new().unwrap();
653 let err = delete_snapshot("no-such-snapshot", tmp.path()).unwrap_err();
654 assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for missing snapshot, got {:?}", err);
655 }
656
657 #[test]
660 fn test_session_context_stores_base_dir() {
661 let tmp = TempDir::new().unwrap();
662 let ctx = resolve_session(Some("base-test"), tmp.path()).unwrap();
663 assert_eq!(ctx.base_dir, tmp.path());
664 assert!(ctx.session_dir.starts_with(&ctx.base_dir));
665 }
666}