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    /// Block-stream transparency: positioned reasoning spans for reopen
134    /// fidelity. Empty for legacy records — frontend falls back to `content`.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub segments: Vec<oxios_ouroboros::ReasoningSegment>,
137}
138
139/// Arbitrary key-value metadata for a session.
140pub type SessionMetadata = std::collections::HashMap<String, serde_json::Value>;
141
142/// A session represents a single user conversation.
143///
144/// Sessions track the full message history and metadata for
145/// a user conversation. They are created per user interaction
146/// and persisted for later retrieval.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct Session {
149    /// Unique session identifier.
150    pub id: SessionId,
151    /// User ID who owns this session.
152    pub user_id: String,
153    /// All user messages in this session.
154    #[serde(default)]
155    pub user_messages: Vec<UserMessage>,
156    /// All agent responses in this session.
157    #[serde(default)]
158    pub agent_responses: Vec<AgentResponse>,
159    /// RFC-015: tool execution trajectory accumulated for this session.
160    /// Appended on each orchestrator run; consumed by the Web UI to render
161    /// the execution timeline when the session is re-opened.
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub trajectory_steps: Vec<TrajectoryStepRecord>,
164    /// P4 (§7 persistence): reasoning text accumulated per turn. One
165    /// record per agent response, capped at ~4 KB at runtime so this
166    /// stays bounded. Consumed by the Web UI's ThinkingPanel on
167    /// session reopen.
168    #[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    /// RFC-025: Project this session belongs to (singular, grouping only).
173    /// Set by the sidebar/drag-to-reparent; consumed for Project-tree view.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub project_id: Option<String>,
176    /// Parent session ID (RFC-035 threads). When `Some`, this session
177    /// is a sub-conversation of the referenced parent. `None` for
178    /// top-level sessions. The parent is unaware of the child; children
179    /// are spawned explicitly and cannot affect the parent's history.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub parent_session_id: Option<String>,
182    /// Timestamp when the session was created.
183    pub created_at: DateTime<Utc>,
184    /// Timestamp when the session was last updated.
185    pub updated_at: DateTime<Utc>,
186    /// Arbitrary key-value metadata.
187    #[serde(default)]
188    pub metadata: SessionMetadata,
189}
190
191impl Session {
192    /// Creates a new session for a user.
193    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    /// Creates a session with a specific ID.
212    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    /// Adds a user message to the session.
231    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    /// Adds an agent response to the session.
240    pub fn add_agent_response(&mut self, response: AgentResponse) {
241        self.agent_responses.push(response);
242        self.updated_at = Utc::now();
243    }
244
245    /// Appends trajectory steps to the session (RFC-015).
246    ///
247    /// Called by the orchestrator after each run so the Web UI can
248    /// re-render the execution timeline when the user re-opens the session.
249    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    /// Returns the trajectory steps recorded in this session.
258    pub fn trajectory(&self) -> &[TrajectoryStepRecord] {
259        &self.trajectory_steps
260    }
261
262    /// Append a reasoning record to the session (P4 §7).
263    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    /// Returns the reasoning records recorded in this session.
272    pub fn reasonings(&self) -> &[ReasoningRecord] {
273        &self.reasoning_records
274    }
275
276    /// Sets the active persona ID.
277    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    /// Sets a metadata value.
283    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    /// Gets a metadata value.
289    pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
290        self.metadata.get(key)
291    }
292
293    /// Returns the total number of exchanges in this session.
294    pub fn exchange_count(&self) -> usize {
295        self.user_messages.len().min(self.agent_responses.len())
296    }
297
298    /// Returns true if the session is empty (no messages).
299    pub fn is_empty(&self) -> bool {
300        self.user_messages.is_empty()
301    }
302}
303/// A filesystem-based persistent state store.
304///
305/// Files are organized as `<base_path>/<category>/<name>.md` or
306/// `<base_path>/<category>/<name>.json`.
307#[derive(Clone)]
308pub struct StateStore {
309    /// Root directory for all state files.
310    pub base_path: PathBuf,
311    /// Per-session write locks used by [`StateStore::update_session_with`]
312    /// to serialize concurrent load-modify-save cycles on the same session
313    /// (state-area F1). Legacy callers that do their own
314    /// `load_session` → `save_session` without this primitive remain racy
315    /// and should migrate.
316    session_locks: Arc<parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
317}
318
319impl StateStore {
320    /// Creates a new state store, initializing the directory if needed.
321    ///
322    /// # Example
323    ///
324    /// ```no_run
325    /// use oxios_kernel::state_store::StateStore;
326    /// use std::path::PathBuf;
327    ///
328    /// let store = StateStore::new(PathBuf::from("/tmp/oxios-state")).unwrap();
329    /// ```
330    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    /// Validate that a category name does not contain path traversal.
338    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    /// Validate that a file name does not contain path traversal.
353    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    /// Durable atomic write: temp file → fsync(file) → rename → fsync(dir).
361    ///
362    /// `rename(2)` only guarantees *metadata* atomicity, not data
363    /// durability: if the data pages haven't been flushed before the
364    /// rename's metadata commit, a crash can surface the new filename
365    /// with stale or zero contents. fsyncing the temp file before the
366    /// rename, and the parent directory after, closes that window. This
367    /// matters because StateStore is the source of truth for sessions,
368    /// agents, and project metadata. (state-area F9.)
369    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        // Best-effort directory fsync; ignore errors (not all platforms
390        // support it, and the file fsync + rename already did the work).
391        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    /// Load a markdown file from the given category.
408    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    /// List all markdown files in a category (names without extension).
420    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    /// Save a serializable value as JSON under the given category.
442    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    /// Load a deserializable value from JSON in the given category.
459    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    /// Delete a file from the given category.
475    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    /// Saves a session to the sessions category.
504    pub async fn save_session(&self, session: &Session) -> Result<()> {
505        self.save_json("sessions", &session.id.0, session).await
506    }
507
508    /// Atomically load, mutate, and save a session under a per-session lock.
509    ///
510    /// This is the safe primitive for read-modify-write on a session: it
511    /// serializes concurrent modifications to the same session, preventing
512    /// the lost-update race that plain `load_session` → `save_session`
513    /// sequences suffer when two callers read the same starting copy and
514    /// the later save clobbers the earlier one (state-area F1).
515    ///
516    /// Returns `Ok(Some(session))` after the mutation+save, or `Ok(None)`
517    /// if the session did not exist.
518    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    /// Get (or lazily create) the per-session mutex. Double-checked under
538    /// the map's read/write guards so concurrent callers for the same
539    /// session share one `Arc<Mutex<()>>`.
540    fn session_lock(
541        map: &parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
542        session_id: &str,
543    ) -> Arc<tokio::sync::Mutex<()>> {
544        // Fast path: shared read.
545        if let Some(lock) = map.read().get(session_id) {
546            return Arc::clone(lock);
547        }
548        // Slow path: exclusive write, insert if absent.
549        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    /// Saves a session and then runs pruning if auto_prune is enabled.
557    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        // Prune in the background — don't block the response
564        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    /// Loads a session by ID.
575    pub async fn load_session(&self, session_id: &SessionId) -> Result<Option<Session>> {
576        self.load_json("sessions", &session_id.0).await
577    }
578
579    /// RFC-025 Phase 5: Load all sessions in full (messages + trajectories).
580    ///
581    /// Used by the Mount auto-promotion scanner, which needs trajectory
582    /// tool_args to identify paths the agent actually worked on. Cheaper to
583    /// call once per scan than `load_session` per id.
584    ///
585    /// **Warning:** loads every session on disk. Prefer
586    /// [`load_sessions_for_promotion`](Self::load_sessions_for_promotion)
587    /// for the promotion scanner to bound memory usage.
588    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    /// RFC-025 Phase 5: Load only sessions updated at or after `since`.
601    ///
602    /// Bounds memory usage for the Mount auto-promotion scanner: instead of
603    /// deserializing every session into a single `Vec`, we deserialize each
604    /// one and skip it immediately if its `updated_at` predates the cutoff.
605    /// The promotion window is bounded by `PromotionConfig::window_days`, so
606    /// loading older sessions is pure waste.
607    ///
608    /// Sessions that fail to deserialize are skipped (best effort), mirroring
609    /// [`load_all_sessions`](Self::load_all_sessions).
610    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                    // Skip sessions outside the promotion window *before*
616                    // collecting — this is the whole point of the cutoff.
617                    if session.updated_at < since {
618                        continue;
619                    }
620                    sessions.push(session);
621                }
622            }
623        }
624        Ok(sessions)
625    }
626
627    /// Lists all sessions (sorted by updated_at descending).
628    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                                // Auto-generate from first user message
645                                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                            // Backward-compat: fall back to legacy metadata keys.
658                            .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        // Sort by updated_at descending (most recent first)
681        sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
682        Ok(sessions)
683    }
684
685    /// Deletes a session by ID.
686    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    /// Gets or creates a session for a user, initializing with the given session ID.
702    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        // Create new session
714        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    /// Updates an existing session, saving it to disk.
724    pub async fn update_session(&self, session: &Session) -> Result<()> {
725        self.save_session(session).await
726    }
727
728    /// RFC-025: Move a session to a different Project (drag-to-reparent).
729    ///
730    /// Pass `None` to unassign (move to "unfiled").
731    pub async fn move_session_to_project(
732        &self,
733        session_id: &SessionId,
734        project_id: Option<&str>,
735    ) -> Result<bool> {
736        // Use update_session_with so concurrent modifications to the same
737        // session can't lose this project reassignment (state-area F1).
738        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    /// Prune sessions based on configuration.
750    ///
751    /// Removes sessions that exceed TTL or exceed the maximum count.
752    /// Returns the number of sessions pruned.
753    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        // TTL-based pruning: remove sessions older than ttl_hours
758        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            // Remove pruned sessions from the list for count-based pruning
774            sessions.retain(|s| !to_prune_ttl.contains(&s.id));
775        }
776
777        // Count-based pruning: keep only the most recent `max_sessions`
778        if config.max_sessions > 0 && sessions.len() > config.max_sessions {
779            // sessions are already sorted by updated_at descending
780            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    /// Prune agent records based on config.
797    ///
798    /// 1. TTL-based: delete agents with created_at older than ttl_hours.
799    /// 2. Count-based: if still over max_entries, delete oldest.
800    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        // 1. TTL-based pruning
816        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                // Load just enough to check created_at
822                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            // Load all created_at for count-based pruning
837            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        // 2. Count-based pruning
848        if max_entries > 0 && remaining.len() > max_entries {
849            // Sort by created_at ascending (oldest first)
850            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    /// List child sessions of the given parent (RFC-035 threads).
870    /// Returns thread SessionSummary records ordered by `created_at` desc.
871    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        // list_sessions already sorts by updated_at desc; re-sort by created_at
878        // so threads appear in the order they were spawned.
879        all.sort_by_key(|b| std::cmp::Reverse(b.created_at));
880        Ok(all)
881    }
882
883    /// Create a new session that is a sub-conversation (thread) of `parent_id`.
884    /// Inherits parent's `project_id`. Persisted and returned.
885    pub async fn create_thread(
886        &self,
887        parent_id: &str,
888        user_id: impl Into<String>,
889    ) -> Result<Session> {
890        // Read parent to inherit project_id. Missing parent is non-fatal:
891        // the thread is still created (orphaned, but recoverable).
892        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/// Summary of a session for listing (without full message history).
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SessionSummary {
915    /// Session ID.
916    pub id: String,
917    /// User ID who owns this session.
918    pub user_id: String,
919    /// Number of messages in this session.
920    pub message_count: usize,
921    /// Auto-generated title for this session. Derived from the first user
922    /// message (truncated to ~60 chars) when not explicitly set.
923    #[serde(skip_serializing_if = "Option::is_none")]
924    pub title: Option<String>,
925    /// Active project ID(s) this session belongs to.
926    #[serde(skip_serializing_if = "Option::is_none")]
927    pub project_id: Option<String>,
928    /// Parent session ID for threads (RFC-035). `Some` = this is a
929    /// sub-conversation spawned from the named parent.
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub parent_session_id: Option<String>,
932    /// When the session was created.
933    pub created_at: DateTime<Utc>,
934    /// When the session was last updated.
935    pub updated_at: DateTime<Utc>,
936}
937
938/// Configuration for session pruning.
939#[derive(Debug, Clone)]
940pub struct PruneConfig {
941    /// Maximum number of sessions to keep. 0 = unlimited.
942    pub max_sessions: usize,
943    /// TTL in hours. Sessions older than this are pruned. 0 = no TTL.
944    pub ttl_hours: u64,
945}
946
947impl Default for PruneConfig {
948    fn default() -> Self {
949        Self {
950            max_sessions: 100,
951            ttl_hours: 168, // 7 days
952        }
953    }
954}
955
956/// Tracks the last time a prune was performed, enabling cooldown.
957pub struct PruneThrottle {
958    /// Instant of the last prune (monotonic).
959    last_prune: std::sync::Mutex<Option<std::time::Instant>>,
960    /// Minimum seconds between prune runs.
961    cooldown_secs: u64,
962}
963
964impl PruneThrottle {
965    /// Create a new throttle with the given cooldown.
966    pub fn new(cooldown_secs: u64) -> Self {
967        Self {
968            last_prune: std::sync::Mutex::new(None),
969            cooldown_secs,
970        }
971    }
972
973    /// Check if enough time has elapsed since the last prune.
974    /// Returns `true` if prune should proceed.
975    pub fn should_prune(&self) -> bool {
976        // std::sync::Mutex can poison if a holder panics; recover here by
977        // taking the inner value so pruning continues.
978        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        // Create a session
1009        let mut session = Session::new("user-123");
1010        session.add_user_message("Hello");
1011
1012        // Save and load
1013        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        // Create multiple sessions
1027        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        // Most recently updated should be first
1037        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        // Delete and verify
1049        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        // Get or create with same ID should return existing
1066        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        // Get or create without existing session should create new
1080        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        // Create 5 sessions
1091        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        // Prune to max 3
1099        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        // Oldest sessions (user-0, user-1) should be pruned
1109        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        // Create a session and manually set updated_at to the past
1121        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        // Create a recent session
1126        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        // Prune with 24-hour TTL
1131        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        // Promo-1: sessions older than the cutoff must be skipped before
1146        // being collected, bounding memory for the promotion scanner.
1147        let temp_dir = tempfile::tempdir().unwrap();
1148        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1149
1150        // Old session — updated 48h ago.
1151        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        // Recent session — updated now.
1156        let recent_session = Session::new("recent-user");
1157        store.save_session(&recent_session).await.unwrap();
1158
1159        // Cutoff 24h ago: only the recent session should be loaded.
1160        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        // A cutoff far in the future returns everything (boundary check).
1166        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}