Skip to main content

oxios_kernel/
state_store.rs

1//! Filesystem-based state store.
2//!
3//! All state is persisted as markdown or JSON files organized
4//! by category. This is the "filesystem" of Oxios.
5
6use 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/// Unique identifier for a session.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct SessionId(pub String);
18
19impl SessionId {
20    /// Creates a new random session ID.
21    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/// A user message in a session.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UserMessage {
60    /// Message content.
61    pub content: String,
62    /// Timestamp when the message was sent.
63    pub timestamp: DateTime<Utc>,
64}
65
66/// An agent response in a session.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct AgentResponse {
69    /// Response content.
70    pub content: String,
71    /// Session ID associated with this response.
72    pub session_id: Option<String>,
73    /// Phase reached during orchestration.
74    pub phase_reached: Option<String>,
75    /// Whether evaluation passed.
76    pub evaluation_passed: Option<bool>,
77    /// Timestamp when the response was generated.
78    pub timestamp: DateTime<Utc>,
79    /// Index range into `Session::trajectory_steps` for tool calls that
80    /// occurred during this response. `None` when no tool calls were made.
81    /// Used by the Web UI to render per-turn execution timelines.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub trajectory_range: Option<TrajectoryRange>,
84}
85
86/// Index range (exclusive end) into `Session::trajectory_steps`.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TrajectoryRange {
89    /// Start index (inclusive).
90    pub start: usize,
91    /// End index (exclusive).
92    pub end: usize,
93}
94
95/// A single tool execution step recorded in a session (RFC-015).
96///
97/// Persisted alongside the agent response so that the Web UI can render the
98/// execution timeline (tool calls, durations, errors) when the user
99/// re-opens the session later. Mirrors `memory::sona::TrajectoryStep` but
100/// is duplicated here to avoid a kernel-state → memory dependency cycle.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct TrajectoryStepRecord {
103    /// Name of the tool that was called.
104    pub tool_name: String,
105    /// Tool input arguments (JSON).
106    pub tool_args: serde_json::Value,
107    /// Truncated output (max ~500 chars).
108    pub output_summary: String,
109    /// Wall-clock duration in milliseconds.
110    pub duration_ms: u64,
111    /// Whether the tool returned an error.
112    pub is_error: bool,
113    /// Provider-specific tool call ID (for start/end correlation).
114    pub tool_call_id: String,
115    /// Timestamp when the step started.
116    pub timestamp: DateTime<Utc>,
117}
118
119/// P4 (§7 persistence): persisted reasoning text for a single agent
120/// response. Captured from the runtime's `ThinkingDelta` accumulator at
121/// turn end, already capped to ~4 KB at runtime. Consumed by the Web
122/// UI's ThinkingPanel on session reopen.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ReasoningRecord {
125    /// Concatenated reasoning text from the agent's ThinkingDelta stream.
126    /// Capped to ~4 KB at the runtime level (see
127    /// `AgentRuntime::run_agent`).
128    pub content: String,
129    /// Source label: "thinking" (live stream) or "compaction" (one-shot).
130    pub source: String,
131    /// Timestamp when the record was captured.
132    pub timestamp: DateTime<Utc>,
133}
134
135/// Arbitrary key-value metadata for a session.
136pub type SessionMetadata = std::collections::HashMap<String, serde_json::Value>;
137
138/// A session represents a single user conversation.
139///
140/// Sessions track the full message history and metadata for
141/// a user conversation. They are created per user interaction
142/// and persisted for later retrieval.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Session {
145    /// Unique session identifier.
146    pub id: SessionId,
147    /// User ID who owns this session.
148    pub user_id: String,
149    /// All user messages in this session.
150    #[serde(default)]
151    pub user_messages: Vec<UserMessage>,
152    /// All agent responses in this session.
153    #[serde(default)]
154    pub agent_responses: Vec<AgentResponse>,
155    /// RFC-015: tool execution trajectory accumulated for this session.
156    /// Appended on each orchestrator run; consumed by the Web UI to render
157    /// the execution timeline when the session is re-opened.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub trajectory_steps: Vec<TrajectoryStepRecord>,
160    /// P4 (§7 persistence): reasoning text accumulated per turn. One
161    /// record per agent response, capped at ~4 KB at runtime so this
162    /// stays bounded. Consumed by the Web UI's ThinkingPanel on
163    /// session reopen.
164    #[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    /// RFC-025: Project this session belongs to (singular, grouping only).
169    /// Set by the sidebar/drag-to-reparent; consumed for Project-tree view.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub project_id: Option<String>,
172    /// Timestamp when the session was created.
173    pub created_at: DateTime<Utc>,
174    /// Timestamp when the session was last updated.
175    pub updated_at: DateTime<Utc>,
176    /// Arbitrary key-value metadata.
177    #[serde(default)]
178    pub metadata: SessionMetadata,
179}
180
181impl Session {
182    /// Creates a new session for a user.
183    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    /// Creates a session with a specific ID.
201    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    /// Adds a user message to the session.
219    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    /// Adds an agent response to the session.
228    pub fn add_agent_response(&mut self, response: AgentResponse) {
229        self.agent_responses.push(response);
230        self.updated_at = Utc::now();
231    }
232
233    /// Appends trajectory steps to the session (RFC-015).
234    ///
235    /// Called by the orchestrator after each run so the Web UI can
236    /// re-render the execution timeline when the user re-opens the session.
237    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    /// Returns the trajectory steps recorded in this session.
246    pub fn trajectory(&self) -> &[TrajectoryStepRecord] {
247        &self.trajectory_steps
248    }
249
250    /// Append a reasoning record to the session (P4 §7).
251    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    /// Returns the reasoning records recorded in this session.
260    pub fn reasonings(&self) -> &[ReasoningRecord] {
261        &self.reasoning_records
262    }
263
264    /// Sets the active persona ID.
265    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    /// Sets a metadata value.
271    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    /// Gets a metadata value.
277    pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
278        self.metadata.get(key)
279    }
280
281    /// Returns the total number of exchanges in this session.
282    pub fn exchange_count(&self) -> usize {
283        self.user_messages.len().min(self.agent_responses.len())
284    }
285
286    /// Returns true if the session is empty (no messages).
287    pub fn is_empty(&self) -> bool {
288        self.user_messages.is_empty()
289    }
290}
291/// A filesystem-based persistent state store.
292///
293/// Files are organized as `<base_path>/<category>/<name>.md` or
294/// `<base_path>/<category>/<name>.json`.
295#[derive(Clone)]
296pub struct StateStore {
297    /// Root directory for all state files.
298    pub base_path: PathBuf,
299    /// Per-session write locks used by [`StateStore::update_session_with`]
300    /// to serialize concurrent load-modify-save cycles on the same session
301    /// (state-area F1). Legacy callers that do their own
302    /// `load_session` → `save_session` without this primitive remain racy
303    /// and should migrate.
304    session_locks: Arc<parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
305}
306
307impl StateStore {
308    /// Creates a new state store, initializing the directory if needed.
309    ///
310    /// # Example
311    ///
312    /// ```no_run
313    /// use oxios_kernel::state_store::StateStore;
314    /// use std::path::PathBuf;
315    ///
316    /// let store = StateStore::new(PathBuf::from("/tmp/oxios-state")).unwrap();
317    /// ```
318    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    /// Validate that a category name does not contain path traversal.
326    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    /// Validate that a file name does not contain path traversal.
341    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    /// Durable atomic write: temp file → fsync(file) → rename → fsync(dir).
349    ///
350    /// `rename(2)` only guarantees *metadata* atomicity, not data
351    /// durability: if the data pages haven't been flushed before the
352    /// rename's metadata commit, a crash can surface the new filename
353    /// with stale or zero contents. fsyncing the temp file before the
354    /// rename, and the parent directory after, closes that window. This
355    /// matters because StateStore is the source of truth for sessions,
356    /// agents, and project metadata. (state-area F9.)
357    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        // Best-effort directory fsync; ignore errors (not all platforms
378        // support it, and the file fsync + rename already did the work).
379        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    /// Load a markdown file from the given category.
396    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    /// List all markdown files in a category (names without extension).
408    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    /// Save a serializable value as JSON under the given category.
430    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    /// Load a deserializable value from JSON in the given category.
447    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    /// Delete a file from the given category.
463    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    /// Saves a session to the sessions category.
492    pub async fn save_session(&self, session: &Session) -> Result<()> {
493        self.save_json("sessions", &session.id.0, session).await
494    }
495
496    /// Atomically load, mutate, and save a session under a per-session lock.
497    ///
498    /// This is the safe primitive for read-modify-write on a session: it
499    /// serializes concurrent modifications to the same session, preventing
500    /// the lost-update race that plain `load_session` → `save_session`
501    /// sequences suffer when two callers read the same starting copy and
502    /// the later save clobbers the earlier one (state-area F1).
503    ///
504    /// Returns `Ok(Some(session))` after the mutation+save, or `Ok(None)`
505    /// if the session did not exist.
506    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    /// Get (or lazily create) the per-session mutex. Double-checked under
526    /// the map's read/write guards so concurrent callers for the same
527    /// session share one `Arc<Mutex<()>>`.
528    fn session_lock(
529        map: &parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
530        session_id: &str,
531    ) -> Arc<tokio::sync::Mutex<()>> {
532        // Fast path: shared read.
533        if let Some(lock) = map.read().get(session_id) {
534            return Arc::clone(lock);
535        }
536        // Slow path: exclusive write, insert if absent.
537        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    /// Saves a session and then runs pruning if auto_prune is enabled.
545    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        // Prune in the background — don't block the response
552        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    /// Loads a session by ID.
563    pub async fn load_session(&self, session_id: &SessionId) -> Result<Option<Session>> {
564        self.load_json("sessions", &session_id.0).await
565    }
566
567    /// RFC-025 Phase 5: Load all sessions in full (messages + trajectories).
568    ///
569    /// Used by the Mount auto-promotion scanner, which needs trajectory
570    /// tool_args to identify paths the agent actually worked on. Cheaper to
571    /// call once per scan than `load_session` per id.
572    ///
573    /// **Warning:** loads every session on disk. Prefer
574    /// [`load_sessions_for_promotion`](Self::load_sessions_for_promotion)
575    /// for the promotion scanner to bound memory usage.
576    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    /// RFC-025 Phase 5: Load only sessions updated at or after `since`.
589    ///
590    /// Bounds memory usage for the Mount auto-promotion scanner: instead of
591    /// deserializing every session into a single `Vec`, we deserialize each
592    /// one and skip it immediately if its `updated_at` predates the cutoff.
593    /// The promotion window is bounded by `PromotionConfig::window_days`, so
594    /// loading older sessions is pure waste.
595    ///
596    /// Sessions that fail to deserialize are skipped (best effort), mirroring
597    /// [`load_all_sessions`](Self::load_all_sessions).
598    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                    // Skip sessions outside the promotion window *before*
604                    // collecting — this is the whole point of the cutoff.
605                    if session.updated_at < since {
606                        continue;
607                    }
608                    sessions.push(session);
609                }
610            }
611        }
612        Ok(sessions)
613    }
614
615    /// Lists all sessions (sorted by updated_at descending).
616    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                                // Auto-generate from first user message
633                                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                            // Backward-compat: fall back to legacy metadata keys.
646                            .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        // Sort by updated_at descending (most recent first)
668        sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
669        Ok(sessions)
670    }
671
672    /// Deletes a session by ID.
673    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    /// Gets or creates a session for a user, initializing with the given session ID.
689    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        // Create new session
701        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    /// Updates an existing session, saving it to disk.
711    pub async fn update_session(&self, session: &Session) -> Result<()> {
712        self.save_session(session).await
713    }
714
715    /// RFC-025: Move a session to a different Project (drag-to-reparent).
716    ///
717    /// Pass `None` to unassign (move to "unfiled").
718    pub async fn move_session_to_project(
719        &self,
720        session_id: &SessionId,
721        project_id: Option<&str>,
722    ) -> Result<bool> {
723        // Use update_session_with so concurrent modifications to the same
724        // session can't lose this project reassignment (state-area F1).
725        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    /// Prune sessions based on configuration.
737    ///
738    /// Removes sessions that exceed TTL or exceed the maximum count.
739    /// Returns the number of sessions pruned.
740    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        // TTL-based pruning: remove sessions older than ttl_hours
745        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            // Remove pruned sessions from the list for count-based pruning
761            sessions.retain(|s| !to_prune_ttl.contains(&s.id));
762        }
763
764        // Count-based pruning: keep only the most recent `max_sessions`
765        if config.max_sessions > 0 && sessions.len() > config.max_sessions {
766            // sessions are already sorted by updated_at descending
767            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    /// Prune agent records based on config.
784    ///
785    /// 1. TTL-based: delete agents with created_at older than ttl_hours.
786    /// 2. Count-based: if still over max_entries, delete oldest.
787    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        // 1. TTL-based pruning
803        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                // Load just enough to check created_at
809                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            // Load all created_at for count-based pruning
824            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        // 2. Count-based pruning
835        if max_entries > 0 && remaining.len() > max_entries {
836            // Sort by created_at ascending (oldest first)
837            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/// Summary of a session for listing (without full message history).
858#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct SessionSummary {
860    /// Session ID.
861    pub id: String,
862    /// User ID who owns this session.
863    pub user_id: String,
864    /// Number of messages in this session.
865    pub message_count: usize,
866    /// Auto-generated title for this session. Derived from the first user
867    /// message (truncated to ~60 chars) when not explicitly set.
868    #[serde(skip_serializing_if = "Option::is_none")]
869    pub title: Option<String>,
870    /// Active project ID(s) this session belongs to.
871    #[serde(skip_serializing_if = "Option::is_none")]
872    pub project_id: Option<String>,
873    /// When the session was created.
874    pub created_at: DateTime<Utc>,
875    /// When the session was last updated.
876    pub updated_at: DateTime<Utc>,
877}
878
879/// Configuration for session pruning.
880#[derive(Debug, Clone)]
881pub struct PruneConfig {
882    /// Maximum number of sessions to keep. 0 = unlimited.
883    pub max_sessions: usize,
884    /// TTL in hours. Sessions older than this are pruned. 0 = no TTL.
885    pub ttl_hours: u64,
886}
887
888impl Default for PruneConfig {
889    fn default() -> Self {
890        Self {
891            max_sessions: 100,
892            ttl_hours: 168, // 7 days
893        }
894    }
895}
896
897/// Tracks the last time a prune was performed, enabling cooldown.
898pub struct PruneThrottle {
899    /// Instant of the last prune (monotonic).
900    last_prune: std::sync::Mutex<Option<std::time::Instant>>,
901    /// Minimum seconds between prune runs.
902    cooldown_secs: u64,
903}
904
905impl PruneThrottle {
906    /// Create a new throttle with the given cooldown.
907    pub fn new(cooldown_secs: u64) -> Self {
908        Self {
909            last_prune: std::sync::Mutex::new(None),
910            cooldown_secs,
911        }
912    }
913
914    /// Check if enough time has elapsed since the last prune.
915    /// Returns `true` if prune should proceed.
916    pub fn should_prune(&self) -> bool {
917        // std::sync::Mutex can poison if a holder panics; recover here by
918        // taking the inner value so pruning continues.
919        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        // Create a session
950        let mut session = Session::new("user-123");
951        session.add_user_message("Hello");
952
953        // Save and load
954        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        // Create multiple sessions
968        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        // Most recently updated should be first
978        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        // Delete and verify
990        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        // Get or create with same ID should return existing
1007        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        // Get or create without existing session should create new
1021        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        // Create 5 sessions
1032        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        // Prune to max 3
1040        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        // Oldest sessions (user-0, user-1) should be pruned
1050        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        // Create a session and manually set updated_at to the past
1062        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        // Create a recent session
1067        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        // Prune with 24-hour TTL
1072        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        // Promo-1: sessions older than the cutoff must be skipped before
1087        // being collected, bounding memory for the promotion scanner.
1088        let temp_dir = tempfile::tempdir().unwrap();
1089        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1090
1091        // Old session — updated 48h ago.
1092        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        // Recent session — updated now.
1097        let recent_session = Session::new("recent-user");
1098        store.save_session(&recent_session).await.unwrap();
1099
1100        // Cutoff 24h ago: only the recent session should be loaded.
1101        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        // A cutoff far in the future returns everything (boundary check).
1107        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}