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 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub parent_session_id: Option<String>,
178 pub created_at: DateTime<Utc>,
180 pub updated_at: DateTime<Utc>,
182 #[serde(default)]
184 pub metadata: SessionMetadata,
185}
186
187impl Session {
188 pub fn new(user_id: impl Into<String>) -> Self {
190 let now = Utc::now();
191 Self {
192 id: SessionId::new(),
193 user_id: user_id.into(),
194 user_messages: Vec::new(),
195 agent_responses: Vec::new(),
196 project_id: None,
197 parent_session_id: None,
198 trajectory_steps: Vec::new(),
199 reasoning_records: Vec::new(),
200 active_persona_id: None,
201 created_at: now,
202 updated_at: now,
203 metadata: SessionMetadata::new(),
204 }
205 }
206
207 pub fn with_id(user_id: impl Into<String>, session_id: SessionId) -> Self {
209 let now = Utc::now();
210 Self {
211 id: session_id,
212 user_id: user_id.into(),
213 user_messages: Vec::new(),
214 agent_responses: Vec::new(),
215 trajectory_steps: Vec::new(),
216 reasoning_records: Vec::new(),
217 active_persona_id: None,
218 project_id: None,
219 parent_session_id: None,
220 created_at: now,
221 updated_at: now,
222 metadata: SessionMetadata::new(),
223 }
224 }
225
226 pub fn add_user_message(&mut self, content: impl Into<String>) {
228 self.user_messages.push(UserMessage {
229 content: content.into(),
230 timestamp: Utc::now(),
231 });
232 self.updated_at = Utc::now();
233 }
234
235 pub fn add_agent_response(&mut self, response: AgentResponse) {
237 self.agent_responses.push(response);
238 self.updated_at = Utc::now();
239 }
240
241 pub fn extend_trajectory(&mut self, steps: Vec<TrajectoryStepRecord>) {
246 if steps.is_empty() {
247 return;
248 }
249 self.trajectory_steps.extend(steps);
250 self.updated_at = Utc::now();
251 }
252
253 pub fn trajectory(&self) -> &[TrajectoryStepRecord] {
255 &self.trajectory_steps
256 }
257
258 pub fn add_reasoning(&mut self, record: ReasoningRecord) {
260 if record.content.is_empty() {
261 return;
262 }
263 self.reasoning_records.push(record);
264 self.updated_at = Utc::now();
265 }
266
267 pub fn reasonings(&self) -> &[ReasoningRecord] {
269 &self.reasoning_records
270 }
271
272 pub fn set_active_persona(&mut self, persona_id: Option<String>) {
274 self.active_persona_id = persona_id;
275 self.updated_at = Utc::now();
276 }
277
278 pub fn set_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
280 self.metadata.insert(key.into(), value);
281 self.updated_at = Utc::now();
282 }
283
284 pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
286 self.metadata.get(key)
287 }
288
289 pub fn exchange_count(&self) -> usize {
291 self.user_messages.len().min(self.agent_responses.len())
292 }
293
294 pub fn is_empty(&self) -> bool {
296 self.user_messages.is_empty()
297 }
298}
299#[derive(Clone)]
304pub struct StateStore {
305 pub base_path: PathBuf,
307 session_locks: Arc<parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
313}
314
315impl StateStore {
316 pub fn new(base_path: PathBuf) -> Result<Self> {
327 Ok(Self {
328 base_path,
329 session_locks: Arc::new(parking_lot::RwLock::new(HashMap::new())),
330 })
331 }
332
333 fn validate_category(category: &str) -> Result<()> {
335 if category.contains("..") || category.contains('\\') {
336 bail!("invalid category name: '{category}'");
337 }
338 if category.is_empty()
339 || category.starts_with('/')
340 || category.ends_with('/')
341 || category.contains("//")
342 {
343 bail!("invalid category name: '{category}'");
344 }
345 Ok(())
346 }
347
348 fn validate_name(name: &str) -> Result<()> {
350 if name.contains("..") || name.contains('/') || name.contains('\\') {
351 bail!("invalid file name: '{name}'");
352 }
353 Ok(())
354 }
355
356 async fn durable_write(
366 dir: &std::path::Path,
367 target: &std::path::Path,
368 content: &[u8],
369 ) -> Result<()> {
370 let file_name = target
371 .file_name()
372 .and_then(|n| n.to_str())
373 .unwrap_or("state");
374 let temp_path = dir.join(format!(
375 "{file_name}.{}.{}.tmp",
376 std::process::id(),
377 uuid::Uuid::new_v4()
378 ));
379 {
380 let mut file = fs::File::create(&temp_path).await?;
381 file.write_all(content).await?;
382 file.sync_all().await?;
383 }
384 tokio::fs::rename(&temp_path, target).await?;
385 if let Ok(dir_file) = fs::File::open(dir).await {
388 let _ = dir_file.sync_all().await;
389 }
390 Ok(())
391 }
392
393 pub async fn save_markdown(&self, category: &str, name: &str, content: &str) -> Result<()> {
394 Self::validate_category(category)?;
395 Self::validate_name(name)?;
396 let dir = self.base_path.join(category);
397 fs::create_dir_all(&dir).await?;
398 let path = dir.join(format!("{name}.md"));
399 Self::durable_write(&dir, &path, content.as_bytes()).await?;
400 Ok(())
401 }
402
403 pub async fn load_markdown(&self, category: &str, name: &str) -> Result<Option<String>> {
405 Self::validate_category(category)?;
406 Self::validate_name(name)?;
407 let path = self.base_path.join(category).join(format!("{name}.md"));
408 match fs::read_to_string(&path).await {
409 Ok(content) => Ok(Some(content)),
410 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
411 Err(e) => Err(e.into()),
412 }
413 }
414
415 pub async fn list_category(&self, category: &str) -> Result<Vec<String>> {
417 Self::validate_category(category)?;
418 let dir = self.base_path.join(category);
419 if !dir.exists() {
420 return Ok(Vec::new());
421 }
422 let mut entries = fs::read_dir(&dir).await?;
423 let mut names = Vec::new();
424 while let Some(entry) = entries.next_entry().await? {
425 let path = entry.path();
426 if let Some(ext) = path.extension()
427 && (ext == "md" || ext == "json")
428 && let Some(stem) = path.file_stem()
429 {
430 names.push(stem.to_string_lossy().into_owned());
431 }
432 }
433 names.sort();
434 Ok(names)
435 }
436
437 pub async fn save_json<T: Serialize>(
439 &self,
440 category: &str,
441 name: &str,
442 data: &T,
443 ) -> Result<()> {
444 Self::validate_category(category)?;
445 Self::validate_name(name)?;
446 let dir = self.base_path.join(category);
447 fs::create_dir_all(&dir).await?;
448 let path = dir.join(format!("{name}.json"));
449 let content = serde_json::to_string_pretty(data)?;
450 Self::durable_write(&dir, &path, content.as_bytes()).await?;
451 Ok(())
452 }
453
454 pub async fn load_json<T: DeserializeOwned>(
456 &self,
457 category: &str,
458 name: &str,
459 ) -> Result<Option<T>> {
460 Self::validate_category(category)?;
461 Self::validate_name(name)?;
462 let path = self.base_path.join(category).join(format!("{name}.json"));
463 match fs::read_to_string(&path).await {
464 Ok(content) => Ok(Some(serde_json::from_str(&content)?)),
465 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
466 Err(e) => Err(e.into()),
467 }
468 }
469
470 pub async fn delete_file(&self, category: &str, name: &str) -> Result<bool> {
472 Self::validate_category(category)?;
473 Self::validate_name(name)?;
474 let path = self.base_path.join(category).join(format!("{name}.json"));
475 if path.exists() {
476 tokio::fs::remove_file(path).await?;
477 Ok(true)
478 } else {
479 let path = self.base_path.join(category).join(format!("{name}.md"));
480 if path.exists() {
481 tokio::fs::remove_file(path).await?;
482 Ok(true)
483 } else {
484 Ok(false)
485 }
486 }
487 }
488}
489
490impl std::fmt::Debug for StateStore {
491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 f.debug_struct("StateStore")
493 .field("base_path", &self.base_path)
494 .finish()
495 }
496}
497
498impl StateStore {
499 pub async fn save_session(&self, session: &Session) -> Result<()> {
501 self.save_json("sessions", &session.id.0, session).await
502 }
503
504 pub async fn update_session_with<F>(
515 &self,
516 session_id: &SessionId,
517 f: F,
518 ) -> Result<Option<Session>>
519 where
520 F: FnOnce(&mut Session) -> Result<()>,
521 {
522 let lock = Self::session_lock(&self.session_locks, &session_id.0);
523 let _guard = lock.lock().await;
524 let mut session = match self.load_session(session_id).await? {
525 Some(s) => s,
526 None => return Ok(None),
527 };
528 f(&mut session)?;
529 self.save_session(&session).await?;
530 Ok(Some(session))
531 }
532
533 fn session_lock(
537 map: &parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
538 session_id: &str,
539 ) -> Arc<tokio::sync::Mutex<()>> {
540 if let Some(lock) = map.read().get(session_id) {
542 return Arc::clone(lock);
543 }
544 Arc::clone(
546 map.write()
547 .entry(session_id.to_string())
548 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
549 )
550 }
551
552 pub async fn save_session_with_prune(
554 &self,
555 session: &Session,
556 prune_config: &PruneConfig,
557 ) -> Result<()> {
558 self.save_session(session).await?;
559 let store = self.clone();
561 let config = prune_config.clone();
562 tokio::spawn(async move {
563 if let Err(e) = store.prune_sessions(&config).await {
564 tracing::warn!(error = %e, "Background session pruning failed");
565 }
566 });
567 Ok(())
568 }
569
570 pub async fn load_session(&self, session_id: &SessionId) -> Result<Option<Session>> {
572 self.load_json("sessions", &session_id.0).await
573 }
574
575 pub async fn load_all_sessions(&self) -> Result<Vec<Session>> {
585 let mut sessions = Vec::new();
586 if let Ok(names) = self.list_category("sessions").await {
587 for name in names {
588 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
589 sessions.push(session);
590 }
591 }
592 }
593 Ok(sessions)
594 }
595
596 pub async fn load_sessions_for_promotion(&self, since: DateTime<Utc>) -> Result<Vec<Session>> {
607 let mut sessions = Vec::new();
608 if let Ok(names) = self.list_category("sessions").await {
609 for name in names {
610 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
611 if session.updated_at < since {
614 continue;
615 }
616 sessions.push(session);
617 }
618 }
619 }
620 Ok(sessions)
621 }
622
623 pub async fn list_sessions(&self) -> Result<Vec<SessionSummary>> {
625 let mut sessions = Vec::new();
626
627 if let Ok(names) = self.list_category("sessions").await {
628 for name in names {
629 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
630 sessions.push(SessionSummary {
631 id: session.id.0.clone(),
632 user_id: session.user_id.clone(),
633 message_count: session.user_messages.len(),
634 title: session
635 .metadata
636 .get("title")
637 .and_then(|v| v.as_str())
638 .map(String::from)
639 .or_else(|| {
640 session.user_messages.first().map(|m| {
642 let s = m.content.lines().next().unwrap_or("");
643 if s.len() > 60 {
644 format!("{}…", &s[..s.ceil_char_boundary(59)])
645 } else {
646 s.to_string()
647 }
648 })
649 }),
650 project_id: session
651 .project_id
652 .clone()
653 .or_else(|| {
655 session
656 .metadata
657 .get("project_id")
658 .and_then(|v| v.as_str())
659 .map(String::from)
660 })
661 .or_else(|| {
662 session
663 .metadata
664 .get("project_ids")
665 .and_then(|v| v.as_str())
666 .and_then(|s| s.split(',').next().map(String::from))
667 }),
668 parent_session_id: session.parent_session_id.clone(),
669 created_at: session.created_at,
670 updated_at: session.updated_at,
671 });
672 }
673 }
674 }
675
676 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
678 Ok(sessions)
679 }
680
681 pub async fn delete_session(&self, session_id: &SessionId) -> Result<bool> {
683 let path = self
684 .base_path
685 .join("sessions")
686 .join(format!("{}.json", session_id.0));
687 match fs::remove_file(&path).await {
688 Ok(()) => {
689 tracing::info!(session_id = %session_id, "Session deleted");
690 Ok(true)
691 }
692 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
693 Err(e) => Err(e.into()),
694 }
695 }
696
697 pub async fn get_or_create_session(
699 &self,
700 user_id: &str,
701 session_id: Option<&SessionId>,
702 ) -> Result<Session> {
703 if let Some(sid) = session_id
704 && let Some(existing) = self.load_session(sid).await?
705 {
706 return Ok(existing);
707 }
708
709 let session = match session_id {
711 Some(sid) => Session::with_id(user_id, sid.clone()),
712 None => Session::new(user_id),
713 };
714
715 self.save_session(&session).await?;
716 Ok(session)
717 }
718
719 pub async fn update_session(&self, session: &Session) -> Result<()> {
721 self.save_session(session).await
722 }
723
724 pub async fn move_session_to_project(
728 &self,
729 session_id: &SessionId,
730 project_id: Option<&str>,
731 ) -> Result<bool> {
732 let project_id_owned = project_id.map(String::from);
735 let updated = self
736 .update_session_with(session_id, |session| {
737 session.project_id = project_id_owned;
738 session.updated_at = Utc::now();
739 Ok(())
740 })
741 .await?;
742 Ok(updated.is_some())
743 }
744
745 pub async fn prune_sessions(&self, config: &PruneConfig) -> Result<usize> {
750 let mut sessions = self.list_sessions().await?;
751 let mut pruned = 0;
752
753 if config.ttl_hours > 0 {
755 let cutoff = Utc::now() - chrono::Duration::hours(config.ttl_hours as i64);
756 let to_prune_ttl: std::collections::HashSet<String> = sessions
757 .iter()
758 .filter(|s| s.updated_at < cutoff)
759 .map(|s| s.id.clone())
760 .collect();
761
762 for id in &to_prune_ttl {
763 let sid = SessionId(id.clone());
764 if self.delete_session(&sid).await.is_ok() {
765 pruned += 1;
766 }
767 }
768
769 sessions.retain(|s| !to_prune_ttl.contains(&s.id));
771 }
772
773 if config.max_sessions > 0 && sessions.len() > config.max_sessions {
775 let excess = sessions.len() - config.max_sessions;
777 for session in sessions.into_iter().rev().take(excess) {
778 let sid = SessionId(session.id);
779 if self.delete_session(&sid).await.is_ok() {
780 pruned += 1;
781 }
782 }
783 }
784
785 if pruned > 0 {
786 tracing::info!(pruned = pruned, "Session pruning completed");
787 }
788
789 Ok(pruned)
790 }
791
792 pub async fn prune_agents_by_config(
797 &self,
798 max_entries: usize,
799 ttl_hours: u64,
800 batch_size: usize,
801 ) -> Result<usize> {
802 let mut pruned = 0usize;
803
804 let names = self.list_category("agents").await?;
805 if names.is_empty() {
806 return Ok(0);
807 }
808
809 let now = Utc::now();
810
811 let mut remaining: Vec<(String, DateTime<Utc>)> = Vec::with_capacity(names.len());
813
814 if ttl_hours > 0 {
815 let cutoff = now - chrono::Duration::hours(ttl_hours as i64);
816 for name in &names {
817 if let Ok(Some(info)) = self
819 .load_json::<crate::types::AgentInfo>("agents", name)
820 .await
821 {
822 if info.created_at < cutoff {
823 if self.delete_file("agents", name).await.unwrap_or(false) {
824 pruned += 1;
825 }
826 } else {
827 remaining.push((name.clone(), info.created_at));
828 }
829 }
830 }
831 } else {
832 for name in &names {
834 if let Ok(Some(info)) = self
835 .load_json::<crate::types::AgentInfo>("agents", name)
836 .await
837 {
838 remaining.push((name.clone(), info.created_at));
839 }
840 }
841 }
842
843 if max_entries > 0 && remaining.len() > max_entries {
845 remaining.sort_by_key(|a| a.1);
847
848 let excess = remaining.len() - max_entries;
849 let to_delete = excess.min(batch_size);
850
851 for (name, _) in remaining.iter().take(to_delete) {
852 if self.delete_file("agents", name).await.unwrap_or(false) {
853 pruned += 1;
854 }
855 }
856 }
857
858 if pruned > 0 {
859 tracing::info!(pruned = pruned, "Agent filesystem pruning completed");
860 }
861
862 Ok(pruned)
863 }
864
865 pub async fn list_child_sessions(
868 &self,
869 parent_session_id: &str,
870 ) -> Result<Vec<SessionSummary>> {
871 let mut all = self.list_sessions().await?;
872 all.retain(|s| s.parent_session_id.as_deref() == Some(parent_session_id));
873 all.sort_by_key(|b| std::cmp::Reverse(b.created_at));
876 Ok(all)
877 }
878
879 pub async fn create_thread(
882 &self,
883 parent_id: &str,
884 user_id: impl Into<String>,
885 ) -> Result<Session> {
886 let project_id = if let Ok(Some(parent)) = self
889 .load_json::<Session>("sessions", &format!("{parent_id}.json"))
890 .await
891 {
892 parent.project_id
893 } else {
894 tracing::warn!(
895 parent_id = %parent_id,
896 "Thread parent session not found; thread will be orphaned"
897 );
898 None
899 };
900 let mut session = Session::new(user_id);
901 session.parent_session_id = Some(parent_id.to_string());
902 session.project_id = project_id;
903 self.save_session(&session).await?;
904 Ok(session)
905 }
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
910pub struct SessionSummary {
911 pub id: String,
913 pub user_id: String,
915 pub message_count: usize,
917 #[serde(skip_serializing_if = "Option::is_none")]
920 pub title: Option<String>,
921 #[serde(skip_serializing_if = "Option::is_none")]
923 pub project_id: Option<String>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub parent_session_id: Option<String>,
928 pub created_at: DateTime<Utc>,
930 pub updated_at: DateTime<Utc>,
932}
933
934#[derive(Debug, Clone)]
936pub struct PruneConfig {
937 pub max_sessions: usize,
939 pub ttl_hours: u64,
941}
942
943impl Default for PruneConfig {
944 fn default() -> Self {
945 Self {
946 max_sessions: 100,
947 ttl_hours: 168, }
949 }
950}
951
952pub struct PruneThrottle {
954 last_prune: std::sync::Mutex<Option<std::time::Instant>>,
956 cooldown_secs: u64,
958}
959
960impl PruneThrottle {
961 pub fn new(cooldown_secs: u64) -> Self {
963 Self {
964 last_prune: std::sync::Mutex::new(None),
965 cooldown_secs,
966 }
967 }
968
969 pub fn should_prune(&self) -> bool {
972 let mut guard = self.last_prune.lock().unwrap_or_else(|e| {
975 tracing::warn!("PruneThrottle mutex poisoned, recovering: {e}");
976 e.into_inner()
977 });
978 let now = std::time::Instant::now();
979 match *guard {
980 Some(last) => {
981 if now.duration_since(last).as_secs() >= self.cooldown_secs {
982 *guard = Some(now);
983 true
984 } else {
985 false
986 }
987 }
988 None => {
989 *guard = Some(now);
990 true
991 }
992 }
993 }
994}
995
996#[cfg(test)]
997mod tests {
998 use super::*;
999 #[tokio::test]
1000 async fn test_session_creation_and_persistence() {
1001 let temp_dir = tempfile::tempdir().unwrap();
1002 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1003
1004 let mut session = Session::new("user-123");
1006 session.add_user_message("Hello");
1007
1008 store.save_session(&session).await.unwrap();
1010 let loaded = store.load_session(&session.id).await.unwrap();
1011 assert!(loaded.is_some());
1012 let loaded = loaded.unwrap();
1013 assert_eq!(loaded.user_id, "user-123");
1014 assert_eq!(loaded.user_messages.len(), 1);
1015 }
1016
1017 #[tokio::test]
1018 async fn test_session_list_sorts_by_updated() {
1019 let temp_dir = tempfile::tempdir().unwrap();
1020 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1021
1022 for i in 0..3 {
1024 let mut session = Session::new(format!("user-{}", i));
1025 session.add_user_message(format!("Message {}", i));
1026 store.save_session(&session).await.unwrap();
1027 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1028 }
1029
1030 let sessions = store.list_sessions().await.unwrap();
1031 assert_eq!(sessions.len(), 3);
1032 assert_eq!(sessions[0].user_id, "user-2");
1034 }
1035
1036 #[tokio::test]
1037 async fn test_delete_session() {
1038 let temp_dir = tempfile::tempdir().unwrap();
1039 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1040
1041 let session = Session::new("user-123");
1042 store.save_session(&session).await.unwrap();
1043
1044 let deleted = store.delete_session(&session.id).await.unwrap();
1046 assert!(deleted);
1047
1048 let loaded = store.load_session(&session.id).await.unwrap();
1049 assert!(loaded.is_none());
1050 }
1051
1052 #[tokio::test]
1053 async fn test_get_or_create_session_existing() {
1054 let temp_dir = tempfile::tempdir().unwrap();
1055 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1056
1057 let mut existing = Session::new("user-123");
1058 existing.add_user_message("Original message");
1059 store.save_session(&existing).await.unwrap();
1060
1061 let retrieved = store
1063 .get_or_create_session("user-123", Some(&existing.id))
1064 .await
1065 .unwrap();
1066 assert_eq!(retrieved.id, existing.id);
1067 assert_eq!(retrieved.user_messages.len(), 1);
1068 }
1069
1070 #[tokio::test]
1071 async fn test_get_or_create_session_new() {
1072 let temp_dir = tempfile::tempdir().unwrap();
1073 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1074
1075 let session = store.get_or_create_session("user-456", None).await.unwrap();
1077 assert_eq!(session.user_id, "user-456");
1078 assert!(session.user_messages.is_empty());
1079 }
1080
1081 #[tokio::test]
1082 async fn test_prune_sessions_by_count() {
1083 let temp_dir = tempfile::tempdir().unwrap();
1084 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1085
1086 for i in 0..5 {
1088 let mut session = Session::new(format!("user-{}", i));
1089 session.add_user_message(format!("Message {}", i));
1090 store.save_session(&session).await.unwrap();
1091 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1092 }
1093
1094 let config = PruneConfig {
1096 max_sessions: 3,
1097 ttl_hours: 0,
1098 };
1099 let pruned = store.prune_sessions(&config).await.unwrap();
1100 assert_eq!(pruned, 2);
1101
1102 let remaining = store.list_sessions().await.unwrap();
1103 assert_eq!(remaining.len(), 3);
1104 let remaining_ids: Vec<&str> = remaining.iter().map(|s| s.user_id.as_str()).collect();
1106 assert!(remaining_ids.contains(&"user-2"));
1107 assert!(remaining_ids.contains(&"user-3"));
1108 assert!(remaining_ids.contains(&"user-4"));
1109 }
1110
1111 #[tokio::test]
1112 async fn test_prune_sessions_by_ttl() {
1113 let temp_dir = tempfile::tempdir().unwrap();
1114 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1115
1116 let mut old_session = Session::new("old-user");
1118 old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1119 store.save_session(&old_session).await.unwrap();
1120
1121 let mut recent_session = Session::new("recent-user");
1123 recent_session.add_user_message("Hello");
1124 store.save_session(&recent_session).await.unwrap();
1125
1126 let config = PruneConfig {
1128 max_sessions: 0,
1129 ttl_hours: 24,
1130 };
1131 let pruned = store.prune_sessions(&config).await.unwrap();
1132 assert_eq!(pruned, 1);
1133
1134 let remaining = store.list_sessions().await.unwrap();
1135 assert_eq!(remaining.len(), 1);
1136 assert_eq!(remaining[0].user_id, "recent-user");
1137 }
1138
1139 #[tokio::test]
1140 async fn test_load_sessions_for_promotion_filters_by_cutoff() {
1141 let temp_dir = tempfile::tempdir().unwrap();
1144 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1145
1146 let mut old_session = Session::new("old-user");
1148 old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1149 store.save_session(&old_session).await.unwrap();
1150
1151 let recent_session = Session::new("recent-user");
1153 store.save_session(&recent_session).await.unwrap();
1154
1155 let cutoff = Utc::now() - chrono::Duration::hours(24);
1157 let sessions = store.load_sessions_for_promotion(cutoff).await.unwrap();
1158 assert_eq!(sessions.len(), 1, "old session must be filtered out");
1159 assert_eq!(sessions[0].user_id, "recent-user");
1160
1161 let far_cutoff = Utc::now() - chrono::Duration::days(365);
1163 let all = store.load_sessions_for_promotion(far_cutoff).await.unwrap();
1164 assert_eq!(all.len(), 2);
1165 }
1166}