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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15pub struct SessionMetadata {
16 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionEntry {
52 pub id: String,
54
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub parent_id: Option<String>,
58
59 pub timestamp: DateTime<Utc>,
61
62 pub role: String,
64
65 pub content: String,
67
68 #[serde(default, skip_serializing_if = "SessionMetadata::is_empty")]
70 pub metadata: SessionMetadata,
71}
72
73#[derive(Debug, Clone)]
78pub struct SessionBranch {
79 pub root_id: String,
81
82 pub entries: Vec<SessionEntry>,
84}
85
86#[derive(Debug, Clone)]
88pub struct SessionInfo {
89 pub id: Uuid,
91
92 pub project: String,
94
95 pub workspace_root: String,
97
98 pub last_message_preview: String,
100
101 pub timestamp: DateTime<Utc>,
103
104 pub message_count: usize,
106}
107
108pub 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 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 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 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 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 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 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 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 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 pub fn get_branch(&self, branch_id: &str) -> Option<&SessionBranch> {
323 self.branches.get(branch_id)
324 }
325
326 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 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 pub fn file_extension(&self) -> &'static str {
348 self.store.file_extension()
349 }
350}
351
352pub(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}