Skip to main content

supercode_harness/
session_index.rs

1//! Revisioned session-list subscriptions for latency-sensitive frontends.
2//!
3//! Native filesystem events are treated as invalidation hints, never as the
4//! session record itself. Each hint causes a bounded re-read of the affected
5//! Claude Code or Codex transcript; a slow periodic catalog reconciliation
6//! repairs dropped/coalesced platform events and fills a page after removals.
7
8use 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/// Stable public identity for a session-index change. Persistence paths remain
27/// inside the trusted host and are sent only as part of complete descriptors.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
29pub struct SessionIndexKey {
30    /// Owning harness id.
31    pub harness: String,
32    /// Harness-native durable session id.
33    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/// One complete replacement in a revisioned index delta.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum SessionIndexChange {
49    /// A session entered the bounded result page.
50    Added {
51        /// Complete current descriptor.
52        descriptor: SessionDescriptor,
53    },
54    /// A visible session's descriptor changed.
55    Updated {
56        /// Complete replacement descriptor.
57        descriptor: SessionDescriptor,
58    },
59    /// A session disappeared from the bounded result page.
60    Removed {
61        /// Stable identity of the removed descriptor.
62        key: SessionIndexKey,
63    },
64}
65
66/// One subscription poll result. Revisions start at one for the initial
67/// snapshot and increase by exactly one for each non-empty delta batch.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct SessionIndexDelta {
70    /// Monotonic subscription-local revision.
71    pub revision: u64,
72    /// Complete replacement changes in deterministic identity order.
73    pub changes: Vec<SessionIndexChange>,
74}
75
76/// Filesystem-backed index subscription. Dropping it drops the platform
77/// watcher and callback channel, so unsubscribe has deterministic cleanup.
78pub(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    /// Drain and coalesce native invalidations once. No events means no I/O
137    /// until the minute-scale recovery reconciliation becomes due.
138    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        // A temporarily unreadable native store must not turn the service's
181        // 250 ms event pump into a hot full-catalog retry loop.
182        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    /// Returns true when a visible row disappeared and a complete page fill is
192    /// required. Unknown/temporary paths are harmless invalidations.
193    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        // macOS FSEvents reports canonical `/private/var/...` paths even when
198        // the subscribed root was supplied through the `/var` symlink.
199        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            // The child is intentionally absent from this root-only index, but
207            // its creation/removal changes the parent's rolled-up child count.
208            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    // Watching an entire home directory because a harness has never created
305    // its store is disproportionate. One parent level catches the ordinary
306    // first-run mkdir; the recovery reconciliation handles rarer deeper gaps.
307    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}