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, UNIX_EPOCH};
14
15use notify::event::{AccessKind, AccessMode};
16use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
17use serde::Serialize;
18use tokio::sync::Notify;
19
20use crate::{
21    catalog::{hermes_session_stores, CodexHistoryTopicIndex},
22    DiscoveryPage, DiscoveryQuery, HarnessCatalog, HarnessId, SessionDescriptor, SessionLocator,
23    StorageLocator,
24};
25
26const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
27const MAX_SUBSCRIPTION_ROWS: usize = 2_048;
28const INVALIDATION_QUEUE_CAPACITY: usize = 1_024;
29
30/// Stable public identity for a session-index change. Persistence paths remain
31/// inside the trusted host and are sent only as part of complete descriptors.
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
33pub struct SessionIndexKey {
34    /// Owning harness id.
35    pub harness: String,
36    /// Harness-native durable session id.
37    pub session_id: String,
38}
39
40impl SessionIndexKey {
41    fn from_locator(locator: &SessionLocator) -> Self {
42        Self {
43            harness: locator.harness.as_str().to_string(),
44            session_id: locator.session_id.clone(),
45        }
46    }
47}
48
49/// One complete replacement in a revisioned index delta.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51#[serde(tag = "kind", rename_all = "snake_case")]
52pub enum SessionIndexChange {
53    /// A session entered the bounded result page.
54    Added {
55        /// Complete current descriptor.
56        descriptor: SessionDescriptor,
57    },
58    /// A visible session's descriptor changed.
59    Updated {
60        /// Complete replacement descriptor.
61        descriptor: SessionDescriptor,
62    },
63    /// A session disappeared from the bounded result page.
64    Removed {
65        /// Stable identity of the removed descriptor.
66        key: SessionIndexKey,
67    },
68}
69
70/// One subscription poll result. Revisions start at one for the initial
71/// snapshot and increase by exactly one for each non-empty delta batch or
72/// committed window resize. They describe the visible window, not changes to
73/// out-of-window inventory totals returned by a same-limit read.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75pub struct SessionIndexDelta {
76    /// Monotonic subscription-local revision.
77    pub revision: u64,
78    /// Complete replacement changes in deterministic identity order.
79    pub changes: Vec<SessionIndexChange>,
80}
81
82/// Filesystem-backed index subscription. Dropping it drops the platform
83/// watcher and callback channel, so unsubscribe has deterministic cleanup.
84pub(crate) struct SessionIndexSubscription {
85    query: DiscoveryQuery,
86    raw: BTreeMap<SessionIndexKey, SessionDescriptor>,
87    paths: BTreeMap<PathBuf, SessionIndexKey>,
88    current: BTreeMap<SessionIndexKey, SessionDescriptor>,
89    fingerprints: BTreeMap<PathBuf, FileFingerprint>,
90    /// Whole-store SQLite files (Hermes `state.db` + its `-wal`/`-shm`), keyed by path. A store
91    /// holds every session in one file, so a stamp change means "re-enumerate this store", not
92    /// "this one path is one session". `None` = the file is absent.
93    store_fingerprints: BTreeMap<PathBuf, Option<FileFingerprint>>,
94    codex_history: Option<CodexHistoryTopicIndex>,
95    revision: u64,
96    receiver: mpsc::Receiver<notify::Result<Event>>,
97    overflowed: Arc<AtomicBool>,
98    _watcher: RecommendedWatcher,
99    last_reconcile: Instant,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103struct FileFingerprint {
104    len: u64,
105    modified_ns: u128,
106    modified_ms: Option<u64>,
107    identity: u128,
108}
109
110/// A complete replacement prepared without changing the subscription. The
111/// transport can serialize it before committing, so a failed response leaves
112/// the old window and revision usable.
113pub(crate) struct PreparedIndexResize {
114    limit: usize,
115    pub(crate) revision: u64,
116    pub(crate) page: DiscoveryPage,
117}
118
119impl SessionIndexSubscription {
120    pub(crate) fn homes(&self) -> &crate::HarnessHomes {
121        &self.query.homes
122    }
123
124    pub(crate) fn prepare_resize(&self, limit: usize) -> Result<PreparedIndexResize, String> {
125        let mut query = self.query.clone();
126        query.limit = Some(limit);
127        validate_query(&query)?;
128        // This is a snapshot of the known index, not a filesystem barrier.
129        // Pending invalidations stay queued and produce the next normal delta.
130        let page = self.project_current(&query, &BTreeSet::new())?;
131        Ok(PreparedIndexResize {
132            limit,
133            revision: if self.query.limit == Some(limit) {
134                self.revision
135            } else {
136                self.revision.saturating_add(1)
137            },
138            page,
139        })
140    }
141
142    pub(crate) fn commit_resize(&mut self, prepared: PreparedIndexResize) {
143        self.query.limit = Some(prepared.limit);
144        self.revision = prepared.revision;
145        self.current = descriptor_map(prepared.page.sessions);
146    }
147
148    pub(crate) fn open(
149        mut query: DiscoveryQuery,
150        notifier: Arc<Notify>,
151    ) -> Result<(Self, Vec<SessionDescriptor>), String> {
152        validate_query(&query)?;
153        query.cursor = None;
154        query.limit = Some(query.limit.unwrap_or(100));
155
156        let catalog = HarnessCatalog::new();
157        let raw = descriptor_map(catalog.discover_raw_index(&query));
158        let projected = catalog
159            .project_index(&query, raw.values().cloned())
160            .map_err(|error| error.to_string())?;
161        let mut codex_history = (query.include_topic_candidates
162            && query
163                .harnesses
164                .iter()
165                .any(|harness| harness.as_str() == HarnessId::CODEX))
166        .then(|| CodexHistoryTopicIndex::new(&query.homes.codex));
167        if let Some(history) = &mut codex_history {
168            // Discovery has always treated an unavailable history file as a
169            // soft fallback to transcript topics. Preserve that behavior.
170            let _ = history.refresh();
171        }
172        let initial = match &codex_history {
173            Some(history) => {
174                catalog.enrich_index_page_with_codex_history(&query, projected, history)
175            }
176            None => catalog.enrich_index_page(&query, projected),
177        }
178        .map_err(|error| error.to_string())?;
179        let paths = descriptor_path_map(&raw);
180        let current = descriptor_map(initial.iter().cloned());
181        let fingerprints = scan_file_fingerprints(&query);
182        let store_fingerprints = scan_store_fingerprints(&query);
183        let (sender, receiver) = mpsc::sync_channel(INVALIDATION_QUEUE_CAPACITY);
184        let overflowed = Arc::new(AtomicBool::new(false));
185        let callback_overflowed = Arc::clone(&overflowed);
186        let callback_notifier = Arc::clone(&notifier);
187        let mut watcher = notify::recommended_watcher(move |event| {
188            if sender.try_send(event).is_err() {
189                callback_overflowed.store(true, Ordering::Release);
190            }
191            callback_notifier.notify_one();
192        })
193        .map_err(|error| error.to_string())?;
194        for root in watch_roots(&query) {
195            if let Some(watched) = existing_watch_root(&root) {
196                watcher
197                    .watch(&watched, RecursiveMode::Recursive)
198                    .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
199            }
200        }
201        for store in store_paths(&query) {
202            // The store's directory, not the store file: a WAL-mode writer creates and removes the
203            // `-wal`/`-shm` siblings, and a first run creates the store itself.
204            let Some(dir) = store.parent() else { continue };
205            if let Some(watched) = existing_watch_root(dir) {
206                watcher
207                    .watch(&watched, RecursiveMode::NonRecursive)
208                    .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
209            }
210        }
211        if let Some(history) = &codex_history {
212            let target = if history.path().is_file() {
213                history.path()
214            } else {
215                history.path().parent().unwrap_or(history.path())
216            };
217            if target.exists() {
218                watcher
219                    .watch(target, RecursiveMode::NonRecursive)
220                    .map_err(|error| format!("cannot watch {}: {error}", target.display()))?;
221            }
222        }
223
224        Ok((
225            Self {
226                query,
227                raw,
228                paths,
229                current,
230                fingerprints,
231                store_fingerprints,
232                codex_history,
233                revision: 1,
234                receiver,
235                overflowed,
236                _watcher: watcher,
237                last_reconcile: Instant::now(),
238            },
239            initial,
240        ))
241    }
242
243    /// Drain and coalesce native invalidations once. No events means no I/O
244    /// until the minute-scale metadata-only recovery sweep becomes due.
245    pub(crate) fn poll(&mut self) -> Result<Option<SessionIndexDelta>, String> {
246        let mut paths = BTreeSet::new();
247        let mut sweep = self.overflowed.swap(false, Ordering::AcqRel);
248        let mut stores = false;
249        while let Ok(event) = self.receiver.try_recv() {
250            match event {
251                // an open, a read or a close without writing changes nothing: on Linux, notify reports every
252                // open (IN_OPEN) in a watched directory, so a Hermes gateway opening its store per request drove
253                // a full re-index per open (a core at 100% in Open Autonomy's reporter); a close after writing stays
254                Ok(event) if matches!(event.kind, EventKind::Access(access) if access != AccessKind::Close(AccessMode::Write)) =>
255                    {}
256                Ok(event) => {
257                    if event.paths.is_empty() {
258                        sweep = true;
259                    }
260                    for path in event.paths {
261                        if is_store_shm(&self.store_fingerprints, &path) {
262                            continue;
263                        }
264                        if path.extension().and_then(|value| value.to_str()) == Some("jsonl") {
265                            paths.insert(path);
266                        } else if self
267                            .store_fingerprints
268                            .contains_key(&normalized_store_path(&path))
269                        {
270                            stores = true;
271                        } else {
272                            sweep = true;
273                        }
274                    }
275                }
276                Err(_) => sweep = true,
277            }
278        }
279        if self.last_reconcile.elapsed() >= RECONCILE_INTERVAL {
280            sweep = true;
281        }
282        if paths.is_empty() && !sweep && !stores {
283            return Ok(None);
284        }
285
286        let before = self.current.clone();
287        let mut content_dirty = BTreeSet::new();
288        let history_path = self
289            .codex_history
290            .as_ref()
291            .map(|history| normalized_path(history.path()));
292        if let Some(history) = &mut self.codex_history {
293            if let Ok(changed) = history.refresh() {
294                content_dirty.extend(changed.into_iter().map(|session_id| SessionIndexKey {
295                    harness: HarnessId::CODEX.to_string(),
296                    session_id,
297                }));
298            }
299        }
300        if sweep {
301            self.reconcile_filesystem(&mut content_dirty)?;
302        }
303        if sweep || stores {
304            self.reconcile_stores(&mut content_dirty)?;
305        }
306        for path in paths {
307            if history_path
308                .as_ref()
309                .is_some_and(|history_path| normalized_path(&path) == *history_path)
310            {
311                continue;
312            }
313            self.refresh_path(&path, &mut content_dirty)?;
314        }
315        self.rebuild_current(&content_dirty)?;
316        let changes = diff_descriptors(&before, &self.current);
317        if changes.is_empty() {
318            return Ok(None);
319        }
320        self.revision = self.revision.saturating_add(1);
321        Ok(Some(SessionIndexDelta {
322            revision: self.revision,
323            changes,
324        }))
325    }
326
327    fn reconcile_filesystem(
328        &mut self,
329        content_dirty: &mut BTreeSet<SessionIndexKey>,
330    ) -> Result<(), String> {
331        self.last_reconcile = Instant::now();
332        let next = scan_file_fingerprints(&self.query);
333        let changed = self
334            .fingerprints
335            .keys()
336            .chain(next.keys())
337            .filter(|path| self.fingerprints.get(*path) != next.get(*path))
338            .cloned()
339            .collect::<BTreeSet<_>>();
340        for path in changed {
341            self.refresh_path(&path, content_dirty)?;
342        }
343        self.fingerprints = next;
344        Ok(())
345    }
346
347    /// Re-enumerate every whole-store harness whose store stamps moved. Rows are diffed by value:
348    /// a store keeps its sessions' `message_count`/`ended_at` current on every append, so a
349    /// descriptor that compares equal is unchanged and one that differs is content-dirty.
350    fn reconcile_stores(
351        &mut self,
352        content_dirty: &mut BTreeSet<SessionIndexKey>,
353    ) -> Result<(), String> {
354        let next = scan_store_fingerprints(&self.query);
355        if next == self.store_fingerprints {
356            return Ok(());
357        }
358        self.store_fingerprints = next;
359        let mut query = self.query.clone();
360        query
361            .harnesses
362            .retain(|harness| harness.as_str() == HarnessId::HERMES);
363        if query.harnesses.is_empty() {
364            return Ok(());
365        }
366        let fresh = descriptor_map(HarnessCatalog::new().discover_raw_index(&query));
367        let stale = self
368            .raw
369            .keys()
370            .filter(|key| key.harness == HarnessId::HERMES)
371            .cloned()
372            .collect::<Vec<_>>();
373        for key in stale {
374            if !fresh.contains_key(&key) {
375                self.raw.remove(&key);
376                content_dirty.insert(key);
377            }
378        }
379        for (key, descriptor) in fresh {
380            if self.raw.get(&key) != Some(&descriptor) {
381                self.raw.insert(key.clone(), descriptor);
382                content_dirty.insert(key);
383            }
384        }
385        Ok(())
386    }
387
388    fn refresh_path(
389        &mut self,
390        path: &Path,
391        content_dirty: &mut BTreeSet<SessionIndexKey>,
392    ) -> Result<(), String> {
393        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
394            return Ok(());
395        }
396        let event_path = normalized_path(path);
397        let previous_key = self.paths.get(&event_path).cloned();
398        let previous = previous_key
399            .as_ref()
400            .and_then(|key| self.raw.get(key))
401            .cloned();
402        let previous_fingerprint = self.fingerprints.get(&event_path).copied();
403        let fingerprint = file_fingerprint(&event_path);
404
405        let Some(fingerprint) = fingerprint else {
406            self.fingerprints.remove(&event_path);
407            if let Some(key) = previous_key {
408                self.paths.remove(&event_path);
409                self.raw.remove(&key);
410                content_dirty.insert(key);
411            }
412            return Ok(());
413        };
414        self.fingerprints.insert(event_path.clone(), fingerprint);
415
416        let locator = previous
417            .as_ref()
418            .map(|descriptor| descriptor.locator.clone())
419            .or_else(|| locator_for_path(&self.query, &event_path));
420        let Some(locator) = locator else {
421            return Ok(());
422        };
423        let refreshed =
424            if let (Some(descriptor), Some(old)) = (previous.as_ref(), previous_fingerprint) {
425                if can_reuse_header(descriptor, old, fingerprint) {
426                    let mut descriptor = descriptor.clone();
427                    descriptor.updated_at_ms = fingerprint.modified_ms;
428                    Some(descriptor)
429                } else {
430                    HarnessCatalog::new()
431                        .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
432                        .map_err(|error| error.to_string())?
433                }
434            } else {
435                HarnessCatalog::new()
436                    .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
437                    .map_err(|error| error.to_string())?
438            };
439        let Some(descriptor) = refreshed else {
440            return Ok(());
441        };
442        let key = SessionIndexKey::from_locator(&descriptor.locator);
443        if let Some(previous_key) = previous_key {
444            if previous_key != key {
445                self.raw.remove(&previous_key);
446                content_dirty.insert(previous_key);
447            }
448        }
449        self.paths.insert(event_path, key.clone());
450        self.raw.insert(key.clone(), descriptor);
451        content_dirty.insert(key);
452        Ok(())
453    }
454
455    fn rebuild_current(&mut self, content_dirty: &BTreeSet<SessionIndexKey>) -> Result<(), String> {
456        let page = self.project_current(&self.query, content_dirty)?;
457        self.current = descriptor_map(page.sessions);
458        Ok(())
459    }
460
461    fn project_current(
462        &self,
463        query: &DiscoveryQuery,
464        content_dirty: &BTreeSet<SessionIndexKey>,
465    ) -> Result<DiscoveryPage, String> {
466        let catalog = HarnessCatalog::new();
467        let mut page = catalog
468            .project_index_page(query, self.raw.values().cloned())
469            .map_err(|error| error.to_string())?;
470        let mut next = Vec::with_capacity(page.sessions.len());
471        for mut descriptor in page.sessions {
472            let key = SessionIndexKey::from_locator(&descriptor.locator);
473            if let Some(previous) = self.current.get(&key) {
474                descriptor.preview_candidates = previous.preview_candidates.clone();
475                descriptor.latest_message_candidates = previous.latest_message_candidates.clone();
476            }
477            if !self.current.contains_key(&key) || content_dirty.contains(&key) {
478                let enriched = match &self.codex_history {
479                    Some(history) => catalog.enrich_index_page_with_codex_history(
480                        query,
481                        vec![descriptor],
482                        history,
483                    ),
484                    None => catalog.enrich_index_page(query, vec![descriptor]),
485                };
486                descriptor = enriched
487                    .map_err(|error| error.to_string())?
488                    .pop()
489                    .expect("one descriptor remains one descriptor");
490            }
491            next.push(descriptor);
492        }
493        page.sessions = next;
494        Ok(page)
495    }
496}
497
498pub(crate) fn validate_query(query: &DiscoveryQuery) -> Result<(), String> {
499    if query.search_previews {
500        return Err(
501            "sessions.index.subscribe does not support preview search; use sessions.discover"
502                .into(),
503        );
504    }
505    if query.cursor.is_some() {
506        return Err("sessions.index.subscribe does not accept a cursor".into());
507    }
508    validate_limit(query.limit.unwrap_or(100))?;
509    if query.harnesses.is_empty()
510        || query.harnesses.iter().any(|harness| {
511            !matches!(
512                harness.as_str(),
513                HarnessId::CLAUDE_CODE | HarnessId::CODEX | HarnessId::HERMES
514            )
515        })
516    {
517        return Err(
518            "sessions.index.subscribe currently requires explicit claude-code, codex and/or hermes harnesses"
519                .into(),
520        );
521    }
522    Ok(())
523}
524
525pub(crate) fn validate_limit(limit: usize) -> Result<(), String> {
526    if limit == 0 || limit > MAX_SUBSCRIPTION_ROWS {
527        return Err(format!(
528            "session index limit must be between 1 and {MAX_SUBSCRIPTION_ROWS}"
529        ));
530    }
531    Ok(())
532}
533
534fn watch_roots(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
535    query
536        .harnesses
537        .iter()
538        .filter_map(|harness| match harness.as_str() {
539            HarnessId::CLAUDE_CODE => Some(query.homes.claude_code.clone()),
540            HarnessId::CODEX => Some(query.homes.codex.clone()),
541            _ => None,
542        })
543        .collect()
544}
545
546fn existing_watch_root(root: &Path) -> Option<PathBuf> {
547    if root.is_dir() {
548        return Some(root.to_path_buf());
549    }
550    // Watching an entire home directory because a harness has never created
551    // its store is disproportionate. One parent level catches the ordinary
552    // first-run mkdir; the recovery reconciliation handles rarer deeper gaps.
553    root.parent()
554        .filter(|parent| parent.is_dir())
555        .map(Path::to_path_buf)
556}
557
558/// Whole-store SQLite files named by the query (one file = every session of that harness).
559fn store_paths(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
560    query
561        .harnesses
562        .iter()
563        .flat_map(|harness| match harness.as_str() {
564            HarnessId::HERMES => hermes_session_stores(&query.homes.hermes),
565            _ => Vec::new(),
566        })
567        .collect()
568}
569
570/// A store's stamp set: the file itself and its WAL-mode siblings, which is where a live writer's
571/// appends land until a checkpoint. Absent files are kept as `None` so their creation is a change.
572fn scan_store_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, Option<FileFingerprint>> {
573    let mut stamps = BTreeMap::new();
574    for store in store_paths(query) {
575        for path in store_sibling_paths(&store) {
576            let stamp = file_fingerprint(&path);
577            stamps.insert(normalized_store_path(&path), stamp);
578        }
579    }
580    stamps
581}
582
583/// The files that hold a store's committed content: the database and its write-ahead log. The
584/// `-shm` sibling is SQLite's shared-memory index, which every reader writes (this index's own
585/// read-only enumeration included): stamping it made each enumeration trigger the next one.
586fn store_sibling_paths(store: &Path) -> [PathBuf; 2] {
587    let name = store
588        .file_name()
589        .and_then(|value| value.to_str())
590        .unwrap_or("state.db");
591    [
592        store.to_path_buf(),
593        store.with_file_name(format!("{name}-wal")),
594    ]
595}
596
597/// A watched store's `-shm` sibling: its changes are readers' bookkeeping, not content.
598fn is_store_shm(stores: &BTreeMap<PathBuf, Option<FileFingerprint>>, path: &Path) -> bool {
599    let path = normalized_store_path(path);
600    path.to_str()
601        .and_then(|value| value.strip_suffix("-shm"))
602        .is_some_and(|store| stores.contains_key(Path::new(store)))
603}
604
605/// Store siblings come and go, so canonicalize through the (stable) directory rather than the file.
606fn normalized_store_path(path: &Path) -> PathBuf {
607    match (path.parent(), path.file_name()) {
608        (Some(dir), Some(name)) => normalized_path(dir).join(name),
609        _ => path.to_path_buf(),
610    }
611}
612
613fn locator_for_path(query: &DiscoveryQuery, path: &Path) -> Option<SessionLocator> {
614    let claude_root = normalized_path(&query.homes.claude_code);
615    let codex_root = normalized_path(&query.homes.codex);
616    let harness = if query
617        .harnesses
618        .iter()
619        .any(|harness| harness.as_str() == HarnessId::CLAUDE_CODE)
620        && path.starts_with(&claude_root)
621    {
622        HarnessId::CLAUDE_CODE
623    } else if query
624        .harnesses
625        .iter()
626        .any(|harness| harness.as_str() == HarnessId::CODEX)
627        && path.starts_with(&codex_root)
628    {
629        HarnessId::CODEX
630    } else {
631        return None;
632    };
633    Some(SessionLocator {
634        harness: HarnessId::new(harness),
635        session_id: path
636            .file_stem()
637            .and_then(|value| value.to_str())
638            .unwrap_or("unknown")
639            .to_string(),
640        storage: StorageLocator::File {
641            path: path.to_path_buf(),
642        },
643    })
644}
645
646fn normalized_path(path: &Path) -> PathBuf {
647    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
648}
649
650fn descriptor_path_map(
651    descriptors: &BTreeMap<SessionIndexKey, SessionDescriptor>,
652) -> BTreeMap<PathBuf, SessionIndexKey> {
653    descriptors
654        .iter()
655        .map(|(key, descriptor)| {
656            (
657                normalized_path(descriptor.locator.storage.path()),
658                key.clone(),
659            )
660        })
661        .collect()
662}
663
664fn scan_file_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, FileFingerprint> {
665    let mut paths = Vec::new();
666    for root in watch_roots(query) {
667        collect_jsonl_paths(&root, &mut paths);
668    }
669    paths
670        .into_iter()
671        .filter_map(|path| {
672            let path = normalized_path(&path);
673            file_fingerprint(&path).map(|fingerprint| (path, fingerprint))
674        })
675        .collect()
676}
677
678fn collect_jsonl_paths(root: &Path, paths: &mut Vec<PathBuf>) {
679    let Ok(entries) = fs::read_dir(root) else {
680        return;
681    };
682    for entry in entries.flatten() {
683        let Ok(file_type) = entry.file_type() else {
684            continue;
685        };
686        let path = entry.path();
687        if file_type.is_dir() {
688            collect_jsonl_paths(&path, paths);
689        } else if file_type.is_file()
690            && path.extension().and_then(|value| value.to_str()) == Some("jsonl")
691        {
692            paths.push(path);
693        }
694    }
695}
696
697fn file_fingerprint(path: &Path) -> Option<FileFingerprint> {
698    let metadata = fs::metadata(path).ok()?;
699    let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
700    #[cfg(unix)]
701    let identity = {
702        use std::os::unix::fs::MetadataExt;
703        (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
704    };
705    #[cfg(not(unix))]
706    let identity = 0;
707    Some(FileFingerprint {
708        len: metadata.len(),
709        modified_ns: modified.as_nanos(),
710        modified_ms: u64::try_from(modified.as_millis()).ok(),
711        identity,
712    })
713}
714
715fn can_reuse_header(
716    descriptor: &SessionDescriptor,
717    previous: FileFingerprint,
718    current: FileFingerprint,
719) -> bool {
720    previous.identity == current.identity
721        && previous.len <= current.len
722        && descriptor.cwd.is_some()
723        && descriptor.model.is_some()
724        && !descriptor.locator.session_id.is_empty()
725}
726
727fn descriptor_map(
728    descriptors: impl IntoIterator<Item = SessionDescriptor>,
729) -> BTreeMap<SessionIndexKey, SessionDescriptor> {
730    descriptors
731        .into_iter()
732        .map(|descriptor| {
733            (
734                SessionIndexKey::from_locator(&descriptor.locator),
735                descriptor,
736            )
737        })
738        .collect()
739}
740
741fn diff_descriptors(
742    before: &BTreeMap<SessionIndexKey, SessionDescriptor>,
743    after: &BTreeMap<SessionIndexKey, SessionDescriptor>,
744) -> Vec<SessionIndexChange> {
745    let mut changes = Vec::new();
746    for (key, descriptor) in after {
747        match before.get(key) {
748            None => changes.push(SessionIndexChange::Added {
749                descriptor: descriptor.clone(),
750            }),
751            Some(previous) if previous != descriptor => {
752                changes.push(SessionIndexChange::Updated {
753                    descriptor: descriptor.clone(),
754                });
755            }
756            Some(_) => {}
757        }
758    }
759    for key in before.keys() {
760        if !after.contains_key(key) {
761            changes.push(SessionIndexChange::Removed { key: key.clone() });
762        }
763    }
764    changes
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn preview_search_is_refused_before_opening_a_retained_index() {
773        let query = DiscoveryQuery {
774            search_previews: true,
775            query: Some("nebula".into()),
776            ..DiscoveryQuery::default()
777        };
778        let error = match SessionIndexSubscription::open(query, Arc::new(Notify::new())) {
779            Err(error) => error,
780            Ok(_) => panic!("preview search must not open live watchers"),
781        };
782        assert!(error.contains("use sessions.discover"), "{error}");
783    }
784
785    fn descriptor(id: &str, updated_at_ms: u64) -> SessionDescriptor {
786        SessionDescriptor {
787            locator: SessionLocator {
788                harness: HarnessId::new(HarnessId::CODEX),
789                session_id: id.into(),
790                storage: StorageLocator::File {
791                    path: PathBuf::from(format!("/{id}.jsonl")),
792                },
793            },
794            cwd: None,
795            title: None,
796            preview_candidates: Vec::new(),
797            latest_message_candidates: Vec::new(),
798            updated_at_ms: Some(updated_at_ms),
799            message_count: None,
800            model: None,
801            parent_session_id: None,
802            child_session_count: 0,
803            nouns: Default::default(),
804        }
805    }
806
807    #[test]
808    fn resize_retains_index_watcher_and_cached_previews_until_commit() {
809        let root = std::env::temp_dir().join(format!(
810            "supercode-index-resize-{}-{}",
811            std::process::id(),
812            std::time::SystemTime::now()
813                .duration_since(UNIX_EPOCH)
814                .unwrap()
815                .as_nanos()
816        ));
817        fs::create_dir_all(&root).unwrap();
818        let root = root.canonicalize().unwrap();
819        let query = DiscoveryQuery {
820            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
821            homes: crate::HarnessHomes {
822                codex: root.clone(),
823                ..crate::HarnessHomes::default()
824            },
825            limit: Some(1),
826            include_topic_candidates: true,
827            ..DiscoveryQuery::default()
828        };
829        let (mut index, _) =
830            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
831        for (id, updated) in [("newest", 3), ("middle", 2), ("oldest", 1)] {
832            let path = root.join(format!("{id}.jsonl"));
833            fs::write(&path, format!(
834                "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"cwd\":\"/workspace\"}}}}\n{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"topic {id}\"}}]}}}}\n"
835            )).unwrap();
836            let mut row = descriptor(id, updated);
837            row.locator.storage = StorageLocator::File { path };
838            index
839                .raw
840                .insert(SessionIndexKey::from_locator(&row.locator), row);
841        }
842        index.paths = descriptor_path_map(&index.raw);
843        index.rebuild_current(&BTreeSet::new()).unwrap();
844        let original = index.current.clone();
845        assert!(!original
846            .values()
847            .next()
848            .unwrap()
849            .preview_candidates
850            .is_empty());
851        // If resize re-read this already-visible transcript, its topic would disappear.
852        fs::write(root.join("newest.jsonl"), "").unwrap();
853        // A new file must not be enumerated by resize; its queued event belongs to poll.
854        let queued = root.join("queued.jsonl");
855        fs::write(
856            &queued,
857            "{\"type\":\"session_meta\",\"payload\":{\"id\":\"queued\",\"cwd\":\"/workspace\"}}\n",
858        )
859        .unwrap();
860        let (sender, receiver) = mpsc::channel();
861        // Keep the native channel alive while driving deterministic invalidations.
862        let _native_receiver = std::mem::replace(&mut index.receiver, receiver);
863        sender
864            .send(Ok(Event::new(notify::EventKind::Any).add_path(queued)))
865            .unwrap();
866        let watcher = &index._watcher as *const _;
867        let raw_row = index.raw.values().next().unwrap() as *const _;
868        let raw = index.raw.clone();
869        let reconcile = index.last_reconcile;
870        let prepared = index.prepare_resize(2).unwrap();
871        assert_eq!(prepared.revision, 2);
872        assert_eq!(prepared.page.receipt.total_matched, 3);
873        assert_eq!(prepared.page.sessions.len(), 2);
874        assert_eq!(
875            prepared.page.sessions[0],
876            *original.values().next().unwrap()
877        );
878        assert!(!prepared.page.sessions[1].preview_candidates.is_empty());
879        assert_eq!(index.current, original);
880        assert_eq!(index.revision, 1);
881        drop(prepared); // A response-construction failure must not commit the candidate.
882        assert!(index.prepare_resize(0).is_err());
883        assert!(index.prepare_resize(2049).is_err());
884        assert_eq!(index.current, original);
885        assert_eq!(index.revision, 1);
886        let prepared = index.prepare_resize(2).unwrap();
887        index.commit_resize(prepared);
888        assert_eq!(index.raw, raw);
889        assert_eq!(index.raw.values().next().unwrap() as *const _, raw_row);
890        assert_eq!(&index._watcher as *const _, watcher);
891        assert_eq!(index.last_reconcile, reconcile);
892        assert_eq!(index.prepare_resize(2).unwrap().revision, 2);
893        let delta = index.poll().unwrap().unwrap();
894        assert_eq!(delta.revision, 3);
895        let shrink = index.prepare_resize(1).unwrap();
896        assert_eq!(shrink.revision, 4);
897        index.commit_resize(shrink);
898        assert_eq!(index.current.len(), 1);
899        assert_eq!(index.prepare_resize(1).unwrap().revision, 4);
900        drop(index);
901        fs::remove_dir_all(root).unwrap();
902    }
903
904    #[test]
905    fn same_limit_receipt_counts_out_of_window_changes_without_visible_revision() {
906        let root = std::env::temp_dir().join(format!(
907            "supercode-index-total-{}-{}",
908            std::process::id(),
909            std::time::SystemTime::now()
910                .duration_since(UNIX_EPOCH)
911                .unwrap()
912                .as_nanos()
913        ));
914        fs::create_dir_all(&root).unwrap();
915        let root = root.canonicalize().unwrap();
916        let query = DiscoveryQuery {
917            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
918            homes: crate::HarnessHomes {
919                codex: root.clone(),
920                ..crate::HarnessHomes::default()
921            },
922            limit: Some(1),
923            ..DiscoveryQuery::default()
924        };
925        let (mut index, _) =
926            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
927        let visible = descriptor("visible", u64::MAX);
928        index.raw = descriptor_map([visible]);
929        index.rebuild_current(&BTreeSet::new()).unwrap();
930        let (sender, receiver) = mpsc::channel();
931        let _native_receiver = std::mem::replace(&mut index.receiver, receiver);
932        let hidden = root.join("hidden.jsonl");
933        fs::write(
934            &hidden,
935            "{\"type\":\"session_meta\",\"payload\":{\"id\":\"hidden\",\"cwd\":\"/workspace\"}}\n",
936        )
937        .unwrap();
938        sender
939            .send(Ok(
940                Event::new(notify::EventKind::Any).add_path(hidden.clone())
941            ))
942            .unwrap();
943        assert!(index.poll().unwrap().is_none());
944        let added = index.prepare_resize(1).unwrap();
945        assert_eq!(added.revision, 1);
946        assert_eq!(added.page.receipt.total_matched, 2);
947        fs::remove_file(&hidden).unwrap();
948        sender
949            .send(Ok(Event::new(notify::EventKind::Any).add_path(hidden)))
950            .unwrap();
951        assert!(index.poll().unwrap().is_none());
952        let removed = index.prepare_resize(1).unwrap();
953        assert_eq!(removed.revision, 1);
954        assert_eq!(removed.page.receipt.total_matched, 1);
955        drop(index);
956        fs::remove_dir_all(root).unwrap();
957    }
958
959    #[test]
960    fn hermes_store_appends_surface_as_index_updates() {
961        // A private copy of the committed Hermes fixture store, in its own directory, so the
962        // subscription watches exactly one store and the test may write to it.
963        let root = std::env::temp_dir().join(format!(
964            "supercode-index-hermes-{}-{}",
965            std::process::id(),
966            std::time::SystemTime::now()
967                .duration_since(UNIX_EPOCH)
968                .unwrap()
969                .as_nanos()
970        ));
971        fs::create_dir_all(&root).unwrap();
972        let db = root.join("state.db");
973        fs::copy(
974            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_home/state.db"),
975            &db,
976        )
977        .unwrap();
978        let query = DiscoveryQuery {
979            harnesses: vec![HarnessId::new(HarnessId::HERMES)],
980            homes: crate::HarnessHomes {
981                hermes: db.clone(),
982                claude_code: root.join("missing-claude"),
983                codex: root.join("missing-codex"),
984                ..crate::HarnessHomes::default()
985            },
986            ..DiscoveryQuery::default()
987        };
988        let (mut subscription, initial) =
989            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
990        assert!(initial.len() >= 2, "{initial:#?}");
991        assert!(initial
992            .iter()
993            .all(|descriptor| descriptor.locator.harness.as_str() == HarnessId::HERMES));
994        assert!(
995            subscription.poll().unwrap().is_none(),
996            "quiet store, quiet index"
997        );
998
999        // Append one message the way Hermes does: a messages row plus the session's count bump.
1000        let target = initial[0].locator.session_id.clone();
1001        std::thread::sleep(Duration::from_millis(20));
1002        {
1003            let conn = rusqlite::Connection::open(&db).unwrap();
1004            conn.execute(
1005                "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'index test append', ?2, 1)",
1006                rusqlite::params![target, 1_800_000_000.0_f64],
1007            )
1008            .unwrap();
1009            conn.execute(
1010                "UPDATE sessions SET message_count = message_count + 1, ended_at = ?2 WHERE id = ?1",
1011                rusqlite::params![target, 1_800_000_000.0_f64],
1012            )
1013            .unwrap();
1014        }
1015        // Wait for the native watcher (bounded), then poll: exactly the appended session changes.
1016        let deadline = Instant::now() + Duration::from_secs(5);
1017        let delta = loop {
1018            if let Some(delta) = subscription.poll().unwrap() {
1019                break delta;
1020            }
1021            assert!(
1022                Instant::now() < deadline,
1023                "no index delta after the store append"
1024            );
1025            std::thread::sleep(Duration::from_millis(50));
1026        };
1027        assert_eq!(delta.changes.len(), 1, "{delta:#?}");
1028        match &delta.changes[0] {
1029            SessionIndexChange::Updated { descriptor } => {
1030                assert_eq!(descriptor.locator.session_id, target);
1031                assert_eq!(
1032                    descriptor.message_count,
1033                    initial[0].message_count.map(|count| count + 1)
1034                );
1035            }
1036            other => panic!("expected an update for {target}, got {other:?}"),
1037        }
1038        assert!(
1039            subscription.poll().unwrap().is_none(),
1040            "one append, one delta"
1041        );
1042        fs::remove_dir_all(&root).ok();
1043    }
1044
1045    #[test]
1046    fn index_delta_is_a_complete_deterministic_replacement_set() {
1047        let before = descriptor_map([descriptor("removed", 1), descriptor("updated", 2)]);
1048        let after = descriptor_map([descriptor("updated", 3), descriptor("added", 4)]);
1049        let changes = diff_descriptors(&before, &after);
1050        assert!(matches!(
1051            &changes[0],
1052            SessionIndexChange::Added { descriptor } if descriptor.locator.session_id == "added"
1053        ));
1054        assert!(matches!(
1055            &changes[1],
1056            SessionIndexChange::Updated { descriptor } if descriptor.locator.session_id == "updated"
1057        ));
1058        assert!(matches!(
1059            &changes[2],
1060            SessionIndexChange::Removed { key } if key.session_id == "removed"
1061        ));
1062    }
1063
1064    #[test]
1065    fn raw_index_projects_child_activity_into_one_root_row() {
1066        let root = descriptor("root", 10);
1067        let mut child = descriptor("child", 20);
1068        child.parent_session_id = Some("root".into());
1069        let query = DiscoveryQuery {
1070            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
1071            limit: Some(100),
1072            ..DiscoveryQuery::default()
1073        };
1074
1075        let projected = HarnessCatalog::new()
1076            .project_index(&query, [root, child])
1077            .unwrap();
1078
1079        assert_eq!(projected.len(), 1);
1080        assert_eq!(projected[0].locator.session_id, "root");
1081        assert_eq!(projected[0].updated_at_ms, Some(20));
1082        assert_eq!(projected[0].child_session_count, 1);
1083    }
1084
1085    #[test]
1086    fn complete_raw_index_backfills_a_bounded_page_without_discovery() {
1087        let query = DiscoveryQuery {
1088            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
1089            limit: Some(2),
1090            ..DiscoveryQuery::default()
1091        };
1092        let catalog = HarnessCatalog::new();
1093        let mut raw = descriptor_map([
1094            descriptor("oldest", 1),
1095            descriptor("middle", 2),
1096            descriptor("newest", 3),
1097        ]);
1098        let initial = catalog
1099            .project_index(&query, raw.values().cloned())
1100            .unwrap();
1101        assert_eq!(
1102            initial
1103                .iter()
1104                .map(|descriptor| descriptor.locator.session_id.as_str())
1105                .collect::<Vec<_>>(),
1106            ["newest", "middle"]
1107        );
1108
1109        raw.remove(&SessionIndexKey {
1110            harness: HarnessId::CODEX.into(),
1111            session_id: "newest".into(),
1112        });
1113        let after = catalog
1114            .project_index(&query, raw.values().cloned())
1115            .unwrap();
1116        assert_eq!(
1117            after
1118                .iter()
1119                .map(|descriptor| descriptor.locator.session_id.as_str())
1120                .collect::<Vec<_>>(),
1121            ["middle", "oldest"]
1122        );
1123    }
1124
1125    #[test]
1126    fn append_reuses_an_immutable_header_but_replacement_does_not() {
1127        let mut existing = descriptor("session", 1);
1128        existing.cwd = Some(PathBuf::from("/workspace"));
1129        existing.model = Some("model".into());
1130        let before = FileFingerprint {
1131            len: 100,
1132            modified_ns: 1,
1133            modified_ms: Some(1),
1134            identity: 7,
1135        };
1136        let append = FileFingerprint {
1137            len: 200,
1138            modified_ns: 2,
1139            modified_ms: Some(2),
1140            identity: 7,
1141        };
1142        let replacement = FileFingerprint {
1143            identity: 8,
1144            ..append
1145        };
1146
1147        assert!(can_reuse_header(&existing, before, append));
1148        assert!(!can_reuse_header(&existing, before, replacement));
1149    }
1150
1151    #[tokio::test]
1152    async fn filesystem_event_wakes_index_without_a_poll_timer() {
1153        let nonce = std::time::SystemTime::now()
1154            .duration_since(UNIX_EPOCH)
1155            .unwrap()
1156            .as_nanos();
1157        let root = std::env::temp_dir().join(format!(
1158            "supercode-session-index-{}-{nonce}",
1159            std::process::id()
1160        ));
1161        let codex = root.join("codex");
1162        fs::create_dir_all(&codex).unwrap();
1163        let query = DiscoveryQuery {
1164            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
1165            homes: crate::HarnessHomes {
1166                codex: codex.clone(),
1167                ..crate::HarnessHomes::default()
1168            },
1169            limit: Some(10),
1170            ..DiscoveryQuery::default()
1171        };
1172        let notifier = Arc::new(Notify::new());
1173        let (mut index, initial) =
1174            SessionIndexSubscription::open(query, Arc::clone(&notifier)).unwrap();
1175        assert!(initial.is_empty());
1176
1177        let session = codex.join("new.jsonl");
1178        fs::write(
1179            &session,
1180            concat!(
1181                "{\"type\":\"session_meta\",\"payload\":{\"id\":\"new\",\"cwd\":\"/workspace\"}}\n",
1182                "{\"type\":\"turn_context\",\"payload\":{\"cwd\":\"/workspace\",\"model\":\"gpt-test\"}}\n"
1183            ),
1184        )
1185        .unwrap();
1186
1187        tokio::time::timeout(Duration::from_secs(5), notifier.notified())
1188            .await
1189            .expect("filesystem invalidation should wake the index");
1190        let delta = index
1191            .poll()
1192            .unwrap()
1193            .expect("the filesystem event should produce a visible delta");
1194        assert!(matches!(
1195            &delta.changes[0],
1196            SessionIndexChange::Added { descriptor }
1197                if descriptor.locator.session_id == "new"
1198        ));
1199
1200        drop(index);
1201        fs::remove_dir_all(root).unwrap();
1202    }
1203}