Skip to main content

talos_session/
store.rs

1//! Session storage abstraction.
2//!
3//! The [`SessionStore`] trait separates session entry persistence from the [`Session`] type,
4//! enabling future compact text format support alongside JSONL.
5
6use crate::{SessionEntry, SessionError, SessionInfo, SessionMetadata};
7use chrono::Utc;
8use std::fs::{self, OpenOptions};
9use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
10use std::path::{Path, PathBuf};
11use talos_core::message::Message;
12use uuid::Uuid;
13
14pub use crate::compact_text::CompactTextSessionStore;
15
16/// Trait abstracting session entry persistence.
17///
18/// Enables different storage formats (JSONL, compact text, segment chains)
19/// while keeping the `Session` and `SessionManager` types format-agnostic.
20pub trait SessionStore: Send + Sync + std::fmt::Debug {
21    /// Read all entries from a session file.
22    fn read_entries(&self, file_path: &Path) -> Result<Vec<SessionEntry>, SessionError>;
23
24    /// Append a single entry to a session file.
25    fn append_entry(&self, file_path: &Path, entry: &SessionEntry) -> Result<(), SessionError>;
26
27    /// Replace a complete log using a temporary sibling file and atomic rename.
28    ///
29    /// Used only by durable turn commits, where all entries for a successful
30    /// turn must become visible together.
31    fn replace_entries_atomically(
32        &self,
33        file_path: &Path,
34        entries: &[SessionEntry],
35    ) -> Result<(), SessionError>;
36
37    /// Read the ID of the last entry in the file.
38    fn read_last_entry_id(&self, file_path: &Path) -> Option<String>;
39
40    /// Scan a session file for message count and last preview text.
41    fn scan_file(&self, file_path: &Path) -> Result<SessionInfo, SessionError>;
42
43    /// Read the session file as raw bytes.
44    fn read_bytes(&self, file_path: &Path) -> Result<Vec<u8>, SessionError>;
45
46    /// The file extension for this store's format (e.g., `"jsonl"`).
47    fn file_extension(&self) -> &'static str;
48}
49
50/// JSONL-based session store implementation.
51///
52/// This is the default store, preserving backward compatibility with
53/// existing `.jsonl` session files.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct JsonlSessionStore;
56
57impl SessionStore for JsonlSessionStore {
58    fn read_entries(&self, file_path: &Path) -> Result<Vec<SessionEntry>, SessionError> {
59        read_entries_from_path(file_path)
60    }
61
62    fn append_entry(&self, file_path: &Path, entry: &SessionEntry) -> Result<(), SessionError> {
63        let line =
64            serde_json::to_string(entry).map_err(|e| SessionError::InvalidJson(e.to_string()))?;
65
66        if !file_path.exists()
67            && let Some(parent) = file_path.parent()
68        {
69            fs::create_dir_all(parent)?;
70        }
71
72        let mut file = OpenOptions::new()
73            .create(true)
74            .append(true)
75            .open(file_path)?;
76        writeln!(file, "{line}")?;
77
78        Ok(())
79    }
80
81    fn replace_entries_atomically(
82        &self,
83        file_path: &Path,
84        entries: &[SessionEntry],
85    ) -> Result<(), SessionError> {
86        let parent = file_path.parent().ok_or_else(|| {
87            SessionError::ParseError("session file has no parent directory".into())
88        })?;
89        fs::create_dir_all(parent)?;
90        let temporary = temporary_sibling(file_path);
91        let mut file = OpenOptions::new()
92            .create_new(true)
93            .write(true)
94            .open(&temporary)?;
95        for entry in entries {
96            let line = serde_json::to_string(entry)
97                .map_err(|error| SessionError::InvalidJson(error.to_string()))?;
98            writeln!(file, "{line}")?;
99        }
100        file.sync_all()?;
101        drop(file);
102        fs::rename(&temporary, file_path)?;
103        Ok(())
104    }
105
106    fn read_last_entry_id(&self, file_path: &Path) -> Option<String> {
107        read_last_entry_id(file_path)
108    }
109
110    fn scan_file(&self, file_path: &Path) -> Result<SessionInfo, SessionError> {
111        let file = fs::File::open(file_path)?;
112        let metadata = file.metadata()?;
113        let timestamp = metadata
114            .modified()
115            .ok()
116            .map(chrono::DateTime::<Utc>::from)
117            .unwrap_or_else(Utc::now);
118
119        let id = file_path
120            .file_stem()
121            .and_then(|s| s.to_str())
122            .and_then(|s| Uuid::parse_str(s).ok())
123            .unwrap_or_else(Uuid::nil);
124
125        let reader = BufReader::new(file);
126        let mut count = 0;
127        let mut last_preview = String::new();
128
129        for line in reader.lines() {
130            let line = line?;
131            if line.is_empty() {
132                continue;
133            }
134
135            if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
136                count += 1;
137                last_preview = crate::jsonl::preview_text(&entry.content);
138                continue;
139            }
140
141            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line)
142                && value.get("type").and_then(|t| t.as_str()) == Some("message")
143                && let Some(data) = value.get("data")
144                && let Ok(msg) = serde_json::from_value::<Message>(data.clone())
145            {
146                count += 1;
147                let (_, content) = crate::jsonl::message_parts(&msg);
148                last_preview = crate::jsonl::preview_text(&content);
149            }
150        }
151
152        Ok(SessionInfo {
153            id,
154            project: String::new(),
155            workspace_root: String::new(),
156            last_message_preview: last_preview,
157            timestamp,
158            message_count: count,
159        })
160    }
161
162    fn read_bytes(&self, file_path: &Path) -> Result<Vec<u8>, SessionError> {
163        std::fs::read(file_path).map_err(SessionError::IoError)
164    }
165
166    fn file_extension(&self) -> &'static str {
167        "jsonl"
168    }
169}
170
171/// Returns a UUID-named temporary file next to a session file.
172pub(crate) fn temporary_sibling(file_path: &Path) -> PathBuf {
173    let file_name = file_path
174        .file_name()
175        .and_then(|name| name.to_str())
176        .unwrap_or("session");
177    file_path.with_file_name(format!(".{file_name}.{}.tmp", Uuid::new_v4()))
178}
179
180fn read_last_entry_id(path: &Path) -> Option<String> {
181    let mut file = fs::File::open(path).ok()?;
182    let file_size = file.metadata().ok()?.len();
183    if file_size == 0 {
184        return None;
185    }
186    let read_size = std::cmp::min(file_size, 8192) as usize;
187    let seek_pos = file_size.saturating_sub(read_size as u64);
188    file.seek(SeekFrom::Start(seek_pos)).ok()?;
189    let mut buf = vec![0u8; read_size];
190    file.read_exact(&mut buf).ok()?;
191    let text = String::from_utf8_lossy(&buf);
192    let last_line = text.lines().rev().find(|l| !l.is_empty())?;
193    let entry: SessionEntry = serde_json::from_str(last_line).ok()?;
194    Some(entry.id)
195}
196
197fn read_entries_from_path(path: &Path) -> Result<Vec<SessionEntry>, SessionError> {
198    if !path.exists() {
199        return Ok(Vec::new());
200    }
201
202    let file = fs::File::open(path)?;
203    let reader = BufReader::new(file);
204    let mut entries = Vec::new();
205    let mut synthetic_counter: u64 = 0;
206
207    for line in reader.lines() {
208        let line = line?;
209        if line.is_empty() {
210            continue;
211        }
212
213        if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
214            entries.push(entry);
215            continue;
216        }
217
218        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line)
219            && value.get("type").and_then(|t| t.as_str()) == Some("message")
220            && let Some(data) = value.get("data")
221            && let Ok(msg) = serde_json::from_value::<Message>(data.clone())
222        {
223            let (role, content) = crate::jsonl::message_parts(&msg);
224            let id = format!("synthetic-{synthetic_counter}");
225            let parent_id = if synthetic_counter > 0 {
226                Some(format!("synthetic-{}", synthetic_counter - 1))
227            } else {
228                None
229            };
230
231            entries.push(SessionEntry {
232                id,
233                parent_id,
234                timestamp: Utc::now(),
235                role,
236                content,
237                metadata: SessionMetadata::default(),
238            });
239            synthetic_counter += 1;
240        }
241        // Invalid lines are silently skipped (crash-safety guarantee)
242    }
243
244    Ok(entries)
245}