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 let known = self.current.iter().find_map(|(key, descriptor)| {
201 (normalized_path(descriptor.locator.storage.path()) == event_path)
202 .then(|| (key.clone(), descriptor.clone()))
203 });
204 let locator = match &known {
205 Some((_, descriptor)) => descriptor.locator.clone(),
206 None => match locator_for_path(&self.query, &event_path) {
207 Some(locator) => locator,
208 None => return Ok(false),
209 },
210 };
211 let refreshed = HarnessCatalog::new()
212 .refresh_file_descriptor(
213 &locator,
214 self.query.workspace.as_deref(),
215 self.query.include_topic_candidates,
216 )
217 .map_err(|error| error.to_string())?
218 .filter(|descriptor| {
219 self.query.include_child_sessions || descriptor.parent_session_id.is_none()
220 });
221 match (known, refreshed) {
222 (Some((old_key, _)), None) => {
223 self.current.remove(&old_key);
224 Ok(true)
225 }
226 (Some((old_key, _)), Some(descriptor)) => {
227 self.current.remove(&old_key);
228 self.current.insert(
229 SessionIndexKey::from_locator(&descriptor.locator),
230 descriptor,
231 );
232 Ok(false)
233 }
234 (None, Some(descriptor)) => {
235 self.current.insert(
236 SessionIndexKey::from_locator(&descriptor.locator),
237 descriptor,
238 );
239 Ok(false)
240 }
241 (None, None) => Ok(false),
242 }
243 }
244
245 fn retain_page_limit(&mut self) {
246 let limit = self.query.limit.unwrap_or(100);
247 let mut sessions = self.current.values().cloned().collect::<Vec<_>>();
248 sort_descriptors(&mut sessions);
249 sessions.truncate(limit);
250 self.current = descriptor_map(sessions);
251 }
252}
253
254pub(crate) fn validate_query(query: &DiscoveryQuery) -> Result<(), String> {
255 if query.cursor.is_some() {
256 return Err("sessions.index.subscribe does not accept a cursor".into());
257 }
258 let limit = query.limit.unwrap_or(100);
259 if limit == 0 || limit > MAX_SUBSCRIPTION_ROWS {
260 return Err(format!(
261 "sessions.index.subscribe limit must be between 1 and {MAX_SUBSCRIPTION_ROWS}"
262 ));
263 }
264 if query.harnesses.is_empty()
265 || query
266 .harnesses
267 .iter()
268 .any(|harness| !matches!(harness.as_str(), HarnessId::CLAUDE_CODE | HarnessId::CODEX))
269 {
270 return Err(
271 "sessions.index.subscribe currently requires explicit claude-code and/or codex harnesses"
272 .into(),
273 );
274 }
275 Ok(())
276}
277
278fn watch_roots(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
279 query
280 .harnesses
281 .iter()
282 .filter_map(|harness| match harness.as_str() {
283 HarnessId::CLAUDE_CODE => Some(query.homes.claude_code.clone()),
284 HarnessId::CODEX => Some(query.homes.codex.clone()),
285 _ => None,
286 })
287 .collect()
288}
289
290fn existing_watch_root(root: &Path) -> Option<PathBuf> {
291 if root.is_dir() {
292 return Some(root.to_path_buf());
293 }
294 root.parent()
298 .filter(|parent| parent.is_dir())
299 .map(Path::to_path_buf)
300}
301
302fn locator_for_path(query: &DiscoveryQuery, path: &Path) -> Option<SessionLocator> {
303 let claude_root = normalized_path(&query.homes.claude_code);
304 let codex_root = normalized_path(&query.homes.codex);
305 let harness = if query
306 .harnesses
307 .iter()
308 .any(|harness| harness.as_str() == HarnessId::CLAUDE_CODE)
309 && path.starts_with(&claude_root)
310 {
311 if path
312 .components()
313 .any(|component| component.as_os_str() == "subagents")
314 {
315 return None;
316 }
317 HarnessId::CLAUDE_CODE
318 } else if query
319 .harnesses
320 .iter()
321 .any(|harness| harness.as_str() == HarnessId::CODEX)
322 && path.starts_with(&codex_root)
323 {
324 HarnessId::CODEX
325 } else {
326 return None;
327 };
328 Some(SessionLocator {
329 harness: HarnessId::new(harness),
330 session_id: path
331 .file_stem()
332 .and_then(|value| value.to_str())
333 .unwrap_or("unknown")
334 .to_string(),
335 storage: StorageLocator::File {
336 path: path.to_path_buf(),
337 },
338 })
339}
340
341fn normalized_path(path: &Path) -> PathBuf {
342 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
343}
344
345fn descriptor_map(
346 descriptors: impl IntoIterator<Item = SessionDescriptor>,
347) -> BTreeMap<SessionIndexKey, SessionDescriptor> {
348 descriptors
349 .into_iter()
350 .map(|descriptor| {
351 (
352 SessionIndexKey::from_locator(&descriptor.locator),
353 descriptor,
354 )
355 })
356 .collect()
357}
358
359fn sort_descriptors(descriptors: &mut [SessionDescriptor]) {
360 descriptors.sort_by(|left, right| {
361 right
362 .updated_at_ms
363 .cmp(&left.updated_at_ms)
364 .then_with(|| left.locator.harness.cmp(&right.locator.harness))
365 .then_with(|| left.locator.session_id.cmp(&right.locator.session_id))
366 });
367}
368
369fn diff_descriptors(
370 before: &BTreeMap<SessionIndexKey, SessionDescriptor>,
371 after: &BTreeMap<SessionIndexKey, SessionDescriptor>,
372) -> Vec<SessionIndexChange> {
373 let mut changes = Vec::new();
374 for (key, descriptor) in after {
375 match before.get(key) {
376 None => changes.push(SessionIndexChange::Added {
377 descriptor: descriptor.clone(),
378 }),
379 Some(previous) if previous != descriptor => {
380 changes.push(SessionIndexChange::Updated {
381 descriptor: descriptor.clone(),
382 });
383 }
384 Some(_) => {}
385 }
386 }
387 for key in before.keys() {
388 if !after.contains_key(key) {
389 changes.push(SessionIndexChange::Removed { key: key.clone() });
390 }
391 }
392 changes
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn descriptor(id: &str, updated_at_ms: u64) -> SessionDescriptor {
400 SessionDescriptor {
401 locator: SessionLocator {
402 harness: HarnessId::new(HarnessId::CODEX),
403 session_id: id.into(),
404 storage: StorageLocator::File {
405 path: PathBuf::from(format!("/{id}.jsonl")),
406 },
407 },
408 cwd: None,
409 title: None,
410 preview_candidates: Vec::new(),
411 latest_message_candidates: Vec::new(),
412 updated_at_ms: Some(updated_at_ms),
413 message_count: None,
414 model: None,
415 parent_session_id: None,
416 }
417 }
418
419 #[test]
420 fn index_delta_is_a_complete_deterministic_replacement_set() {
421 let before = descriptor_map([descriptor("removed", 1), descriptor("updated", 2)]);
422 let after = descriptor_map([descriptor("updated", 3), descriptor("added", 4)]);
423 let changes = diff_descriptors(&before, &after);
424 assert!(matches!(
425 &changes[0],
426 SessionIndexChange::Added { descriptor } if descriptor.locator.session_id == "added"
427 ));
428 assert!(matches!(
429 &changes[1],
430 SessionIndexChange::Updated { descriptor } if descriptor.locator.session_id == "updated"
431 ));
432 assert!(matches!(
433 &changes[2],
434 SessionIndexChange::Removed { key } if key.session_id == "removed"
435 ));
436 }
437}