1 //! Durable session storage.
2 //!
3 //! One JSON-lines file per session at `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`
4 //! (config dir resolution matches [`crate::settings`] / [`crate::credentials`]:
5 //! `$SIGIT_CONFIG_DIR` or `~/.config/sigit`). Each line is one history message
6 //! as produced by `InferenceBackend::history_snapshot`, so a saved file can be
7 //! restored into either backend.
8 //!
9 //! A session also carries a small sidecar at `<id>.meta.json` holding the
10 //! directories it ran in. History alone can't say where a thread belongs, and
11 //! ACP's `session/list` — the "Import Threads" picker in editors — must report
12 //! an absolute `cwd` per session and may ask for only the sessions of one
13 //! directory. A session without a sidecar is therefore not listable (it still
14 //! loads fine); that is the case for threads saved before sidecars existed.
15 //!
16 //! Writes are atomic (temp file + rename) so a crash mid-save never leaves a
17 //! truncated session behind. Session ids are sanitized to a filename-safe
18 //! alphabet before touching the filesystem.
19
20 use std::path::{Path, PathBuf};
21
22 use serde::{Deserialize, Serialize};
23 use serde_json::Value;
24
25 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
26 fn config_dir() -> Option<PathBuf> {
27 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
28 return Some(PathBuf::from(dir));
29 }
30 let home = std::env::var("HOME").ok()?;
31 Some(PathBuf::from(home).join(".config/sigit"))
32 }
33
34 fn sessions_dir() -> Option<PathBuf> {
35 config_dir().map(|dir| dir.join("sessions"))
36 }
37
38 /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through,
39 /// anything else becomes `_`. An empty id maps to `_` so the file name never
40 /// collapses to just the extension.
41 fn sanitize_id(session_id: &str) -> String {
42 if session_id.is_empty() {
43 return "_".to_string();
44 }
45 session_id
46 .chars()
47 .map(|c| {
48 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
49 c
50 } else {
51 '_'
52 }
53 })
54 .collect()
55 }
56
57 fn session_path(session_id: &str) -> Option<PathBuf> {
58 sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
59 }
60
61 fn meta_path(session_id: &str) -> Option<PathBuf> {
62 sessions_dir().map(|dir| dir.join(format!("{}.meta.json", sanitize_id(session_id))))
63 }
64
65 /// Write `body` to `path` through a uniquely named temp file in the same
66 /// directory, then rename it into place. The unique name keeps two processes
67 /// saving the same session from clobbering each other's half-written file;
68 /// rename is atomic on the same filesystem.
69 fn write_atomic(path: &Path, session_id: &str, body: &str) -> Result<(), String> {
70 let dir = path
71 .parent()
72 .ok_or_else(|| "session path has no parent".to_string())?;
73 std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
74
75 let tmp = dir.join(format!(
76 ".{}.{}.tmp",
77 sanitize_id(session_id),
78 std::process::id()
79 ));
80 std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
81 std::fs::rename(&tmp, path).map_err(|error| {
82 let _ = std::fs::remove_file(&tmp);
83 format!("rename {tmp:?} -> {path:?}: {error}")
84 })
85 }
86
87 /// The directories a saved session ran in, as stored in its sidecar.
88 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89 pub struct SessionMeta {
90 /// The session's primary working directory (absolute).
91 pub cwd: PathBuf,
92 /// Extra workspace roots of a multi-root project, in order.
93 #[serde(default)]
94 pub additional_directories: Vec<PathBuf>,
95 /// Model selection last used by this session. Older sidecars omit it.
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub model_id: Option<String>,
98 }
99
100 /// Record where `session_id` is running so it can be listed later. Cheap enough
101 /// to call on every save; the sidecar is a few hundred bytes.
102 pub fn save_meta(
103 session_id: &str,
104 cwd: &Path,
105 additional_directories: &[PathBuf],
106 model_id: Option<&str>,
107 ) {
108 let Some(path) = meta_path(session_id) else {
109 return;
110 };
111 let meta = SessionMeta {
112 cwd: cwd.to_path_buf(),
113 additional_directories: additional_directories.to_vec(),
114 model_id: model_id.map(str::to_string),
115 };
116 let Ok(body) = serde_json::to_string(&meta) else {
117 return;
118 };
119 if let Err(error) = write_atomic(&path, session_id, &body) {
120 log::warn!("session meta save failed for {session_id}: {error}");
121 }
122 }
123
124 /// Read the sidecar for `session_id`, or `None` when there isn't a readable one.
125 fn load_meta(session_id: &str) -> Option<SessionMeta> {
126 let path = meta_path(session_id)?;
127 let contents = std::fs::read_to_string(&path).ok()?;
128 serde_json::from_str(&contents).ok()
129 }
130
131 /// Persist a history snapshot for `session_id`, replacing any previous save.
132 /// The write is atomic: a temp file in the same directory is renamed over the
133 /// final path.
134 pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
135 let path =
136 session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
137
138 let mut body = String::new();
139 for message in history {
140 body.push_str(&message.to_string());
141 body.push('\n');
142 }
143
144 write_atomic(&path, session_id, &body)
145 }
146
147 /// Load the saved history for `session_id`, or `None` when no save exists (or
148 /// it cannot be read). Unparseable lines are skipped rather than failing the
149 /// whole restore.
150 pub fn load(session_id: &str) -> Option<Vec<Value>> {
151 let path = session_path(session_id)?;
152 let contents = std::fs::read_to_string(&path).ok()?;
153 Some(
154 contents
155 .lines()
156 .filter(|line| !line.trim().is_empty())
157 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
158 .collect(),
159 )
160 }
161
162 /// Remove the saved history for `session_id`, sidecar included. Missing files
163 /// are fine.
164 pub fn delete(session_id: &str) {
165 if let Some(path) = session_path(session_id) {
166 let _ = std::fs::remove_file(path);
167 }
168 if let Some(path) = meta_path(session_id) {
169 let _ = std::fs::remove_file(path);
170 }
171 }
172
173 /// When `session_id` was last saved, as [`list`] reports it.
174 pub fn modified(session_id: &str) -> Option<std::time::SystemTime> {
175 std::fs::metadata(session_path(session_id)?)
176 .ok()?
177 .modified()
178 .ok()
179 }
180
181 /// One saved session as seen on disk.
182 ///
183 /// Consumed by the Unix-only TUI (`chat.rs` History tab) and, on every
184 /// platform, by the ACP `session/list` handler in `main.rs`.
185 #[derive(Debug, Clone, PartialEq, Eq)]
186 pub struct SessionEntry {
187 /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
188 pub id: String,
189 /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable.
190 pub modified: std::time::SystemTime,
191 /// Number of history messages (non-empty lines) in the file.
192 pub message_count: usize,
193 /// The session's working directory, when a sidecar recorded one.
194 pub cwd: Option<PathBuf>,
195 /// Extra workspace roots recorded alongside `cwd`.
196 pub additional_directories: Vec<PathBuf>,
197 /// Model selection saved alongside the session, when available.
198 pub model_id: Option<String>,
199 /// A one-line title taken from the first user message, when there is one.
200 pub title: Option<String>,
201 }
202
203 /// Longest title handed out by [`session_title`] before it is cut short.
204 const TITLE_MAX_CHARS: usize = 80;
205
206 /// The plain text of a history message, whether `content` is a string or the
207 /// structured `[{ "type": "text", "text": … }]` form.
208 fn message_text(message: &Value) -> String {
209 match &message["content"] {
210 Value::String(text) => text.clone(),
211 Value::Array(blocks) => blocks
212 .iter()
213 .filter_map(|block| block["text"].as_str())
214 .collect::<Vec<_>>()
215 .join("\n"),
216 _ => String::new(),
217 }
218 }
219
220 /// A human-readable title for a session: the first line of its first user
221 /// message, trimmed and cut to [`TITLE_MAX_CHARS`].
222 ///
223 /// A user message with no text at all — one that carried only an attachment,
224 /// say — is passed over for the next one rather than producing a blank title;
225 /// a message with any text, however short, wins. When no user message has text
226 /// there is no title, and the client falls back to whatever it shows for an
227 /// untitled thread.
228 fn session_title(contents: &str) -> Option<String> {
229 let messages = contents
230 .lines()
231 .filter(|line| !line.trim().is_empty())
232 .filter_map(|line| serde_json::from_str::<Value>(line).ok());
233 title_of(messages)
234 }
235
236 /// The title [`list`] will report for a session holding `history`, worked
237 /// out from the history in memory rather than the file it was saved to.
238 pub fn history_title(history: &[Value]) -> Option<String> {
239 title_of(history.iter().cloned())
240 }
241
242 fn title_of(messages: impl Iterator<Item = Value>) -> Option<String> {
243 for message in messages {
244 if message["role"] != "user" {
245 continue;
246 }
247 let text = message_text(&message);
248 let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
249 continue;
250 };
251 let mut title: String = first_line.chars().take(TITLE_MAX_CHARS).collect();
252 if first_line.chars().count() > TITLE_MAX_CHARS {
253 title.push('…');
254 }
255 return Some(title);
256 }
257 None
258 }
259
260 /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
261 /// sidecars, stray junk) are skipped. A missing or unreadable sessions dir
262 /// yields an empty list.
263 pub fn list() -> Vec<SessionEntry> {
264 let Some(dir) = sessions_dir() else {
265 return Vec::new();
266 };
267 let Ok(entries) = std::fs::read_dir(&dir) else {
268 return Vec::new();
269 };
270 let mut sessions: Vec<SessionEntry> = entries
271 .flatten()
272 .filter_map(|entry| {
273 let path = entry.path();
274 if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
275 return None;
276 }
277 let id = path.file_stem()?.to_str()?.to_string();
278 let contents = std::fs::read_to_string(&path).ok()?;
279 let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
280 let title = session_title(&contents);
281 let modified = entry
282 .metadata()
283 .ok()
284 .and_then(|m| m.modified().ok())
285 .unwrap_or(std::time::UNIX_EPOCH);
286 let meta = load_meta(&id);
287 let (cwd, additional_directories, model_id) = match meta {
288 Some(meta) => (Some(meta.cwd), meta.additional_directories, meta.model_id),
289 None => (None, Vec::new(), None),
290 };
291 Some(SessionEntry {
292 id,
293 modified,
294 message_count,
295 cwd,
296 additional_directories,
297 model_id,
298 title,
299 })
300 })
301 .collect();
302 sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id)));
303 sessions
304 }
305
306 /// Format a timestamp as an ISO 8601 UTC instant with milliseconds
307 /// (`2026-09-16T10:08:15.123Z`), which is what ACP's `SessionInfo.updatedAt`
308 /// expects. The sub-second part is not decoration: two sessions saved in the
309 /// same second would otherwise carry identical timestamps and a client sorting
310 /// its import list by `updatedAt` could not tell which thread is the recent
311 /// one. Hand-rolled rather than pulling in a date crate for one field;
312 /// pre-epoch times clamp to the epoch.
313 pub fn iso8601(time: std::time::SystemTime) -> String {
314 let since_epoch = time
315 .duration_since(std::time::UNIX_EPOCH)
316 .unwrap_or_default();
317 let secs = since_epoch.as_secs();
318 let millis = since_epoch.subsec_millis();
319 let days = (secs / 86_400) as i64;
320 let seconds_of_day = secs % 86_400;
321 let (hour, minute, second) = (
322 seconds_of_day / 3600,
323 (seconds_of_day % 3600) / 60,
324 seconds_of_day % 60,
325 );
326
327 // Howard Hinnant's civil_from_days: shift the epoch to 0000-03-01 so leap
328 // days land at the end of the era and the month arithmetic stays branchless.
329 let z = days + 719_468;
330 let era = z.div_euclid(146_097);
331 let day_of_era = z.rem_euclid(146_097);
332 let year_of_era =
333 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
334 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
335 let mp = (5 * day_of_year + 2) / 153;
336 let day = day_of_year - (153 * mp + 2) / 5 + 1;
337 let month = if mp < 10 { mp + 3 } else { mp - 9 };
338 let year = year_of_era + era * 400 + i64::from(month <= 2);
339
340 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
341 }
342
343 #[cfg(test)]
344 mod tests {
345 use super::*;
346
347 #[test]
348 fn sanitize_keeps_safe_chars_and_replaces_the_rest() {
349 assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z");
350 assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e");
351 assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
352 assert_eq!(sanitize_id(""), "_");
353 }
354
355 // One test for the filesystem behavior because it mutates the
356 // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings
357 // tests): splitting would race under the parallel test runner.
358 #[test]
359 fn save_load_delete_round_trip() {
360 let _guard = crate::ENV_TEST_LOCK
361 .lock()
362 .unwrap_or_else(|poisoned| poisoned.into_inner());
363 let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
364 let _ = std::fs::remove_dir_all(&dir);
365 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
366 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
367
368 // Missing file → None.
369 assert_eq!(load("nope"), None);
370
371 let history = vec![
372 serde_json::json!({ "role": "system", "content": "sys" }),
373 serde_json::json!({ "role": "user", "content": "hi\nthere" }),
374 serde_json::json!({
375 "role": "assistant", "content": null,
376 "tool_calls": [{ "id": "call_1", "type": "function",
377 "function": { "name": "read_file", "arguments": "{}" } }],
378 }),
379 ];
380 save("sess-1", &history).unwrap();
381 assert_eq!(load("sess-1"), Some(history.clone()));
382
383 // Saving again replaces, not appends.
384 let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })];
385 save("sess-1", &shorter).unwrap();
386 assert_eq!(load("sess-1"), Some(shorter));
387
388 // A hostile id stays inside the sessions dir via sanitization.
389 save("../escape", &history).unwrap();
390 assert!(dir.join("sessions").join(".._escape.jsonl").is_file());
391 assert_eq!(load("../escape"), Some(history));
392 delete("../escape");
393 assert_eq!(load("../escape"), None);
394
395 delete("sess-1");
396 assert_eq!(load("sess-1"), None);
397 // Deleting a missing session is a no-op.
398 delete("sess-1");
399
400 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
401 let _ = std::fs::remove_dir_all(&dir);
402 }
403
404 // Same single-test-per-env-var pattern as `save_load_delete_round_trip`:
405 // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold.
406 #[test]
407 fn list_returns_sessions_newest_first_and_skips_junk() {
408 let _guard = crate::ENV_TEST_LOCK
409 .lock()
410 .unwrap_or_else(|poisoned| poisoned.into_inner());
411 let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id()));
412 let _ = std::fs::remove_dir_all(&dir);
413 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
414 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
415
416 // No sessions dir at all → empty list, not an error.
417 assert!(list().is_empty());
418
419 let msg = |text: &str| serde_json::json!({ "role": "user", "content": text });
420 save("older", &[msg("a"), msg("b"), msg("c")]).unwrap();
421 // Distinct mtimes so the newest-first order is deterministic.
422 std::thread::sleep(std::time::Duration::from_millis(50));
423 save("newer", &[msg("x")]).unwrap();
424
425 // Junk the lister must skip: wrong extension, a stray temp file, and a
426 // subdirectory.
427 let sessions = dir.join("sessions");
428 std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap();
429 std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap();
430 std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap();
431
432 let listed = list();
433 assert_eq!(listed.len(), 2);
434 assert_eq!(listed[0].id, "newer");
435 assert_eq!(listed[0].message_count, 1);
436 assert_eq!(listed[1].id, "older");
437 assert_eq!(listed[1].message_count, 3);
438 assert!(listed[0].modified >= listed[1].modified);
439 // Titles come from the first user message; no sidecar means no cwd.
440 assert_eq!(listed[0].title.as_deref(), Some("x"));
441 assert_eq!(listed[1].title.as_deref(), Some("a"));
442 assert_eq!(listed[0].cwd, None);
443 assert!(listed[0].additional_directories.is_empty());
444
445 // Sidecars written before model persistence remain readable.
446 std::fs::write(
447 sessions.join("newer.meta.json"),
448 r#"{"cwd":"/tmp/project","additional_directories":["/tmp/lib"]}"#,
449 )
450 .unwrap();
451 let listed = list();
452 assert_eq!(listed[0].model_id, None);
453
454 // A sidecar makes the session listable with its directories, and
455 // deleting the session takes the sidecar with it.
456 save_meta(
457 "newer",
458 Path::new("/tmp/project"),
459 &[PathBuf::from("/tmp/lib")],
460 Some("test-model"),
461 );
462 let listed = list();
463 assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/tmp/project")));
464 assert_eq!(
465 listed[0].additional_directories,
466 vec![PathBuf::from("/tmp/lib")]
467 );
468 assert_eq!(listed[0].model_id.as_deref(), Some("test-model"));
469 assert!(sessions.join("newer.meta.json").is_file());
470 delete("newer");
471 assert!(!sessions.join("newer.meta.json").exists());
472 assert_eq!(list().len(), 1);
473
474 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
475 let _ = std::fs::remove_dir_all(&dir);
476 }
477
478 #[test]
479 fn history_title_matches_the_title_of_the_saved_file() {
480 let history = vec![
481 serde_json::json!({"role": "system", "content": "sys"}),
482 serde_json::json!({"role": "user", "content": " fix the parser\nmore detail"}),
483 ];
484 let saved: String = history.iter().map(|m| format!("{m}\n")).collect();
485 assert_eq!(history_title(&history), session_title(&saved));
486 assert_eq!(history_title(&history).as_deref(), Some("fix the parser"));
487 assert_eq!(history_title(&history[..1]), None);
488 }
489
490 #[test]
491 fn title_uses_the_first_line_of_the_first_user_message() {
492 let contents = "{\"role\":\"system\",\"content\":\"sys\"}\n\
493 {\"role\":\"user\",\"content\":\" fix the parser\\nmore detail\"}\n\
494 {\"role\":\"user\",\"content\":\"second\"}\n";
495 assert_eq!(session_title(contents).as_deref(), Some("fix the parser"));
496
497 // Structured content blocks, not just plain strings.
498 let blocks =
499 "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"from a block\"}]}\n";
500 assert_eq!(session_title(blocks).as_deref(), Some("from a block"));
501
502 // Unparseable lines are skipped, not fatal.
503 let junk = "not json\n{\"role\":\"user\",\"content\":\"still found\"}\n";
504 assert_eq!(session_title(junk).as_deref(), Some("still found"));
505
506 // Long titles are cut short with an ellipsis.
507 let long = format!(
508 "{{\"role\":\"user\",\"content\":\"{}\"}}\n",
509 "x".repeat(TITLE_MAX_CHARS + 10)
510 );
511 let title = session_title(&long).unwrap();
512 assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1);
513 assert!(title.ends_with('…'));
514
515 // Nothing from the user yet → no title.
516 assert_eq!(
517 session_title("{\"role\":\"assistant\",\"content\":\"hi\"}\n"),
518 None
519 );
520 assert_eq!(session_title(""), None);
521
522 // A user message with no text of its own — an attachment-only turn —
523 // hands the title to the next one rather than coming back blank.
524 let attachment_only = "{\"role\":\"user\",\"content\":\" \"}\n\
525 {\"role\":\"user\",\"content\":\"the real question\"}\n";
526 assert_eq!(
527 session_title(attachment_only).as_deref(),
528 Some("the real question")
529 );
530 // However short, any text wins.
531 assert_eq!(
532 session_title("{\"role\":\"user\",\"content\":\"?\"}\n").as_deref(),
533 Some("?")
534 );
535 }
536
537 #[test]
538 fn iso8601_formats_utc_instants() {
539 use std::time::{Duration, UNIX_EPOCH};
540
541 assert_eq!(iso8601(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
542 // 2026-09-16T10:08:15.123Z — milliseconds keep two saves in the same
543 // second apart when a client sorts its import list.
544 assert_eq!(
545 iso8601(UNIX_EPOCH + Duration::from_millis(1_789_553_295_123)),
546 "2026-09-16T10:08:15.123Z"
547 );
548 // A leap day, to exercise the civil-date arithmetic.
549 assert_eq!(
550 iso8601(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
551 "2024-02-29T00:00:00.000Z"
552 );
553 }
554 }