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}