main
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 | #[cfg(test)] |
| 108 | mod tests { |
| 109 | use super::*; |
| 110 | |
| 111 | #[test] |
| 112 | fn sanitize_keeps_safe_chars_and_replaces_the_rest() { |
| 113 | assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z"); |
| 114 | assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e"); |
| 115 | assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); |
| 116 | assert_eq!(sanitize_id(""), "_"); |
| 117 | } |
| 118 | |
| 119 | // One test for the filesystem behavior because it mutates the |
| 120 | // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings |
| 121 | // tests): splitting would race under the parallel test runner. |
| 122 | #[test] |
| 123 | fn save_load_delete_round_trip() { |
| 124 | let _guard = crate::ENV_TEST_LOCK |
| 125 | .lock() |
| 126 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 127 | let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); |
| 128 | let _ = std::fs::remove_dir_all(&dir); |
| 129 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 130 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 131 | |
| 132 | // Missing file → None. |
| 133 | assert_eq!(load("nope"), None); |
| 134 | |
| 135 | let history = vec![ |
| 136 | serde_json::json!({ "role": "system", "content": "sys" }), |
| 137 | serde_json::json!({ "role": "user", "content": "hi\nthere" }), |
| 138 | serde_json::json!({ |
| 139 | "role": "assistant", "content": null, |
| 140 | "tool_calls": [{ "id": "call_1", "type": "function", |
| 141 | "function": { "name": "read_file", "arguments": "{}" } }], |
| 142 | }), |
| 143 | ]; |
| 144 | save("sess-1", &history).unwrap(); |
| 145 | assert_eq!(load("sess-1"), Some(history.clone())); |
| 146 | |
| 147 | // Saving again replaces, not appends. |
| 148 | let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })]; |
| 149 | save("sess-1", &shorter).unwrap(); |
| 150 | assert_eq!(load("sess-1"), Some(shorter)); |
| 151 | |
| 152 | // A hostile id stays inside the sessions dir via sanitization. |
| 153 | save("../escape", &history).unwrap(); |
| 154 | assert!(dir.join("sessions").join(".._escape.jsonl").is_file()); |
| 155 | assert_eq!(load("../escape"), Some(history)); |
| 156 | delete("../escape"); |
| 157 | assert_eq!(load("../escape"), None); |
| 158 | |
| 159 | delete("sess-1"); |
| 160 | assert_eq!(load("sess-1"), None); |
| 161 | // Deleting a missing session is a no-op. |
| 162 | delete("sess-1"); |
| 163 | |
| 164 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 165 | let _ = std::fs::remove_dir_all(&dir); |
| 166 | } |
| 167 | } |