fix/acp-replay-loaded-session-history
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.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::{Deserialize, Serialize}; |
| 16 | use serde_json::Value; |
| 17 | |
| 18 | /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. |
| 19 | fn config_dir() -> Option<PathBuf> { |
| 20 | if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { |
| 21 | return Some(PathBuf::from(dir)); |
| 22 | } |
| 23 | let home = std::env::var("HOME").ok()?; |
| 24 | Some(PathBuf::from(home).join(".config/sigit")) |
| 25 | } |
| 26 | |
| 27 | fn sessions_dir() -> Option<PathBuf> { |
| 28 | config_dir().map(|dir| dir.join("sessions")) |
| 29 | } |
| 30 | |
| 31 | /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through, |
| 32 | /// anything else becomes `_`. An empty id maps to `_` so the file name never |
| 33 | /// collapses to just the extension. |
| 34 | fn sanitize_id(session_id: &str) -> String { |
| 35 | if session_id.is_empty() { |
| 36 | return "_".to_string(); |
| 37 | } |
| 38 | session_id |
| 39 | .chars() |
| 40 | .map(|c| { |
| 41 | if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { |
| 42 | c |
| 43 | } else { |
| 44 | '_' |
| 45 | } |
| 46 | }) |
| 47 | .collect() |
| 48 | } |
| 49 | |
| 50 | fn session_path(session_id: &str) -> Option<PathBuf> { |
| 51 | sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id)))) |
| 52 | } |
| 53 | |
| 54 | /// `<sessions_dir>/<id>.meta.json`. Note this does not collide with the |
| 55 | /// `list()` extension filter (`== Some("jsonl")`): the extension of |
| 56 | /// `"a.meta.json"` is `json`, not `jsonl`. |
| 57 | fn meta_path(session_id: &str) -> Option<PathBuf> { |
| 58 | sessions_dir().map(|dir| dir.join(format!("{}.meta.json", sanitize_id(session_id)))) |
| 59 | } |
| 60 | |
| 61 | /// ACP-facing metadata for a saved session, kept alongside its transcript so |
| 62 | /// `session/list` can be served without touching the model backend. |
| 63 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 64 | pub struct SessionMeta { |
| 65 | pub cwd: PathBuf, |
| 66 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 67 | pub additional_directories: Vec<PathBuf>, |
| 68 | #[serde(default)] |
| 69 | pub title: Option<String>, |
| 70 | } |
| 71 | |
| 72 | /// Persist `meta` for `session_id`, replacing any previous sidecar. Same |
| 73 | /// atomic temp-file + rename pattern as [`save`]. |
| 74 | pub fn save_meta(session_id: &str, meta: &SessionMeta) -> Result<(), String> { |
| 75 | let path = |
| 76 | meta_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?; |
| 77 | let dir = path |
| 78 | .parent() |
| 79 | .ok_or_else(|| "session meta path has no parent".to_string())?; |
| 80 | std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?; |
| 81 | |
| 82 | let body = serde_json::to_string(meta).map_err(|error| format!("serialize meta: {error}"))?; |
| 83 | |
| 84 | let tmp = dir.join(format!( |
| 85 | ".{}.{}.tmp", |
| 86 | sanitize_id(session_id), |
| 87 | std::process::id() |
| 88 | )); |
| 89 | std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?; |
| 90 | std::fs::rename(&tmp, &path).map_err(|error| { |
| 91 | let _ = std::fs::remove_file(&tmp); |
| 92 | format!("rename {tmp:?} -> {path:?}: {error}") |
| 93 | })?; |
| 94 | Ok(()) |
| 95 | } |
| 96 | |
| 97 | /// Load the saved metadata for `session_id`, or `None` when no sidecar exists |
| 98 | /// (or it cannot be parsed) — matching how [`load`] tolerates junk. |
| 99 | pub fn load_meta(session_id: &str) -> Option<SessionMeta> { |
| 100 | let path = meta_path(session_id)?; |
| 101 | let contents = std::fs::read_to_string(&path).ok()?; |
| 102 | serde_json::from_str(&contents).ok() |
| 103 | } |
| 104 | |
| 105 | /// Persist a history snapshot for `session_id`, replacing any previous save. |
| 106 | /// The write is atomic: a temp file in the same directory is renamed over the |
| 107 | /// final path. |
| 108 | pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> { |
| 109 | let path = |
| 110 | session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?; |
| 111 | let dir = path |
| 112 | .parent() |
| 113 | .ok_or_else(|| "session path has no parent".to_string())?; |
| 114 | std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?; |
| 115 | |
| 116 | let mut body = String::new(); |
| 117 | for message in history { |
| 118 | body.push_str(&message.to_string()); |
| 119 | body.push('\n'); |
| 120 | } |
| 121 | |
| 122 | // Unique temp name so two processes saving the same session can't clobber |
| 123 | // each other's half-written file; rename is atomic on the same filesystem. |
| 124 | let tmp = dir.join(format!( |
| 125 | ".{}.{}.tmp", |
| 126 | sanitize_id(session_id), |
| 127 | std::process::id() |
| 128 | )); |
| 129 | std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?; |
| 130 | std::fs::rename(&tmp, &path).map_err(|error| { |
| 131 | let _ = std::fs::remove_file(&tmp); |
| 132 | format!("rename {tmp:?} -> {path:?}: {error}") |
| 133 | })?; |
| 134 | Ok(()) |
| 135 | } |
| 136 | |
| 137 | /// Load the saved history for `session_id`, or `None` when no save exists (or |
| 138 | /// it cannot be read). Unparseable lines are skipped rather than failing the |
| 139 | /// whole restore. |
| 140 | pub fn load(session_id: &str) -> Option<Vec<Value>> { |
| 141 | let path = session_path(session_id)?; |
| 142 | let contents = std::fs::read_to_string(&path).ok()?; |
| 143 | Some( |
| 144 | contents |
| 145 | .lines() |
| 146 | .filter(|line| !line.trim().is_empty()) |
| 147 | .filter_map(|line| serde_json::from_str::<Value>(line).ok()) |
| 148 | .collect(), |
| 149 | ) |
| 150 | } |
| 151 | |
| 152 | /// Remove the saved history for `session_id`, along with its metadata |
| 153 | /// sidecar. Missing files are fine. |
| 154 | pub fn delete(session_id: &str) { |
| 155 | if let Some(path) = session_path(session_id) { |
| 156 | let _ = std::fs::remove_file(path); |
| 157 | } |
| 158 | if let Some(path) = meta_path(session_id) { |
| 159 | let _ = std::fs::remove_file(path); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// One saved session as seen on disk. |
| 164 | /// |
| 165 | /// Cross-platform: consumed by the Unix-only TUI (`chat.rs` History tab) and |
| 166 | /// by the ACP `session/list` handler (`main.rs`) on every platform. |
| 167 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 168 | pub struct SessionEntry { |
| 169 | /// The sanitized id (the file stem), which `load`/`delete` accept as-is. |
| 170 | pub id: String, |
| 171 | /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable. |
| 172 | pub modified: std::time::SystemTime, |
| 173 | /// Number of history messages (non-empty lines) in the file. |
| 174 | pub message_count: usize, |
| 175 | /// The metadata sidecar, when one was recorded. `None` for sessions saved |
| 176 | /// before this existed, or for the singleton `tui`/`headless` entries. |
| 177 | pub meta: Option<SessionMeta>, |
| 178 | } |
| 179 | |
| 180 | /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files, |
| 181 | /// stray junk) are skipped. A missing or unreadable sessions dir yields an |
| 182 | /// empty list. |
| 183 | pub fn list() -> Vec<SessionEntry> { |
| 184 | let Some(dir) = sessions_dir() else { |
| 185 | return Vec::new(); |
| 186 | }; |
| 187 | let Ok(entries) = std::fs::read_dir(&dir) else { |
| 188 | return Vec::new(); |
| 189 | }; |
| 190 | let mut sessions: Vec<SessionEntry> = entries |
| 191 | .flatten() |
| 192 | .filter_map(|entry| { |
| 193 | let path = entry.path(); |
| 194 | if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { |
| 195 | return None; |
| 196 | } |
| 197 | let id = path.file_stem()?.to_str()?.to_string(); |
| 198 | // Cheap line count: no JSON parsing, just non-empty lines. |
| 199 | let contents = std::fs::read_to_string(&path).ok()?; |
| 200 | let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count(); |
| 201 | let modified = entry |
| 202 | .metadata() |
| 203 | .ok() |
| 204 | .and_then(|m| m.modified().ok()) |
| 205 | .unwrap_or(std::time::UNIX_EPOCH); |
| 206 | let meta = load_meta(&id); |
| 207 | Some(SessionEntry { |
| 208 | id, |
| 209 | modified, |
| 210 | message_count, |
| 211 | meta, |
| 212 | }) |
| 213 | }) |
| 214 | .collect(); |
| 215 | sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id))); |
| 216 | sessions |
| 217 | } |
| 218 | |
| 219 | #[cfg(test)] |
| 220 | mod tests { |
| 221 | use super::*; |
| 222 | |
| 223 | #[test] |
| 224 | fn sanitize_keeps_safe_chars_and_replaces_the_rest() { |
| 225 | assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z"); |
| 226 | assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e"); |
| 227 | assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); |
| 228 | assert_eq!(sanitize_id(""), "_"); |
| 229 | } |
| 230 | |
| 231 | // One test for the filesystem behavior because it mutates the |
| 232 | // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings |
| 233 | // tests): splitting would race under the parallel test runner. |
| 234 | #[test] |
| 235 | fn save_load_delete_round_trip() { |
| 236 | let _guard = crate::ENV_TEST_LOCK |
| 237 | .lock() |
| 238 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 239 | let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); |
| 240 | let _ = std::fs::remove_dir_all(&dir); |
| 241 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 242 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 243 | |
| 244 | // Missing file → None. |
| 245 | assert_eq!(load("nope"), None); |
| 246 | |
| 247 | let history = vec![ |
| 248 | serde_json::json!({ "role": "system", "content": "sys" }), |
| 249 | serde_json::json!({ "role": "user", "content": "hi\nthere" }), |
| 250 | serde_json::json!({ |
| 251 | "role": "assistant", "content": null, |
| 252 | "tool_calls": [{ "id": "call_1", "type": "function", |
| 253 | "function": { "name": "read_file", "arguments": "{}" } }], |
| 254 | }), |
| 255 | ]; |
| 256 | save("sess-1", &history).unwrap(); |
| 257 | assert_eq!(load("sess-1"), Some(history.clone())); |
| 258 | |
| 259 | // Saving again replaces, not appends. |
| 260 | let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })]; |
| 261 | save("sess-1", &shorter).unwrap(); |
| 262 | assert_eq!(load("sess-1"), Some(shorter)); |
| 263 | |
| 264 | // A hostile id stays inside the sessions dir via sanitization. |
| 265 | save("../escape", &history).unwrap(); |
| 266 | assert!(dir.join("sessions").join(".._escape.jsonl").is_file()); |
| 267 | assert_eq!(load("../escape"), Some(history)); |
| 268 | delete("../escape"); |
| 269 | assert_eq!(load("../escape"), None); |
| 270 | |
| 271 | delete("sess-1"); |
| 272 | assert_eq!(load("sess-1"), None); |
| 273 | // Deleting a missing session is a no-op. |
| 274 | delete("sess-1"); |
| 275 | |
| 276 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 277 | let _ = std::fs::remove_dir_all(&dir); |
| 278 | } |
| 279 | |
| 280 | // Same single-test-per-env-var pattern as `save_load_delete_round_trip`: |
| 281 | // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold. |
| 282 | #[test] |
| 283 | fn list_returns_sessions_newest_first_and_skips_junk() { |
| 284 | let _guard = crate::ENV_TEST_LOCK |
| 285 | .lock() |
| 286 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 287 | let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id())); |
| 288 | let _ = std::fs::remove_dir_all(&dir); |
| 289 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 290 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 291 | |
| 292 | // No sessions dir at all → empty list, not an error. |
| 293 | assert!(list().is_empty()); |
| 294 | |
| 295 | let msg = |text: &str| serde_json::json!({ "role": "user", "content": text }); |
| 296 | save("older", &[msg("a"), msg("b"), msg("c")]).unwrap(); |
| 297 | // Distinct mtimes so the newest-first order is deterministic. |
| 298 | std::thread::sleep(std::time::Duration::from_millis(50)); |
| 299 | save("newer", &[msg("x")]).unwrap(); |
| 300 | |
| 301 | // Junk the lister must skip: wrong extension, a stray temp file, and a |
| 302 | // subdirectory. |
| 303 | let sessions = dir.join("sessions"); |
| 304 | std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap(); |
| 305 | std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap(); |
| 306 | std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap(); |
| 307 | |
| 308 | let listed = list(); |
| 309 | assert_eq!(listed.len(), 2); |
| 310 | assert_eq!(listed[0].id, "newer"); |
| 311 | assert_eq!(listed[0].message_count, 1); |
| 312 | assert!( |
| 313 | listed[0].meta.is_none(), |
| 314 | "no sidecar was written for either session" |
| 315 | ); |
| 316 | assert_eq!(listed[1].id, "older"); |
| 317 | assert_eq!(listed[1].message_count, 3); |
| 318 | assert!(listed[0].modified >= listed[1].modified); |
| 319 | |
| 320 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 321 | let _ = std::fs::remove_dir_all(&dir); |
| 322 | } |
| 323 | |
| 324 | // Same single-test-per-env-var pattern as the tests above. |
| 325 | #[test] |
| 326 | fn meta_round_trips_and_is_removed_with_the_session() { |
| 327 | let _guard = crate::ENV_TEST_LOCK |
| 328 | .lock() |
| 329 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 330 | let dir = std::env::temp_dir().join(format!("sigit_sessions_meta_{}", std::process::id())); |
| 331 | let _ = std::fs::remove_dir_all(&dir); |
| 332 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 333 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 334 | |
| 335 | // Missing sidecar → None. |
| 336 | assert_eq!(load_meta("sess-1"), None); |
| 337 | |
| 338 | let meta = SessionMeta { |
| 339 | cwd: PathBuf::from("/tmp/project"), |
| 340 | additional_directories: vec![PathBuf::from("/tmp/other-root")], |
| 341 | title: Some("what do the notes say?".to_string()), |
| 342 | }; |
| 343 | save( |
| 344 | "sess-1", |
| 345 | &[serde_json::json!({ "role": "user", "content": "hi" })], |
| 346 | ) |
| 347 | .unwrap(); |
| 348 | save_meta("sess-1", &meta).unwrap(); |
| 349 | assert_eq!(load_meta("sess-1"), Some(meta.clone())); |
| 350 | |
| 351 | // A sidecar with no title round-trips too. |
| 352 | let untitled = SessionMeta { |
| 353 | cwd: PathBuf::from("/tmp/project"), |
| 354 | additional_directories: vec![], |
| 355 | title: None, |
| 356 | }; |
| 357 | save_meta("sess-1", &untitled).unwrap(); |
| 358 | assert_eq!(load_meta("sess-1"), Some(untitled)); |
| 359 | |
| 360 | // list() surfaces the sidecar for a session that has one. |
| 361 | let listed = list(); |
| 362 | assert_eq!(listed.len(), 1); |
| 363 | assert_eq!( |
| 364 | listed[0].meta.as_ref().map(|m| &m.cwd), |
| 365 | Some(&PathBuf::from("/tmp/project")) |
| 366 | ); |
| 367 | |
| 368 | // delete() removes both the transcript and the sidecar. |
| 369 | delete("sess-1"); |
| 370 | assert_eq!(load("sess-1"), None); |
| 371 | assert_eq!(load_meta("sess-1"), None); |
| 372 | assert!(list().is_empty()); |
| 373 | |
| 374 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 375 | let _ = std::fs::remove_dir_all(&dir); |
| 376 | } |
| 377 | } |