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        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    // Watching an entire home directory because a harness has never created
295    // its store is disproportionate. One parent level catches the ordinary
296    // first-run mkdir; the recovery reconciliation handles rarer deeper gaps.
297    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}