1use anyhow::{Result, bail};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
9use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12use tokio::fs;
13use tokio::io::AsyncWriteExt;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct SessionId(pub String);
18
19impl SessionId {
20 pub fn new() -> Self {
22 Self(uuid::Uuid::new_v4().to_string())
23 }
24}
25
26impl Default for SessionId {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl std::fmt::Display for SessionId {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 write!(f, "{}", self.0)
35 }
36}
37
38impl Serialize for SessionId {
39 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
40 where
41 S: Serializer,
42 {
43 serializer.serialize_str(&self.0)
44 }
45}
46
47impl<'de> Deserialize<'de> for SessionId {
48 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
49 where
50 D: Deserializer<'de>,
51 {
52 let s = String::deserialize(deserializer)?;
53 Ok(Self(s))
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UserMessage {
60 pub content: String,
62 pub timestamp: DateTime<Utc>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct AgentResponse {
69 pub content: String,
71 pub session_id: Option<String>,
73 pub phase_reached: Option<String>,
75 pub evaluation_passed: Option<bool>,
77 pub timestamp: DateTime<Utc>,
79 #[serde(skip_serializing_if = "Option::is_none")]
83 pub trajectory_range: Option<TrajectoryRange>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TrajectoryRange {
89 pub start: usize,
91 pub end: usize,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct TrajectoryStepRecord {
103 pub tool_name: String,
105 pub tool_args: serde_json::Value,
107 pub output_summary: String,
109 pub duration_ms: u64,
111 pub is_error: bool,
113 pub tool_call_id: String,
115 pub timestamp: DateTime<Utc>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ReasoningRecord {
125 pub content: String,
129 pub source: String,
131 pub timestamp: DateTime<Utc>,
133}
134
135pub type SessionMetadata = std::collections::HashMap<String, serde_json::Value>;
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Session {
145 pub id: SessionId,
147 pub user_id: String,
149 #[serde(default)]
151 pub user_messages: Vec<UserMessage>,
152 #[serde(default)]
154 pub agent_responses: Vec<AgentResponse>,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub trajectory_steps: Vec<TrajectoryStepRecord>,
160 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub reasoning_records: Vec<ReasoningRecord>,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub active_persona_id: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub project_id: Option<String>,
172 pub created_at: DateTime<Utc>,
174 pub updated_at: DateTime<Utc>,
176 #[serde(default)]
178 pub metadata: SessionMetadata,
179}
180
181impl Session {
182 pub fn new(user_id: impl Into<String>) -> Self {
184 let now = Utc::now();
185 Self {
186 id: SessionId::new(),
187 user_id: user_id.into(),
188 user_messages: Vec::new(),
189 agent_responses: Vec::new(),
190 trajectory_steps: Vec::new(),
191 reasoning_records: Vec::new(),
192 active_persona_id: None,
193 project_id: None,
194 created_at: now,
195 updated_at: now,
196 metadata: SessionMetadata::new(),
197 }
198 }
199
200 pub fn with_id(user_id: impl Into<String>, session_id: SessionId) -> Self {
202 let now = Utc::now();
203 Self {
204 id: session_id,
205 user_id: user_id.into(),
206 user_messages: Vec::new(),
207 agent_responses: Vec::new(),
208 trajectory_steps: Vec::new(),
209 reasoning_records: Vec::new(),
210 active_persona_id: None,
211 project_id: None,
212 created_at: now,
213 updated_at: now,
214 metadata: SessionMetadata::new(),
215 }
216 }
217
218 pub fn add_user_message(&mut self, content: impl Into<String>) {
220 self.user_messages.push(UserMessage {
221 content: content.into(),
222 timestamp: Utc::now(),
223 });
224 self.updated_at = Utc::now();
225 }
226
227 pub fn add_agent_response(&mut self, response: AgentResponse) {
229 self.agent_responses.push(response);
230 self.updated_at = Utc::now();
231 }
232
233 pub fn extend_trajectory(&mut self, steps: Vec<TrajectoryStepRecord>) {
238 if steps.is_empty() {
239 return;
240 }
241 self.trajectory_steps.extend(steps);
242 self.updated_at = Utc::now();
243 }
244
245 pub fn trajectory(&self) -> &[TrajectoryStepRecord] {
247 &self.trajectory_steps
248 }
249
250 pub fn add_reasoning(&mut self, record: ReasoningRecord) {
252 if record.content.is_empty() {
253 return;
254 }
255 self.reasoning_records.push(record);
256 self.updated_at = Utc::now();
257 }
258
259 pub fn reasonings(&self) -> &[ReasoningRecord] {
261 &self.reasoning_records
262 }
263
264 pub fn set_active_persona(&mut self, persona_id: Option<String>) {
266 self.active_persona_id = persona_id;
267 self.updated_at = Utc::now();
268 }
269
270 pub fn set_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
272 self.metadata.insert(key.into(), value);
273 self.updated_at = Utc::now();
274 }
275
276 pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
278 self.metadata.get(key)
279 }
280
281 pub fn exchange_count(&self) -> usize {
283 self.user_messages.len().min(self.agent_responses.len())
284 }
285
286 pub fn is_empty(&self) -> bool {
288 self.user_messages.is_empty()
289 }
290}
291#[derive(Clone)]
296pub struct StateStore {
297 pub base_path: PathBuf,
299 session_locks: Arc<parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
305}
306
307impl StateStore {
308 pub fn new(base_path: PathBuf) -> Result<Self> {
319 Ok(Self {
320 base_path,
321 session_locks: Arc::new(parking_lot::RwLock::new(HashMap::new())),
322 })
323 }
324
325 fn validate_category(category: &str) -> Result<()> {
327 if category.contains("..") || category.contains('\\') {
328 bail!("invalid category name: '{category}'");
329 }
330 if category.is_empty()
331 || category.starts_with('/')
332 || category.ends_with('/')
333 || category.contains("//")
334 {
335 bail!("invalid category name: '{category}'");
336 }
337 Ok(())
338 }
339
340 fn validate_name(name: &str) -> Result<()> {
342 if name.contains("..") || name.contains('/') || name.contains('\\') {
343 bail!("invalid file name: '{name}'");
344 }
345 Ok(())
346 }
347
348 async fn durable_write(
358 dir: &std::path::Path,
359 target: &std::path::Path,
360 content: &[u8],
361 ) -> Result<()> {
362 let file_name = target
363 .file_name()
364 .and_then(|n| n.to_str())
365 .unwrap_or("state");
366 let temp_path = dir.join(format!(
367 "{file_name}.{}.{}.tmp",
368 std::process::id(),
369 uuid::Uuid::new_v4()
370 ));
371 {
372 let mut file = fs::File::create(&temp_path).await?;
373 file.write_all(content).await?;
374 file.sync_all().await?;
375 }
376 tokio::fs::rename(&temp_path, target).await?;
377 if let Ok(dir_file) = fs::File::open(dir).await {
380 let _ = dir_file.sync_all().await;
381 }
382 Ok(())
383 }
384
385 pub async fn save_markdown(&self, category: &str, name: &str, content: &str) -> Result<()> {
386 Self::validate_category(category)?;
387 Self::validate_name(name)?;
388 let dir = self.base_path.join(category);
389 fs::create_dir_all(&dir).await?;
390 let path = dir.join(format!("{name}.md"));
391 Self::durable_write(&dir, &path, content.as_bytes()).await?;
392 Ok(())
393 }
394
395 pub async fn load_markdown(&self, category: &str, name: &str) -> Result<Option<String>> {
397 Self::validate_category(category)?;
398 Self::validate_name(name)?;
399 let path = self.base_path.join(category).join(format!("{name}.md"));
400 match fs::read_to_string(&path).await {
401 Ok(content) => Ok(Some(content)),
402 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
403 Err(e) => Err(e.into()),
404 }
405 }
406
407 pub async fn list_category(&self, category: &str) -> Result<Vec<String>> {
409 Self::validate_category(category)?;
410 let dir = self.base_path.join(category);
411 if !dir.exists() {
412 return Ok(Vec::new());
413 }
414 let mut entries = fs::read_dir(&dir).await?;
415 let mut names = Vec::new();
416 while let Some(entry) = entries.next_entry().await? {
417 let path = entry.path();
418 if let Some(ext) = path.extension()
419 && (ext == "md" || ext == "json")
420 && let Some(stem) = path.file_stem()
421 {
422 names.push(stem.to_string_lossy().into_owned());
423 }
424 }
425 names.sort();
426 Ok(names)
427 }
428
429 pub async fn save_json<T: Serialize>(
431 &self,
432 category: &str,
433 name: &str,
434 data: &T,
435 ) -> Result<()> {
436 Self::validate_category(category)?;
437 Self::validate_name(name)?;
438 let dir = self.base_path.join(category);
439 fs::create_dir_all(&dir).await?;
440 let path = dir.join(format!("{name}.json"));
441 let content = serde_json::to_string_pretty(data)?;
442 Self::durable_write(&dir, &path, content.as_bytes()).await?;
443 Ok(())
444 }
445
446 pub async fn load_json<T: DeserializeOwned>(
448 &self,
449 category: &str,
450 name: &str,
451 ) -> Result<Option<T>> {
452 Self::validate_category(category)?;
453 Self::validate_name(name)?;
454 let path = self.base_path.join(category).join(format!("{name}.json"));
455 match fs::read_to_string(&path).await {
456 Ok(content) => Ok(Some(serde_json::from_str(&content)?)),
457 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
458 Err(e) => Err(e.into()),
459 }
460 }
461
462 pub async fn delete_file(&self, category: &str, name: &str) -> Result<bool> {
464 Self::validate_category(category)?;
465 Self::validate_name(name)?;
466 let path = self.base_path.join(category).join(format!("{name}.json"));
467 if path.exists() {
468 tokio::fs::remove_file(path).await?;
469 Ok(true)
470 } else {
471 let path = self.base_path.join(category).join(format!("{name}.md"));
472 if path.exists() {
473 tokio::fs::remove_file(path).await?;
474 Ok(true)
475 } else {
476 Ok(false)
477 }
478 }
479 }
480}
481
482impl std::fmt::Debug for StateStore {
483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484 f.debug_struct("StateStore")
485 .field("base_path", &self.base_path)
486 .finish()
487 }
488}
489
490impl StateStore {
491 pub async fn save_session(&self, session: &Session) -> Result<()> {
493 self.save_json("sessions", &session.id.0, session).await
494 }
495
496 pub async fn update_session_with<F>(
507 &self,
508 session_id: &SessionId,
509 f: F,
510 ) -> Result<Option<Session>>
511 where
512 F: FnOnce(&mut Session) -> Result<()>,
513 {
514 let lock = Self::session_lock(&self.session_locks, &session_id.0);
515 let _guard = lock.lock().await;
516 let mut session = match self.load_session(session_id).await? {
517 Some(s) => s,
518 None => return Ok(None),
519 };
520 f(&mut session)?;
521 self.save_session(&session).await?;
522 Ok(Some(session))
523 }
524
525 fn session_lock(
529 map: &parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
530 session_id: &str,
531 ) -> Arc<tokio::sync::Mutex<()>> {
532 if let Some(lock) = map.read().get(session_id) {
534 return Arc::clone(lock);
535 }
536 Arc::clone(
538 map.write()
539 .entry(session_id.to_string())
540 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
541 )
542 }
543
544 pub async fn save_session_with_prune(
546 &self,
547 session: &Session,
548 prune_config: &PruneConfig,
549 ) -> Result<()> {
550 self.save_session(session).await?;
551 let store = self.clone();
553 let config = prune_config.clone();
554 tokio::spawn(async move {
555 if let Err(e) = store.prune_sessions(&config).await {
556 tracing::warn!(error = %e, "Background session pruning failed");
557 }
558 });
559 Ok(())
560 }
561
562 pub async fn load_session(&self, session_id: &SessionId) -> Result<Option<Session>> {
564 self.load_json("sessions", &session_id.0).await
565 }
566
567 pub async fn load_all_sessions(&self) -> Result<Vec<Session>> {
577 let mut sessions = Vec::new();
578 if let Ok(names) = self.list_category("sessions").await {
579 for name in names {
580 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
581 sessions.push(session);
582 }
583 }
584 }
585 Ok(sessions)
586 }
587
588 pub async fn load_sessions_for_promotion(&self, since: DateTime<Utc>) -> Result<Vec<Session>> {
599 let mut sessions = Vec::new();
600 if let Ok(names) = self.list_category("sessions").await {
601 for name in names {
602 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
603 if session.updated_at < since {
606 continue;
607 }
608 sessions.push(session);
609 }
610 }
611 }
612 Ok(sessions)
613 }
614
615 pub async fn list_sessions(&self) -> Result<Vec<SessionSummary>> {
617 let mut sessions = Vec::new();
618
619 if let Ok(names) = self.list_category("sessions").await {
620 for name in names {
621 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
622 sessions.push(SessionSummary {
623 id: session.id.0.clone(),
624 user_id: session.user_id.clone(),
625 message_count: session.user_messages.len(),
626 title: session
627 .metadata
628 .get("title")
629 .and_then(|v| v.as_str())
630 .map(String::from)
631 .or_else(|| {
632 session.user_messages.first().map(|m| {
634 let s = m.content.lines().next().unwrap_or("");
635 if s.len() > 60 {
636 format!("{}…", &s[..s.ceil_char_boundary(59)])
637 } else {
638 s.to_string()
639 }
640 })
641 }),
642 project_id: session
643 .project_id
644 .clone()
645 .or_else(|| {
647 session
648 .metadata
649 .get("project_id")
650 .and_then(|v| v.as_str())
651 .map(String::from)
652 })
653 .or_else(|| {
654 session
655 .metadata
656 .get("project_ids")
657 .and_then(|v| v.as_str())
658 .and_then(|s| s.split(',').next().map(String::from))
659 }),
660 created_at: session.created_at,
661 updated_at: session.updated_at,
662 });
663 }
664 }
665 }
666
667 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
669 Ok(sessions)
670 }
671
672 pub async fn delete_session(&self, session_id: &SessionId) -> Result<bool> {
674 let path = self
675 .base_path
676 .join("sessions")
677 .join(format!("{}.json", session_id.0));
678 match fs::remove_file(&path).await {
679 Ok(()) => {
680 tracing::info!(session_id = %session_id, "Session deleted");
681 Ok(true)
682 }
683 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
684 Err(e) => Err(e.into()),
685 }
686 }
687
688 pub async fn get_or_create_session(
690 &self,
691 user_id: &str,
692 session_id: Option<&SessionId>,
693 ) -> Result<Session> {
694 if let Some(sid) = session_id
695 && let Some(existing) = self.load_session(sid).await?
696 {
697 return Ok(existing);
698 }
699
700 let session = match session_id {
702 Some(sid) => Session::with_id(user_id, sid.clone()),
703 None => Session::new(user_id),
704 };
705
706 self.save_session(&session).await?;
707 Ok(session)
708 }
709
710 pub async fn update_session(&self, session: &Session) -> Result<()> {
712 self.save_session(session).await
713 }
714
715 pub async fn move_session_to_project(
719 &self,
720 session_id: &SessionId,
721 project_id: Option<&str>,
722 ) -> Result<bool> {
723 let project_id_owned = project_id.map(String::from);
726 let updated = self
727 .update_session_with(session_id, |session| {
728 session.project_id = project_id_owned;
729 session.updated_at = Utc::now();
730 Ok(())
731 })
732 .await?;
733 Ok(updated.is_some())
734 }
735
736 pub async fn prune_sessions(&self, config: &PruneConfig) -> Result<usize> {
741 let mut sessions = self.list_sessions().await?;
742 let mut pruned = 0;
743
744 if config.ttl_hours > 0 {
746 let cutoff = Utc::now() - chrono::Duration::hours(config.ttl_hours as i64);
747 let to_prune_ttl: std::collections::HashSet<String> = sessions
748 .iter()
749 .filter(|s| s.updated_at < cutoff)
750 .map(|s| s.id.clone())
751 .collect();
752
753 for id in &to_prune_ttl {
754 let sid = SessionId(id.clone());
755 if self.delete_session(&sid).await.is_ok() {
756 pruned += 1;
757 }
758 }
759
760 sessions.retain(|s| !to_prune_ttl.contains(&s.id));
762 }
763
764 if config.max_sessions > 0 && sessions.len() > config.max_sessions {
766 let excess = sessions.len() - config.max_sessions;
768 for session in sessions.into_iter().rev().take(excess) {
769 let sid = SessionId(session.id);
770 if self.delete_session(&sid).await.is_ok() {
771 pruned += 1;
772 }
773 }
774 }
775
776 if pruned > 0 {
777 tracing::info!(pruned = pruned, "Session pruning completed");
778 }
779
780 Ok(pruned)
781 }
782
783 pub async fn prune_agents_by_config(
788 &self,
789 max_entries: usize,
790 ttl_hours: u64,
791 batch_size: usize,
792 ) -> Result<usize> {
793 let mut pruned = 0usize;
794
795 let names = self.list_category("agents").await?;
796 if names.is_empty() {
797 return Ok(0);
798 }
799
800 let now = Utc::now();
801
802 let mut remaining: Vec<(String, DateTime<Utc>)> = Vec::with_capacity(names.len());
804
805 if ttl_hours > 0 {
806 let cutoff = now - chrono::Duration::hours(ttl_hours as i64);
807 for name in &names {
808 if let Ok(Some(info)) = self
810 .load_json::<crate::types::AgentInfo>("agents", name)
811 .await
812 {
813 if info.created_at < cutoff {
814 if self.delete_file("agents", name).await.unwrap_or(false) {
815 pruned += 1;
816 }
817 } else {
818 remaining.push((name.clone(), info.created_at));
819 }
820 }
821 }
822 } else {
823 for name in &names {
825 if let Ok(Some(info)) = self
826 .load_json::<crate::types::AgentInfo>("agents", name)
827 .await
828 {
829 remaining.push((name.clone(), info.created_at));
830 }
831 }
832 }
833
834 if max_entries > 0 && remaining.len() > max_entries {
836 remaining.sort_by_key(|a| a.1);
838
839 let excess = remaining.len() - max_entries;
840 let to_delete = excess.min(batch_size);
841
842 for (name, _) in remaining.iter().take(to_delete) {
843 if self.delete_file("agents", name).await.unwrap_or(false) {
844 pruned += 1;
845 }
846 }
847 }
848
849 if pruned > 0 {
850 tracing::info!(pruned = pruned, "Agent filesystem pruning completed");
851 }
852
853 Ok(pruned)
854 }
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct SessionSummary {
860 pub id: String,
862 pub user_id: String,
864 pub message_count: usize,
866 #[serde(skip_serializing_if = "Option::is_none")]
869 pub title: Option<String>,
870 #[serde(skip_serializing_if = "Option::is_none")]
872 pub project_id: Option<String>,
873 pub created_at: DateTime<Utc>,
875 pub updated_at: DateTime<Utc>,
877}
878
879#[derive(Debug, Clone)]
881pub struct PruneConfig {
882 pub max_sessions: usize,
884 pub ttl_hours: u64,
886}
887
888impl Default for PruneConfig {
889 fn default() -> Self {
890 Self {
891 max_sessions: 100,
892 ttl_hours: 168, }
894 }
895}
896
897pub struct PruneThrottle {
899 last_prune: std::sync::Mutex<Option<std::time::Instant>>,
901 cooldown_secs: u64,
903}
904
905impl PruneThrottle {
906 pub fn new(cooldown_secs: u64) -> Self {
908 Self {
909 last_prune: std::sync::Mutex::new(None),
910 cooldown_secs,
911 }
912 }
913
914 pub fn should_prune(&self) -> bool {
917 let mut guard = self.last_prune.lock().unwrap_or_else(|e| {
920 tracing::warn!("PruneThrottle mutex poisoned, recovering: {e}");
921 e.into_inner()
922 });
923 let now = std::time::Instant::now();
924 match *guard {
925 Some(last) => {
926 if now.duration_since(last).as_secs() >= self.cooldown_secs {
927 *guard = Some(now);
928 true
929 } else {
930 false
931 }
932 }
933 None => {
934 *guard = Some(now);
935 true
936 }
937 }
938 }
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 #[tokio::test]
945 async fn test_session_creation_and_persistence() {
946 let temp_dir = tempfile::tempdir().unwrap();
947 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
948
949 let mut session = Session::new("user-123");
951 session.add_user_message("Hello");
952
953 store.save_session(&session).await.unwrap();
955 let loaded = store.load_session(&session.id).await.unwrap();
956 assert!(loaded.is_some());
957 let loaded = loaded.unwrap();
958 assert_eq!(loaded.user_id, "user-123");
959 assert_eq!(loaded.user_messages.len(), 1);
960 }
961
962 #[tokio::test]
963 async fn test_session_list_sorts_by_updated() {
964 let temp_dir = tempfile::tempdir().unwrap();
965 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
966
967 for i in 0..3 {
969 let mut session = Session::new(format!("user-{}", i));
970 session.add_user_message(format!("Message {}", i));
971 store.save_session(&session).await.unwrap();
972 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
973 }
974
975 let sessions = store.list_sessions().await.unwrap();
976 assert_eq!(sessions.len(), 3);
977 assert_eq!(sessions[0].user_id, "user-2");
979 }
980
981 #[tokio::test]
982 async fn test_delete_session() {
983 let temp_dir = tempfile::tempdir().unwrap();
984 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
985
986 let session = Session::new("user-123");
987 store.save_session(&session).await.unwrap();
988
989 let deleted = store.delete_session(&session.id).await.unwrap();
991 assert!(deleted);
992
993 let loaded = store.load_session(&session.id).await.unwrap();
994 assert!(loaded.is_none());
995 }
996
997 #[tokio::test]
998 async fn test_get_or_create_session_existing() {
999 let temp_dir = tempfile::tempdir().unwrap();
1000 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1001
1002 let mut existing = Session::new("user-123");
1003 existing.add_user_message("Original message");
1004 store.save_session(&existing).await.unwrap();
1005
1006 let retrieved = store
1008 .get_or_create_session("user-123", Some(&existing.id))
1009 .await
1010 .unwrap();
1011 assert_eq!(retrieved.id, existing.id);
1012 assert_eq!(retrieved.user_messages.len(), 1);
1013 }
1014
1015 #[tokio::test]
1016 async fn test_get_or_create_session_new() {
1017 let temp_dir = tempfile::tempdir().unwrap();
1018 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1019
1020 let session = store.get_or_create_session("user-456", None).await.unwrap();
1022 assert_eq!(session.user_id, "user-456");
1023 assert!(session.user_messages.is_empty());
1024 }
1025
1026 #[tokio::test]
1027 async fn test_prune_sessions_by_count() {
1028 let temp_dir = tempfile::tempdir().unwrap();
1029 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1030
1031 for i in 0..5 {
1033 let mut session = Session::new(format!("user-{}", i));
1034 session.add_user_message(format!("Message {}", i));
1035 store.save_session(&session).await.unwrap();
1036 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1037 }
1038
1039 let config = PruneConfig {
1041 max_sessions: 3,
1042 ttl_hours: 0,
1043 };
1044 let pruned = store.prune_sessions(&config).await.unwrap();
1045 assert_eq!(pruned, 2);
1046
1047 let remaining = store.list_sessions().await.unwrap();
1048 assert_eq!(remaining.len(), 3);
1049 let remaining_ids: Vec<&str> = remaining.iter().map(|s| s.user_id.as_str()).collect();
1051 assert!(remaining_ids.contains(&"user-2"));
1052 assert!(remaining_ids.contains(&"user-3"));
1053 assert!(remaining_ids.contains(&"user-4"));
1054 }
1055
1056 #[tokio::test]
1057 async fn test_prune_sessions_by_ttl() {
1058 let temp_dir = tempfile::tempdir().unwrap();
1059 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1060
1061 let mut old_session = Session::new("old-user");
1063 old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1064 store.save_session(&old_session).await.unwrap();
1065
1066 let mut recent_session = Session::new("recent-user");
1068 recent_session.add_user_message("Hello");
1069 store.save_session(&recent_session).await.unwrap();
1070
1071 let config = PruneConfig {
1073 max_sessions: 0,
1074 ttl_hours: 24,
1075 };
1076 let pruned = store.prune_sessions(&config).await.unwrap();
1077 assert_eq!(pruned, 1);
1078
1079 let remaining = store.list_sessions().await.unwrap();
1080 assert_eq!(remaining.len(), 1);
1081 assert_eq!(remaining[0].user_id, "recent-user");
1082 }
1083
1084 #[tokio::test]
1085 async fn test_load_sessions_for_promotion_filters_by_cutoff() {
1086 let temp_dir = tempfile::tempdir().unwrap();
1089 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1090
1091 let mut old_session = Session::new("old-user");
1093 old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1094 store.save_session(&old_session).await.unwrap();
1095
1096 let recent_session = Session::new("recent-user");
1098 store.save_session(&recent_session).await.unwrap();
1099
1100 let cutoff = Utc::now() - chrono::Duration::hours(24);
1102 let sessions = store.load_sessions_for_promotion(cutoff).await.unwrap();
1103 assert_eq!(sessions.len(), 1, "old session must be filtered out");
1104 assert_eq!(sessions[0].user_id, "recent-user");
1105
1106 let far_cutoff = Utc::now() - chrono::Duration::days(365);
1108 let all = store.load_sessions_for_promotion(far_cutoff).await.unwrap();
1109 assert_eq!(all.len(), 2);
1110 }
1111}