1use crate::core::{Message, Role};
2use crate::core::{SessionReader, SessionSink};
3use crate::session::types::{SessionEvent, SessionMetadata};
4use anyhow::Context;
5use std::fs::{self, OpenOptions};
6use std::io::{BufRead, BufReader, Write};
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9use uuid::Uuid;
10
11#[derive(Debug, Clone)]
12pub struct SessionStore {
13 file: PathBuf,
14 metadata_file: PathBuf,
15 pub id: String,
16}
17
18impl SessionStore {
19 pub fn new(
24 root: &Path,
25 cwd: &Path,
26 id: Option<&str>,
27 title: Option<String>,
28 ) -> anyhow::Result<Self> {
29 Self::new_with_parent(root, cwd, id, title, None)
30 }
31
32 pub fn new_with_parent(
33 root: &Path,
34 cwd: &Path,
35 id: Option<&str>,
36 title: Option<String>,
37 parent_session_id: Option<String>,
38 ) -> anyhow::Result<Self> {
39 let workspace_id = workspace_key(cwd);
40 let dir = root.join(&workspace_id);
41 fs::create_dir_all(&dir)?;
42
43 let old_file = root.join(format!("{}.jsonl", workspace_id));
44 if old_file.exists() {
45 let migration_id = Uuid::new_v4().to_string();
46 let new_file = dir.join(format!("{}.jsonl", migration_id));
47 fs::rename(&old_file, &new_file)?;
48
49 let timestamp = now();
50 let meta_file = dir.join(format!("{}.meta.json", migration_id));
51 let metadata = SessionMetadata {
52 id: migration_id.clone(),
53 title: "Migrated Session".to_string(),
54 created_at: timestamp,
55 last_updated_at: timestamp,
56 parent_session_id: None,
57 };
58 let f = fs::File::create(&meta_file)?;
59 serde_json::to_writer(f, &metadata)?;
60 }
61
62 let session_id = id
63 .map(ToString::to_string)
64 .unwrap_or_else(|| Uuid::new_v4().to_string());
65 let file = dir.join(format!("{}.jsonl", session_id));
66 let metadata_file = dir.join(format!("{}.meta.json", session_id));
67
68 let store = Self {
69 file,
70 metadata_file,
71 id: session_id.clone(),
72 };
73
74 if !store.file.exists()
75 && !store.metadata_file.exists()
76 && let Some(title) = title
77 {
78 let timestamp = now();
79 store.write_metadata(SessionMetadata {
80 id: session_id,
81 title,
82 created_at: timestamp,
83 last_updated_at: timestamp,
84 parent_session_id,
85 })?;
86 }
87
88 Ok(store)
89 }
90
91 pub fn list(root: &Path, cwd: &Path) -> anyhow::Result<Vec<SessionMetadata>> {
92 Self::list_with_options(root, cwd, false)
93 }
94
95 pub fn list_with_options(
96 root: &Path,
97 cwd: &Path,
98 include_children: bool,
99 ) -> anyhow::Result<Vec<SessionMetadata>> {
100 let workspace_id = workspace_key(cwd);
101 let dir = root.join(&workspace_id);
102 if !dir.exists() {
103 return Ok(Vec::new());
104 }
105
106 let mut sessions = Vec::new();
107 for entry in fs::read_dir(dir)? {
108 let entry = entry?;
109 let path = entry.path();
110 if path
111 .file_name()
112 .is_some_and(|n| n.to_string_lossy().ends_with(".meta.json"))
113 && let Ok(file) = fs::File::open(&path)
114 && let Ok(metadata) = serde_json::from_reader::<_, SessionMetadata>(file)
115 {
116 if !include_children && metadata.parent_session_id.is_some() {
117 continue;
118 }
119 sessions.push(metadata);
120 }
121 }
122 sessions.sort_by(|a, b| b.last_updated_at.cmp(&a.last_updated_at));
123 Ok(sessions)
124 }
125
126 fn write_metadata(&self, metadata: SessionMetadata) -> anyhow::Result<()> {
127 let file = fs::File::create(&self.metadata_file)?;
128 serde_json::to_writer(file, &metadata)?;
129 Ok(())
130 }
131
132 fn update_timestamp(&self) -> anyhow::Result<()> {
133 if self.metadata_file.exists() {
134 let file = fs::File::open(&self.metadata_file)?;
135 let mut metadata: SessionMetadata = serde_json::from_reader(file)?;
136 metadata.last_updated_at = now();
137 self.write_metadata(metadata)?;
138 }
139 Ok(())
140 }
141
142 pub fn update_title(&self, title: impl Into<String>) -> anyhow::Result<()> {
143 let title = title.into();
144 let timestamp = now();
145 let metadata = if self.metadata_file.exists() {
146 let file = fs::File::open(&self.metadata_file)?;
147 let mut metadata: SessionMetadata = serde_json::from_reader(file)?;
148 metadata.title = title;
149 metadata.last_updated_at = timestamp;
150 metadata
151 } else {
152 SessionMetadata {
153 id: self.id.clone(),
154 title,
155 created_at: timestamp,
156 last_updated_at: timestamp,
157 parent_session_id: None,
158 }
159 };
160 self.write_metadata(metadata)
161 }
162
163 pub fn append(&self, event: &SessionEvent) -> anyhow::Result<()> {
164 if let Some(parent) = self.file.parent() {
165 fs::create_dir_all(parent)?;
166 }
167
168 let mut file = OpenOptions::new()
169 .create(true)
170 .append(true)
171 .open(&self.file)
172 .with_context(|| format!("failed opening {}", self.file.display()))?;
173 let line = serde_json::to_string(event)?;
174 writeln!(file, "{}", line)?;
175
176 if self.metadata_file.exists() {
177 let _ = self.update_timestamp();
178 }
179
180 Ok(())
181 }
182
183 pub fn replay_messages(&self) -> anyhow::Result<Vec<Message>> {
184 let events = self.replay_events()?;
185 let mut messages = Vec::new();
186
187 for event in events {
188 match event {
189 SessionEvent::Message { message, .. } => messages.push(message),
190 SessionEvent::ToolResult {
191 id, output, result, ..
192 } => {
193 let content = result.map(|value| value.output).unwrap_or(output);
194 messages.push(Message {
195 role: Role::Tool,
196 content,
197 attachments: Vec::new(),
198 tool_call_id: Some(id),
199 });
200 }
201 SessionEvent::Compact { summary, .. } => {
202 messages.clear();
203 messages.push(Message {
204 role: Role::Assistant,
205 content: summary,
206 attachments: Vec::new(),
207 tool_call_id: None,
208 });
209 }
210 _ => {}
211 }
212 }
213 Ok(messages)
214 }
215
216 pub fn replay_events(&self) -> anyhow::Result<Vec<SessionEvent>> {
217 if !self.file.exists() {
218 return Ok(Vec::new());
219 }
220
221 let file = OpenOptions::new().read(true).open(&self.file)?;
222 let reader = BufReader::new(file);
223 let mut events = Vec::new();
224
225 for line in reader.lines() {
226 let line = line?;
227 if line.trim().is_empty() {
228 continue;
229 }
230 match serde_json::from_str::<SessionEvent>(&line) {
231 Ok(event) => events.push(event),
232 Err(e) => {
233 eprintln!("Failed to parse session line: {}", e);
234 }
235 }
236 }
237
238 Ok(events)
239 }
240
241 pub fn file(&self) -> &Path {
242 &self.file
243 }
244}
245
246impl SessionSink for SessionStore {
247 fn append(&self, event: &SessionEvent) -> anyhow::Result<()> {
248 self.append(event)
249 }
250}
251
252impl SessionReader for SessionStore {
253 fn replay_messages(&self) -> anyhow::Result<Vec<Message>> {
254 self.replay_messages()
255 }
256
257 fn replay_events(&self) -> anyhow::Result<Vec<SessionEvent>> {
258 self.replay_events()
259 }
260}
261
262fn workspace_key(cwd: &Path) -> String {
263 let raw = cwd.display().to_string();
264 if raw.is_empty() {
265 Uuid::new_v4().to_string()
266 } else {
267 raw.replace('/', "_")
268 }
269}
270
271pub fn event_id() -> String {
272 Uuid::new_v4().to_string()
273}
274
275pub fn user_message(content: String) -> SessionEvent {
276 SessionEvent::Message {
277 id: event_id(),
278 message: Message {
279 role: Role::User,
280 content,
281 attachments: Vec::new(),
282 tool_call_id: None,
283 },
284 }
285}
286
287fn now() -> u64 {
288 SystemTime::now()
289 .duration_since(UNIX_EPOCH)
290 .map_or(0, |duration| duration.as_secs())
291}