Skip to main content

microclaw_engine/internal/storage/db/
sessions.rs

1use super::*;
2
3#[derive(Debug, Clone, Default)]
4pub struct SessionSettings {
5    pub label: Option<String>,
6    pub thinking_level: Option<String>,
7    pub verbose_level: Option<String>,
8    pub reasoning_level: Option<String>,
9}
10
11pub type SessionMetaRow = (String, String, Option<String>, Option<i64>);
12
13pub type SessionTreeRow = (i64, Option<String>, Option<i64>, String);
14
15impl Database {
16    pub fn save_session(&self, chat_id: i64, messages_json: &str) -> Result<(), MicroClawError> {
17        self.save_session_with_meta(chat_id, messages_json, None, None, None)
18    }
19
20    pub fn save_session_with_meta(
21        &self,
22        chat_id: i64,
23        messages_json: &str,
24        parent_session_key: Option<&str>,
25        fork_point: Option<i64>,
26        skill_envs_json: Option<&str>,
27    ) -> Result<(), MicroClawError> {
28        let conn = self.lock_conn();
29        let now = chrono::Utc::now().to_rfc3339();
30        conn.execute(
31            "INSERT INTO sessions (chat_id, messages_json, updated_at, parent_session_key, fork_point, skill_envs_json)
32             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
33             ON CONFLICT(chat_id) DO UPDATE SET
34                messages_json = ?2,
35                updated_at = ?3,
36                parent_session_key = COALESCE(?4, parent_session_key),
37                fork_point = COALESCE(?5, fork_point),
38                skill_envs_json = COALESCE(?6, skill_envs_json)",
39            params![chat_id, messages_json, now, parent_session_key, fork_point, skill_envs_json],
40        )?;
41        Ok(())
42    }
43
44    pub fn load_session(&self, chat_id: i64) -> Result<Option<(String, String)>, MicroClawError> {
45        let conn = self.lock_conn();
46        let result = conn.query_row(
47            "SELECT messages_json, updated_at FROM sessions WHERE chat_id = ?1",
48            params![chat_id],
49            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
50        );
51        match result {
52            Ok(pair) => Ok(Some(pair)),
53            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
54            Err(e) => Err(e.into()),
55        }
56    }
57
58    /// Overwrite the session label for a chat. No-op if the chat has no
59    /// session row yet. Used by the title generator background task.
60    pub fn set_session_label(&self, chat_id: i64, label: &str) -> Result<(), MicroClawError> {
61        let conn = self.lock_conn();
62        conn.execute(
63            "UPDATE sessions SET label = ?1 WHERE chat_id = ?2",
64            params![label, chat_id],
65        )?;
66        Ok(())
67    }
68
69    /// Return the current session label + message count for a chat. Used
70    /// to decide whether the title generator should run.
71    pub fn get_session_label_and_length(
72        &self,
73        chat_id: i64,
74    ) -> Result<Option<(Option<String>, usize)>, MicroClawError> {
75        let conn = self.lock_conn();
76        let result = conn
77            .query_row(
78                "SELECT label, messages_json FROM sessions WHERE chat_id = ?1",
79                params![chat_id],
80                |row| {
81                    let label: Option<String> = row.get(0)?;
82                    let json: String = row.get(1)?;
83                    Ok((label, json))
84                },
85            )
86            .optional()?;
87        let Some((label, json)) = result else {
88            return Ok(None);
89        };
90        let count = serde_json::from_str::<Vec<serde_json::Value>>(&json)
91            .map(|v| v.len())
92            .unwrap_or(0);
93        Ok(Some((label, count)))
94    }
95
96    pub fn save_session_settings(
97        &self,
98        chat_id: i64,
99        settings: &SessionSettings,
100    ) -> Result<(), MicroClawError> {
101        let conn = self.lock_conn();
102        let now = chrono::Utc::now().to_rfc3339();
103        conn.execute(
104            "INSERT INTO sessions (
105                chat_id,
106                messages_json,
107                updated_at,
108                label,
109                thinking_level,
110                verbose_level,
111                reasoning_level
112             )
113             VALUES (?1, '[]', ?2, ?3, ?4, ?5, ?6)
114             ON CONFLICT(chat_id) DO UPDATE SET
115                updated_at = excluded.updated_at,
116                label = COALESCE(excluded.label, sessions.label),
117                thinking_level = COALESCE(excluded.thinking_level, sessions.thinking_level),
118                verbose_level = COALESCE(excluded.verbose_level, sessions.verbose_level),
119                reasoning_level = COALESCE(excluded.reasoning_level, sessions.reasoning_level)",
120            params![
121                chat_id,
122                now,
123                settings.label,
124                settings.thinking_level,
125                settings.verbose_level,
126                settings.reasoning_level
127            ],
128        )?;
129        Ok(())
130    }
131
132    pub fn load_session_settings(
133        &self,
134        chat_id: i64,
135    ) -> Result<Option<SessionSettings>, MicroClawError> {
136        let conn = self.lock_conn();
137        let result = conn.query_row(
138            "SELECT label, thinking_level, verbose_level, reasoning_level
139             FROM sessions
140             WHERE chat_id = ?1",
141            params![chat_id],
142            |row| {
143                Ok(SessionSettings {
144                    label: row.get::<_, Option<String>>(0)?,
145                    thinking_level: row.get::<_, Option<String>>(1)?,
146                    verbose_level: row.get::<_, Option<String>>(2)?,
147                    reasoning_level: row.get::<_, Option<String>>(3)?,
148                })
149            },
150        );
151        match result {
152            Ok(settings) => Ok(Some(settings)),
153            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
154            Err(e) => Err(e.into()),
155        }
156    }
157
158    pub fn load_session_meta(
159        &self,
160        chat_id: i64,
161    ) -> Result<Option<SessionMetaRow>, MicroClawError> {
162        let conn = self.lock_conn();
163        let result = conn.query_row(
164            "SELECT messages_json, updated_at, parent_session_key, fork_point
165             FROM sessions WHERE chat_id = ?1",
166            params![chat_id],
167            |row| {
168                Ok((
169                    row.get::<_, String>(0)?,
170                    row.get::<_, String>(1)?,
171                    row.get::<_, Option<String>>(2)?,
172                    row.get::<_, Option<i64>>(3)?,
173                ))
174            },
175        );
176        match result {
177            Ok(v) => Ok(Some(v)),
178            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
179            Err(e) => Err(e.into()),
180        }
181    }
182
183    pub fn list_session_meta(&self, limit: usize) -> Result<Vec<SessionTreeRow>, MicroClawError> {
184        let conn = self.lock_conn();
185        let mut stmt = conn.prepare(
186            "SELECT chat_id, parent_session_key, fork_point, updated_at
187             FROM sessions
188             ORDER BY updated_at DESC
189             LIMIT ?1",
190        )?;
191        let rows = stmt
192            .query_map(params![limit as i64], |row| {
193                Ok((
194                    row.get::<_, i64>(0)?,
195                    row.get::<_, Option<String>>(1)?,
196                    row.get::<_, Option<i64>>(2)?,
197                    row.get::<_, String>(3)?,
198                ))
199            })?
200            .collect::<Result<Vec<_>, _>>()?;
201        Ok(rows)
202    }
203
204    pub fn delete_session(&self, chat_id: i64) -> Result<bool, MicroClawError> {
205        let conn = self.lock_conn();
206        let rows = conn.execute("DELETE FROM sessions WHERE chat_id = ?1", params![chat_id])?;
207        Ok(rows > 0)
208    }
209}
210
211impl Database {
212    pub fn save_session_skill_envs(
213        &self,
214        chat_id: i64,
215        skill_envs_json: &str,
216    ) -> Result<(), MicroClawError> {
217        let conn = self.lock_conn();
218        conn.execute(
219            "UPDATE sessions SET skill_envs_json = ?2 WHERE chat_id = ?1",
220            params![chat_id, skill_envs_json],
221        )?;
222        Ok(())
223    }
224
225    pub fn load_session_skill_envs(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
226        let conn = self.lock_conn();
227        let result = conn.query_row(
228            "SELECT skill_envs_json FROM sessions WHERE chat_id = ?1",
229            params![chat_id],
230            |row| row.get::<_, Option<String>>(0),
231        );
232        match result {
233            Ok(v) => Ok(v),
234            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
235            Err(e) => Err(e.into()),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::internal::storage::db::test_support::*;
244
245    #[test]
246    fn test_save_and_load_session() {
247        let (db, dir) = test_db();
248        let json = r#"[{"role":"user","content":"hello"}]"#;
249        db.save_session(100, json).unwrap();
250
251        let result = db.load_session(100).unwrap();
252        assert!(result.is_some());
253        let (loaded_json, updated_at) = result.unwrap();
254        assert_eq!(loaded_json, json);
255        assert!(!updated_at.is_empty());
256
257        // Upsert: save again with different data
258        let json2 = r#"[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"}]"#;
259        db.save_session(100, json2).unwrap();
260        let (loaded_json2, _) = db.load_session(100).unwrap().unwrap();
261        assert_eq!(loaded_json2, json2);
262
263        cleanup(&dir);
264    }
265
266    #[test]
267    fn test_save_and_load_session_settings() {
268        let (db, dir) = test_db();
269        let settings = SessionSettings {
270            label: Some("Ops".into()),
271            thinking_level: Some("high".into()),
272            verbose_level: Some("full".into()),
273            reasoning_level: Some("stream".into()),
274        };
275        db.save_session_settings(100, &settings).unwrap();
276
277        let loaded = db.load_session_settings(100).unwrap().unwrap();
278        assert_eq!(loaded.label.as_deref(), Some("Ops"));
279        assert_eq!(loaded.thinking_level.as_deref(), Some("high"));
280        assert_eq!(loaded.verbose_level.as_deref(), Some("full"));
281        assert_eq!(loaded.reasoning_level.as_deref(), Some("stream"));
282
283        cleanup(&dir);
284    }
285
286    #[test]
287    fn test_load_session_nonexistent() {
288        let (db, dir) = test_db();
289        let result = db.load_session(999).unwrap();
290        assert!(result.is_none());
291        cleanup(&dir);
292    }
293
294    #[test]
295    fn test_delete_session() {
296        let (db, dir) = test_db();
297        db.save_session(100, "[]").unwrap();
298        assert!(db.delete_session(100).unwrap());
299        assert!(db.load_session(100).unwrap().is_none());
300        // Delete again returns false
301        assert!(!db.delete_session(100).unwrap());
302        cleanup(&dir);
303    }
304}