agentmux_srv\backend\history/
index.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! In-memory session index built from adapter discovery.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9
10use super::adapter::*;
11
12/// AgentMux-ISOLATED provider-home roots under which delete/clear is permitted:
13/// `<shared>/providers/` and `<shared>/identities/`. Anything outside these
14/// (the user's personal `~/.claude` / `~/.config/claude-*`) is OFF-LIMITS so a
15/// "clear all" can never nuke transcripts AgentMux didn't create.
16fn default_isolated_roots() -> Vec<PathBuf> {
17    let shared = std::env::var_os("AGENTMUX_SHARED_DIR")
18        .map(PathBuf::from)
19        .or_else(|| dirs::home_dir().map(|h| h.join(".agentmux").join("shared")));
20    match shared {
21        Some(s) => vec![s.join("providers"), s.join("identities")],
22        None => Vec::new(),
23    }
24}
25
26/// In-memory index of discovered sessions.
27pub struct SessionIndex {
28    /// session_id -> SessionMeta
29    sessions: Mutex<HashMap<String, SessionMeta>>,
30    /// Adapters for all registered providers
31    adapters: Vec<Box<dyn HistoryAdapter>>,
32    /// Roots under which destructive ops (delete/clear) are allowed.
33    isolated_roots: Vec<PathBuf>,
34}
35
36impl SessionIndex {
37    pub fn new(adapters: Vec<Box<dyn HistoryAdapter>>) -> Self {
38        Self::with_isolated_roots(adapters, default_isolated_roots())
39    }
40
41    /// Construct with explicit isolated roots (used by new() and tests).
42    pub fn with_isolated_roots(
43        adapters: Vec<Box<dyn HistoryAdapter>>,
44        isolated_roots: Vec<PathBuf>,
45    ) -> Self {
46        SessionIndex {
47            sessions: Mutex::new(HashMap::new()),
48            adapters,
49            isolated_roots,
50        }
51    }
52
53    /// True if `path` lives under an AgentMux-isolated provider home and is
54    /// therefore safe to delete. Personal global homes are never isolated.
55    fn is_isolated(&self, path: &Path) -> bool {
56        self.isolated_roots.iter().any(|r| path.starts_with(r))
57    }
58
59    /// Full scan: discover all files and extract metadata.
60    /// Returns (discovered, updated, new) counts.
61    pub fn refresh(&self) -> (u32, u32, u32) {
62        let mut discovered: u32 = 0;
63        let mut updated: u32 = 0;
64        let mut new_count: u32 = 0;
65
66        let mut new_sessions: HashMap<String, SessionMeta> = HashMap::new();
67
68        for adapter in &self.adapters {
69            let files = match adapter.discover_files() {
70                Ok(f) => f,
71                Err(e) => {
72                    tracing::warn!(
73                        "history: failed to discover {} files: {}",
74                        adapter.provider(),
75                        e
76                    );
77                    continue;
78                }
79            };
80
81            discovered += files.len() as u32;
82
83            for file in &files {
84                match adapter.extract_meta(&file.file_path) {
85                    Ok(Some(meta)) => {
86                        new_sessions.insert(meta.session_id.clone(), meta);
87                    }
88                    Ok(None) => {} // empty/invalid session
89                    Err(e) => {
90                        tracing::debug!(
91                            "history: failed to extract meta from {}: {}",
92                            file.file_path,
93                            e
94                        );
95                    }
96                }
97            }
98        }
99
100        // Compare with existing index
101        let mut sessions = self.sessions.lock().unwrap();
102        for (id, _meta) in &new_sessions {
103            if sessions.contains_key(id) {
104                updated += 1;
105            } else {
106                new_count += 1;
107            }
108        }
109
110        *sessions = new_sessions;
111
112        (discovered, updated, new_count)
113    }
114
115    /// List sessions with pagination and optional filters.
116    pub fn list(
117        &self,
118        provider: Option<&str>,
119        project: Option<&str>,
120        offset: usize,
121        limit: usize,
122        sort_by: &str,
123        sort_dir: &str,
124    ) -> (Vec<SessionMeta>, u32, bool) {
125        let sessions = self.sessions.lock().unwrap();
126
127        let mut filtered: Vec<&SessionMeta> = sessions
128            .values()
129            .filter(|s| {
130                if let Some(p) = provider {
131                    if s.provider != p {
132                        return false;
133                    }
134                }
135                if let Some(proj) = project {
136                    if !s.working_directory.contains(proj) {
137                        return false;
138                    }
139                }
140                true
141            })
142            .collect();
143
144        // Sort
145        let desc = sort_dir != "asc";
146        match sort_by {
147            "created_at" | "created" => {
148                filtered.sort_by(|a, b| {
149                    if desc {
150                        b.created_at.cmp(&a.created_at)
151                    } else {
152                        a.created_at.cmp(&b.created_at)
153                    }
154                });
155            }
156            "messages" => {
157                filtered.sort_by(|a, b| {
158                    if desc {
159                        b.message_count.cmp(&a.message_count)
160                    } else {
161                        a.message_count.cmp(&b.message_count)
162                    }
163                });
164            }
165            "tokens" => {
166                filtered.sort_by(|a, b| {
167                    if desc {
168                        b.total_tokens.cmp(&a.total_tokens)
169                    } else {
170                        a.total_tokens.cmp(&b.total_tokens)
171                    }
172                });
173            }
174            _ => {
175                // Default: modified_at desc
176                filtered.sort_by(|a, b| {
177                    if desc {
178                        b.modified_at.cmp(&a.modified_at)
179                    } else {
180                        a.modified_at.cmp(&b.modified_at)
181                    }
182                });
183            }
184        }
185
186        let total = filtered.len() as u32;
187        let has_more = offset + limit < filtered.len();
188        let page: Vec<SessionMeta> = filtered
189            .into_iter()
190            .skip(offset)
191            .take(limit)
192            .cloned()
193            .collect();
194
195        (page, total, has_more)
196    }
197
198    /// Get a session by ID — returns just the meta from index.
199    pub fn get_meta(&self, session_id: &str) -> Option<SessionMeta> {
200        let sessions = self.sessions.lock().unwrap();
201        sessions.get(session_id).cloned()
202    }
203
204    /// Full parse of a session by ID.
205    pub fn get_full(&self, session_id: &str) -> Result<Option<HistorySession>, HistoryError> {
206        let meta = match self.get_meta(session_id) {
207            Some(m) => m,
208            None => return Ok(None),
209        };
210
211        // Find the adapter for this provider
212        for adapter in &self.adapters {
213            if adapter.provider() == meta.provider {
214                return adapter.parse_file(&meta.file_path);
215            }
216        }
217
218        Err(HistoryError::Other(format!(
219            "no adapter for provider: {}",
220            meta.provider
221        )))
222    }
223
224    /// Check if the index has been populated.
225    pub fn is_empty(&self) -> bool {
226        self.sessions.lock().unwrap().is_empty()
227    }
228
229    /// Delete a session: remove its on-disk transcript (and the sibling
230    /// `<session_id>/` subagents dir Claude keeps next to it) and drop it from
231    /// the index. Returns Ok(true) if a file was removed, Ok(false) if the
232    /// session id is unknown.
233    pub fn delete(&self, session_id: &str) -> Result<bool, HistoryError> {
234        let meta = match self.get_meta(session_id) {
235            Some(m) => m,
236            None => return Ok(false),
237        };
238        let path = Path::new(&meta.file_path);
239        // Safety: only delete inside AgentMux-isolated homes — never the user's
240        // personal Claude/Codex/... transcripts that the browse also surfaces.
241        if !self.is_isolated(path) {
242            return Err(HistoryError::Other(format!(
243                "refusing to delete '{}' — it lives in your personal {} home, not AgentMux's",
244                meta.file_path, meta.provider
245            )));
246        }
247        // Treat an already-removed file (GC'd out-of-band) as success so the
248        // stale index entry still gets dropped (otherwise clear-all can't
249        // converge and the index stays non-empty).
250        match std::fs::remove_file(path) {
251            Ok(()) => {}
252            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
253            Err(e) => return Err(e.into()),
254        }
255        // Claude stores subagent transcripts in a sibling `<session_id>/` dir;
256        // remove it too so the clear actually frees the space. Best-effort.
257        if let Some(parent) = path.parent() {
258            let sidecar = parent.join(session_id);
259            if sidecar.is_dir() {
260                let _ = std::fs::remove_dir_all(&sidecar);
261            }
262        }
263        self.sessions.lock().unwrap().remove(session_id);
264        Ok(true)
265    }
266
267    /// Bulk-delete all indexed sessions matching the optional provider/project
268    /// filter (no filter = clear everything). Returns the number removed.
269    pub fn clear(&self, provider: Option<&str>, project: Option<&str>) -> u32 {
270        // Snapshot matching ids while holding the lock, then do fs ops without
271        // it (delete() re-locks per id).
272        let ids: Vec<String> = {
273            let sessions = self.sessions.lock().unwrap();
274            sessions
275                .values()
276                .filter(|s| provider.map_or(true, |p| s.provider == p))
277                .filter(|s| project.map_or(true, |proj| s.working_directory.contains(proj)))
278                // Never bulk-delete the user's personal global transcripts.
279                .filter(|s| self.is_isolated(Path::new(&s.file_path)))
280                .map(|s| s.session_id.clone())
281                .collect()
282        };
283        let mut deleted = 0;
284        for id in ids {
285            if matches!(self.delete(&id), Ok(true)) {
286                deleted += 1;
287            }
288        }
289        deleted
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use std::fs;
297    use std::path::PathBuf;
298
299    /// Adapter that "discovers" caller-supplied files and derives the session id
300    /// from each file stem. Just enough to populate the index for delete tests.
301    struct MockAdapter {
302        files: Vec<DiscoveredFile>,
303        provider: String,
304        working_directory: String,
305    }
306    impl HistoryAdapter for MockAdapter {
307        fn provider(&self) -> &str {
308            &self.provider
309        }
310        fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
311            Ok(self
312                .files
313                .iter()
314                .map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms })
315                .collect())
316        }
317        fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
318            let id = PathBuf::from(file_path)
319                .file_stem()
320                .map(|s| s.to_string_lossy().into_owned())
321                .unwrap_or_default();
322            Ok(Some(SessionMeta {
323                session_id: id,
324                file_path: file_path.to_string(),
325                provider: self.provider.clone(),
326                model: String::new(),
327                slug: String::new(),
328                working_directory: self.working_directory.clone(),
329                created_at: 0,
330                modified_at: 0,
331                message_count: 0,
332                first_user_message: String::new(),
333                file_size_bytes: 0,
334                git_branch: String::new(),
335                total_tokens: 0,
336                subagent_count: 0,
337            }))
338        }
339        fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
340            Ok(None)
341        }
342    }
343
344    fn write_session(dir: &std::path::Path, id: &str) -> String {
345        let f = dir.join(format!("{id}.jsonl"));
346        fs::write(&f, b"{}").unwrap();
347        f.to_string_lossy().into_owned()
348    }
349
350    #[test]
351    fn delete_removes_file_and_index_entry() {
352        let dir = std::env::temp_dir().join(format!("amux-hist-del-{}", std::process::id()));
353        fs::create_dir_all(&dir).unwrap();
354        let fp = write_session(&dir, "sess-abc");
355        // A sibling subagents dir should be removed too.
356        let sidecar = dir.join("sess-abc");
357        fs::create_dir_all(sidecar.join("subagents")).unwrap();
358
359        let idx = SessionIndex::with_isolated_roots(
360            vec![Box::new(MockAdapter {
361                files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
362                provider: "mock".into(),
363                working_directory: "/proj".into(),
364            })],
365            vec![dir.clone()],
366        );
367        idx.refresh();
368        assert!(idx.get_meta("sess-abc").is_some());
369
370        assert!(matches!(idx.delete("sess-abc"), Ok(true)));
371        assert!(!std::path::Path::new(&fp).exists(), "transcript removed");
372        assert!(!sidecar.exists(), "subagents sidecar removed");
373        assert!(idx.get_meta("sess-abc").is_none(), "dropped from index");
374        assert!(matches!(idx.delete("sess-abc"), Ok(false)), "second delete is a no-op");
375
376        let _ = fs::remove_dir_all(&dir);
377    }
378
379    #[test]
380    fn clear_respects_provider_filter() {
381        let dir = std::env::temp_dir().join(format!("amux-hist-clr-{}", std::process::id()));
382        fs::create_dir_all(&dir).unwrap();
383        let a = write_session(&dir, "a");
384        let b = write_session(&dir, "b");
385
386        let idx = SessionIndex::with_isolated_roots(
387            vec![Box::new(MockAdapter {
388                files: vec![
389                    DiscoveredFile { file_path: a.clone(), mtime_ms: 0 },
390                    DiscoveredFile { file_path: b.clone(), mtime_ms: 0 },
391                ],
392                provider: "mock".into(),
393                working_directory: "/proj".into(),
394            })],
395            vec![dir.clone()],
396        );
397        idx.refresh();
398
399        // Non-matching provider clears nothing; matching clears both.
400        assert_eq!(idx.clear(Some("other"), None), 0);
401        assert!(std::path::Path::new(&a).exists());
402        assert_eq!(idx.clear(Some("mock"), None), 2);
403        assert!(!std::path::Path::new(&a).exists() && !std::path::Path::new(&b).exists());
404        assert!(idx.is_empty());
405
406        let _ = fs::remove_dir_all(&dir);
407    }
408
409    #[test]
410    fn refuses_to_delete_outside_isolated_roots() {
411        // A session that lives OUTSIDE the isolated roots (i.e. the user's
412        // personal global home) must never be deletable.
413        let dir = std::env::temp_dir().join(format!("amux-hist-guard-{}", std::process::id()));
414        fs::create_dir_all(&dir).unwrap();
415        let fp = write_session(&dir, "personal-sess");
416
417        let idx = SessionIndex::with_isolated_roots(
418            vec![Box::new(MockAdapter {
419                files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
420                provider: "claude".into(),
421                working_directory: "/home/me/.claude".into(),
422            })],
423            // isolated roots deliberately do NOT include `dir`.
424            vec![std::env::temp_dir().join("amux-some-other-isolated-root")],
425        );
426        idx.refresh();
427
428        assert!(idx.delete("personal-sess").is_err(), "single delete must refuse");
429        assert!(std::path::Path::new(&fp).exists(), "file must be intact");
430        assert_eq!(idx.clear(None, None), 0, "clear-all must skip it");
431        assert!(std::path::Path::new(&fp).exists(), "still intact after clear");
432
433        let _ = fs::remove_dir_all(&dir);
434    }
435}