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    /// Parent session ID (RFC-035 threads). When `Some`, this session
173    /// is a sub-conversation of the referenced parent. `None` for
174    /// top-level sessions. The parent is unaware of the child; children
175    /// are spawned explicitly and cannot affect the parent's history.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub parent_session_id: Option<String>,
178    /// Timestamp when the session was created.
179    pub created_at: DateTime<Utc>,
180    /// Timestamp when the session was last updated.
181    pub updated_at: DateTime<Utc>,
182    /// Arbitrary key-value metadata.
183    #[serde(default)]
184    pub metadata: SessionMetadata,
185}
186
187impl Session {
188    /// Creates a new session for a user.
189    pub fn new(user_id: impl Into<String>) -> Self {
190        let now = Utc::now();
191        Self {
192            id: SessionId::new(),
193            user_id: user_id.into(),
194            user_messages: Vec::new(),
195            agent_responses: Vec::new(),
196            project_id: None,
197            parent_session_id: None,
198            trajectory_steps: Vec::new(),
199            reasoning_records: Vec::new(),
200            active_persona_id: None,
201            created_at: now,
202            updated_at: now,
203            metadata: SessionMetadata::new(),
204        }
205    }
206
207    /// Creates a session with a specific ID.
208    pub fn with_id(user_id: impl Into<String>, session_id: SessionId) -> Self {
209        let now = Utc::now();
210        Self {
211            id: session_id,
212            user_id: user_id.into(),
213            user_messages: Vec::new(),
214            agent_responses: Vec::new(),
215            trajectory_steps: Vec::new(),
216            reasoning_records: Vec::new(),
217            active_persona_id: None,
218            project_id: None,
219            parent_session_id: None,
220            created_at: now,
221            updated_at: now,
222            metadata: SessionMetadata::new(),
223        }
224    }
225
226    /// Adds a user message to the session.
227    pub fn add_user_message(&mut self, content: impl Into<String>) {
228        self.user_messages.push(UserMessage {
229            content: content.into(),
230            timestamp: Utc::now(),
231        });
232        self.updated_at = Utc::now();
233    }
234
235    /// Adds an agent response to the session.
236    pub fn add_agent_response(&mut self, response: AgentResponse) {
237        self.agent_responses.push(response);
238        self.updated_at = Utc::now();
239    }
240
241    /// Appends trajectory steps to the session (RFC-015).
242    ///
243    /// Called by the orchestrator after each run so the Web UI can
244    /// re-render the execution timeline when the user re-opens the session.
245    pub fn extend_trajectory(&mut self, steps: Vec<TrajectoryStepRecord>) {
246        if steps.is_empty() {
247            return;
248        }
249        self.trajectory_steps.extend(steps);
250        self.updated_at = Utc::now();
251    }
252
253    /// Returns the trajectory steps recorded in this session.
254    pub fn trajectory(&self) -> &[TrajectoryStepRecord] {
255        &self.trajectory_steps
256    }
257
258    /// Append a reasoning record to the session (P4 §7).
259    pub fn add_reasoning(&mut self, record: ReasoningRecord) {
260        if record.content.is_empty() {
261            return;
262        }
263        self.reasoning_records.push(record);
264        self.updated_at = Utc::now();
265    }
266
267    /// Returns the reasoning records recorded in this session.
268    pub fn reasonings(&self) -> &[ReasoningRecord] {
269        &self.reasoning_records
270    }
271
272    /// Sets the active persona ID.
273    pub fn set_active_persona(&mut self, persona_id: Option<String>) {
274        self.active_persona_id = persona_id;
275        self.updated_at = Utc::now();
276    }
277
278    /// Sets a metadata value.
279    pub fn set_metadata(&mut self, key: impl Into<String>, value: serde_json::Value) {
280        self.metadata.insert(key.into(), value);
281        self.updated_at = Utc::now();
282    }
283
284    /// Gets a metadata value.
285    pub fn get_metadata(&self, key: &str) -> Option<&serde_json::Value> {
286        self.metadata.get(key)
287    }
288
289    /// Returns the total number of exchanges in this session.
290    pub fn exchange_count(&self) -> usize {
291        self.user_messages.len().min(self.agent_responses.len())
292    }
293
294    /// Returns true if the session is empty (no messages).
295    pub fn is_empty(&self) -> bool {
296        self.user_messages.is_empty()
297    }
298}
299/// A filesystem-based persistent state store.
300///
301/// Files are organized as `<base_path>/<category>/<name>.md` or
302/// `<base_path>/<category>/<name>.json`.
303#[derive(Clone)]
304pub struct StateStore {
305    /// Root directory for all state files.
306    pub base_path: PathBuf,
307    /// Per-session write locks used by [`StateStore::update_session_with`]
308    /// to serialize concurrent load-modify-save cycles on the same session
309    /// (state-area F1). Legacy callers that do their own
310    /// `load_session` → `save_session` without this primitive remain racy
311    /// and should migrate.
312    session_locks: Arc<parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
313}
314
315impl StateStore {
316    /// Creates a new state store, initializing the directory if needed.
317    ///
318    /// # Example
319    ///
320    /// ```no_run
321    /// use oxios_kernel::state_store::StateStore;
322    /// use std::path::PathBuf;
323    ///
324    /// let store = StateStore::new(PathBuf::from("/tmp/oxios-state")).unwrap();
325    /// ```
326    pub fn new(base_path: PathBuf) -> Result<Self> {
327        Ok(Self {
328            base_path,
329            session_locks: Arc::new(parking_lot::RwLock::new(HashMap::new())),
330        })
331    }
332
333    /// Validate that a category name does not contain path traversal.
334    fn validate_category(category: &str) -> Result<()> {
335        if category.contains("..") || category.contains('\\') {
336            bail!("invalid category name: '{category}'");
337        }
338        if category.is_empty()
339            || category.starts_with('/')
340            || category.ends_with('/')
341            || category.contains("//")
342        {
343            bail!("invalid category name: '{category}'");
344        }
345        Ok(())
346    }
347
348    /// Validate that a file name does not contain path traversal.
349    fn validate_name(name: &str) -> Result<()> {
350        if name.contains("..") || name.contains('/') || name.contains('\\') {
351            bail!("invalid file name: '{name}'");
352        }
353        Ok(())
354    }
355
356    /// Durable atomic write: temp file → fsync(file) → rename → fsync(dir).
357    ///
358    /// `rename(2)` only guarantees *metadata* atomicity, not data
359    /// durability: if the data pages haven't been flushed before the
360    /// rename's metadata commit, a crash can surface the new filename
361    /// with stale or zero contents. fsyncing the temp file before the
362    /// rename, and the parent directory after, closes that window. This
363    /// matters because StateStore is the source of truth for sessions,
364    /// agents, and project metadata. (state-area F9.)
365    async fn durable_write(
366        dir: &std::path::Path,
367        target: &std::path::Path,
368        content: &[u8],
369    ) -> Result<()> {
370        let file_name = target
371            .file_name()
372            .and_then(|n| n.to_str())
373            .unwrap_or("state");
374        let temp_path = dir.join(format!(
375            "{file_name}.{}.{}.tmp",
376            std::process::id(),
377            uuid::Uuid::new_v4()
378        ));
379        {
380            let mut file = fs::File::create(&temp_path).await?;
381            file.write_all(content).await?;
382            file.sync_all().await?;
383        }
384        tokio::fs::rename(&temp_path, target).await?;
385        // Best-effort directory fsync; ignore errors (not all platforms
386        // support it, and the file fsync + rename already did the work).
387        if let Ok(dir_file) = fs::File::open(dir).await {
388            let _ = dir_file.sync_all().await;
389        }
390        Ok(())
391    }
392
393    pub async fn save_markdown(&self, category: &str, name: &str, content: &str) -> Result<()> {
394        Self::validate_category(category)?;
395        Self::validate_name(name)?;
396        let dir = self.base_path.join(category);
397        fs::create_dir_all(&dir).await?;
398        let path = dir.join(format!("{name}.md"));
399        Self::durable_write(&dir, &path, content.as_bytes()).await?;
400        Ok(())
401    }
402
403    /// Load a markdown file from the given category.
404    pub async fn load_markdown(&self, category: &str, name: &str) -> Result<Option<String>> {
405        Self::validate_category(category)?;
406        Self::validate_name(name)?;
407        let path = self.base_path.join(category).join(format!("{name}.md"));
408        match fs::read_to_string(&path).await {
409            Ok(content) => Ok(Some(content)),
410            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
411            Err(e) => Err(e.into()),
412        }
413    }
414
415    /// List all markdown files in a category (names without extension).
416    pub async fn list_category(&self, category: &str) -> Result<Vec<String>> {
417        Self::validate_category(category)?;
418        let dir = self.base_path.join(category);
419        if !dir.exists() {
420            return Ok(Vec::new());
421        }
422        let mut entries = fs::read_dir(&dir).await?;
423        let mut names = Vec::new();
424        while let Some(entry) = entries.next_entry().await? {
425            let path = entry.path();
426            if let Some(ext) = path.extension()
427                && (ext == "md" || ext == "json")
428                && let Some(stem) = path.file_stem()
429            {
430                names.push(stem.to_string_lossy().into_owned());
431            }
432        }
433        names.sort();
434        Ok(names)
435    }
436
437    /// Save a serializable value as JSON under the given category.
438    pub async fn save_json<T: Serialize>(
439        &self,
440        category: &str,
441        name: &str,
442        data: &T,
443    ) -> Result<()> {
444        Self::validate_category(category)?;
445        Self::validate_name(name)?;
446        let dir = self.base_path.join(category);
447        fs::create_dir_all(&dir).await?;
448        let path = dir.join(format!("{name}.json"));
449        let content = serde_json::to_string_pretty(data)?;
450        Self::durable_write(&dir, &path, content.as_bytes()).await?;
451        Ok(())
452    }
453
454    /// Load a deserializable value from JSON in the given category.
455    pub async fn load_json<T: DeserializeOwned>(
456        &self,
457        category: &str,
458        name: &str,
459    ) -> Result<Option<T>> {
460        Self::validate_category(category)?;
461        Self::validate_name(name)?;
462        let path = self.base_path.join(category).join(format!("{name}.json"));
463        match fs::read_to_string(&path).await {
464            Ok(content) => Ok(Some(serde_json::from_str(&content)?)),
465            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
466            Err(e) => Err(e.into()),
467        }
468    }
469
470    /// Delete a file from the given category.
471    pub async fn delete_file(&self, category: &str, name: &str) -> Result<bool> {
472        Self::validate_category(category)?;
473        Self::validate_name(name)?;
474        let path = self.base_path.join(category).join(format!("{name}.json"));
475        if path.exists() {
476            tokio::fs::remove_file(path).await?;
477            Ok(true)
478        } else {
479            let path = self.base_path.join(category).join(format!("{name}.md"));
480            if path.exists() {
481                tokio::fs::remove_file(path).await?;
482                Ok(true)
483            } else {
484                Ok(false)
485            }
486        }
487    }
488}
489
490impl std::fmt::Debug for StateStore {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        f.debug_struct("StateStore")
493            .field("base_path", &self.base_path)
494            .finish()
495    }
496}
497
498impl StateStore {
499    /// Saves a session to the sessions category.
500    pub async fn save_session(&self, session: &Session) -> Result<()> {
501        self.save_json("sessions", &session.id.0, session).await
502    }
503
504    /// Atomically load, mutate, and save a session under a per-session lock.
505    ///
506    /// This is the safe primitive for read-modify-write on a session: it
507    /// serializes concurrent modifications to the same session, preventing
508    /// the lost-update race that plain `load_session` → `save_session`
509    /// sequences suffer when two callers read the same starting copy and
510    /// the later save clobbers the earlier one (state-area F1).
511    ///
512    /// Returns `Ok(Some(session))` after the mutation+save, or `Ok(None)`
513    /// if the session did not exist.
514    pub async fn update_session_with<F>(
515        &self,
516        session_id: &SessionId,
517        f: F,
518    ) -> Result<Option<Session>>
519    where
520        F: FnOnce(&mut Session) -> Result<()>,
521    {
522        let lock = Self::session_lock(&self.session_locks, &session_id.0);
523        let _guard = lock.lock().await;
524        let mut session = match self.load_session(session_id).await? {
525            Some(s) => s,
526            None => return Ok(None),
527        };
528        f(&mut session)?;
529        self.save_session(&session).await?;
530        Ok(Some(session))
531    }
532
533    /// Get (or lazily create) the per-session mutex. Double-checked under
534    /// the map's read/write guards so concurrent callers for the same
535    /// session share one `Arc<Mutex<()>>`.
536    fn session_lock(
537        map: &parking_lot::RwLock<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
538        session_id: &str,
539    ) -> Arc<tokio::sync::Mutex<()>> {
540        // Fast path: shared read.
541        if let Some(lock) = map.read().get(session_id) {
542            return Arc::clone(lock);
543        }
544        // Slow path: exclusive write, insert if absent.
545        Arc::clone(
546            map.write()
547                .entry(session_id.to_string())
548                .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
549        )
550    }
551
552    /// Saves a session and then runs pruning if auto_prune is enabled.
553    pub async fn save_session_with_prune(
554        &self,
555        session: &Session,
556        prune_config: &PruneConfig,
557    ) -> Result<()> {
558        self.save_session(session).await?;
559        // Prune in the background — don't block the response
560        let store = self.clone();
561        let config = prune_config.clone();
562        tokio::spawn(async move {
563            if let Err(e) = store.prune_sessions(&config).await {
564                tracing::warn!(error = %e, "Background session pruning failed");
565            }
566        });
567        Ok(())
568    }
569
570    /// Loads a session by ID.
571    pub async fn load_session(&self, session_id: &SessionId) -> Result<Option<Session>> {
572        self.load_json("sessions", &session_id.0).await
573    }
574
575    /// RFC-025 Phase 5: Load all sessions in full (messages + trajectories).
576    ///
577    /// Used by the Mount auto-promotion scanner, which needs trajectory
578    /// tool_args to identify paths the agent actually worked on. Cheaper to
579    /// call once per scan than `load_session` per id.
580    ///
581    /// **Warning:** loads every session on disk. Prefer
582    /// [`load_sessions_for_promotion`](Self::load_sessions_for_promotion)
583    /// for the promotion scanner to bound memory usage.
584    pub async fn load_all_sessions(&self) -> Result<Vec<Session>> {
585        let mut sessions = Vec::new();
586        if let Ok(names) = self.list_category("sessions").await {
587            for name in names {
588                if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
589                    sessions.push(session);
590                }
591            }
592        }
593        Ok(sessions)
594    }
595
596    /// RFC-025 Phase 5: Load only sessions updated at or after `since`.
597    ///
598    /// Bounds memory usage for the Mount auto-promotion scanner: instead of
599    /// deserializing every session into a single `Vec`, we deserialize each
600    /// one and skip it immediately if its `updated_at` predates the cutoff.
601    /// The promotion window is bounded by `PromotionConfig::window_days`, so
602    /// loading older sessions is pure waste.
603    ///
604    /// Sessions that fail to deserialize are skipped (best effort), mirroring
605    /// [`load_all_sessions`](Self::load_all_sessions).
606    pub async fn load_sessions_for_promotion(&self, since: DateTime<Utc>) -> Result<Vec<Session>> {
607        let mut sessions = Vec::new();
608        if let Ok(names) = self.list_category("sessions").await {
609            for name in names {
610                if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
611                    // Skip sessions outside the promotion window *before*
612                    // collecting — this is the whole point of the cutoff.
613                    if session.updated_at < since {
614                        continue;
615                    }
616                    sessions.push(session);
617                }
618            }
619        }
620        Ok(sessions)
621    }
622
623    /// Lists all sessions (sorted by updated_at descending).
624    pub async fn list_sessions(&self) -> Result<Vec<SessionSummary>> {
625        let mut sessions = Vec::new();
626
627        if let Ok(names) = self.list_category("sessions").await {
628            for name in names {
629                if let Ok(Some(session)) = self.load_json::<Session>("sessions", &name).await {
630                    sessions.push(SessionSummary {
631                        id: session.id.0.clone(),
632                        user_id: session.user_id.clone(),
633                        message_count: session.user_messages.len(),
634                        title: session
635                            .metadata
636                            .get("title")
637                            .and_then(|v| v.as_str())
638                            .map(String::from)
639                            .or_else(|| {
640                                // Auto-generate from first user message
641                                session.user_messages.first().map(|m| {
642                                    let s = m.content.lines().next().unwrap_or("");
643                                    if s.len() > 60 {
644                                        format!("{}…", &s[..s.ceil_char_boundary(59)])
645                                    } else {
646                                        s.to_string()
647                                    }
648                                })
649                            }),
650                        project_id: session
651                            .project_id
652                            .clone()
653                            // Backward-compat: fall back to legacy metadata keys.
654                            .or_else(|| {
655                                session
656                                    .metadata
657                                    .get("project_id")
658                                    .and_then(|v| v.as_str())
659                                    .map(String::from)
660                            })
661                            .or_else(|| {
662                                session
663                                    .metadata
664                                    .get("project_ids")
665                                    .and_then(|v| v.as_str())
666                                    .and_then(|s| s.split(',').next().map(String::from))
667                            }),
668                        parent_session_id: session.parent_session_id.clone(),
669                        created_at: session.created_at,
670                        updated_at: session.updated_at,
671                    });
672                }
673            }
674        }
675
676        // Sort by updated_at descending (most recent first)
677        sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
678        Ok(sessions)
679    }
680
681    /// Deletes a session by ID.
682    pub async fn delete_session(&self, session_id: &SessionId) -> Result<bool> {
683        let path = self
684            .base_path
685            .join("sessions")
686            .join(format!("{}.json", session_id.0));
687        match fs::remove_file(&path).await {
688            Ok(()) => {
689                tracing::info!(session_id = %session_id, "Session deleted");
690                Ok(true)
691            }
692            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
693            Err(e) => Err(e.into()),
694        }
695    }
696
697    /// Gets or creates a session for a user, initializing with the given session ID.
698    pub async fn get_or_create_session(
699        &self,
700        user_id: &str,
701        session_id: Option<&SessionId>,
702    ) -> Result<Session> {
703        if let Some(sid) = session_id
704            && let Some(existing) = self.load_session(sid).await?
705        {
706            return Ok(existing);
707        }
708
709        // Create new session
710        let session = match session_id {
711            Some(sid) => Session::with_id(user_id, sid.clone()),
712            None => Session::new(user_id),
713        };
714
715        self.save_session(&session).await?;
716        Ok(session)
717    }
718
719    /// Updates an existing session, saving it to disk.
720    pub async fn update_session(&self, session: &Session) -> Result<()> {
721        self.save_session(session).await
722    }
723
724    /// RFC-025: Move a session to a different Project (drag-to-reparent).
725    ///
726    /// Pass `None` to unassign (move to "unfiled").
727    pub async fn move_session_to_project(
728        &self,
729        session_id: &SessionId,
730        project_id: Option<&str>,
731    ) -> Result<bool> {
732        // Use update_session_with so concurrent modifications to the same
733        // session can't lose this project reassignment (state-area F1).
734        let project_id_owned = project_id.map(String::from);
735        let updated = self
736            .update_session_with(session_id, |session| {
737                session.project_id = project_id_owned;
738                session.updated_at = Utc::now();
739                Ok(())
740            })
741            .await?;
742        Ok(updated.is_some())
743    }
744
745    /// Prune sessions based on configuration.
746    ///
747    /// Removes sessions that exceed TTL or exceed the maximum count.
748    /// Returns the number of sessions pruned.
749    pub async fn prune_sessions(&self, config: &PruneConfig) -> Result<usize> {
750        let mut sessions = self.list_sessions().await?;
751        let mut pruned = 0;
752
753        // TTL-based pruning: remove sessions older than ttl_hours
754        if config.ttl_hours > 0 {
755            let cutoff = Utc::now() - chrono::Duration::hours(config.ttl_hours as i64);
756            let to_prune_ttl: std::collections::HashSet<String> = sessions
757                .iter()
758                .filter(|s| s.updated_at < cutoff)
759                .map(|s| s.id.clone())
760                .collect();
761
762            for id in &to_prune_ttl {
763                let sid = SessionId(id.clone());
764                if self.delete_session(&sid).await.is_ok() {
765                    pruned += 1;
766                }
767            }
768
769            // Remove pruned sessions from the list for count-based pruning
770            sessions.retain(|s| !to_prune_ttl.contains(&s.id));
771        }
772
773        // Count-based pruning: keep only the most recent `max_sessions`
774        if config.max_sessions > 0 && sessions.len() > config.max_sessions {
775            // sessions are already sorted by updated_at descending
776            let excess = sessions.len() - config.max_sessions;
777            for session in sessions.into_iter().rev().take(excess) {
778                let sid = SessionId(session.id);
779                if self.delete_session(&sid).await.is_ok() {
780                    pruned += 1;
781                }
782            }
783        }
784
785        if pruned > 0 {
786            tracing::info!(pruned = pruned, "Session pruning completed");
787        }
788
789        Ok(pruned)
790    }
791
792    /// Prune agent records based on config.
793    ///
794    /// 1. TTL-based: delete agents with created_at older than ttl_hours.
795    /// 2. Count-based: if still over max_entries, delete oldest.
796    pub async fn prune_agents_by_config(
797        &self,
798        max_entries: usize,
799        ttl_hours: u64,
800        batch_size: usize,
801    ) -> Result<usize> {
802        let mut pruned = 0usize;
803
804        let names = self.list_category("agents").await?;
805        if names.is_empty() {
806            return Ok(0);
807        }
808
809        let now = Utc::now();
810
811        // 1. TTL-based pruning
812        let mut remaining: Vec<(String, DateTime<Utc>)> = Vec::with_capacity(names.len());
813
814        if ttl_hours > 0 {
815            let cutoff = now - chrono::Duration::hours(ttl_hours as i64);
816            for name in &names {
817                // Load just enough to check created_at
818                if let Ok(Some(info)) = self
819                    .load_json::<crate::types::AgentInfo>("agents", name)
820                    .await
821                {
822                    if info.created_at < cutoff {
823                        if self.delete_file("agents", name).await.unwrap_or(false) {
824                            pruned += 1;
825                        }
826                    } else {
827                        remaining.push((name.clone(), info.created_at));
828                    }
829                }
830            }
831        } else {
832            // Load all created_at for count-based pruning
833            for name in &names {
834                if let Ok(Some(info)) = self
835                    .load_json::<crate::types::AgentInfo>("agents", name)
836                    .await
837                {
838                    remaining.push((name.clone(), info.created_at));
839                }
840            }
841        }
842
843        // 2. Count-based pruning
844        if max_entries > 0 && remaining.len() > max_entries {
845            // Sort by created_at ascending (oldest first)
846            remaining.sort_by_key(|a| a.1);
847
848            let excess = remaining.len() - max_entries;
849            let to_delete = excess.min(batch_size);
850
851            for (name, _) in remaining.iter().take(to_delete) {
852                if self.delete_file("agents", name).await.unwrap_or(false) {
853                    pruned += 1;
854                }
855            }
856        }
857
858        if pruned > 0 {
859            tracing::info!(pruned = pruned, "Agent filesystem pruning completed");
860        }
861
862        Ok(pruned)
863    }
864
865    /// List child sessions of the given parent (RFC-035 threads).
866    /// Returns thread SessionSummary records ordered by `created_at` desc.
867    pub async fn list_child_sessions(
868        &self,
869        parent_session_id: &str,
870    ) -> Result<Vec<SessionSummary>> {
871        let mut all = self.list_sessions().await?;
872        all.retain(|s| s.parent_session_id.as_deref() == Some(parent_session_id));
873        // list_sessions already sorts by updated_at desc; re-sort by created_at
874        // so threads appear in the order they were spawned.
875        all.sort_by_key(|b| std::cmp::Reverse(b.created_at));
876        Ok(all)
877    }
878
879    /// Create a new session that is a sub-conversation (thread) of `parent_id`.
880    /// Inherits parent's `project_id`. Persisted and returned.
881    pub async fn create_thread(
882        &self,
883        parent_id: &str,
884        user_id: impl Into<String>,
885    ) -> Result<Session> {
886        // Read parent to inherit project_id. Missing parent is non-fatal:
887        // the thread is still created (orphaned, but recoverable).
888        let project_id = if let Ok(Some(parent)) = self
889            .load_json::<Session>("sessions", &format!("{parent_id}.json"))
890            .await
891        {
892            parent.project_id
893        } else {
894            tracing::warn!(
895                parent_id = %parent_id,
896                "Thread parent session not found; thread will be orphaned"
897            );
898            None
899        };
900        let mut session = Session::new(user_id);
901        session.parent_session_id = Some(parent_id.to_string());
902        session.project_id = project_id;
903        self.save_session(&session).await?;
904        Ok(session)
905    }
906}
907
908/// Summary of a session for listing (without full message history).
909#[derive(Debug, Clone, Serialize, Deserialize)]
910pub struct SessionSummary {
911    /// Session ID.
912    pub id: String,
913    /// User ID who owns this session.
914    pub user_id: String,
915    /// Number of messages in this session.
916    pub message_count: usize,
917    /// Auto-generated title for this session. Derived from the first user
918    /// message (truncated to ~60 chars) when not explicitly set.
919    #[serde(skip_serializing_if = "Option::is_none")]
920    pub title: Option<String>,
921    /// Active project ID(s) this session belongs to.
922    #[serde(skip_serializing_if = "Option::is_none")]
923    pub project_id: Option<String>,
924    /// Parent session ID for threads (RFC-035). `Some` = this is a
925    /// sub-conversation spawned from the named parent.
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub parent_session_id: Option<String>,
928    /// When the session was created.
929    pub created_at: DateTime<Utc>,
930    /// When the session was last updated.
931    pub updated_at: DateTime<Utc>,
932}
933
934/// Configuration for session pruning.
935#[derive(Debug, Clone)]
936pub struct PruneConfig {
937    /// Maximum number of sessions to keep. 0 = unlimited.
938    pub max_sessions: usize,
939    /// TTL in hours. Sessions older than this are pruned. 0 = no TTL.
940    pub ttl_hours: u64,
941}
942
943impl Default for PruneConfig {
944    fn default() -> Self {
945        Self {
946            max_sessions: 100,
947            ttl_hours: 168, // 7 days
948        }
949    }
950}
951
952/// Tracks the last time a prune was performed, enabling cooldown.
953pub struct PruneThrottle {
954    /// Instant of the last prune (monotonic).
955    last_prune: std::sync::Mutex<Option<std::time::Instant>>,
956    /// Minimum seconds between prune runs.
957    cooldown_secs: u64,
958}
959
960impl PruneThrottle {
961    /// Create a new throttle with the given cooldown.
962    pub fn new(cooldown_secs: u64) -> Self {
963        Self {
964            last_prune: std::sync::Mutex::new(None),
965            cooldown_secs,
966        }
967    }
968
969    /// Check if enough time has elapsed since the last prune.
970    /// Returns `true` if prune should proceed.
971    pub fn should_prune(&self) -> bool {
972        // std::sync::Mutex can poison if a holder panics; recover here by
973        // taking the inner value so pruning continues.
974        let mut guard = self.last_prune.lock().unwrap_or_else(|e| {
975            tracing::warn!("PruneThrottle mutex poisoned, recovering: {e}");
976            e.into_inner()
977        });
978        let now = std::time::Instant::now();
979        match *guard {
980            Some(last) => {
981                if now.duration_since(last).as_secs() >= self.cooldown_secs {
982                    *guard = Some(now);
983                    true
984                } else {
985                    false
986                }
987            }
988            None => {
989                *guard = Some(now);
990                true
991            }
992        }
993    }
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    #[tokio::test]
1000    async fn test_session_creation_and_persistence() {
1001        let temp_dir = tempfile::tempdir().unwrap();
1002        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1003
1004        // Create a session
1005        let mut session = Session::new("user-123");
1006        session.add_user_message("Hello");
1007
1008        // Save and load
1009        store.save_session(&session).await.unwrap();
1010        let loaded = store.load_session(&session.id).await.unwrap();
1011        assert!(loaded.is_some());
1012        let loaded = loaded.unwrap();
1013        assert_eq!(loaded.user_id, "user-123");
1014        assert_eq!(loaded.user_messages.len(), 1);
1015    }
1016
1017    #[tokio::test]
1018    async fn test_session_list_sorts_by_updated() {
1019        let temp_dir = tempfile::tempdir().unwrap();
1020        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1021
1022        // Create multiple sessions
1023        for i in 0..3 {
1024            let mut session = Session::new(format!("user-{}", i));
1025            session.add_user_message(format!("Message {}", i));
1026            store.save_session(&session).await.unwrap();
1027            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1028        }
1029
1030        let sessions = store.list_sessions().await.unwrap();
1031        assert_eq!(sessions.len(), 3);
1032        // Most recently updated should be first
1033        assert_eq!(sessions[0].user_id, "user-2");
1034    }
1035
1036    #[tokio::test]
1037    async fn test_delete_session() {
1038        let temp_dir = tempfile::tempdir().unwrap();
1039        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1040
1041        let session = Session::new("user-123");
1042        store.save_session(&session).await.unwrap();
1043
1044        // Delete and verify
1045        let deleted = store.delete_session(&session.id).await.unwrap();
1046        assert!(deleted);
1047
1048        let loaded = store.load_session(&session.id).await.unwrap();
1049        assert!(loaded.is_none());
1050    }
1051
1052    #[tokio::test]
1053    async fn test_get_or_create_session_existing() {
1054        let temp_dir = tempfile::tempdir().unwrap();
1055        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1056
1057        let mut existing = Session::new("user-123");
1058        existing.add_user_message("Original message");
1059        store.save_session(&existing).await.unwrap();
1060
1061        // Get or create with same ID should return existing
1062        let retrieved = store
1063            .get_or_create_session("user-123", Some(&existing.id))
1064            .await
1065            .unwrap();
1066        assert_eq!(retrieved.id, existing.id);
1067        assert_eq!(retrieved.user_messages.len(), 1);
1068    }
1069
1070    #[tokio::test]
1071    async fn test_get_or_create_session_new() {
1072        let temp_dir = tempfile::tempdir().unwrap();
1073        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1074
1075        // Get or create without existing session should create new
1076        let session = store.get_or_create_session("user-456", None).await.unwrap();
1077        assert_eq!(session.user_id, "user-456");
1078        assert!(session.user_messages.is_empty());
1079    }
1080
1081    #[tokio::test]
1082    async fn test_prune_sessions_by_count() {
1083        let temp_dir = tempfile::tempdir().unwrap();
1084        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1085
1086        // Create 5 sessions
1087        for i in 0..5 {
1088            let mut session = Session::new(format!("user-{}", i));
1089            session.add_user_message(format!("Message {}", i));
1090            store.save_session(&session).await.unwrap();
1091            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1092        }
1093
1094        // Prune to max 3
1095        let config = PruneConfig {
1096            max_sessions: 3,
1097            ttl_hours: 0,
1098        };
1099        let pruned = store.prune_sessions(&config).await.unwrap();
1100        assert_eq!(pruned, 2);
1101
1102        let remaining = store.list_sessions().await.unwrap();
1103        assert_eq!(remaining.len(), 3);
1104        // Oldest sessions (user-0, user-1) should be pruned
1105        let remaining_ids: Vec<&str> = remaining.iter().map(|s| s.user_id.as_str()).collect();
1106        assert!(remaining_ids.contains(&"user-2"));
1107        assert!(remaining_ids.contains(&"user-3"));
1108        assert!(remaining_ids.contains(&"user-4"));
1109    }
1110
1111    #[tokio::test]
1112    async fn test_prune_sessions_by_ttl() {
1113        let temp_dir = tempfile::tempdir().unwrap();
1114        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1115
1116        // Create a session and manually set updated_at to the past
1117        let mut old_session = Session::new("old-user");
1118        old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1119        store.save_session(&old_session).await.unwrap();
1120
1121        // Create a recent session
1122        let mut recent_session = Session::new("recent-user");
1123        recent_session.add_user_message("Hello");
1124        store.save_session(&recent_session).await.unwrap();
1125
1126        // Prune with 24-hour TTL
1127        let config = PruneConfig {
1128            max_sessions: 0,
1129            ttl_hours: 24,
1130        };
1131        let pruned = store.prune_sessions(&config).await.unwrap();
1132        assert_eq!(pruned, 1);
1133
1134        let remaining = store.list_sessions().await.unwrap();
1135        assert_eq!(remaining.len(), 1);
1136        assert_eq!(remaining[0].user_id, "recent-user");
1137    }
1138
1139    #[tokio::test]
1140    async fn test_load_sessions_for_promotion_filters_by_cutoff() {
1141        // Promo-1: sessions older than the cutoff must be skipped before
1142        // being collected, bounding memory for the promotion scanner.
1143        let temp_dir = tempfile::tempdir().unwrap();
1144        let store = StateStore::new(temp_dir.path().to_path_buf()).unwrap();
1145
1146        // Old session — updated 48h ago.
1147        let mut old_session = Session::new("old-user");
1148        old_session.updated_at = Utc::now() - chrono::Duration::hours(48);
1149        store.save_session(&old_session).await.unwrap();
1150
1151        // Recent session — updated now.
1152        let recent_session = Session::new("recent-user");
1153        store.save_session(&recent_session).await.unwrap();
1154
1155        // Cutoff 24h ago: only the recent session should be loaded.
1156        let cutoff = Utc::now() - chrono::Duration::hours(24);
1157        let sessions = store.load_sessions_for_promotion(cutoff).await.unwrap();
1158        assert_eq!(sessions.len(), 1, "old session must be filtered out");
1159        assert_eq!(sessions[0].user_id, "recent-user");
1160
1161        // A cutoff far in the future returns everything (boundary check).
1162        let far_cutoff = Utc::now() - chrono::Duration::days(365);
1163        let all = store.load_sessions_for_promotion(far_cutoff).await.unwrap();
1164        assert_eq!(all.len(), 2);
1165    }
1166}