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 /// One saved session as seen on disk.
174 ///
175 /// Consumed by the Unix-only TUI (`chat.rs` History tab) and, on every
176 /// platform, by the ACP `session/list` handler in `main.rs`.
177 #[derive(Debug, Clone, PartialEq, Eq)]
178 pub struct SessionEntry {
179 /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
180 pub id: String,
181 /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable.
182 pub modified: std::time::SystemTime,
183 /// Number of history messages (non-empty lines) in the file.
184 pub message_count: usize,
185 /// The session's working directory, when a sidecar recorded one.
186 pub cwd: Option<PathBuf>,
187 /// Extra workspace roots recorded alongside `cwd`.
188 pub additional_directories: Vec<PathBuf>,
189 /// Model selection saved alongside the session, when available.
190 pub model_id: Option<String>,
191 /// A one-line title taken from the first user message, when there is one.
192 pub title: Option<String>,
193 }
194
195 /// Longest title handed out by [`session_title`] before it is cut short.
196 const TITLE_MAX_CHARS: usize = 80;
197
198 /// The plain text of a history message, whether `content` is a string or the
199 /// structured `[{ "type": "text", "text": … }]` form.
200 fn message_text(message: &Value) -> String {
201 match &message["content"] {
202 Value::String(text) => text.clone(),
203 Value::Array(blocks) => blocks
204 .iter()
205 .filter_map(|block| block["text"].as_str())
206 .collect::<Vec<_>>()
207 .join("\n"),
208 _ => String::new(),
209 }
210 }
211
212 /// A human-readable title for a session: the first line of its first user
213 /// message, trimmed and cut to [`TITLE_MAX_CHARS`].
214 ///
215 /// A user message with no text at all — one that carried only an attachment,
216 /// say — is passed over for the next one rather than producing a blank title;
217 /// a message with any text, however short, wins. When no user message has text
218 /// there is no title, and the client falls back to whatever it shows for an
219 /// untitled thread.
220 fn session_title(contents: &str) -> Option<String> {
221 for line in contents.lines() {
222 if line.trim().is_empty() {
223 continue;
224 }
225 let Ok(message) = serde_json::from_str::<Value>(line) else {
226 continue;
227 };
228 if message["role"] != "user" {
229 continue;
230 }
231 let text = message_text(&message);
232 let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
233 continue;
234 };
235 let mut title: String = first_line.chars().take(TITLE_MAX_CHARS).collect();
236 if first_line.chars().count() > TITLE_MAX_CHARS {
237 title.push('…');
238 }
239 return Some(title);
240 }
241 None
242 }
243
244 /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
245 /// sidecars, stray junk) are skipped. A missing or unreadable sessions dir
246 /// yields an empty list.
247 pub fn list() -> Vec<SessionEntry> {
248 let Some(dir) = sessions_dir() else {
249 return Vec::new();
250 };
251 let Ok(entries) = std::fs::read_dir(&dir) else {
252 return Vec::new();
253 };
254 let mut sessions: Vec<SessionEntry> = entries
255 .flatten()
256 .filter_map(|entry| {
257 let path = entry.path();
258 if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
259 return None;
260 }
261 let id = path.file_stem()?.to_str()?.to_string();
262 let contents = std::fs::read_to_string(&path).ok()?;
263 let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
264 let title = session_title(&contents);
265 let modified = entry
266 .metadata()
267 .ok()
268 .and_then(|m| m.modified().ok())
269 .unwrap_or(std::time::UNIX_EPOCH);
270 let meta = load_meta(&id);
271 let (cwd, additional_directories, model_id) = match meta {
272 Some(meta) => (Some(meta.cwd), meta.additional_directories, meta.model_id),
273 None => (None, Vec::new(), None),
274 };
275 Some(SessionEntry {
276 id,
277 modified,
278 message_count,
279 cwd,
280 additional_directories,
281 model_id,
282 title,
283 })
284 })
285 .collect();
286 sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id)));
287 sessions
288 }
289
290 /// Format a timestamp as an ISO 8601 UTC instant with milliseconds
291 /// (`2026-09-16T10:08:15.123Z`), which is what ACP's `SessionInfo.updatedAt`
292 /// expects. The sub-second part is not decoration: two sessions saved in the
293 /// same second would otherwise carry identical timestamps and a client sorting
294 /// its import list by `updatedAt` could not tell which thread is the recent
295 /// one. Hand-rolled rather than pulling in a date crate for one field;
296 /// pre-epoch times clamp to the epoch.
297 pub fn iso8601(time: std::time::SystemTime) -> String {
298 let since_epoch = time
299 .duration_since(std::time::UNIX_EPOCH)
300 .unwrap_or_default();
301 let secs = since_epoch.as_secs();
302 let millis = since_epoch.subsec_millis();
303 let days = (secs / 86_400) as i64;
304 let seconds_of_day = secs % 86_400;
305 let (hour, minute, second) = (
306 seconds_of_day / 3600,
307 (seconds_of_day % 3600) / 60,
308 seconds_of_day % 60,
309 );
310
311 // Howard Hinnant's civil_from_days: shift the epoch to 0000-03-01 so leap
312 // days land at the end of the era and the month arithmetic stays branchless.
313 let z = days + 719_468;
314 let era = z.div_euclid(146_097);
315 let day_of_era = z.rem_euclid(146_097);
316 let year_of_era =
317 (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
318 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
319 let mp = (5 * day_of_year + 2) / 153;
320 let day = day_of_year - (153 * mp + 2) / 5 + 1;
321 let month = if mp < 10 { mp + 3 } else { mp - 9 };
322 let year = year_of_era + era * 400 + i64::from(month <= 2);
323
324 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z")
325 }
326
327 #[cfg(test)]
328 mod tests {
329 use super::*;
330
331 #[test]
332 fn sanitize_keeps_safe_chars_and_replaces_the_rest() {
333 assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z");
334 assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e");
335 assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
336 assert_eq!(sanitize_id(""), "_");
337 }
338
339 // One test for the filesystem behavior because it mutates the
340 // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings
341 // tests): splitting would race under the parallel test runner.
342 #[test]
343 fn save_load_delete_round_trip() {
344 let _guard = crate::ENV_TEST_LOCK
345 .lock()
346 .unwrap_or_else(|poisoned| poisoned.into_inner());
347 let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
348 let _ = std::fs::remove_dir_all(&dir);
349 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
350 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
351
352 // Missing file → None.
353 assert_eq!(load("nope"), None);
354
355 let history = vec![
356 serde_json::json!({ "role": "system", "content": "sys" }),
357 serde_json::json!({ "role": "user", "content": "hi\nthere" }),
358 serde_json::json!({
359 "role": "assistant", "content": null,
360 "tool_calls": [{ "id": "call_1", "type": "function",
361 "function": { "name": "read_file", "arguments": "{}" } }],
362 }),
363 ];
364 save("sess-1", &history).unwrap();
365 assert_eq!(load("sess-1"), Some(history.clone()));
366
367 // Saving again replaces, not appends.
368 let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })];
369 save("sess-1", &shorter).unwrap();
370 assert_eq!(load("sess-1"), Some(shorter));
371
372 // A hostile id stays inside the sessions dir via sanitization.
373 save("../escape", &history).unwrap();
374 assert!(dir.join("sessions").join(".._escape.jsonl").is_file());
375 assert_eq!(load("../escape"), Some(history));
376 delete("../escape");
377 assert_eq!(load("../escape"), None);
378
379 delete("sess-1");
380 assert_eq!(load("sess-1"), None);
381 // Deleting a missing session is a no-op.
382 delete("sess-1");
383
384 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
385 let _ = std::fs::remove_dir_all(&dir);
386 }
387
388 // Same single-test-per-env-var pattern as `save_load_delete_round_trip`:
389 // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold.
390 #[test]
391 fn list_returns_sessions_newest_first_and_skips_junk() {
392 let _guard = crate::ENV_TEST_LOCK
393 .lock()
394 .unwrap_or_else(|poisoned| poisoned.into_inner());
395 let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id()));
396 let _ = std::fs::remove_dir_all(&dir);
397 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
398 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
399
400 // No sessions dir at all → empty list, not an error.
401 assert!(list().is_empty());
402
403 let msg = |text: &str| serde_json::json!({ "role": "user", "content": text });
404 save("older", &[msg("a"), msg("b"), msg("c")]).unwrap();
405 // Distinct mtimes so the newest-first order is deterministic.
406 std::thread::sleep(std::time::Duration::from_millis(50));
407 save("newer", &[msg("x")]).unwrap();
408
409 // Junk the lister must skip: wrong extension, a stray temp file, and a
410 // subdirectory.
411 let sessions = dir.join("sessions");
412 std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap();
413 std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap();
414 std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap();
415
416 let listed = list();
417 assert_eq!(listed.len(), 2);
418 assert_eq!(listed[0].id, "newer");
419 assert_eq!(listed[0].message_count, 1);
420 assert_eq!(listed[1].id, "older");
421 assert_eq!(listed[1].message_count, 3);
422 assert!(listed[0].modified >= listed[1].modified);
423 // Titles come from the first user message; no sidecar means no cwd.
424 assert_eq!(listed[0].title.as_deref(), Some("x"));
425 assert_eq!(listed[1].title.as_deref(), Some("a"));
426 assert_eq!(listed[0].cwd, None);
427 assert!(listed[0].additional_directories.is_empty());
428
429 // Sidecars written before model persistence remain readable.
430 std::fs::write(
431 sessions.join("newer.meta.json"),
432 r#"{"cwd":"/tmp/project","additional_directories":["/tmp/lib"]}"#,
433 )
434 .unwrap();
435 let listed = list();
436 assert_eq!(listed[0].model_id, None);
437
438 // A sidecar makes the session listable with its directories, and
439 // deleting the session takes the sidecar with it.
440 save_meta(
441 "newer",
442 Path::new("/tmp/project"),
443 &[PathBuf::from("/tmp/lib")],
444 Some("test-model"),
445 );
446 let listed = list();
447 assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/tmp/project")));
448 assert_eq!(
449 listed[0].additional_directories,
450 vec![PathBuf::from("/tmp/lib")]
451 );
452 assert_eq!(listed[0].model_id.as_deref(), Some("test-model"));
453 assert!(sessions.join("newer.meta.json").is_file());
454 delete("newer");
455 assert!(!sessions.join("newer.meta.json").exists());
456 assert_eq!(list().len(), 1);
457
458 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
459 let _ = std::fs::remove_dir_all(&dir);
460 }
461
462 #[test]
463 fn title_uses_the_first_line_of_the_first_user_message() {
464 let contents = "{\"role\":\"system\",\"content\":\"sys\"}\n\
465 {\"role\":\"user\",\"content\":\" fix the parser\\nmore detail\"}\n\
466 {\"role\":\"user\",\"content\":\"second\"}\n";
467 assert_eq!(session_title(contents).as_deref(), Some("fix the parser"));
468
469 // Structured content blocks, not just plain strings.
470 let blocks =
471 "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"from a block\"}]}\n";
472 assert_eq!(session_title(blocks).as_deref(), Some("from a block"));
473
474 // Unparseable lines are skipped, not fatal.
475 let junk = "not json\n{\"role\":\"user\",\"content\":\"still found\"}\n";
476 assert_eq!(session_title(junk).as_deref(), Some("still found"));
477
478 // Long titles are cut short with an ellipsis.
479 let long = format!(
480 "{{\"role\":\"user\",\"content\":\"{}\"}}\n",
481 "x".repeat(TITLE_MAX_CHARS + 10)
482 );
483 let title = session_title(&long).unwrap();
484 assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1);
485 assert!(title.ends_with('…'));
486
487 // Nothing from the user yet → no title.
488 assert_eq!(
489 session_title("{\"role\":\"assistant\",\"content\":\"hi\"}\n"),
490 None
491 );
492 assert_eq!(session_title(""), None);
493
494 // A user message with no text of its own — an attachment-only turn —
495 // hands the title to the next one rather than coming back blank.
496 let attachment_only = "{\"role\":\"user\",\"content\":\" \"}\n\
497 {\"role\":\"user\",\"content\":\"the real question\"}\n";
498 assert_eq!(
499 session_title(attachment_only).as_deref(),
500 Some("the real question")
501 );
502 // However short, any text wins.
503 assert_eq!(
504 session_title("{\"role\":\"user\",\"content\":\"?\"}\n").as_deref(),
505 Some("?")
506 );
507 }
508
509 #[test]
510 fn iso8601_formats_utc_instants() {
511 use std::time::{Duration, UNIX_EPOCH};
512
513 assert_eq!(iso8601(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
514 // 2026-09-16T10:08:15.123Z — milliseconds keep two saves in the same
515 // second apart when a client sorts its import list.
516 assert_eq!(
517 iso8601(UNIX_EPOCH + Duration::from_millis(1_789_553_295_123)),
518 "2026-09-16T10:08:15.123Z"
519 );
520 // A leap day, to exercise the civil-date arithmetic.
521 assert_eq!(
522 iso8601(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
523 "2024-02-29T00:00:00.000Z"
524 );
525 }
526 }