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