Skip to main content

lean_ctx/core/context_os/
shared_sessions.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex};
4use std::time::Instant;
5
6use tokio::sync::RwLock;
7
8use crate::core::project_hash;
9use crate::core::session::SessionState;
10
11const MAX_CACHED_SESSIONS: usize = 8;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct SharedSessionKey {
15    pub project_hash: String,
16    pub workspace_id: String,
17    pub channel_id: String,
18}
19
20impl SharedSessionKey {
21    pub fn new(project_root: &str, workspace_id: &str, channel_id: &str) -> Self {
22        Self {
23            project_hash: project_hash::hash_project_root(project_root),
24            workspace_id: normalize_id(workspace_id, "default"),
25            channel_id: normalize_id(channel_id, "default"),
26        }
27    }
28}
29
30struct SessionEntry {
31    session: Arc<RwLock<SessionState>>,
32    project_root: String,
33    last_accessed: Instant,
34}
35
36pub struct SharedSessionStore {
37    sessions: Mutex<HashMap<SharedSessionKey, SessionEntry>>,
38}
39
40impl Default for SharedSessionStore {
41    fn default() -> Self {
42        Self {
43            sessions: Mutex::new(HashMap::new()),
44        }
45    }
46}
47
48impl SharedSessionStore {
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    pub fn get_or_load(
54        &self,
55        project_root: &str,
56        workspace_id: &str,
57        channel_id: &str,
58    ) -> Arc<RwLock<SessionState>> {
59        let key = SharedSessionKey::new(project_root, workspace_id, channel_id);
60        let disk_key = key.clone();
61        let root = project_root.to_string();
62        let mut map = self
63            .sessions
64            .lock()
65            .unwrap_or_else(std::sync::PoisonError::into_inner);
66
67        if let Some(entry) = map.get_mut(&key) {
68            entry.last_accessed = Instant::now();
69            return entry.session.clone();
70        }
71
72        Self::evict_lru_if_needed(&mut map);
73
74        let mut loaded = load_session_from_disk(&root, &disk_key)
75            .or_else(|| SessionState::load_latest_for_project_root(&root))
76            .unwrap_or_default();
77        loaded.project_root = Some(root.clone());
78        let session = Arc::new(RwLock::new(loaded));
79
80        map.insert(
81            key,
82            SessionEntry {
83                session: session.clone(),
84                project_root: root,
85                last_accessed: Instant::now(),
86            },
87        );
88
89        session
90    }
91
92    fn evict_lru_if_needed(map: &mut HashMap<SharedSessionKey, SessionEntry>) {
93        if map.len() < MAX_CACHED_SESSIONS {
94            return;
95        }
96
97        let lru_key = map
98            .iter()
99            .min_by_key(|(_, e)| e.last_accessed)
100            .map(|(k, _)| k.clone());
101
102        if let Some(key) = lru_key
103            && let Some(entry) = map.remove(&key)
104            && let Ok(session) = entry.session.try_read()
105        {
106            persist_session_to_disk(&key, &entry.project_root, &session);
107        }
108    }
109
110    pub fn active_count(&self) -> usize {
111        self.sessions
112            .lock()
113            .unwrap_or_else(std::sync::PoisonError::into_inner)
114            .len()
115    }
116
117    /// Returns the max sessions limit for diagnostics.
118    pub fn max_sessions() -> usize {
119        MAX_CACHED_SESSIONS
120    }
121
122    pub fn persist_best_effort(
123        &self,
124        project_root: &str,
125        workspace_id: &str,
126        channel_id: &str,
127        session: &SessionState,
128    ) {
129        let key = SharedSessionKey::new(project_root, workspace_id, channel_id);
130        persist_session_to_disk(&key, project_root, session);
131    }
132}
133
134fn persist_session_to_disk(key: &SharedSessionKey, project_root: &str, session: &SessionState) {
135    // Canonical shared data is a projection of the committed primary owner,
136    // never a second place to advance a checkpoint from a pending snapshot.
137    let committed = if session.canonical_checkpoint.is_some() {
138        let Some(committed) = SessionState::load_by_id(&session.id) else {
139            return;
140        };
141        if committed.version != session.version
142            || committed.project_root.as_deref() != Some(project_root)
143        {
144            return;
145        }
146        Some(committed)
147    } else {
148        None
149    };
150    let session = committed.as_ref().unwrap_or(session);
151    let Some(dir) = shared_session_dir(key) else {
152        return;
153    };
154    let _ = std::fs::create_dir_all(&dir);
155    let state_path = dir.join("session.json");
156    let tmp = dir.join("session.json.tmp");
157
158    if let Ok((json, _)) = session.storage_json() {
159        let _ = std::fs::write(&tmp, json);
160        let _ = std::fs::rename(&tmp, &state_path);
161    }
162
163    if session.task.is_some() {
164        let snapshot = session.build_compaction_snapshot();
165        let _ = std::fs::write(dir.join("snapshot.txt"), snapshot);
166    }
167}
168
169fn normalize_id(s: &str, fallback: &str) -> String {
170    let t = s.trim();
171    if t.is_empty() {
172        fallback.to_string()
173    } else {
174        // Keep IDs URL/header safe.
175        t.chars()
176            .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
177            .collect::<String>()
178    }
179}
180
181fn shared_session_dir(key: &SharedSessionKey) -> Option<PathBuf> {
182    let data = crate::core::data_dir::lean_ctx_data_dir().ok()?;
183    Some(
184        data.join("context-os")
185            .join("sessions")
186            .join(&key.project_hash)
187            .join(&key.workspace_id)
188            .join(&key.channel_id),
189    )
190}
191
192fn load_session_from_disk(project_root: &str, key: &SharedSessionKey) -> Option<SessionState> {
193    let dir = shared_session_dir(key)?;
194    let state_path = dir.join("session.json");
195    let json = std::fs::read_to_string(&state_path).ok()?;
196    let mut session = SessionState::from_storage_json(&json).ok()?;
197    if session.canonical_checkpoint.is_some()
198        && session.project_root.as_deref() != Some(project_root)
199    {
200        return None;
201    }
202    // Safety: enforce project_root from the current server.
203    session.project_root = Some(project_root.to_string());
204    Some(session)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn normalize_id_filters_weird_chars() {
213        assert_eq!(normalize_id("  ", "x"), "x");
214        assert_eq!(normalize_id("abc-123_DEF", "x"), "abc-123_DEF");
215        assert_eq!(normalize_id("a b$c", "x"), "abc");
216    }
217
218    #[test]
219    fn key_is_stable() {
220        let k1 = SharedSessionKey::new("/tmp/proj", "ws", "ch");
221        let k2 = SharedSessionKey::new("/tmp/proj", "ws", "ch");
222        assert_eq!(k1, k2);
223    }
224
225    #[tokio::test]
226    async fn concurrent_session_access_no_data_race() {
227        let store = Arc::new(SharedSessionStore::new());
228        let n_tasks: usize = 8;
229
230        let mut handles = vec![];
231        for task_idx in 0..n_tasks {
232            let store = Arc::clone(&store);
233            handles.push(tokio::spawn(async move {
234                let project_root = "/tmp/test-concurrent";
235                for i in 0..10 {
236                    let session_arc = store.get_or_load(project_root, "ws-shared", "ch-shared");
237                    let mut s = session_arc.write().await;
238                    s.files_touched.push(crate::core::session::FileTouched {
239                        path: format!("file-{task_idx}-{i}.rs"),
240                        file_ref: None,
241                        read_count: 1,
242                        modified: false,
243                        last_mode: "full".to_string(),
244                        tokens: 0,
245                        stale: false,
246                        context_item_id: None,
247                        summary: None,
248                    });
249                }
250            }));
251        }
252
253        for h in handles {
254            h.await.unwrap();
255        }
256
257        let final_arc = store.get_or_load("/tmp/test-concurrent", "ws-shared", "ch-shared");
258        let final_session = final_arc.read().await;
259        assert_eq!(
260            final_session.files_touched.len(),
261            n_tasks * 10,
262            "all concurrent mutations must be persisted"
263        );
264    }
265
266    #[tokio::test]
267    async fn different_workspace_channels_are_isolated() {
268        let store = SharedSessionStore::new();
269
270        {
271            let arc_a = store.get_or_load("/tmp/proj-iso", "ws-a", "ch-1");
272            arc_a
273                .write()
274                .await
275                .files_touched
276                .push(crate::core::session::FileTouched {
277                    path: "fileA.rs".to_string(),
278                    file_ref: None,
279                    read_count: 1,
280                    modified: false,
281                    last_mode: "full".to_string(),
282                    tokens: 0,
283                    stale: false,
284                    context_item_id: None,
285                    summary: None,
286                });
287        }
288        {
289            let arc_b = store.get_or_load("/tmp/proj-iso", "ws-b", "ch-1");
290            arc_b
291                .write()
292                .await
293                .files_touched
294                .push(crate::core::session::FileTouched {
295                    path: "fileB.rs".to_string(),
296                    file_ref: None,
297                    read_count: 1,
298                    modified: false,
299                    last_mode: "full".to_string(),
300                    tokens: 0,
301                    stale: false,
302                    summary: None,
303                    context_item_id: None,
304                });
305        }
306
307        let session_a = store.get_or_load("/tmp/proj-iso", "ws-a", "ch-1");
308        let session_b = store.get_or_load("/tmp/proj-iso", "ws-b", "ch-1");
309
310        assert_eq!(session_a.read().await.files_touched.len(), 1);
311        assert_eq!(session_a.read().await.files_touched[0].path, "fileA.rs");
312        assert_eq!(session_b.read().await.files_touched.len(), 1);
313        assert_eq!(session_b.read().await.files_touched[0].path, "fileB.rs");
314    }
315}