Skip to main content

hh_cli/session/
store.rs

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    /// Creates a new session or loads an existing one.
20    /// If `id` is provided, it loads that session.
21    /// If `id` is None, it creates a new session.
22    /// `title` is used when creating a new session or updating metadata.
23    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}