1use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{mpsc, Arc};
13use std::time::{Duration, Instant};
14
15use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
16use serde::Serialize;
17
18use crate::{
19 DiscoveryQuery, HarnessCatalog, HarnessId, SessionDescriptor, SessionLocator, StorageLocator,
20};
21
22const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
23const MAX_SUBSCRIPTION_ROWS: usize = 2_048;
24const INVALIDATION_QUEUE_CAPACITY: usize = 1_024;
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
29pub struct SessionIndexKey {
30 pub harness: String,
32 pub session_id: String,
34}
35
36impl SessionIndexKey {
37 fn from_locator(locator: &SessionLocator) -> Self {
38 Self {
39 harness: locator.harness.as_str().to_string(),
40 session_id: locator.session_id.clone(),
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum SessionIndexChange {
49 Added {
51 descriptor: SessionDescriptor,
53 },
54 Updated {
56 descriptor: SessionDescriptor,
58 },
59 Removed {
61 key: SessionIndexKey,
63 },
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct SessionIndexDelta {
70 pub revision: u64,
72 pub changes: Vec<SessionIndexChange>,
74}
75
76pub(crate) struct SessionIndexSubscription {
79 query: DiscoveryQuery,
80 current: BTreeMap<SessionIndexKey, SessionDescriptor>,
81 revision: u64,
82 receiver: mpsc::Receiver<notify::Result<Event>>,
83 overflowed: Arc<AtomicBool>,
84 _watcher: RecommendedWatcher,
85 last_reconcile: Instant,
86}
87
88impl SessionIndexSubscription {
89 pub(crate) fn homes(&self) -> &crate::HarnessHomes {
90 &self.query.homes
91 }
92
93 pub(crate) fn open(
94 mut query: DiscoveryQuery,
95 ) -> Result<(Self, Vec<SessionDescriptor>), String> {
96 validate_query(&query)?;
97 query.cursor = None;
98 query.limit = Some(query.limit.unwrap_or(100));
99
100 let initial = HarnessCatalog::new()
101 .discover_page(&query)
102 .map_err(|error| error.to_string())?
103 .sessions;
104 let current = descriptor_map(initial.iter().cloned());
105 let (sender, receiver) = mpsc::sync_channel(INVALIDATION_QUEUE_CAPACITY);
106 let overflowed = Arc::new(AtomicBool::new(false));
107 let callback_overflowed = Arc::clone(&overflowed);
108 let mut watcher = notify::recommended_watcher(move |event| {
109 if sender.try_send(event).is_err() {
110 callback_overflowed.store(true, Ordering::Release);
111 }
112 })
113 .map_err(|error| error.to_string())?;
114 for root in watch_roots(&query) {
115 if let Some(watched) = existing_watch_root(&root) {
116 watcher
117 .watch(&watched, RecursiveMode::Recursive)
118 .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
119 }
120 }
121
122 Ok((
123 Self {
124 query,
125 current,
126 revision: 1,
127 receiver,
128 overflowed,
129 _watcher: watcher,
130 last_reconcile: Instant::now(),
131 },
132 initial,
133 ))
134 }
135
136 pub(crate) fn poll(&mut self) -> Result<Option<SessionIndexDelta>, String> {
139 let mut paths = BTreeSet::new();
140 let mut reconcile = self.overflowed.swap(false, Ordering::AcqRel);
141 while let Ok(event) = self.receiver.try_recv() {
142 match event {
143 Ok(event) => paths.extend(event.paths),
144 Err(_) => reconcile = true,
145 }
146 }
147 if self.last_reconcile.elapsed() >= RECONCILE_INTERVAL {
148 reconcile = true;
149 }
150 if paths.is_empty() && !reconcile {
151 return Ok(None);
152 }
153
154 let before = self.current.clone();
155 if reconcile {
156 self.reconcile()?;
157 } else {
158 let mut needs_fill = false;
159 for path in paths {
160 needs_fill |= self.refresh_path(&path)?;
161 }
162 if needs_fill {
163 self.reconcile()?;
164 } else {
165 self.retain_page_limit();
166 }
167 }
168 let changes = diff_descriptors(&before, &self.current);
169 if changes.is_empty() {
170 return Ok(None);
171 }
172 self.revision = self.revision.saturating_add(1);
173 Ok(Some(SessionIndexDelta {
174 revision: self.revision,
175 changes,
176 }))
177 }
178
179 fn reconcile(&mut self) -> Result<(), String> {
180 self.last_reconcile = Instant::now();
183 let sessions = HarnessCatalog::new()
184 .discover_page(&self.query)
185 .map_err(|error| error.to_string())?
186 .sessions;
187 self.current = descriptor_map(sessions);
188 Ok(())
189 }
190
191 fn refresh_path(&mut self, path: &Path) -> Result<bool, String> {
194 if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
195 return Ok(false);
196 }
197 let event_path = normalized_path(path);
200 if !self.query.include_child_sessions
201 && event_path.starts_with(normalized_path(&self.query.homes.claude_code))
202 && event_path
203 .components()
204 .any(|component| component.as_os_str() == "subagents")
205 {
206 return Ok(true);
209 }
210 let known = self.current.iter().find_map(|(key, descriptor)| {
211 (normalized_path(descriptor.locator.storage.path()) == event_path)
212 .then(|| (key.clone(), descriptor.clone()))
213 });
214 let locator = match &known {
215 Some((_, descriptor)) => descriptor.locator.clone(),
216 None => match locator_for_path(&self.query, &event_path) {
217 Some(locator) => locator,
218 None => return Ok(false),
219 },
220 };
221 let refreshed = HarnessCatalog::new()
222 .refresh_file_descriptor(
223 &locator,
224 self.query.workspace.as_deref(),
225 self.query.include_topic_candidates,
226 )
227 .map_err(|error| error.to_string())?
228 .filter(|descriptor| {
229 self.query.include_child_sessions || descriptor.parent_session_id.is_none()
230 });
231 match (known, refreshed) {
232 (Some((old_key, _)), None) => {
233 self.current.remove(&old_key);
234 Ok(true)
235 }
236 (Some((old_key, _)), Some(descriptor)) => {
237 self.current.remove(&old_key);
238 self.current.insert(
239 SessionIndexKey::from_locator(&descriptor.locator),
240 descriptor,
241 );
242 Ok(false)
243 }
244 (None, Some(descriptor)) => {
245 self.current.insert(
246 SessionIndexKey::from_locator(&descriptor.locator),
247 descriptor,
248 );
249 Ok(false)
250 }
251 (None, None) => Ok(false),
252 }
253 }
254
255 fn retain_page_limit(&mut self) {
256 let limit = self.query.limit.unwrap_or(100);
257 let mut sessions = self.current.values().cloned().collect::<Vec<_>>();
258 sort_descriptors(&mut sessions);
259 sessions.truncate(limit);
260 self.current = descriptor_map(sessions);
261 }
262}
263
264pub(crate) fn validate_query(query: &DiscoveryQuery) -> Result<(), String> {
265 if query.cursor.is_some() {
266 return Err("sessions.index.subscribe does not accept a cursor".into());
267 }
268 let limit = query.limit.unwrap_or(100);
269 if limit == 0 || limit > MAX_SUBSCRIPTION_ROWS {
270 return Err(format!(
271 "sessions.index.subscribe limit must be between 1 and {MAX_SUBSCRIPTION_ROWS}"
272 ));
273 }
274 if query.harnesses.is_empty()
275 || query
276 .harnesses
277 .iter()
278 .any(|harness| !matches!(harness.as_str(), HarnessId::CLAUDE_CODE | HarnessId::CODEX))
279 {
280 return Err(
281 "sessions.index.subscribe currently requires explicit claude-code and/or codex harnesses"
282 .into(),
283 );
284 }
285 Ok(())
286}
287
288fn watch_roots(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
289 query
290 .harnesses
291 .iter()
292 .filter_map(|harness| match harness.as_str() {
293 HarnessId::CLAUDE_CODE => Some(query.homes.claude_code.clone()),
294 HarnessId::CODEX => Some(query.homes.codex.clone()),
295 _ => None,
296 })
297 .collect()
298}
299
300fn existing_watch_root(root: &Path) -> Option<PathBuf> {
301 if root.is_dir() {
302 return Some(root.to_path_buf());
303 }
304 root.parent()
308 .filter(|parent| parent.is_dir())
309 .map(Path::to_path_buf)
310}
311
312fn locator_for_path(query: &DiscoveryQuery, path: &Path) -> Option<SessionLocator> {
313 let claude_root = normalized_path(&query.homes.claude_code);
314 let codex_root = normalized_path(&query.homes.codex);
315 let harness = if query
316 .harnesses
317 .iter()
318 .any(|harness| harness.as_str() == HarnessId::CLAUDE_CODE)
319 && path.starts_with(&claude_root)
320 {
321 if !query.include_child_sessions
322 && path
323 .components()
324 .any(|component| component.as_os_str() == "subagents")
325 {
326 return None;
327 }
328 HarnessId::CLAUDE_CODE
329 } else if query
330 .harnesses
331 .iter()
332 .any(|harness| harness.as_str() == HarnessId::CODEX)
333 && path.starts_with(&codex_root)
334 {
335 HarnessId::CODEX
336 } else {
337 return None;
338 };
339 Some(SessionLocator {
340 harness: HarnessId::new(harness),
341 session_id: path
342 .file_stem()
343 .and_then(|value| value.to_str())
344 .unwrap_or("unknown")
345 .to_string(),
346 storage: StorageLocator::File {
347 path: path.to_path_buf(),
348 },
349 })
350}
351
352fn normalized_path(path: &Path) -> PathBuf {
353 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
354}
355
356fn descriptor_map(
357 descriptors: impl IntoIterator<Item = SessionDescriptor>,
358) -> BTreeMap<SessionIndexKey, SessionDescriptor> {
359 descriptors
360 .into_iter()
361 .map(|descriptor| {
362 (
363 SessionIndexKey::from_locator(&descriptor.locator),
364 descriptor,
365 )
366 })
367 .collect()
368}
369
370fn sort_descriptors(descriptors: &mut [SessionDescriptor]) {
371 descriptors.sort_by(|left, right| {
372 right
373 .updated_at_ms
374 .cmp(&left.updated_at_ms)
375 .then_with(|| left.locator.harness.cmp(&right.locator.harness))
376 .then_with(|| left.locator.session_id.cmp(&right.locator.session_id))
377 });
378}
379
380fn diff_descriptors(
381 before: &BTreeMap<SessionIndexKey, SessionDescriptor>,
382 after: &BTreeMap<SessionIndexKey, SessionDescriptor>,
383) -> Vec<SessionIndexChange> {
384 let mut changes = Vec::new();
385 for (key, descriptor) in after {
386 match before.get(key) {
387 None => changes.push(SessionIndexChange::Added {
388 descriptor: descriptor.clone(),
389 }),
390 Some(previous) if previous != descriptor => {
391 changes.push(SessionIndexChange::Updated {
392 descriptor: descriptor.clone(),
393 });
394 }
395 Some(_) => {}
396 }
397 }
398 for key in before.keys() {
399 if !after.contains_key(key) {
400 changes.push(SessionIndexChange::Removed { key: key.clone() });
401 }
402 }
403 changes
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn descriptor(id: &str, updated_at_ms: u64) -> SessionDescriptor {
411 SessionDescriptor {
412 locator: SessionLocator {
413 harness: HarnessId::new(HarnessId::CODEX),
414 session_id: id.into(),
415 storage: StorageLocator::File {
416 path: PathBuf::from(format!("/{id}.jsonl")),
417 },
418 },
419 cwd: None,
420 title: None,
421 preview_candidates: Vec::new(),
422 latest_message_candidates: Vec::new(),
423 updated_at_ms: Some(updated_at_ms),
424 message_count: None,
425 model: None,
426 parent_session_id: None,
427 child_session_count: 0,
428 }
429 }
430
431 #[test]
432 fn index_delta_is_a_complete_deterministic_replacement_set() {
433 let before = descriptor_map([descriptor("removed", 1), descriptor("updated", 2)]);
434 let after = descriptor_map([descriptor("updated", 3), descriptor("added", 4)]);
435 let changes = diff_descriptors(&before, &after);
436 assert!(matches!(
437 &changes[0],
438 SessionIndexChange::Added { descriptor } if descriptor.locator.session_id == "added"
439 ));
440 assert!(matches!(
441 &changes[1],
442 SessionIndexChange::Updated { descriptor } if descriptor.locator.session_id == "updated"
443 ));
444 assert!(matches!(
445 &changes[2],
446 SessionIndexChange::Removed { key } if key.session_id == "removed"
447 ));
448 }
449}