lean_ctx/core/context_os/
shared_sessions.rs1use 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 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 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 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 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}