development
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 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 | //! Writes are atomic (temp file + rename) so a crash mid-save never leaves a |
| 10 | //! truncated session behind. Session ids are sanitized to a filename-safe |
| 11 | //! alphabet before touching the filesystem. |
| 12 | |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | use serde_json::Value; |
| 16 | |
| 17 | /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. |
| 18 | fn config_dir() -> Option<PathBuf> { |
| 19 | if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { |
| 20 | return Some(PathBuf::from(dir)); |
| 21 | } |
| 22 | let home = std::env::var("HOME").ok()?; |
| 23 | Some(PathBuf::from(home).join(".config/sigit")) |
| 24 | } |
| 25 | |
| 26 | fn sessions_dir() -> Option<PathBuf> { |
| 27 | config_dir().map(|dir| dir.join("sessions")) |
| 28 | } |
| 29 | |
| 30 | /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through, |
| 31 | /// anything else becomes `_`. An empty id maps to `_` so the file name never |
| 32 | /// collapses to just the extension. |
| 33 | fn sanitize_id(session_id: &str) -> String { |
| 34 | if session_id.is_empty() { |
| 35 | return "_".to_string(); |
| 36 | } |
| 37 | session_id |
| 38 | .chars() |
| 39 | .map(|c| { |
| 40 | if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { |
| 41 | c |
| 42 | } else { |
| 43 | '_' |
| 44 | } |
| 45 | }) |
| 46 | .collect() |
| 47 | } |
| 48 | |
| 49 | fn session_path(session_id: &str) -> Option<PathBuf> { |
| 50 | sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id)))) |
| 51 | } |
| 52 | |
| 53 | /// Persist a history snapshot for `session_id`, replacing any previous save. |
| 54 | /// The write is atomic: a temp file in the same directory is renamed over the |
| 55 | /// final path. |
| 56 | pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> { |
| 57 | let path = |
| 58 | session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?; |
| 59 | let dir = path |
| 60 | .parent() |
| 61 | .ok_or_else(|| "session path has no parent".to_string())?; |
| 62 | std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?; |
| 63 | |
| 64 | let mut body = String::new(); |
| 65 | for message in history { |
| 66 | body.push_str(&message.to_string()); |
| 67 | body.push('\n'); |
| 68 | } |
| 69 | |
| 70 | // Unique temp name so two processes saving the same session can't clobber |
| 71 | // each other's half-written file; rename is atomic on the same filesystem. |
| 72 | let tmp = dir.join(format!( |
| 73 | ".{}.{}.tmp", |
| 74 | sanitize_id(session_id), |
| 75 | std::process::id() |
| 76 | )); |
| 77 | std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?; |
| 78 | std::fs::rename(&tmp, &path).map_err(|error| { |
| 79 | let _ = std::fs::remove_file(&tmp); |
| 80 | format!("rename {tmp:?} -> {path:?}: {error}") |
| 81 | })?; |
| 82 | Ok(()) |
| 83 | } |
| 84 | |
| 85 | /// Load the saved history for `session_id`, or `None` when no save exists (or |
| 86 | /// it cannot be read). Unparseable lines are skipped rather than failing the |
| 87 | /// whole restore. |
| 88 | pub fn load(session_id: &str) -> Option<Vec<Value>> { |
| 89 | let path = session_path(session_id)?; |
| 90 | let contents = std::fs::read_to_string(&path).ok()?; |
| 91 | Some( |
| 92 | contents |
| 93 | .lines() |
| 94 | .filter(|line| !line.trim().is_empty()) |
| 95 | .filter_map(|line| serde_json::from_str::<Value>(line).ok()) |
| 96 | .collect(), |
| 97 | ) |
| 98 | } |
| 99 | |
| 100 | /// Remove the saved history for `session_id`. Missing files are fine. |
| 101 | pub fn delete(session_id: &str) { |
| 102 | if let Some(path) = session_path(session_id) { |
| 103 | let _ = std::fs::remove_file(path); |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// One saved session as seen on disk. |
| 108 | /// |
| 109 | /// Cross-platform like the rest of the store, though today only the Unix-only |
| 110 | /// TUI (`chat.rs` History tab) consumes it — hence the non-Unix dead-code gate, |
| 111 | /// mirroring `permissions::TUI_SESSION`. |
| 112 | #[cfg_attr(not(unix), allow(dead_code))] |
| 113 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 114 | pub struct SessionEntry { |
| 115 | /// The sanitized id (the file stem), which `load`/`delete` accept as-is. |
| 116 | pub id: String, |
| 117 | /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable. |
| 118 | pub modified: std::time::SystemTime, |
| 119 | /// Number of history messages (non-empty lines) in the file. |
| 120 | pub message_count: usize, |
| 121 | } |
| 122 | |
| 123 | /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files, |
| 124 | /// stray junk) are skipped. A missing or unreadable sessions dir yields an |
| 125 | /// empty list. |
| 126 | #[cfg_attr(not(unix), allow(dead_code))] |
| 127 | pub fn list() -> Vec<SessionEntry> { |
| 128 | let Some(dir) = sessions_dir() else { |
| 129 | return Vec::new(); |
| 130 | }; |
| 131 | let Ok(entries) = std::fs::read_dir(&dir) else { |
| 132 | return Vec::new(); |
| 133 | }; |
| 134 | let mut sessions: Vec<SessionEntry> = entries |
| 135 | .flatten() |
| 136 | .filter_map(|entry| { |
| 137 | let path = entry.path(); |
| 138 | if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { |
| 139 | return None; |
| 140 | } |
| 141 | let id = path.file_stem()?.to_str()?.to_string(); |
| 142 | // Cheap line count: no JSON parsing, just non-empty lines. |
| 143 | let contents = std::fs::read_to_string(&path).ok()?; |
| 144 | let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count(); |
| 145 | let modified = entry |
| 146 | .metadata() |
| 147 | .ok() |
| 148 | .and_then(|m| m.modified().ok()) |
| 149 | .unwrap_or(std::time::UNIX_EPOCH); |
| 150 | Some(SessionEntry { |
| 151 | id, |
| 152 | modified, |
| 153 | message_count, |
| 154 | }) |
| 155 | }) |
| 156 | .collect(); |
| 157 | sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id))); |
| 158 | sessions |
| 159 | } |
| 160 | |
| 161 | #[cfg(test)] |
| 162 | mod tests { |
| 163 | use super::*; |
| 164 | |
| 165 | #[test] |
| 166 | fn sanitize_keeps_safe_chars_and_replaces_the_rest() { |
| 167 | assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z"); |
| 168 | assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e"); |
| 169 | assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); |
| 170 | assert_eq!(sanitize_id(""), "_"); |
| 171 | } |
| 172 | |
| 173 | // One test for the filesystem behavior because it mutates the |
| 174 | // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings |
| 175 | // tests): splitting would race under the parallel test runner. |
| 176 | #[test] |
| 177 | fn save_load_delete_round_trip() { |
| 178 | let _guard = crate::ENV_TEST_LOCK |
| 179 | .lock() |
| 180 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 181 | let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); |
| 182 | let _ = std::fs::remove_dir_all(&dir); |
| 183 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 184 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 185 | |
| 186 | // Missing file → None. |
| 187 | assert_eq!(load("nope"), None); |
| 188 | |
| 189 | let history = vec![ |
| 190 | serde_json::json!({ "role": "system", "content": "sys" }), |
| 191 | serde_json::json!({ "role": "user", "content": "hi\nthere" }), |
| 192 | serde_json::json!({ |
| 193 | "role": "assistant", "content": null, |
| 194 | "tool_calls": [{ "id": "call_1", "type": "function", |
| 195 | "function": { "name": "read_file", "arguments": "{}" } }], |
| 196 | }), |
| 197 | ]; |
| 198 | save("sess-1", &history).unwrap(); |
| 199 | assert_eq!(load("sess-1"), Some(history.clone())); |
| 200 | |
| 201 | // Saving again replaces, not appends. |
| 202 | let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })]; |
| 203 | save("sess-1", &shorter).unwrap(); |
| 204 | assert_eq!(load("sess-1"), Some(shorter)); |
| 205 | |
| 206 | // A hostile id stays inside the sessions dir via sanitization. |
| 207 | save("../escape", &history).unwrap(); |
| 208 | assert!(dir.join("sessions").join(".._escape.jsonl").is_file()); |
| 209 | assert_eq!(load("../escape"), Some(history)); |
| 210 | delete("../escape"); |
| 211 | assert_eq!(load("../escape"), None); |
| 212 | |
| 213 | delete("sess-1"); |
| 214 | assert_eq!(load("sess-1"), None); |
| 215 | // Deleting a missing session is a no-op. |
| 216 | delete("sess-1"); |
| 217 | |
| 218 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 219 | let _ = std::fs::remove_dir_all(&dir); |
| 220 | } |
| 221 | |
| 222 | // Same single-test-per-env-var pattern as `save_load_delete_round_trip`: |
| 223 | // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold. |
| 224 | #[test] |
| 225 | fn list_returns_sessions_newest_first_and_skips_junk() { |
| 226 | let _guard = crate::ENV_TEST_LOCK |
| 227 | .lock() |
| 228 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 229 | let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id())); |
| 230 | let _ = std::fs::remove_dir_all(&dir); |
| 231 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 232 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 233 | |
| 234 | // No sessions dir at all → empty list, not an error. |
| 235 | assert!(list().is_empty()); |
| 236 | |
| 237 | let msg = |text: &str| serde_json::json!({ "role": "user", "content": text }); |
| 238 | save("older", &[msg("a"), msg("b"), msg("c")]).unwrap(); |
| 239 | // Distinct mtimes so the newest-first order is deterministic. |
| 240 | std::thread::sleep(std::time::Duration::from_millis(50)); |
| 241 | save("newer", &[msg("x")]).unwrap(); |
| 242 | |
| 243 | // Junk the lister must skip: wrong extension, a stray temp file, and a |
| 244 | // subdirectory. |
| 245 | let sessions = dir.join("sessions"); |
| 246 | std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap(); |
| 247 | std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap(); |
| 248 | std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap(); |
| 249 | |
| 250 | let listed = list(); |
| 251 | assert_eq!(listed.len(), 2); |
| 252 | assert_eq!(listed[0].id, "newer"); |
| 253 | assert_eq!(listed[0].message_count, 1); |
| 254 | assert_eq!(listed[1].id, "older"); |
| 255 | assert_eq!(listed[1].message_count, 3); |
| 256 | assert!(listed[0].modified >= listed[1].modified); |
| 257 | |
| 258 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 259 | let _ = std::fs::remove_dir_all(&dir); |
| 260 | } |
| 261 | } |