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::sync::Mutex;
8
9use super::adapter::*;
10
11/// In-memory index of discovered sessions.
12pub struct SessionIndex {
13    /// session_id -> SessionMeta
14    sessions: Mutex<HashMap<String, SessionMeta>>,
15    /// Adapters for all registered providers
16    adapters: Vec<Box<dyn HistoryAdapter>>,
17}
18
19impl SessionIndex {
20    pub fn new(adapters: Vec<Box<dyn HistoryAdapter>>) -> Self {
21        SessionIndex {
22            sessions: Mutex::new(HashMap::new()),
23            adapters,
24        }
25    }
26
27    /// Full scan: discover all files and extract metadata.
28    /// Returns (discovered, updated, new) counts.
29    pub fn refresh(&self) -> (u32, u32, u32) {
30        let mut discovered: u32 = 0;
31        let mut updated: u32 = 0;
32        let mut new_count: u32 = 0;
33
34        let mut new_sessions: HashMap<String, SessionMeta> = HashMap::new();
35
36        for adapter in &self.adapters {
37            let files = match adapter.discover_files() {
38                Ok(f) => f,
39                Err(e) => {
40                    tracing::warn!(
41                        "history: failed to discover {} files: {}",
42                        adapter.provider(),
43                        e
44                    );
45                    continue;
46                }
47            };
48
49            discovered += files.len() as u32;
50
51            for file in &files {
52                match adapter.extract_meta(&file.file_path) {
53                    Ok(Some(meta)) => {
54                        new_sessions.insert(meta.session_id.clone(), meta);
55                    }
56                    Ok(None) => {} // empty/invalid session
57                    Err(e) => {
58                        tracing::debug!(
59                            "history: failed to extract meta from {}: {}",
60                            file.file_path,
61                            e
62                        );
63                    }
64                }
65            }
66        }
67
68        // Compare with existing index
69        let mut sessions = self.sessions.lock().unwrap();
70        for (id, _meta) in &new_sessions {
71            if sessions.contains_key(id) {
72                updated += 1;
73            } else {
74                new_count += 1;
75            }
76        }
77
78        *sessions = new_sessions;
79
80        (discovered, updated, new_count)
81    }
82
83    /// List sessions with pagination and optional filters.
84    pub fn list(
85        &self,
86        provider: Option<&str>,
87        project: Option<&str>,
88        offset: usize,
89        limit: usize,
90        sort_by: &str,
91        sort_dir: &str,
92    ) -> (Vec<SessionMeta>, u32, bool) {
93        let sessions = self.sessions.lock().unwrap();
94
95        let mut filtered: Vec<&SessionMeta> = sessions
96            .values()
97            .filter(|s| {
98                if let Some(p) = provider {
99                    if s.provider != p {
100                        return false;
101                    }
102                }
103                if let Some(proj) = project {
104                    if !s.working_directory.contains(proj) {
105                        return false;
106                    }
107                }
108                true
109            })
110            .collect();
111
112        // Sort
113        let desc = sort_dir != "asc";
114        match sort_by {
115            "created_at" | "created" => {
116                filtered.sort_by(|a, b| {
117                    if desc {
118                        b.created_at.cmp(&a.created_at)
119                    } else {
120                        a.created_at.cmp(&b.created_at)
121                    }
122                });
123            }
124            "messages" => {
125                filtered.sort_by(|a, b| {
126                    if desc {
127                        b.message_count.cmp(&a.message_count)
128                    } else {
129                        a.message_count.cmp(&b.message_count)
130                    }
131                });
132            }
133            "tokens" => {
134                filtered.sort_by(|a, b| {
135                    if desc {
136                        b.total_tokens.cmp(&a.total_tokens)
137                    } else {
138                        a.total_tokens.cmp(&b.total_tokens)
139                    }
140                });
141            }
142            _ => {
143                // Default: modified_at desc
144                filtered.sort_by(|a, b| {
145                    if desc {
146                        b.modified_at.cmp(&a.modified_at)
147                    } else {
148                        a.modified_at.cmp(&b.modified_at)
149                    }
150                });
151            }
152        }
153
154        let total = filtered.len() as u32;
155        let has_more = offset + limit < filtered.len();
156        let page: Vec<SessionMeta> = filtered
157            .into_iter()
158            .skip(offset)
159            .take(limit)
160            .cloned()
161            .collect();
162
163        (page, total, has_more)
164    }
165
166    /// Get a session by ID — returns just the meta from index.
167    pub fn get_meta(&self, session_id: &str) -> Option<SessionMeta> {
168        let sessions = self.sessions.lock().unwrap();
169        sessions.get(session_id).cloned()
170    }
171
172    /// Full parse of a session by ID.
173    pub fn get_full(&self, session_id: &str) -> Result<Option<HistorySession>, HistoryError> {
174        let meta = match self.get_meta(session_id) {
175            Some(m) => m,
176            None => return Ok(None),
177        };
178
179        // Find the adapter for this provider
180        for adapter in &self.adapters {
181            if adapter.provider() == meta.provider {
182                return adapter.parse_file(&meta.file_path);
183            }
184        }
185
186        Err(HistoryError::Other(format!(
187            "no adapter for provider: {}",
188            meta.provider
189        )))
190    }
191
192    /// Check if the index has been populated.
193    pub fn is_empty(&self) -> bool {
194        self.sessions.lock().unwrap().is_empty()
195    }
196}