Skip to main content

sac/sessions/
db.rs

1use super::*;
2
3pub fn create_session(snapshot: &SessionSnapshot) -> Result<()> {
4    create_session_at(&snapshot.store_path, snapshot)
5}
6
7pub fn create_session_at(path: &Path, snapshot: &SessionSnapshot) -> Result<()> {
8    tracing::debug!(
9        session_id = %snapshot.session_id,
10        db_path = %path.display(),
11        cwd = %snapshot.cwd.display(),
12        model = %snapshot.model,
13        backend = ?snapshot.backend,
14        message_count = snapshot.messages.len(),
15        "creating session snapshot"
16    );
17    let mut conn = crate::store::open_connection(path)?;
18    let tx = conn.transaction()?;
19
20    let existing: Option<String> = tx
21        .query_row(
22            "SELECT session_id FROM sessions WHERE session_id = ?1",
23            params![snapshot.session_id],
24            |row| row.get(0),
25        )
26        .optional()?;
27    if existing.is_some() {
28        return Err(anyhow!(
29            "session '{}' already exists; use 'sac resume {}' to continue it",
30            snapshot.session_id,
31            snapshot.session_id
32        ));
33    }
34
35    insert_or_replace_session(&tx, snapshot)?;
36    tx.commit()?;
37    tracing::info!(session_id = %snapshot.session_id, db_path = %path.display(), "session snapshot created");
38    Ok(())
39}
40
41pub fn save_session(snapshot: &SessionSnapshot) -> Result<()> {
42    save_session_at(&snapshot.store_path, snapshot)
43}
44
45pub fn save_session_at(path: &Path, snapshot: &SessionSnapshot) -> Result<()> {
46    tracing::debug!(
47        session_id = %snapshot.session_id,
48        db_path = %path.display(),
49        cwd = %snapshot.cwd.display(),
50        model = %snapshot.model,
51        backend = ?snapshot.backend,
52        message_count = snapshot.messages.len(),
53        "saving session snapshot"
54    );
55    let mut conn = crate::store::open_connection(path)?;
56    let tx = conn.transaction()?;
57    insert_or_replace_session(&tx, snapshot)?;
58    tx.commit()?;
59    tracing::info!(session_id = %snapshot.session_id, db_path = %path.display(), "session snapshot saved");
60    Ok(())
61}
62
63pub fn load_session(path: &Path, session_id: &str) -> Result<SessionSnapshot> {
64    tracing::debug!(session_id = %session_id, db_path = %path.display(), "loading session snapshot");
65    let conn = crate::store::open_connection(path)?;
66    let row = conn
67        .query_row(
68            "SELECT session_id, cwd, store_path, model, base_url, backend, reasoning_effort, sandbox_json, messages_json, last_response_duration_ms, previous_response_duration_ms, response_durations_ms_json, timeline_json, created_at, updated_at
69             FROM sessions
70             WHERE session_id = ?1",
71            params![session_id],
72            |row| {
73                Ok(SessionRow {
74                    session_id: row.get(0)?,
75                    cwd: row.get(1)?,
76                    store_path: row.get(2)?,
77                    model: row.get(3)?,
78                    base_url: row.get(4)?,
79                    backend: row.get(5)?,
80                    reasoning_effort: row.get(6)?,
81                    sandbox_json: row.get(7)?,
82                    messages_json: row.get(8)?,
83                    last_response_duration_ms: row.get(9)?,
84                    previous_response_duration_ms: row.get(10)?,
85                    response_durations_ms_json: row.get(11)?,
86                    timeline_json: row.get(12)?,
87                    created_at: row.get(13)?,
88                    updated_at: row.get(14)?,
89                })
90            },
91        )
92        .optional()?;
93
94    let Some(row) = row else {
95        return Err(anyhow!("session '{}' was not found", session_id));
96    };
97
98    let snapshot = row.into_snapshot()?;
99    tracing::info!(
100        session_id = %snapshot.session_id,
101        db_path = %path.display(),
102        cwd = %snapshot.cwd.display(),
103        model = %snapshot.model,
104        backend = ?snapshot.backend,
105        message_count = snapshot.messages.len(),
106        "loaded session snapshot"
107    );
108    Ok(snapshot)
109}
110
111pub fn load_last_session(path: &Path) -> Result<SessionSnapshot> {
112    tracing::debug!(db_path = %path.display(), "loading last session snapshot");
113    let conn = crate::store::open_connection(path)?;
114    let row = conn
115        .query_row(
116            "SELECT session_id, cwd, store_path, model, base_url, backend, reasoning_effort, sandbox_json, messages_json, last_response_duration_ms, previous_response_duration_ms, response_durations_ms_json, timeline_json, created_at, updated_at
117             FROM sessions
118             ORDER BY updated_at DESC, created_at DESC
119             LIMIT 1",
120            [],
121            |row| {
122                Ok(SessionRow {
123                    session_id: row.get(0)?,
124                    cwd: row.get(1)?,
125                    store_path: row.get(2)?,
126                    model: row.get(3)?,
127                    base_url: row.get(4)?,
128                    backend: row.get(5)?,
129                    reasoning_effort: row.get(6)?,
130                    sandbox_json: row.get(7)?,
131                    messages_json: row.get(8)?,
132                    last_response_duration_ms: row.get(9)?,
133                    previous_response_duration_ms: row.get(10)?,
134                    response_durations_ms_json: row.get(11)?,
135                    timeline_json: row.get(12)?,
136                    created_at: row.get(13)?,
137                    updated_at: row.get(14)?,
138                })
139            },
140        )
141        .optional()?;
142
143    let Some(row) = row else {
144        return Err(anyhow!("no resumable sac sessions were found"));
145    };
146
147    let snapshot = row.into_snapshot()?;
148    tracing::info!(
149        session_id = %snapshot.session_id,
150        db_path = %path.display(),
151        cwd = %snapshot.cwd.display(),
152        model = %snapshot.model,
153        backend = ?snapshot.backend,
154        message_count = snapshot.messages.len(),
155        "loaded last session snapshot"
156    );
157    Ok(snapshot)
158}
159
160pub fn list_sessions(path: &Path) -> Result<Vec<SessionSummary>> {
161    tracing::debug!(db_path = %path.display(), "listing resumable sessions");
162    let conn = crate::store::open_connection(path)?;
163    let mut stmt = conn.prepare(
164        "SELECT session_id, cwd, model, base_url, backend, sandbox_json, messages_json, created_at, updated_at
165         FROM sessions
166         ORDER BY updated_at DESC, created_at DESC",
167    )?;
168    let rows = stmt.query_map([], |row| {
169        Ok((
170            row.get::<_, String>(0)?,
171            row.get::<_, String>(1)?,
172            row.get::<_, String>(2)?,
173            row.get::<_, String>(3)?,
174            row.get::<_, Option<String>>(4)?,
175            row.get::<_, Option<String>>(5)?,
176            row.get::<_, String>(6)?,
177            row.get::<_, String>(7)?,
178            row.get::<_, String>(8)?,
179        ))
180    })?;
181
182    let mut sessions = Vec::new();
183    for row in rows {
184        let (
185            session_id,
186            cwd,
187            model,
188            base_url,
189            backend_raw,
190            sandbox_json,
191            messages_json,
192            created_at,
193            updated_at,
194        ) = row?;
195        let backend = parse_backend(backend_raw, &base_url)?;
196        let messages: Vec<Message> = serde_json::from_str(&messages_json)
197            .context("failed to parse stored session messages")?;
198        sessions.push(SessionSummary {
199            session_id,
200            cwd: PathBuf::from(cwd),
201            model,
202            backend,
203            visible_message_count: visible_message_count(&messages),
204            last_user_prompt: last_user_prompt(&messages),
205            sandboxed: sandbox_json.is_some(),
206            created_at,
207            updated_at,
208        });
209    }
210
211    tracing::info!(db_path = %path.display(), session_count = sessions.len(), "listed resumable sessions");
212    Ok(sessions)
213}
214
215pub fn archive_messages(path: &Path, session_id: &str) -> Result<()> {
216    tracing::debug!(session_id = %session_id, db_path = %path.display(), "archiving messages");
217    let conn = crate::store::open_connection(path)?;
218    conn.execute(
219        "UPDATE sessions SET archived_messages_json = messages_json WHERE session_id = ?1",
220        params![session_id],
221    )?;
222    tracing::info!(session_id = %session_id, db_path = %path.display(), "messages archived");
223    Ok(())
224}
225
226pub fn messages_json_byte_size(path: &Path, session_id: &str) -> Result<Option<usize>> {
227    tracing::debug!(session_id = %session_id, db_path = %path.display(), "querying messages_json byte size");
228    let conn = crate::store::open_connection(path)?;
229    let size: Option<usize> = conn
230        .query_row(
231            "SELECT LENGTH(messages_json) FROM sessions WHERE session_id = ?1",
232            params![session_id],
233            |row| row.get(0),
234        )
235        .optional()?
236        .flatten();
237    tracing::info!(session_id = %session_id, db_path = %path.display(), byte_size = ?size, "queried messages_json byte size");
238    Ok(size)
239}
240
241fn insert_or_replace_session(
242    tx: &rusqlite::Transaction<'_>,
243    snapshot: &SessionSnapshot,
244) -> Result<()> {
245    let sandbox_json = snapshot
246        .sandbox_spec
247        .as_ref()
248        .map(serialize_sandbox)
249        .transpose()?;
250    let messages_json = serde_json::to_string(&snapshot.messages)
251        .context("failed to serialize session messages")?;
252    let response_durations_ms_json = snapshot
253        .response_durations_ms
254        .as_ref()
255        .map(serde_json::to_string)
256        .transpose()
257        .context("failed to serialize session response durations")?;
258
259    tx.execute(
260        "INSERT INTO sessions (
261             session_id, cwd, store_path, model, base_url, backend, reasoning_effort, sandbox_json, messages_json, last_response_duration_ms, previous_response_duration_ms, response_durations_ms_json, timeline_json, created_at, updated_at
262         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
263         ON CONFLICT(session_id) DO UPDATE SET
264             cwd = excluded.cwd,
265             store_path = excluded.store_path,
266             model = excluded.model,
267             base_url = excluded.base_url,
268             backend = excluded.backend,
269             reasoning_effort = excluded.reasoning_effort,
270             sandbox_json = excluded.sandbox_json,
271             messages_json = excluded.messages_json,
272             last_response_duration_ms = excluded.last_response_duration_ms,
273             previous_response_duration_ms = excluded.previous_response_duration_ms,
274             response_durations_ms_json = excluded.response_durations_ms_json,
275             timeline_json = excluded.timeline_json,
276             updated_at = excluded.updated_at",
277        params![
278            snapshot.session_id,
279            snapshot.cwd.display().to_string(),
280            snapshot.store_path.display().to_string(),
281            snapshot.model,
282            snapshot.base_url,
283            snapshot.backend.as_str(),
284            snapshot.reasoning_effort.as_ref().map(|effort| effort.as_str().to_string()),
285            sandbox_json,
286            messages_json,
287            snapshot.last_response_duration_ms,
288            snapshot.previous_response_duration_ms,
289            response_durations_ms_json,
290            snapshot.timeline_json,
291            snapshot.created_at,
292            snapshot.updated_at,
293        ],
294    )?;
295    Ok(())
296}
297
298struct SessionRow {
299    session_id: String,
300    cwd: String,
301    store_path: String,
302    model: String,
303    base_url: String,
304    backend: Option<String>,
305    reasoning_effort: Option<String>,
306    sandbox_json: Option<String>,
307    messages_json: String,
308    last_response_duration_ms: Option<u64>,
309    previous_response_duration_ms: Option<u64>,
310    response_durations_ms_json: Option<String>,
311    timeline_json: Option<String>,
312    created_at: String,
313    updated_at: String,
314}
315
316impl SessionRow {
317    fn into_snapshot(self) -> Result<SessionSnapshot> {
318        let messages = serde_json::from_str(&self.messages_json)
319            .context("failed to parse stored session messages")?;
320        let response_durations_ms = self
321            .response_durations_ms_json
322            .map(|json| {
323                serde_json::from_str::<Vec<Option<u64>>>(&json)
324                    .context("failed to parse stored session response durations")
325            })
326            .transpose()?;
327        let base_url = self.base_url;
328        let backend = parse_backend(self.backend, &base_url)?;
329        Ok(SessionSnapshot {
330            session_id: self.session_id,
331            cwd: PathBuf::from(self.cwd),
332            store_path: PathBuf::from(self.store_path),
333            model: self.model,
334            base_url,
335            backend,
336            reasoning_effort: parse_reasoning_effort(self.reasoning_effort)?,
337            sandbox_spec: deserialize_sandbox(self.sandbox_json)?,
338            messages,
339            last_response_duration_ms: self.last_response_duration_ms,
340            previous_response_duration_ms: self.previous_response_duration_ms,
341            response_durations_ms,
342            timeline_json: self.timeline_json,
343            created_at: self.created_at,
344            updated_at: self.updated_at,
345        })
346    }
347}
348
349fn parse_backend(raw: Option<String>, base_url: &str) -> Result<BackendKind> {
350    match raw.as_deref() {
351        Some("deepseek-chat") => Ok(BackendKind::DeepSeekChat),
352        Some("fireworks-chat") => Ok(BackendKind::FireworksChat),
353        Some("openai-responses") => Ok(BackendKind::OpenAiResponses),
354        Some("chatgpt-codex-responses") => Ok(BackendKind::ChatGptCodexResponses),
355        Some(other) => Err(anyhow!("unsupported stored backend '{}'", other)),
356        None => detect_backend(base_url),
357    }
358}
359
360fn parse_reasoning_effort(raw: Option<String>) -> Result<Option<ReasoningEffort>> {
361    match raw {
362        Some(s) => {
363            let effort: ReasoningEffort = s.parse().map_err(|e: String| anyhow!(e))?;
364            Ok(Some(effort))
365        }
366        None => Ok(None),
367    }
368}