1use crate::llm::{Message, TokenUsage, ToolDefinition};
33use crate::planning::Task;
34use crate::session::{ContextUsage, SessionConfig, SessionState};
35use anyhow::{Context, Result};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39use tokio::fs;
40use tokio::io::AsyncWriteExt;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionData {
52 pub id: String,
54
55 pub config: SessionConfig,
57
58 pub state: SessionState,
60
61 pub messages: Vec<Message>,
63
64 pub context_usage: ContextUsage,
66
67 pub total_usage: TokenUsage,
69
70 #[serde(default)]
72 pub total_cost: f64,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub model_name: Option<String>,
77
78 #[serde(default)]
80 pub cost_records: Vec<crate::telemetry::LlmCostRecord>,
81
82 pub tool_names: Vec<String>,
84
85 pub thinking_enabled: bool,
87
88 pub thinking_budget: Option<usize>,
90
91 pub created_at: i64,
93
94 pub updated_at: i64,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub llm_config: Option<LlmConfigData>,
100
101 #[serde(default, alias = "todos")]
103 pub tasks: Vec<Task>,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub parent_id: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct LlmConfigData {
113 pub provider: String,
114 pub model: String,
115 #[serde(skip_serializing, default)]
117 pub api_key: Option<String>,
118 pub base_url: Option<String>,
119}
120
121impl SessionData {
122 pub fn tool_names_from_definitions(tools: &[ToolDefinition]) -> Vec<String> {
124 tools.iter().map(|t| t.name.clone()).collect()
125 }
126}
127
128#[async_trait::async_trait]
134pub trait SessionStore: Send + Sync {
135 async fn save(&self, session: &SessionData) -> Result<()>;
137
138 async fn load(&self, id: &str) -> Result<Option<SessionData>>;
140
141 async fn delete(&self, id: &str) -> Result<()>;
143
144 async fn list(&self) -> Result<Vec<String>>;
146
147 async fn exists(&self, id: &str) -> Result<bool>;
149
150 async fn health_check(&self) -> Result<()> {
152 Ok(())
153 }
154
155 fn backend_name(&self) -> &str {
157 "unknown"
158 }
159}
160
161pub struct FileSessionStore {
174 dir: PathBuf,
176}
177
178impl FileSessionStore {
179 pub async fn new<P: AsRef<Path>>(dir: P) -> Result<Self> {
183 let dir = dir.as_ref().to_path_buf();
184
185 fs::create_dir_all(&dir)
187 .await
188 .with_context(|| format!("Failed to create session directory: {}", dir.display()))?;
189
190 Ok(Self { dir })
191 }
192
193 fn session_path(&self, id: &str) -> PathBuf {
195 let safe_id = id.replace(['/', '\\'], "_").replace("..", "_");
197 self.dir.join(format!("{}.json", safe_id))
198 }
199}
200
201#[async_trait::async_trait]
202impl SessionStore for FileSessionStore {
203 async fn save(&self, session: &SessionData) -> Result<()> {
204 let path = self.session_path(&session.id);
205
206 let json = serde_json::to_string_pretty(session)
208 .with_context(|| format!("Failed to serialize session: {}", session.id))?;
209
210 let unique_suffix = format!(
213 "{}.{}",
214 std::time::SystemTime::now()
215 .duration_since(std::time::UNIX_EPOCH)
216 .unwrap()
217 .as_nanos(),
218 std::process::id()
219 );
220 let temp_path = path.with_extension(format!("json.{}.tmp", unique_suffix));
221
222 let mut file = fs::File::create(&temp_path)
223 .await
224 .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
225
226 file.write_all(json.as_bytes())
227 .await
228 .with_context(|| format!("Failed to write session data: {}", session.id))?;
229
230 file.sync_all()
231 .await
232 .with_context(|| format!("Failed to sync session file: {}", session.id))?;
233
234 fs::rename(&temp_path, &path)
236 .await
237 .with_context(|| format!("Failed to rename session file: {}", session.id))?;
238
239 tracing::debug!("Saved session {} to {}", session.id, path.display());
240 Ok(())
241 }
242
243 async fn load(&self, id: &str) -> Result<Option<SessionData>> {
244 let path = self.session_path(id);
245
246 if !path.exists() {
247 return Ok(None);
248 }
249
250 let json = fs::read_to_string(&path)
251 .await
252 .with_context(|| format!("Failed to read session file: {}", path.display()))?;
253
254 let session: SessionData = serde_json::from_str(&json)
255 .with_context(|| format!("Failed to parse session file: {}", path.display()))?;
256
257 tracing::debug!("Loaded session {} from {}", id, path.display());
258 Ok(Some(session))
259 }
260
261 async fn delete(&self, id: &str) -> Result<()> {
262 let path = self.session_path(id);
263
264 if path.exists() {
265 fs::remove_file(&path)
266 .await
267 .with_context(|| format!("Failed to delete session file: {}", path.display()))?;
268
269 tracing::debug!("Deleted session {} from {}", id, path.display());
270 }
271
272 Ok(())
273 }
274
275 async fn list(&self) -> Result<Vec<String>> {
276 let mut session_ids = Vec::new();
277
278 let mut entries = fs::read_dir(&self.dir)
279 .await
280 .with_context(|| format!("Failed to read session directory: {}", self.dir.display()))?;
281
282 while let Some(entry) = entries.next_entry().await? {
283 let path = entry.path();
284
285 if path.extension().is_some_and(|ext| ext == "json") {
286 if let Some(stem) = path.file_stem() {
287 if let Some(id) = stem.to_str() {
288 session_ids.push(id.to_string());
289 }
290 }
291 }
292 }
293
294 Ok(session_ids)
295 }
296
297 async fn exists(&self, id: &str) -> Result<bool> {
298 let path = self.session_path(id);
299 Ok(path.exists())
300 }
301
302 async fn health_check(&self) -> Result<()> {
303 let probe = self.dir.join(".health_check");
305 fs::write(&probe, b"ok")
306 .await
307 .with_context(|| format!("Store directory not writable: {}", self.dir.display()))?;
308 let _ = fs::remove_file(&probe).await;
309 Ok(())
310 }
311
312 fn backend_name(&self) -> &str {
313 "file"
314 }
315}
316
317pub struct MemorySessionStore {
323 sessions: tokio::sync::RwLock<HashMap<String, SessionData>>,
324}
325
326impl MemorySessionStore {
327 pub fn new() -> Self {
328 Self {
329 sessions: tokio::sync::RwLock::new(HashMap::new()),
330 }
331 }
332}
333
334impl Default for MemorySessionStore {
335 fn default() -> Self {
336 Self::new()
337 }
338}
339
340#[async_trait::async_trait]
341impl SessionStore for MemorySessionStore {
342 async fn save(&self, session: &SessionData) -> Result<()> {
343 let mut sessions = self.sessions.write().await;
344 sessions.insert(session.id.clone(), session.clone());
345 Ok(())
346 }
347
348 async fn load(&self, id: &str) -> Result<Option<SessionData>> {
349 let sessions = self.sessions.read().await;
350 Ok(sessions.get(id).cloned())
351 }
352
353 async fn delete(&self, id: &str) -> Result<()> {
354 let mut sessions = self.sessions.write().await;
355 sessions.remove(id);
356 Ok(())
357 }
358
359 async fn list(&self) -> Result<Vec<String>> {
360 let sessions = self.sessions.read().await;
361 Ok(sessions.keys().cloned().collect())
362 }
363
364 async fn exists(&self, id: &str) -> Result<bool> {
365 let sessions = self.sessions.read().await;
366 Ok(sessions.contains_key(id))
367 }
368
369 fn backend_name(&self) -> &str {
370 "memory"
371 }
372}
373
374#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::hitl::ConfirmationPolicy;
382 use crate::permissions::PermissionPolicy;
383 use crate::prompts::PlanningMode;
384 use crate::queue::SessionQueueConfig;
385 use tempfile::tempdir;
386
387 fn create_test_session_data() -> SessionData {
388 SessionData {
389 id: "test-session-1".to_string(),
390 config: SessionConfig {
391 name: "Test Session".to_string(),
392 workspace: "/tmp/workspace".to_string(),
393 system_prompt: Some("You are helpful.".to_string()),
394 max_context_length: 200000,
395 auto_compact: false,
396 auto_compact_threshold: crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD,
397 storage_type: crate::config::StorageBackend::File,
398 queue_config: None,
399 confirmation_policy: None,
400 permission_policy: None,
401 parent_id: None,
402 security_config: None,
403 hook_engine: None,
404 planning_mode: PlanningMode::default(),
405 goal_tracking: false,
406 },
407 state: SessionState::Active,
408 messages: vec![
409 Message::user("Hello"),
410 Message {
411 role: "assistant".to_string(),
412 content: vec![crate::llm::ContentBlock::Text {
413 text: "Hi there!".to_string(),
414 }],
415 reasoning_content: None,
416 },
417 ],
418 context_usage: ContextUsage {
419 used_tokens: 100,
420 max_tokens: 200000,
421 percent: 0.0005,
422 turns: 2,
423 },
424 total_usage: TokenUsage {
425 prompt_tokens: 50,
426 completion_tokens: 50,
427 total_tokens: 100,
428 cache_read_tokens: None,
429 cache_write_tokens: None,
430 },
431 tool_names: vec!["bash".to_string(), "read".to_string()],
432 thinking_enabled: false,
433 thinking_budget: None,
434 created_at: 1700000000,
435 updated_at: 1700000100,
436 llm_config: None,
437 tasks: vec![],
438 parent_id: None,
439 total_cost: 0.0,
440 model_name: None,
441 cost_records: Vec::new(),
442 }
443 }
444
445 #[tokio::test]
450 async fn test_file_store_save_and_load() {
451 let dir = tempdir().unwrap();
452 let store = FileSessionStore::new(dir.path()).await.unwrap();
453
454 let session = create_test_session_data();
455
456 store.save(&session).await.unwrap();
458
459 let loaded = store.load(&session.id).await.unwrap();
461 assert!(loaded.is_some());
462
463 let loaded = loaded.unwrap();
464 assert_eq!(loaded.id, session.id);
465 assert_eq!(loaded.config.name, session.config.name);
466 assert_eq!(loaded.messages.len(), 2);
467 assert_eq!(loaded.state, SessionState::Active);
468 }
469
470 #[tokio::test]
471 async fn test_file_store_load_nonexistent() {
472 let dir = tempdir().unwrap();
473 let store = FileSessionStore::new(dir.path()).await.unwrap();
474
475 let loaded = store.load("nonexistent").await.unwrap();
476 assert!(loaded.is_none());
477 }
478
479 #[tokio::test]
480 async fn test_file_store_delete() {
481 let dir = tempdir().unwrap();
482 let store = FileSessionStore::new(dir.path()).await.unwrap();
483
484 let session = create_test_session_data();
485 store.save(&session).await.unwrap();
486
487 assert!(store.exists(&session.id).await.unwrap());
489
490 store.delete(&session.id).await.unwrap();
492
493 assert!(!store.exists(&session.id).await.unwrap());
495 assert!(store.load(&session.id).await.unwrap().is_none());
496 }
497
498 #[tokio::test]
499 async fn test_file_store_list() {
500 let dir = tempdir().unwrap();
501 let store = FileSessionStore::new(dir.path()).await.unwrap();
502
503 let list = store.list().await.unwrap();
505 assert!(list.is_empty());
506
507 for i in 1..=3 {
509 let mut session = create_test_session_data();
510 session.id = format!("session-{}", i);
511 store.save(&session).await.unwrap();
512 }
513
514 let list = store.list().await.unwrap();
516 assert_eq!(list.len(), 3);
517 assert!(list.contains(&"session-1".to_string()));
518 assert!(list.contains(&"session-2".to_string()));
519 assert!(list.contains(&"session-3".to_string()));
520 }
521
522 #[tokio::test]
523 async fn test_file_store_overwrite() {
524 let dir = tempdir().unwrap();
525 let store = FileSessionStore::new(dir.path()).await.unwrap();
526
527 let mut session = create_test_session_data();
528 store.save(&session).await.unwrap();
529
530 session.messages.push(Message::user("Another message"));
532 session.updated_at = 1700000200;
533 store.save(&session).await.unwrap();
534
535 let loaded = store.load(&session.id).await.unwrap().unwrap();
537 assert_eq!(loaded.messages.len(), 3);
538 assert_eq!(loaded.updated_at, 1700000200);
539 }
540
541 #[tokio::test]
542 async fn test_file_store_path_traversal_prevention() {
543 let dir = tempdir().unwrap();
544 let store = FileSessionStore::new(dir.path()).await.unwrap();
545
546 let mut session = create_test_session_data();
548 session.id = "../../../etc/passwd".to_string();
549 store.save(&session).await.unwrap();
550
551 let files: Vec<_> = std::fs::read_dir(dir.path())
553 .unwrap()
554 .filter_map(|e| e.ok())
555 .collect();
556 assert_eq!(files.len(), 1);
557
558 let loaded = store.load(&session.id).await.unwrap();
560 assert!(loaded.is_some());
561 }
562
563 #[tokio::test]
564 async fn test_file_store_with_policies() {
565 let dir = tempdir().unwrap();
566 let store = FileSessionStore::new(dir.path()).await.unwrap();
567
568 let mut session = create_test_session_data();
569 session.config.confirmation_policy = Some(ConfirmationPolicy::enabled());
570 session.config.permission_policy = Some(PermissionPolicy::new().allow("Bash(cargo:*)"));
571 session.config.queue_config = Some(SessionQueueConfig::default());
572
573 store.save(&session).await.unwrap();
574
575 let loaded = store.load(&session.id).await.unwrap().unwrap();
576 assert!(loaded.config.confirmation_policy.is_some());
577 assert!(loaded.config.permission_policy.is_some());
578 assert!(loaded.config.queue_config.is_some());
579 }
580
581 #[tokio::test]
582 async fn test_file_store_with_llm_config() {
583 let dir = tempdir().unwrap();
584 let store = FileSessionStore::new(dir.path()).await.unwrap();
585
586 let mut session = create_test_session_data();
587 session.llm_config = Some(LlmConfigData {
588 provider: "anthropic".to_string(),
589 model: "claude-3-5-sonnet-20241022".to_string(),
590 api_key: Some("secret".to_string()), base_url: None,
592 });
593
594 store.save(&session).await.unwrap();
595
596 let loaded = store.load(&session.id).await.unwrap().unwrap();
597 let llm_config = loaded.llm_config.unwrap();
598 assert_eq!(llm_config.provider, "anthropic");
599 assert_eq!(llm_config.model, "claude-3-5-sonnet-20241022");
600 assert!(llm_config.api_key.is_none());
602 }
603
604 #[tokio::test]
609 async fn test_memory_store_save_and_load() {
610 let store = MemorySessionStore::new();
611 let session = create_test_session_data();
612
613 store.save(&session).await.unwrap();
614
615 let loaded = store.load(&session.id).await.unwrap();
616 assert!(loaded.is_some());
617 assert_eq!(loaded.unwrap().id, session.id);
618 }
619
620 #[tokio::test]
621 async fn test_memory_store_delete() {
622 let store = MemorySessionStore::new();
623 let session = create_test_session_data();
624
625 store.save(&session).await.unwrap();
626 assert!(store.exists(&session.id).await.unwrap());
627
628 store.delete(&session.id).await.unwrap();
629 assert!(!store.exists(&session.id).await.unwrap());
630 }
631
632 #[tokio::test]
633 async fn test_memory_store_list() {
634 let store = MemorySessionStore::new();
635
636 for i in 1..=3 {
637 let mut session = create_test_session_data();
638 session.id = format!("session-{}", i);
639 store.save(&session).await.unwrap();
640 }
641
642 let list = store.list().await.unwrap();
643 assert_eq!(list.len(), 3);
644 }
645
646 #[test]
651 fn test_session_data_serialization() {
652 let session = create_test_session_data();
653 let json = serde_json::to_string(&session).unwrap();
654 let parsed: SessionData = serde_json::from_str(&json).unwrap();
655
656 assert_eq!(parsed.id, session.id);
657 assert_eq!(parsed.messages.len(), session.messages.len());
658 }
659
660 #[test]
661 fn test_tool_names_from_definitions() {
662 let tools = vec![
663 crate::llm::ToolDefinition {
664 name: "bash".to_string(),
665 description: "Execute bash".to_string(),
666 parameters: serde_json::json!({}),
667 },
668 crate::llm::ToolDefinition {
669 name: "read".to_string(),
670 description: "Read file".to_string(),
671 parameters: serde_json::json!({}),
672 },
673 ];
674
675 let names = SessionData::tool_names_from_definitions(&tools);
676 assert_eq!(names, vec!["bash", "read"]);
677 }
678
679 #[tokio::test]
684 async fn test_file_store_backslash_sanitization() {
685 let dir = tempdir().unwrap();
686 let store = FileSessionStore::new(dir.path()).await.unwrap();
687
688 let mut session = create_test_session_data();
689 session.id = r"foo\bar\baz".to_string();
690 store.save(&session).await.unwrap();
691
692 let loaded = store.load(&session.id).await.unwrap();
693 assert!(loaded.is_some());
694
695 let loaded = loaded.unwrap();
696 assert_eq!(loaded.id, session.id);
697
698 let expected_path = dir.path().join("foo_bar_baz.json");
700 assert!(expected_path.exists());
701 }
702
703 #[tokio::test]
704 async fn test_file_store_mixed_separator_sanitization() {
705 let dir = tempdir().unwrap();
706 let store = FileSessionStore::new(dir.path()).await.unwrap();
707
708 let mut session = create_test_session_data();
709 session.id = r"foo/bar\baz..qux".to_string();
710 store.save(&session).await.unwrap();
711
712 let loaded = store.load(&session.id).await.unwrap();
713 assert!(loaded.is_some());
714
715 let loaded = loaded.unwrap();
716 assert_eq!(loaded.id, session.id);
717
718 let expected_path = dir.path().join("foo_bar_baz_qux.json");
720 assert!(expected_path.exists());
721 }
722
723 #[tokio::test]
728 async fn test_file_store_corrupted_json_recovery() {
729 let dir = tempdir().unwrap();
730 let store = FileSessionStore::new(dir.path()).await.unwrap();
731
732 let corrupted_path = dir.path().join("test-id.json");
734 tokio::fs::write(&corrupted_path, b"not valid json {{{")
735 .await
736 .unwrap();
737
738 let result = store.load("test-id").await;
740 assert!(result.is_err());
741 }
742
743 #[tokio::test]
748 async fn test_file_store_exists() {
749 let dir = tempdir().unwrap();
750 let store = FileSessionStore::new(dir.path()).await.unwrap();
751
752 let session = create_test_session_data();
753
754 assert!(!store.exists(&session.id).await.unwrap());
756
757 store.save(&session).await.unwrap();
759 assert!(store.exists(&session.id).await.unwrap());
760
761 store.delete(&session.id).await.unwrap();
763 assert!(!store.exists(&session.id).await.unwrap());
764 }
765
766 #[tokio::test]
767 async fn test_memory_store_exists() {
768 let store = MemorySessionStore::new();
769
770 assert!(!store.exists("unknown-id").await.unwrap());
772
773 let session = create_test_session_data();
775 store.save(&session).await.unwrap();
776 assert!(store.exists(&session.id).await.unwrap());
777 }
778
779 #[tokio::test]
780 async fn test_file_store_health_check() {
781 let dir = tempfile::tempdir().unwrap();
782 let store = FileSessionStore::new(dir.path()).await.unwrap();
783 assert!(store.health_check().await.is_ok());
784 assert_eq!(store.backend_name(), "file");
785 }
786
787 #[tokio::test]
788 async fn test_file_store_health_check_bad_dir() {
789 let store = FileSessionStore {
790 dir: std::path::PathBuf::from("/nonexistent/path/that/does/not/exist"),
791 };
792 assert!(store.health_check().await.is_err());
793 }
794
795 #[tokio::test]
796 async fn test_memory_store_health_check() {
797 let store = MemorySessionStore::new();
798 assert!(store.health_check().await.is_ok());
799 assert_eq!(store.backend_name(), "memory");
800 }
801
802 #[tokio::test]
807 async fn test_file_store_load_empty_file() {
808 let dir = tempdir().unwrap();
809 let store = FileSessionStore::new(dir.path()).await.unwrap();
810
811 let empty_path = dir.path().join("empty-session.json");
813 tokio::fs::write(&empty_path, b"").await.unwrap();
814
815 let result = store.load("empty-session").await;
816 assert!(
817 result.is_err(),
818 "Empty file must return error, not Ok(None)"
819 );
820 }
821
822 #[tokio::test]
823 async fn test_file_store_load_partial_json() {
824 let dir = tempdir().unwrap();
825 let store = FileSessionStore::new(dir.path()).await.unwrap();
826
827 let partial_path = dir.path().join("partial-session.json");
829 tokio::fs::write(&partial_path, b"{\"id\":\"partial-session\",\"message")
830 .await
831 .unwrap();
832
833 let result = store.load("partial-session").await;
834 assert!(result.is_err(), "Partial JSON must return error");
835 }
836
837 #[tokio::test]
838 async fn test_file_store_concurrent_save() {
839 let dir = tempdir().unwrap();
840 let store = std::sync::Arc::new(FileSessionStore::new(dir.path()).await.unwrap());
841
842 let session = create_test_session_data();
843 let id = session.id.clone();
844
845 store.save(&session).await.unwrap();
847
848 let mut handles = Vec::new();
850 for _ in 0..5 {
851 let s = store.clone();
852 let sess = session.clone();
853 handles.push(tokio::spawn(async move { s.save(&sess).await }));
854 }
855 for h in handles {
856 h.await.unwrap().unwrap();
857 }
858
859 let loaded = store.load(&id).await.unwrap();
861 assert!(loaded.is_some());
862 assert_eq!(loaded.unwrap().id, id);
863 }
864
865 #[tokio::test]
866 async fn test_file_store_load_nonexistent_returns_none() {
867 let dir = tempdir().unwrap();
868 let store = FileSessionStore::new(dir.path()).await.unwrap();
869
870 let result = store.load("does-not-exist-at-all").await.unwrap();
871 assert!(result.is_none(), "Missing session must return Ok(None)");
872 }
873}