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