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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub segments: Vec<oxios_ouroboros::ReasoningSegment>,
137}
138
139pub type SessionMetadata = std::collections::HashMap<String, serde_json::Value>;
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct Session {
149 pub id: SessionId,
151 pub user_id: String,
153 #[serde(default)]
155 pub user_messages: Vec<UserMessage>,
156 #[serde(default)]
158 pub agent_responses: Vec<AgentResponse>,
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub trajectory_steps: Vec<TrajectoryStepRecord>,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
169 pub reasoning_records: Vec<ReasoningRecord>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub active_persona_id: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub project_id: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub parent_session_id: Option<String>,
182 pub created_at: DateTime<Utc>,
184 pub updated_at: DateTime<Utc>,
186 #[serde(default)]
188 pub metadata: SessionMetadata,
189}
190
191impl Session {
192 pub fn new(user_id: impl Into<String>) -> Self {
194 let now = Utc::now();
195 Self {
196 id: SessionId::new(),
197 user_id: user_id.into(),
198 user_messages: Vec::new(),
199 agent_responses: Vec::new(),
200 project_id: None,
201 parent_session_id: None,
202 trajectory_steps: Vec::new(),
203 reasoning_records: Vec::new(),
204 active_persona_id: None,
205 created_at: now,
206 updated_at: now,
207 metadata: SessionMetadata::new(),
208 }
209 }
210
211 pub fn with_id(user_id: impl Into<String>, session_id: SessionId) -> Self {
213 let now = Utc::now();
214 Self {
215 id: session_id,
216 user_id: user_id.into(),
217 user_messages: Vec::new(),
218 agent_responses: Vec::new(),
219 trajectory_steps: Vec::new(),
220 reasoning_records: Vec::new(),
221 active_persona_id: None,
222 project_id: None,
223 parent_session_id: None,
224 created_at: now,
225 updated_at: now,
226 metadata: SessionMetadata::new(),
227 }
228 }
229
230 pub fn add_user_message(&mut self, content: impl Into<String>) {
232 self.user_messages.push(UserMessage {
233 content: content.into(),
234 timestamp: Utc::now(),
235 });
236 self.updated_at = Utc::now();
237 }
238
239 pub fn add_agent_response(&mut self, response: AgentResponse) {
241 self.agent_responses.push(response);
242 self.updated_at = Utc::now();
243 }
244
245 pub fn extend_trajectory(&mut self, steps: Vec<TrajectoryStepRecord>) {
250 if steps.is_empty() {
251 return;
252 }
253 self.trajectory_steps.extend(steps);
254 self.updated_at = Utc::now();
255 }
256
257 pub fn trajectory(&self) -> &[TrajectoryStepRecord] {
259 &self.trajectory_steps
260 }
261
262 pub fn add_reasoning(&mut self, record: ReasoningRecord) {
264 if record.content.is_empty() {
265 return;
266 }
267 self.reasoning_records.push(record);
268 self.updated_at = Utc::now();
269 }
270
271 pub fn reasonings(&self) -> &[ReasoningRecord] {
273 &self.reasoning_records
274 }
275
276 pub fn set_active_persona(&mut self, persona_id: Option<String>) {
278 self.active_persona_id = persona_id;
279 self.updated_at = Utc::now();
280 }
281
282 pub fn set_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
284 self.metadata.insert(key.into(), value);
285 self.updated_at = Utc::now();
286 }
287
288 pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
290 self.metadata.get(key)
291 }
292
293 pub fn exchange_count(&self) -> usize {
295 self.user_messages.len().min(self.agent_responses.len())
296 }
297
298 pub fn is_empty(&self) -> bool {
300 self.user_messages.is_empty()
301 }
302}
303#[derive(Clone)]
308pub struct StateStore {
309 pub base_path: PathBuf,
311 session_locks: Arc<parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
317}
318
319impl StateStore {
320 pub fn new(base_path: PathBuf) -> Result<Self> {
331 Ok(Self {
332 base_path,
333 session_locks: Arc::new(parking_lot::RwLock::new(HashMap::new())),
334 })
335 }
336
337 fn validate_category(category: &str) -> Result<()> {
339 if category.contains("..") || category.contains('\\') {
340 bail!("invalid category name: '{category}'");
341 }
342 if category.is_empty()
343 || category.starts_with('/')
344 || category.ends_with('/')
345 || category.contains("//")
346 {
347 bail!("invalid category name: '{category}'");
348 }
349 Ok(())
350 }
351
352 fn validate_name(name: &str) -> Result<()> {
354 if name.contains("..") || name.contains('/') || name.contains('\\') {
355 bail!("invalid file name: '{name}'");
356 }
357 Ok(())
358 }
359
360 async fn durable_write(
370 dir: &std::path::Path,
371 target: &std::path::Path,
372 content: &[u8],
373 ) -> Result<()> {
374 let file_name = target
375 .file_name()
376 .and_then(|n| n.to_str())
377 .unwrap_or("state");
378 let temp_path = dir.join(format!(
379 "{file_name}.{}.{}.tmp",
380 std::process::id(),
381 uuid::Uuid::new_v4()
382 ));
383 {
384 let mut file = fs::File::create(&temp_path).await?;
385 file.write_all(content).await?;
386 file.sync_all().await?;
387 }
388 tokio::fs::rename(&temp_path, target).await?;
389 if let Ok(dir_file) = fs::File::open(dir).await {
392 let _ = dir_file.sync_all().await;
393 }
394 Ok(())
395 }
396
397 pub async fn save_markdown(&self, category: &str, name: &str, content: &str) -> Result<()> {
398 Self::validate_category(category)?;
399 Self::validate_name(name)?;
400 let dir = self.base_path.join(category);
401 fs::create_dir_all(&dir).await?;
402 let path = dir.join(format!("{name}.md"));
403 Self::durable_write(&dir, &path, content.as_bytes()).await?;
404 Ok(())
405 }
406
407 pub async fn load_markdown(&self, category: &str, name: &str) -> Result<Option<String>> {
409 Self::validate_category(category)?;
410 Self::validate_name(name)?;
411 let path = self.base_path.join(category).join(format!("{name}.md"));
412 match fs::read_to_string(&path).await {
413 Ok(content) => Ok(Some(content)),
414 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
415 Err(e) => Err(e.into()),
416 }
417 }
418
419 pub async fn list_category(&self, category: &str) -> Result<Vec<String>> {
421 Self::validate_category(category)?;
422 let dir = self.base_path.join(category);
423 if !dir.exists() {
424 return Ok(Vec::new());
425 }
426 let mut entries = fs::read_dir(&dir).await?;
427 let mut names = Vec::new();
428 while let Some(entry) = entries.next_entry().await? {
429 let path = entry.path();
430 if let Some(ext) = path.extension()
431 && (ext == "md" || ext == "json")
432 && let Some(stem) = path.file_stem()
433 {
434 names.push(stem.to_string_lossy().into_owned());
435 }
436 }
437 names.sort();
438 Ok(names)
439 }
440
441 pub async fn save_json<T: Serialize>(
443 &self,
444 category: &str,
445 name: &str,
446 data: &T,
447 ) -> Result<()> {
448 Self::validate_category(category)?;
449 Self::validate_name(name)?;
450 let dir = self.base_path.join(category);
451 fs::create_dir_all(&dir).await?;
452 let path = dir.join(format!("{name}.json"));
453 let content = serde_json::to_string_pretty(data)?;
454 Self::durable_write(&dir, &path, content.as_bytes()).await?;
455 Ok(())
456 }
457
458 pub async fn load_json<T: DeserializeOwned>(
460 &self,
461 category: &str,
462 name: &str,
463 ) -> Result<Option<T>> {
464 Self::validate_category(category)?;
465 Self::validate_name(name)?;
466 let path = self.base_path.join(category).join(format!("{name}.json"));
467 match fs::read_to_string(&path).await {
468 Ok(content) => Ok(Some(serde_json::from_str(&content)?)),
469 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
470 Err(e) => Err(e.into()),
471 }
472 }
473
474 pub async fn delete_file(&self, category: &str, name: &str) -> Result<bool> {
476 Self::validate_category(category)?;
477 Self::validate_name(name)?;
478 let path = self.base_path.join(category).join(format!("{name}.json"));
479 if path.exists() {
480 tokio::fs::remove_file(path).await?;
481 Ok(true)
482 } else {
483 let path = self.base_path.join(category).join(format!("{name}.md"));
484 if path.exists() {
485 tokio::fs::remove_file(path).await?;
486 Ok(true)
487 } else {
488 Ok(false)
489 }
490 }
491 }
492}
493
494impl std::fmt::Debug for StateStore {
495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 f.debug_struct("StateStore")
497 .field("base_path", &self.base_path)
498 .finish()
499 }
500}
501
502impl StateStore {
503 pub async fn save_session(&self, session: &Session) -> Result<()> {
505 self.save_json("sessions", &session.id.0, session).await
506 }
507
508 pub async fn update_session_with<F>(
519 &self,
520 session_id: &SessionId,
521 f: F,
522 ) -> Result<Option<Session>>
523 where
524 F: FnOnce(&mut Session) -> Result<()>,
525 {
526 let lock = Self::session_lock(&self.session_locks, &session_id.0);
527 let _guard = lock.lock().await;
528 let mut session = match self.load_session(session_id).await? {
529 Some(s) => s,
530 None => return Ok(None),
531 };
532 f(&mut session)?;
533 self.save_session(&session).await?;
534 Ok(Some(session))
535 }
536
537 fn session_lock(
541 map: &parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
542 session_id: &str,
543 ) -> Arc<tokio::sync::Mutex<()>> {
544 if let Some(lock) = map.read().get(session_id) {
546 return Arc::clone(lock);
547 }
548 Arc::clone(
550 map.write()
551 .entry(session_id.to_string())
552 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
553 )
554 }
555
556 pub async fn save_session_with_prune(
558 &self,
559 session: &Session,
560 prune_config: &PruneConfig,
561 ) -> Result<()> {
562 self.save_session(session).await?;
563 let store = self.clone();
565 let config = prune_config.clone();
566 tokio::spawn(async move {
567 if let Err(e) = store.prune_sessions(&config).await {
568 tracing::warn!(error = %e, "Background session pruning failed");
569 }
570 });
571 Ok(())
572 }
573
574 pub async fn load_session(&self, session_id: &SessionId) -> Result<Option<Session>> {
576 self.load_json("sessions", &session_id.0).await
577 }
578
579 pub async fn load_all_sessions(&self) -> Result<Vec<Session>> {
589 let mut sessions = Vec::new();
590 if let Ok(names) = self.list_category("sessions").await {
591 for name in names {
592 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
593 sessions.push(session);
594 }
595 }
596 }
597 Ok(sessions)
598 }
599
600 pub async fn load_sessions_for_promotion(&self, since: DateTime<Utc>) -> Result<Vec<Session>> {
611 let mut sessions = Vec::new();
612 if let Ok(names) = self.list_category("sessions").await {
613 for name in names {
614 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
615 if session.updated_at < since {
618 continue;
619 }
620 sessions.push(session);
621 }
622 }
623 }
624 Ok(sessions)
625 }
626
627 pub async fn list_sessions(&self) -> Result<Vec<SessionSummary>> {
629 let mut sessions = Vec::new();
630
631 if let Ok(names) = self.list_category("sessions").await {
632 for name in names {
633 if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
634 sessions.push(SessionSummary {
635 id: session.id.0.clone(),
636 user_id: session.user_id.clone(),
637 message_count: session.user_messages.len(),
638 title: session
639 .metadata
640 .get("title")
641 .and_then(|v| v.as_str())
642 .map(String::from)
643 .or_else(|| {
644 session.user_messages.first().map(|m| {
646 let s = m.content.lines().next().unwrap_or("");
647 if s.len() > 60 {
648 format!("{}…", &s[..s.ceil_char_boundary(59)])
649 } else {
650 s.to_string()
651 }
652 })
653 }),
654 project_id: session
655 .project_id
656 .clone()
657 .or_else(|| {
659 session
660 .metadata
661 .get("project_id")
662 .and_then(|v| v.as_str())
663 .map(String::from)
664 })
665 .or_else(|| {
666 session
667 .metadata
668 .get("project_ids")
669 .and_then(|v| v.as_str())
670 .and_then(|s| s.split(',').next().map(String::from))
671 }),
672 parent_session_id: session.parent_session_id.clone(),
673 created_at: session.created_at,
674 updated_at: session.updated_at,
675 });
676 }
677 }
678 }
679
680 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
682 Ok(sessions)
683 }
684
685 pub async fn delete_session(&self, session_id: &SessionId) -> Result<bool> {
687 let path = self
688 .base_path
689 .join("sessions")
690 .join(format!("{}.json", session_id.0));
691 match fs::remove_file(&path).await {
692 Ok(()) => {
693 tracing::info!(session_id = %session_id, "Session deleted");
694 Ok(true)
695 }
696 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
697 Err(e) => Err(e.into()),
698 }
699 }
700
701 pub async fn get_or_create_session(
703 &self,
704 user_id: &str,
705 session_id: Option<&SessionId>,
706 ) -> Result<Session> {
707 if let Some(sid) = session_id
708 && let Some(existing) = self.load_session(sid).await?
709 {
710 return Ok(existing);
711 }
712
713 let session = match session_id {
715 Some(sid) => Session::with_id(user_id, sid.clone()),
716 None => Session::new(user_id),
717 };
718
719 self.save_session(&session).await?;
720 Ok(session)
721 }
722
723 pub async fn update_session(&self, session: &Session) -> Result<()> {
725 self.save_session(session).await
726 }
727
728 pub async fn move_session_to_project(
732 &self,
733 session_id: &SessionId,
734 project_id: Option<&str>,
735 ) -> Result<bool> {
736 let project_id_owned = project_id.map(String::from);
739 let updated = self
740 .update_session_with(session_id, |session| {
741 session.project_id = project_id_owned;
742 session.updated_at = Utc::now();
743 Ok(())
744 })
745 .await?;
746 Ok(updated.is_some())
747 }
748
749 pub async fn prune_sessions(&self, config: &PruneConfig) -> Result<usize> {
754 let mut sessions = self.list_sessions().await?;
755 let mut pruned = 0;
756
757 if config.ttl_hours > 0 {
759 let cutoff = Utc::now() - chrono::Duration::hours(config.ttl_hours as i64);
760 let to_prune_ttl: std::collections::HashSet<String> = sessions
761 .iter()
762 .filter(|s| s.updated_at < cutoff)
763 .map(|s| s.id.clone())
764 .collect();
765
766 for id in &to_prune_ttl {
767 let sid = SessionId(id.clone());
768 if self.delete_session(&sid).await.is_ok() {
769 pruned += 1;
770 }
771 }
772
773 sessions.retain(|s| !to_prune_ttl.contains(&s.id));
775 }
776
777 if config.max_sessions > 0 && sessions.len() > config.max_sessions {
779 let excess = sessions.len() - config.max_sessions;
781 for session in sessions.into_iter().rev().take(excess) {
782 let sid = SessionId(session.id);
783 if self.delete_session(&sid).await.is_ok() {
784 pruned += 1;
785 }
786 }
787 }
788
789 if pruned > 0 {
790 tracing::info!(pruned = pruned, "Session pruning completed");
791 }
792
793 Ok(pruned)
794 }
795
796 pub async fn prune_agents_by_config(
801 &self,
802 max_entries: usize,
803 ttl_hours: u64,
804 batch_size: usize,
805 ) -> Result<usize> {
806 let mut pruned = 0usize;
807
808 let names = self.list_category("agents").await?;
809 if names.is_empty() {
810 return Ok(0);
811 }
812
813 let now = Utc::now();
814
815 let mut remaining: Vec<(String, DateTime<Utc>)> = Vec::with_capacity(names.len());
817
818 if ttl_hours > 0 {
819 let cutoff = now - chrono::Duration::hours(ttl_hours as i64);
820 for name in &names {
821 if let Ok(Some(info)) = self
823 .load_json::<crate::types::AgentInfo>("agents", name)
824 .await
825 {
826 if info.created_at < cutoff {
827 if self.delete_file("agents", name).await.unwrap_or(false) {
828 pruned += 1;
829 }
830 } else {
831 remaining.push((name.clone(), info.created_at));
832 }
833 }
834 }
835 } else {
836 for name in &names {
838 if let Ok(Some(info)) = self
839 .load_json::<crate::types::AgentInfo>("agents", name)
840 .await
841 {
842 remaining.push((name.clone(), info.created_at));
843 }
844 }
845 }
846
847 if max_entries > 0 && remaining.len() > max_entries {
849 remaining.sort_by_key(|a| a.1);
851
852 let excess = remaining.len() - max_entries;
853 let to_delete = excess.min(batch_size);
854
855 for (name, _) in remaining.iter().take(to_delete) {
856 if self.delete_file("agents", name).await.unwrap_or(false) {
857 pruned += 1;
858 }
859 }
860 }
861
862 if pruned > 0 {
863 tracing::info!(pruned = pruned, "Agent filesystem pruning completed");
864 }
865
866 Ok(pruned)
867 }
868
869 pub async fn list_child_sessions(
872 &self,
873 parent_session_id: &str,
874 ) -> Result<Vec<SessionSummary>> {
875 let mut all = self.list_sessions().await?;
876 all.retain(|s| s.parent_session_id.as_deref() == Some(parent_session_id));
877 all.sort_by_key(|b| std::cmp::Reverse(b.created_at));
880 Ok(all)
881 }
882
883 pub async fn create_thread(
886 &self,
887 parent_id: &str,
888 user_id: impl Into<String>,
889 ) -> Result<Session> {
890 let project_id = if let Ok(Some(parent)) = self
893 .load_json::<Session>("sessions", &format!("{parent_id}.json"))
894 .await
895 {
896 parent.project_id
897 } else {
898 tracing::warn!(
899 parent_id = %parent_id,
900 "Thread parent session not found; thread will be orphaned"
901 );
902 None
903 };
904 let mut session = Session::new(user_id);
905 session.parent_session_id = Some(parent_id.to_string());
906 session.project_id = project_id;
907 self.save_session(&session).await?;
908 Ok(session)
909 }
910}
911
912#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SessionSummary {
915 pub id: String,
917 pub user_id: String,
919 pub message_count: usize,
921 #[serde(skip_serializing_if = "Option::is_none")]
924 pub title: Option<String>,
925 #[serde(skip_serializing_if = "Option::is_none")]
927 pub project_id: Option<String>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub parent_session_id: Option<String>,
932 pub created_at: DateTime<Utc>,
934 pub updated_at: DateTime<Utc>,
936}
937
938#[derive(Debug, Clone)]
940pub struct PruneConfig {
941 pub max_sessions: usize,
943 pub ttl_hours: u64,
945}
946
947impl Default for PruneConfig {
948 fn default() -> Self {
949 Self {
950 max_sessions: 100,
951 ttl_hours: 168, }
953 }
954}
955
956pub struct PruneThrottle {
958 last_prune: std::sync::Mutex<Option<std::time::Instant>>,
960 cooldown_secs: u64,
962}
963
964impl PruneThrottle {
965 pub fn new(cooldown_secs: u64) -> Self {
967 Self {
968 last_prune: std::sync::Mutex::new(None),
969 cooldown_secs,
970 }
971 }
972
973 pub fn should_prune(&self) -> bool {
976 let mut guard = self.last_prune.lock().unwrap_or_else(|e| {
979 tracing::warn!("PruneThrottle mutex poisoned, recovering: {e}");
980 e.into_inner()
981 });
982 let now = std::time::Instant::now();
983 match *guard {
984 Some(last) => {
985 if now.duration_since(last).as_secs() >= self.cooldown_secs {
986 *guard = Some(now);
987 true
988 } else {
989 false
990 }
991 }
992 None => {
993 *guard = Some(now);
994 true
995 }
996 }
997 }
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003 #[tokio::test]
1004 async fn test_session_creation_and_persistence() {
1005 let temp_dir = tempfile::tempdir().unwrap();
1006 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1007
1008 let mut session = Session::new("user-123");
1010 session.add_user_message("Hello");
1011
1012 store.save_session(&session).await.unwrap();
1014 let loaded = store.load_session(&session.id).await.unwrap();
1015 assert!(loaded.is_some());
1016 let loaded = loaded.unwrap();
1017 assert_eq!(loaded.user_id, "user-123");
1018 assert_eq!(loaded.user_messages.len(), 1);
1019 }
1020
1021 #[tokio::test]
1022 async fn test_session_list_sorts_by_updated() {
1023 let temp_dir = tempfile::tempdir().unwrap();
1024 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1025
1026 for i in 0..3 {
1028 let mut session = Session::new(format!("user-{}", i));
1029 session.add_user_message(format!("Message {}", i));
1030 store.save_session(&session).await.unwrap();
1031 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1032 }
1033
1034 let sessions = store.list_sessions().await.unwrap();
1035 assert_eq!(sessions.len(), 3);
1036 assert_eq!(sessions[0].user_id, "user-2");
1038 }
1039
1040 #[tokio::test]
1041 async fn test_delete_session() {
1042 let temp_dir = tempfile::tempdir().unwrap();
1043 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1044
1045 let session = Session::new("user-123");
1046 store.save_session(&session).await.unwrap();
1047
1048 let deleted = store.delete_session(&session.id).await.unwrap();
1050 assert!(deleted);
1051
1052 let loaded = store.load_session(&session.id).await.unwrap();
1053 assert!(loaded.is_none());
1054 }
1055
1056 #[tokio::test]
1057 async fn test_get_or_create_session_existing() {
1058 let temp_dir = tempfile::tempdir().unwrap();
1059 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1060
1061 let mut existing = Session::new("user-123");
1062 existing.add_user_message("Original message");
1063 store.save_session(&existing).await.unwrap();
1064
1065 let retrieved = store
1067 .get_or_create_session("user-123", Some(&existing.id))
1068 .await
1069 .unwrap();
1070 assert_eq!(retrieved.id, existing.id);
1071 assert_eq!(retrieved.user_messages.len(), 1);
1072 }
1073
1074 #[tokio::test]
1075 async fn test_get_or_create_session_new() {
1076 let temp_dir = tempfile::tempdir().unwrap();
1077 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1078
1079 let session = store.get_or_create_session("user-456", None).await.unwrap();
1081 assert_eq!(session.user_id, "user-456");
1082 assert!(session.user_messages.is_empty());
1083 }
1084
1085 #[tokio::test]
1086 async fn test_prune_sessions_by_count() {
1087 let temp_dir = tempfile::tempdir().unwrap();
1088 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1089
1090 for i in 0..5 {
1092 let mut session = Session::new(format!("user-{}", i));
1093 session.add_user_message(format!("Message {}", i));
1094 store.save_session(&session).await.unwrap();
1095 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1096 }
1097
1098 let config = PruneConfig {
1100 max_sessions: 3,
1101 ttl_hours: 0,
1102 };
1103 let pruned = store.prune_sessions(&config).await.unwrap();
1104 assert_eq!(pruned, 2);
1105
1106 let remaining = store.list_sessions().await.unwrap();
1107 assert_eq!(remaining.len(), 3);
1108 let remaining_ids: Vec<&str> = remaining.iter().map(|s| s.user_id.as_str()).collect();
1110 assert!(remaining_ids.contains(&"user-2"));
1111 assert!(remaining_ids.contains(&"user-3"));
1112 assert!(remaining_ids.contains(&"user-4"));
1113 }
1114
1115 #[tokio::test]
1116 async fn test_prune_sessions_by_ttl() {
1117 let temp_dir = tempfile::tempdir().unwrap();
1118 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1119
1120 let mut old_session = Session::new("old-user");
1122 old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1123 store.save_session(&old_session).await.unwrap();
1124
1125 let mut recent_session = Session::new("recent-user");
1127 recent_session.add_user_message("Hello");
1128 store.save_session(&recent_session).await.unwrap();
1129
1130 let config = PruneConfig {
1132 max_sessions: 0,
1133 ttl_hours: 24,
1134 };
1135 let pruned = store.prune_sessions(&config).await.unwrap();
1136 assert_eq!(pruned, 1);
1137
1138 let remaining = store.list_sessions().await.unwrap();
1139 assert_eq!(remaining.len(), 1);
1140 assert_eq!(remaining[0].user_id, "recent-user");
1141 }
1142
1143 #[tokio::test]
1144 async fn test_load_sessions_for_promotion_filters_by_cutoff() {
1145 let temp_dir = tempfile::tempdir().unwrap();
1148 let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1149
1150 let mut old_session = Session::new("old-user");
1152 old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1153 store.save_session(&old_session).await.unwrap();
1154
1155 let recent_session = Session::new("recent-user");
1157 store.save_session(&recent_session).await.unwrap();
1158
1159 let cutoff = Utc::now() - chrono::Duration::hours(24);
1161 let sessions = store.load_sessions_for_promotion(cutoff).await.unwrap();
1162 assert_eq!(sessions.len(), 1, "old session must be filtered out");
1163 assert_eq!(sessions[0].user_id, "recent-user");
1164
1165 let far_cutoff = Utc::now() - chrono::Duration::days(365);
1167 let all = store.load_sessions_for_promotion(far_cutoff).await.unwrap();
1168 assert_eq!(all.len(), 2);
1169 }
1170}