Skip to main content

talos_session/
types.rs

1use crate::SessionError;
2use crate::store::{CompactTextSessionStore, JsonlSessionStore, SessionStore};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex};
8use uuid::Uuid;
9
10/// Metadata associated with a session entry.
11///
12/// Captures optional context about the model, token usage, and working directory
13/// at the time the entry was created.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct SessionMetadata {
16    /// Durable runtime turn that committed this entry, when applicable.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub turn_id: Option<String>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub provider: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub model: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub token_count: Option<u32>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub working_directory: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub reasoning: Option<talos_core::message::AssistantReasoning>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub raw_content: Option<String>,
31}
32
33impl SessionMetadata {
34    /// Returns `true` if all fields are `None`.
35    pub(crate) fn is_empty(&self) -> bool {
36        self.turn_id.is_none()
37            && self.provider.is_none()
38            && self.model.is_none()
39            && self.token_count.is_none()
40            && self.working_directory.is_none()
41            && self.reasoning.is_none()
42            && self.raw_content.is_none()
43    }
44}
45
46/// A single entry in a session branch.
47///
48/// Each entry has a unique ID and an optional parent ID that links it to a previous
49/// entry, enabling tree-structured branching conversations.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionEntry {
52    /// Unique identifier for this entry.
53    pub id: String,
54
55    /// ID of the parent entry. `None` for root entries (first entry in a branch).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub parent_id: Option<String>,
58
59    /// When this entry was created.
60    pub timestamp: DateTime<Utc>,
61
62    /// The role of this entry: `"user"`, `"assistant"`, or `"system"`.
63    pub role: String,
64
65    /// The content of this entry.
66    pub content: String,
67
68    /// Optional metadata about this entry.
69    #[serde(default, skip_serializing_if = "SessionMetadata::is_empty")]
70    pub metadata: SessionMetadata,
71}
72
73/// A linear branch within a session.
74///
75/// A branch is a sequence of entries sharing a common root. Branches are created
76/// via [`Session::fork`] and are identified by a unique branch ID.
77#[derive(Debug, Clone)]
78pub struct SessionBranch {
79    /// ID of the root entry this branch originates from.
80    pub root_id: String,
81
82    /// Ordered entries in this branch.
83    pub entries: Vec<SessionEntry>,
84}
85
86/// Information about a session, returned when listing sessions.
87#[derive(Debug, Clone)]
88pub struct SessionInfo {
89    /// Unique session identifier.
90    pub id: Uuid,
91
92    /// Human-readable project display name (basename).
93    pub project: String,
94
95    /// Stable workspace identity (canonical absolute path).
96    pub workspace_root: String,
97
98    /// Preview of the last message in the session.
99    pub last_message_preview: String,
100
101    /// When the session file was last modified.
102    pub timestamp: DateTime<Utc>,
103
104    /// Total number of entries across all branches.
105    pub message_count: usize,
106}
107
108/// A single session, backed by a JSONL file, with support for tree-branching.
109///
110/// Sessions maintain a collection of branches, each identified by a unique ID.
111/// The `current_branch` field tracks which branch is active for appending new entries.
112pub struct Session {
113    pub id: Uuid,
114    pub project: String,
115    pub workspace_root: String,
116    pub created_at: DateTime<Utc>,
117    pub file_path: PathBuf,
118    pub current_branch: String,
119    pub branches: HashMap<String, SessionBranch>,
120    pub persisted: bool,
121    pub(crate) last_entry_id: Arc<Mutex<Option<String>>>,
122    pub(crate) write_lock: Arc<Mutex<()>>,
123    pub(crate) store: Arc<dyn SessionStore>,
124}
125
126impl std::fmt::Debug for Session {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("Session")
129            .field("id", &self.id)
130            .field("project", &self.project)
131            .field("workspace_root", &self.workspace_root)
132            .field("created_at", &self.created_at)
133            .field("file_path", &self.file_path)
134            .field("current_branch", &self.current_branch)
135            .field("branches", &self.branches)
136            .field("persisted", &self.persisted)
137            .field("last_entry_id", &self.last_entry_id)
138            .field("write_lock", &self.write_lock)
139            .finish()
140    }
141}
142
143impl Clone for Session {
144    fn clone(&self) -> Self {
145        Self {
146            id: self.id,
147            project: self.project.clone(),
148            workspace_root: self.workspace_root.clone(),
149            created_at: self.created_at,
150            file_path: self.file_path.clone(),
151            current_branch: self.current_branch.clone(),
152            branches: self.branches.clone(),
153            persisted: self.persisted,
154            last_entry_id: Arc::clone(&self.last_entry_id),
155            write_lock: Arc::clone(&self.write_lock),
156            store: Arc::clone(&self.store),
157        }
158    }
159}
160
161impl Session {
162    /// Archives the current log while holding the session write lock.
163    pub fn compact_archived(
164        &self,
165        max_entries: usize,
166    ) -> Result<crate::compaction_engine::CompactionResult, SessionError> {
167        let _lock = self
168            .write_lock
169            .lock()
170            .map_err(|_| SessionError::LockPoisoned)?;
171        let engine = crate::compaction_engine::CompactionEngine::new(Arc::clone(&self.store));
172        let dir = self.file_path.parent().ok_or_else(|| {
173            SessionError::ParseError("session file has no parent directory".into())
174        })?;
175        engine.compact_segment(&self.file_path, dir, max_entries)
176    }
177    /// Create a new session with a single empty root branch.
178    /// The file path MUST already exist on disk.
179    pub fn new(id: Uuid, project: String, workspace_root: String, file_path: PathBuf) -> Self {
180        let root_id = Uuid::new_v4().to_string();
181        let mut branches = HashMap::new();
182        branches.insert(
183            root_id.clone(),
184            SessionBranch {
185                root_id: root_id.clone(),
186                entries: Vec::new(),
187            },
188        );
189
190        let store = store_for_path(&file_path);
191
192        Self {
193            id,
194            project,
195            workspace_root,
196            created_at: Utc::now(),
197            file_path,
198            current_branch: root_id,
199            branches,
200            persisted: true,
201            last_entry_id: Arc::new(Mutex::new(None)),
202            write_lock: Arc::new(Mutex::new(())),
203            store,
204        }
205    }
206
207    /// Create a deferred session — metadata only, no file on disk.
208    /// The file is created lazily on the first `ensure_persisted()` call.
209    pub fn new_deferred(
210        id: Uuid,
211        project: String,
212        workspace_root: String,
213        file_path: PathBuf,
214    ) -> Self {
215        let mut session = Self::new(id, project, workspace_root, file_path);
216        session.persisted = false;
217        session
218    }
219
220    /// Create a session with an explicit store, chosen by the caller.
221    /// Used by `SessionManager::get_session` when loading an existing file
222    /// whose format is determined by extension.
223    pub fn with_store(
224        id: Uuid,
225        project: String,
226        workspace_root: String,
227        file_path: PathBuf,
228        store: Arc<dyn SessionStore>,
229    ) -> Self {
230        let mut session = Self::new(id, project, workspace_root, file_path);
231        session.store = store;
232        session
233    }
234
235    /// Create the parent directory and empty JSONL file if not yet persisted.
236    /// No-op if already persisted (e.g., resumed or forked sessions).
237    pub fn ensure_persisted(&mut self) -> Result<(), SessionError> {
238        if self.persisted {
239            return Ok(());
240        }
241        if let Some(parent) = self.file_path.parent() {
242            std::fs::create_dir_all(parent)?;
243        }
244        // create_new(true) avoids truncating an existing file. `Session` is
245        // Clone and `persisted: bool` is copied per clone, so a clone from a
246        // watch channel can report `persisted = false` even when another
247        // clone already created the file on disk. EVOLUTION lesson #30 path.
248        match std::fs::OpenOptions::new()
249            .create_new(true)
250            .write(true)
251            .open(&self.file_path)
252        {
253            Ok(_) => {}
254            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
255            Err(e) => return Err(e.into()),
256        }
257        self.persisted = true;
258        Ok(())
259    }
260
261    /// Fork the current session from a specific entry, creating a new branch.
262    ///
263    /// Returns the ID of the newly created branch.
264    ///
265    /// # Arguments
266    ///
267    /// * `from_entry_id` - The ID of the entry to fork from. This entry must exist
268    ///   in one of the existing branches.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`SessionError::EntryNotFound`] if the entry ID doesn't exist.
273    pub fn fork(&mut self, from_entry_id: &str) -> Result<String, SessionError> {
274        let all_entries = self.read_entries()?;
275
276        let pos = all_entries
277            .iter()
278            .position(|e| e.id == from_entry_id)
279            .ok_or_else(|| SessionError::EntryNotFound(from_entry_id.to_string()))?;
280
281        let entries_up_to_fork: Vec<SessionEntry> = all_entries[..=pos].to_vec();
282
283        let new_branch_id = Uuid::new_v4().to_string();
284
285        let new_branch = SessionBranch {
286            root_id: from_entry_id.to_string(),
287            entries: entries_up_to_fork,
288        };
289
290        self.branches.insert(new_branch_id.clone(), new_branch);
291        self.current_branch = new_branch_id.clone();
292
293        Ok(new_branch_id)
294    }
295
296    /// Atomically re-stamp a forked session with a new identity.
297    ///
298    /// After [`fork`](Self::fork) creates a new branch in memory, callers typically write
299    /// the branched entries to a fresh JSONL file under a new [`Uuid`]. This method
300    /// updates the in-memory [`Session`] so that subsequent [`append`](Self::append)
301    /// and [`append_event`](Self::append_event) calls write to the new file and so the
302    /// SQLite index sees a coherent `(id, file_path, branch_id)` triple.
303    ///
304    /// Without this, the original session's `id`/`file_path` would survive a fork in
305    /// memory while the on-disk file moved to a new UUID — the SQLite index would then
306    /// either point at the wrong file or fail to locate the fork.
307    ///
308    /// # Arguments
309    ///
310    /// * `new_id` - The new session [`Uuid`] (must match the JSONL filename).
311    /// * `new_file_path` - The path to the new JSONL file the fork was written to.
312    /// * `branch_id` - The branch ID to mark as currently active on the fork.
313    pub fn with_fork_identity(&mut self, new_id: Uuid, new_file_path: PathBuf, branch_id: String) {
314        self.id = new_id;
315        self.file_path = new_file_path;
316        self.current_branch = branch_id;
317    }
318
319    /// Get a reference to a branch by its ID.
320    ///
321    /// Returns `None` if the branch ID doesn't exist.
322    pub fn get_branch(&self, branch_id: &str) -> Option<&SessionBranch> {
323        self.branches.get(branch_id)
324    }
325
326    /// Read the session JSONL file as raw bytes, holding the per-session write
327    /// lock so concurrent `append` calls cannot produce a torn read.
328    ///
329    /// Used by fork-style operations that need to copy the source file
330    /// byte-for-byte without racing with in-flight event persistence.
331    pub fn snapshot_bytes(&self) -> Result<Vec<u8>, SessionError> {
332        let _guard = self
333            .write_lock
334            .lock()
335            .map_err(|_| SessionError::LockPoisoned)?;
336        self.store.read_bytes(&self.file_path)
337    }
338
339    /// List all branch IDs in this session.
340    pub fn list_branches(&self) -> Vec<String> {
341        let mut ids: Vec<String> = self.branches.keys().cloned().collect();
342        ids.sort();
343        ids
344    }
345
346    /// Returns the file extension used by this session's store.
347    pub fn file_extension(&self) -> &'static str {
348        self.store.file_extension()
349    }
350}
351
352/// Select the appropriate [`SessionStore`] based on a file path's extension.
353///
354/// `.tlog` → [`CompactTextSessionStore`], `.jsonl` → [`JsonlSessionStore`],
355/// unknown or missing → [`JsonlSessionStore`] (legacy default).
356pub(crate) fn store_for_path(file_path: &std::path::Path) -> Arc<dyn SessionStore> {
357    match file_path.extension().and_then(|e| e.to_str()) {
358        Some("tlog") => Arc::new(CompactTextSessionStore),
359        _ => Arc::new(JsonlSessionStore),
360    }
361}