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 mut content_dirty = BTreeSet::new();
287        let history_path = self
288            .codex_history
289            .as_ref()
290            .map(|history| normalized_path(history.path()));
291        if let Some(history) = &mut self.codex_history {
292            if let Ok(changed) = history.refresh() {
293                content_dirty.extend(changed.into_iter().map(|session_id| SessionIndexKey {
294                    harness: HarnessId::CODEX.to_string(),
295                    session_id,
296                }));
297            }
298        }
299        if sweep {
300            self.reconcile_filesystem(&mut content_dirty)?;
301        }
302        if sweep || stores {
303            self.reconcile_stores(&mut content_dirty)?;
304        }
305        for path in paths {
306            if history_path
307                .as_ref()
308                .is_some_and(|history_path| normalized_path(&path) == *history_path)
309            {
310                continue;
311            }
312            self.refresh_path(&path, &mut content_dirty)?;
313        }
314        // Nothing that feeds the index moved: whatever woke this poll (a reader's -wal close or attribute change, an
315        // unrelated file beside a store) changed no session, and the rebuild would reproduce the index it has. On a
316        // Hermes store its gateway's every read woke it, and rebuilding the whole index each time held a core.
317        if content_dirty.is_empty() {
318            return Ok(None);
319        }
320        let before = self.current.clone();
321        self.rebuild_current(&content_dirty)?;
322        let changes = diff_descriptors(&before, &self.current);
323        if changes.is_empty() {
324            return Ok(None);
325        }
326        self.revision = self.revision.saturating_add(1);
327        Ok(Some(SessionIndexDelta {
328            revision: self.revision,
329            changes,
330        }))
331    }
332
333    fn reconcile_filesystem(
334        &mut self,
335        content_dirty: &mut BTreeSet<SessionIndexKey>,
336    ) -> Result<(), String> {
337        self.last_reconcile = Instant::now();
338        let next = scan_file_fingerprints(&self.query);
339        let changed = self
340            .fingerprints
341            .keys()
342            .chain(next.keys())
343            .filter(|path| self.fingerprints.get(*path) != next.get(*path))
344            .cloned()
345            .collect::<BTreeSet<_>>();
346        for path in changed {
347            self.refresh_path(&path, content_dirty)?;
348        }
349        self.fingerprints = next;
350        Ok(())
351    }
352
353    /// Re-enumerate every whole-store harness whose store stamps moved. Rows are diffed by value:
354    /// a store keeps its sessions' `message_count`/`ended_at` current on every append, so a
355    /// descriptor that compares equal is unchanged and one that differs is content-dirty.
356    fn reconcile_stores(
357        &mut self,
358        content_dirty: &mut BTreeSet<SessionIndexKey>,
359    ) -> Result<(), String> {
360        let next = scan_store_fingerprints(&self.query);
361        if next == self.store_fingerprints {
362            return Ok(());
363        }
364        self.store_fingerprints = next;
365        let mut query = self.query.clone();
366        query
367            .harnesses
368            .retain(|harness| harness.as_str() == HarnessId::HERMES);
369        if query.harnesses.is_empty() {
370            return Ok(());
371        }
372        let fresh = descriptor_map(HarnessCatalog::new().discover_raw_index(&query));
373        let stale = self
374            .raw
375            .keys()
376            .filter(|key| key.harness == HarnessId::HERMES)
377            .cloned()
378            .collect::<Vec<_>>();
379        for key in stale {
380            if !fresh.contains_key(&key) {
381                self.raw.remove(&key);
382                content_dirty.insert(key);
383            }
384        }
385        for (key, descriptor) in fresh {
386            if self.raw.get(&key) != Some(&descriptor) {
387                self.raw.insert(key.clone(), descriptor);
388                content_dirty.insert(key);
389            }
390        }
391        Ok(())
392    }
393
394    fn refresh_path(
395        &mut self,
396        path: &Path,
397        content_dirty: &mut BTreeSet<SessionIndexKey>,
398    ) -> Result<(), String> {
399        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
400            return Ok(());
401        }
402        let event_path = normalized_path(path);
403        let previous_key = self.paths.get(&event_path).cloned();
404        let previous = previous_key
405            .as_ref()
406            .and_then(|key| self.raw.get(key))
407            .cloned();
408        let previous_fingerprint = self.fingerprints.get(&event_path).copied();
409        let fingerprint = file_fingerprint(&event_path);
410
411        let Some(fingerprint) = fingerprint else {
412            self.fingerprints.remove(&event_path);
413            if let Some(key) = previous_key {
414                self.paths.remove(&event_path);
415                self.raw.remove(&key);
416                content_dirty.insert(key);
417            }
418            return Ok(());
419        };
420        self.fingerprints.insert(event_path.clone(), fingerprint);
421
422        let locator = previous
423            .as_ref()
424            .map(|descriptor| descriptor.locator.clone())
425            .or_else(|| locator_for_path(&self.query, &event_path));
426        let Some(locator) = locator else {
427            return Ok(());
428        };
429        let refreshed =
430            if let (Some(descriptor), Some(old)) = (previous.as_ref(), previous_fingerprint) {
431                if can_reuse_header(descriptor, old, fingerprint) {
432                    let mut descriptor = descriptor.clone();
433                    descriptor.updated_at_ms = fingerprint.modified_ms;
434                    Some(descriptor)
435                } else {
436                    HarnessCatalog::new()
437                        .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
438                        .map_err(|error| error.to_string())?
439                }
440            } else {
441                HarnessCatalog::new()
442                    .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
443                    .map_err(|error| error.to_string())?
444            };
445        let Some(descriptor) = refreshed else {
446            return Ok(());
447        };
448        let key = SessionIndexKey::from_locator(&descriptor.locator);
449        if let Some(previous_key) = previous_key {
450            if previous_key != key {
451                self.raw.remove(&previous_key);
452                content_dirty.insert(previous_key);
453            }
454        }
455        self.paths.insert(event_path, key.clone());
456        self.raw.insert(key.clone(), descriptor);
457        content_dirty.insert(key);
458        Ok(())
459    }
460
461    fn rebuild_current(&mut self, content_dirty: &BTreeSet<SessionIndexKey>) -> Result<(), String> {
462        let page = self.project_current(&self.query, content_dirty)?;
463        self.current = descriptor_map(page.sessions);
464        Ok(())
465    }
466
467    fn project_current(
468        &self,
469        query: &DiscoveryQuery,
470        content_dirty: &BTreeSet<SessionIndexKey>,
471    ) -> Result<DiscoveryPage, String> {
472        let catalog = HarnessCatalog::new();
473        let mut page = catalog
474            .project_index_page(query, self.raw.values().cloned())
475            .map_err(|error| error.to_string())?;
476        let mut next = Vec::with_capacity(page.sessions.len());
477        for mut descriptor in page.sessions {
478            let key = SessionIndexKey::from_locator(&descriptor.locator);
479            if let Some(previous) = self.current.get(&key) {
480                descriptor.preview_candidates = previous.preview_candidates.clone();
481                descriptor.latest_message_candidates = previous.latest_message_candidates.clone();
482            }
483            if !self.current.contains_key(&key) || content_dirty.contains(&key) {
484                let enriched = match &self.codex_history {
485                    Some(history) => catalog.enrich_index_page_with_codex_history(
486                        query,
487                        vec![descriptor],
488                        history,
489                    ),
490                    None => catalog.enrich_index_page(query, vec![descriptor]),
491                };
492                descriptor = enriched
493                    .map_err(|error| error.to_string())?
494                    .pop()
495                    .expect("one descriptor remains one descriptor");
496            }
497            next.push(descriptor);
498        }
499        page.sessions = next;
500        Ok(page)
501    }
502}
503
504pub(crate) fn validate_query(query: &DiscoveryQuery) -> Result<(), String> {
505    if query.search_previews {
506        return Err(
507            "sessions.index.subscribe does not support preview search; use sessions.discover"
508                .into(),
509        );
510    }
511    if query.cursor.is_some() {
512        return Err("sessions.index.subscribe does not accept a cursor".into());
513    }
514    validate_limit(query.limit.unwrap_or(100))?;
515    if query.harnesses.is_empty()
516        || query.harnesses.iter().any(|harness| {
517            !matches!(
518                harness.as_str(),
519                HarnessId::CLAUDE_CODE | HarnessId::CODEX | HarnessId::HERMES
520            )
521        })
522    {
523        return Err(
524            "sessions.index.subscribe currently requires explicit claude-code, codex and/or hermes harnesses"
525                .into(),
526        );
527    }
528    Ok(())
529}
530
531pub(crate) fn validate_limit(limit: usize) -> Result<(), String> {
532    if limit == 0 || limit > MAX_SUBSCRIPTION_ROWS {
533        return Err(format!(
534            "session index limit must be between 1 and {MAX_SUBSCRIPTION_ROWS}"
535        ));
536    }
537    Ok(())
538}
539
540fn watch_roots(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
541    query
542        .harnesses
543        .iter()
544        .filter_map(|harness| match harness.as_str() {
545            HarnessId::CLAUDE_CODE => Some(query.homes.claude_code.clone()),
546            HarnessId::CODEX => Some(query.homes.codex.clone()),
547            _ => None,
548        })
549        .collect()
550}
551
552fn existing_watch_root(root: &Path) -> Option<PathBuf> {
553    if root.is_dir() {
554        return Some(root.to_path_buf());
555    }
556    // Watching an entire home directory because a harness has never created
557    // its store is disproportionate. One parent level catches the ordinary
558    // first-run mkdir; the recovery reconciliation handles rarer deeper gaps.
559    root.parent()
560        .filter(|parent| parent.is_dir())
561        .map(Path::to_path_buf)
562}
563
564/// Whole-store SQLite files named by the query (one file = every session of that harness).
565fn store_paths(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
566    query
567        .harnesses
568        .iter()
569        .flat_map(|harness| match harness.as_str() {
570            HarnessId::HERMES => hermes_session_stores(&query.homes.hermes),
571            _ => Vec::new(),
572        })
573        .collect()
574}
575
576/// A store's stamp set: the file itself and its WAL-mode siblings, which is where a live writer's
577/// appends land until a checkpoint. Absent files are kept as `None` so their creation is a change.
578fn scan_store_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, Option<FileFingerprint>> {
579    let mut stamps = BTreeMap::new();
580    for store in store_paths(query) {
581        for path in store_sibling_paths(&store) {
582            let stamp = file_fingerprint(&path);
583            stamps.insert(normalized_store_path(&path), stamp);
584        }
585    }
586    stamps
587}
588
589/// The files that hold a store's committed content: the database and its write-ahead log. The
590/// `-shm` sibling is SQLite's shared-memory index, which every reader writes (this index's own
591/// read-only enumeration included): stamping it made each enumeration trigger the next one.
592fn store_sibling_paths(store: &Path) -> [PathBuf; 2] {
593    let name = store
594        .file_name()
595        .and_then(|value| value.to_str())
596        .unwrap_or("state.db");
597    [
598        store.to_path_buf(),
599        store.with_file_name(format!("{name}-wal")),
600    ]
601}
602
603/// A watched store's `-shm` sibling: its changes are readers' bookkeeping, not content.
604fn is_store_shm(stores: &BTreeMap<PathBuf, Option<FileFingerprint>>, path: &Path) -> bool {
605    let path = normalized_store_path(path);
606    path.to_str()
607        .and_then(|value| value.strip_suffix("-shm"))
608        .is_some_and(|store| stores.contains_key(Path::new(store)))
609}
610
611/// Store siblings come and go, so canonicalize through the (stable) directory rather than the file.
612fn normalized_store_path(path: &Path) -> PathBuf {
613    match (path.parent(), path.file_name()) {
614        (Some(dir), Some(name)) => normalized_path(dir).join(name),
615        _ => path.to_path_buf(),
616    }
617}
618
619fn locator_for_path(query: &DiscoveryQuery, path: &Path) -> Option<SessionLocator> {
620    let claude_root = normalized_path(&query.homes.claude_code);
621    let codex_root = normalized_path(&query.homes.codex);
622    let harness = if query
623        .harnesses
624        .iter()
625        .any(|harness| harness.as_str() == HarnessId::CLAUDE_CODE)
626        && path.starts_with(&claude_root)
627    {
628        HarnessId::CLAUDE_CODE
629    } else if query
630        .harnesses
631        .iter()
632        .any(|harness| harness.as_str() == HarnessId::CODEX)
633        && path.starts_with(&codex_root)
634    {
635        HarnessId::CODEX
636    } else {
637        return None;
638    };
639    Some(SessionLocator {
640        harness: HarnessId::new(harness),
641        session_id: path
642            .file_stem()
643            .and_then(|value| value.to_str())
644            .unwrap_or("unknown")
645            .to_string(),
646        storage: StorageLocator::File {
647            path: path.to_path_buf(),
648        },
649    })
650}
651
652fn normalized_path(path: &Path) -> PathBuf {
653    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
654}
655
656fn descriptor_path_map(
657    descriptors: &BTreeMap<SessionIndexKey, SessionDescriptor>,
658) -> BTreeMap<PathBuf, SessionIndexKey> {
659    descriptors
660        .iter()
661        .map(|(key, descriptor)| {
662            (
663                normalized_path(descriptor.locator.storage.path()),
664                key.clone(),
665            )
666        })
667        .collect()
668}
669
670fn scan_file_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, FileFingerprint> {
671    let mut paths = Vec::new();
672    for root in watch_roots(query) {
673        collect_jsonl_paths(&root, &mut paths);
674    }
675    paths
676        .into_iter()
677        .filter_map(|path| {
678            let path = normalized_path(&path);
679            file_fingerprint(&path).map(|fingerprint| (path, fingerprint))
680        })
681        .collect()
682}
683
684fn collect_jsonl_paths(root: &Path, paths: &mut Vec<PathBuf>) {
685    let Ok(entries) = fs::read_dir(root) else {
686        return;
687    };
688    for entry in entries.flatten() {
689        let Ok(file_type) = entry.file_type() else {
690            continue;
691        };
692        let path = entry.path();
693        if file_type.is_dir() {
694            collect_jsonl_paths(&path, paths);
695        } else if file_type.is_file()
696            && path.extension().and_then(|value| value.to_str()) == Some("jsonl")
697        {
698            paths.push(path);
699        }
700    }
701}
702
703fn file_fingerprint(path: &Path) -> Option<FileFingerprint> {
704    let metadata = fs::metadata(path).ok()?;
705    let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
706    #[cfg(unix)]
707    let identity = {
708        use std::os::unix::fs::MetadataExt;
709        (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
710    };
711    #[cfg(not(unix))]
712    let identity = 0;
713    Some(FileFingerprint {
714        len: metadata.len(),
715        modified_ns: modified.as_nanos(),
716        modified_ms: u64::try_from(modified.as_millis()).ok(),
717        identity,
718    })
719}
720
721fn can_reuse_header(
722    descriptor: &SessionDescriptor,
723    previous: FileFingerprint,
724    current: FileFingerprint,
725) -> bool {
726    previous.identity == current.identity
727        && previous.len <= current.len
728        && descriptor.cwd.is_some()
729        && descriptor.model.is_some()
730        && !descriptor.locator.session_id.is_empty()
731}
732
733fn descriptor_map(
734    descriptors: impl IntoIterator<Item = SessionDescriptor>,
735) -> BTreeMap<SessionIndexKey, SessionDescriptor> {
736    descriptors
737        .into_iter()
738        .map(|descriptor| {
739            (
740                SessionIndexKey::from_locator(&descriptor.locator),
741                descriptor,
742            )
743        })
744        .collect()
745}
746
747fn diff_descriptors(
748    before: &BTreeMap<SessionIndexKey, SessionDescriptor>,
749    after: &BTreeMap<SessionIndexKey, SessionDescriptor>,
750) -> Vec<SessionIndexChange> {
751    let mut changes = Vec::new();
752    for (key, descriptor) in after {
753        match before.get(key) {
754            None => changes.push(SessionIndexChange::Added {
755                descriptor: descriptor.clone(),
756            }),
757            Some(previous) if previous != descriptor => {
758                changes.push(SessionIndexChange::Updated {
759                    descriptor: descriptor.clone(),
760                });
761            }
762            Some(_) => {}
763        }
764    }
765    for key in before.keys() {
766        if !after.contains_key(key) {
767            changes.push(SessionIndexChange::Removed { key: key.clone() });
768        }
769    }
770    changes
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn preview_search_is_refused_before_opening_a_retained_index() {
779        let query = DiscoveryQuery {
780            search_previews: true,
781            query: Some("nebula".into()),
782            ..DiscoveryQuery::default()
783        };
784        let error = match SessionIndexSubscription::open(query, Arc::new(Notify::new())) {
785            Err(error) => error,
786            Ok(_) => panic!("preview search must not open live watchers"),
787        };
788        assert!(error.contains("use sessions.discover"), "{error}");
789    }
790
791    fn descriptor(id: &str, updated_at_ms: u64) -> SessionDescriptor {
792        SessionDescriptor {
793            locator: SessionLocator {
794                harness: HarnessId::new(HarnessId::CODEX),
795                session_id: id.into(),
796                storage: StorageLocator::File {
797                    path: PathBuf::from(format!("/{id}.jsonl")),
798                },
799            },
800            cwd: None,
801            title: None,
802            preview_candidates: Vec::new(),
803            latest_message_candidates: Vec::new(),
804            updated_at_ms: Some(updated_at_ms),
805            message_count: None,
806            model: None,
807            parent_session_id: None,
808            child_session_count: 0,
809            nouns: Default::default(),
810        }
811    }
812
813    #[test]
814    fn resize_retains_index_watcher_and_cached_previews_until_commit() {
815        let root = std::env::temp_dir().join(format!(
816            "supercode-index-resize-{}-{}",
817            std::process::id(),
818            std::time::SystemTime::now()
819                .duration_since(UNIX_EPOCH)
820                .unwrap()
821                .as_nanos()
822        ));
823        fs::create_dir_all(&root).unwrap();
824        let root = root.canonicalize().unwrap();
825        let query = DiscoveryQuery {
826            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
827            homes: crate::HarnessHomes {
828                codex: root.clone(),
829                ..crate::HarnessHomes::default()
830            },
831            limit: Some(1),
832            include_topic_candidates: true,
833            ..DiscoveryQuery::default()
834        };
835        let (mut index, _) =
836            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
837        for (id, updated) in [("newest", 3), ("middle", 2), ("oldest", 1)] {
838            let path = root.join(format!("{id}.jsonl"));
839            fs::write(&path, format!(
840                "{{\"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"
841            )).unwrap();
842            let mut row = descriptor(id, updated);
843            row.locator.storage = StorageLocator::File { path };
844            index
845                .raw
846                .insert(SessionIndexKey::from_locator(&row.locator), row);
847        }
848        index.paths = descriptor_path_map(&index.raw);
849        index.rebuild_current(&BTreeSet::new()).unwrap();
850        let original = index.current.clone();
851        assert!(!original
852            .values()
853            .next()
854            .unwrap()
855            .preview_candidates
856            .is_empty());
857        // If resize re-read this already-visible transcript, its topic would disappear.
858        fs::write(root.join("newest.jsonl"), "").unwrap();
859        // A new file must not be enumerated by resize; its queued event belongs to poll.
860        let queued = root.join("queued.jsonl");
861        fs::write(
862            &queued,
863            "{\"type\":\"session_meta\",\"payload\":{\"id\":\"queued\",\"cwd\":\"/workspace\"}}\n",
864        )
865        .unwrap();
866        let (sender, receiver) = mpsc::channel();
867        // Keep the native channel alive while driving deterministic invalidations.
868        let _native_receiver = std::mem::replace(&mut index.receiver, receiver);
869        sender
870            .send(Ok(Event::new(notify::EventKind::Any).add_path(queued)))
871            .unwrap();
872        let watcher = &index._watcher as *const _;
873        let raw_row = index.raw.values().next().unwrap() as *const _;
874        let raw = index.raw.clone();
875        let reconcile = index.last_reconcile;
876        let prepared = index.prepare_resize(2).unwrap();
877        assert_eq!(prepared.revision, 2);
878        assert_eq!(prepared.page.receipt.total_matched, 3);
879        assert_eq!(prepared.page.sessions.len(), 2);
880        assert_eq!(
881            prepared.page.sessions[0],
882            *original.values().next().unwrap()
883        );
884        assert!(!prepared.page.sessions[1].preview_candidates.is_empty());
885        assert_eq!(index.current, original);
886        assert_eq!(index.revision, 1);
887        drop(prepared); // A response-construction failure must not commit the candidate.
888        assert!(index.prepare_resize(0).is_err());
889        assert!(index.prepare_resize(2049).is_err());
890        assert_eq!(index.current, original);
891        assert_eq!(index.revision, 1);
892        let prepared = index.prepare_resize(2).unwrap();
893        index.commit_resize(prepared);
894        assert_eq!(index.raw, raw);
895        assert_eq!(index.raw.values().next().unwrap() as *const _, raw_row);
896        assert_eq!(&index._watcher as *const _, watcher);
897        assert_eq!(index.last_reconcile, reconcile);
898        assert_eq!(index.prepare_resize(2).unwrap().revision, 2);
899        let delta = index.poll().unwrap().unwrap();
900        assert_eq!(delta.revision, 3);
901        let shrink = index.prepare_resize(1).unwrap();
902        assert_eq!(shrink.revision, 4);
903        index.commit_resize(shrink);
904        assert_eq!(index.current.len(), 1);
905        assert_eq!(index.prepare_resize(1).unwrap().revision, 4);
906        drop(index);
907        fs::remove_dir_all(root).unwrap();
908    }
909
910    #[test]
911    fn same_limit_receipt_counts_out_of_window_changes_without_visible_revision() {
912        let root = std::env::temp_dir().join(format!(
913            "supercode-index-total-{}-{}",
914            std::process::id(),
915            std::time::SystemTime::now()
916                .duration_since(UNIX_EPOCH)
917                .unwrap()
918                .as_nanos()
919        ));
920        fs::create_dir_all(&root).unwrap();
921        let root = root.canonicalize().unwrap();
922        let query = DiscoveryQuery {
923            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
924            homes: crate::HarnessHomes {
925                codex: root.clone(),
926                ..crate::HarnessHomes::default()
927            },
928            limit: Some(1),
929            ..DiscoveryQuery::default()
930        };
931        let (mut index, _) =
932            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
933        let visible = descriptor("visible", u64::MAX);
934        index.raw = descriptor_map([visible]);
935        index.rebuild_current(&BTreeSet::new()).unwrap();
936        let (sender, receiver) = mpsc::channel();
937        let _native_receiver = std::mem::replace(&mut index.receiver, receiver);
938        let hidden = root.join("hidden.jsonl");
939        fs::write(
940            &hidden,
941            "{\"type\":\"session_meta\",\"payload\":{\"id\":\"hidden\",\"cwd\":\"/workspace\"}}\n",
942        )
943        .unwrap();
944        sender
945            .send(Ok(
946                Event::new(notify::EventKind::Any).add_path(hidden.clone())
947            ))
948            .unwrap();
949        assert!(index.poll().unwrap().is_none());
950        let added = index.prepare_resize(1).unwrap();
951        assert_eq!(added.revision, 1);
952        assert_eq!(added.page.receipt.total_matched, 2);
953        fs::remove_file(&hidden).unwrap();
954        sender
955            .send(Ok(Event::new(notify::EventKind::Any).add_path(hidden)))
956            .unwrap();
957        assert!(index.poll().unwrap().is_none());
958        let removed = index.prepare_resize(1).unwrap();
959        assert_eq!(removed.revision, 1);
960        assert_eq!(removed.page.receipt.total_matched, 1);
961        drop(index);
962        fs::remove_dir_all(root).unwrap();
963    }
964
965    #[test]
966    fn hermes_store_appends_surface_as_index_updates() {
967        // A private copy of the committed Hermes fixture store, in its own directory, so the
968        // subscription watches exactly one store and the test may write to it.
969        let root = std::env::temp_dir().join(format!(
970            "supercode-index-hermes-{}-{}",
971            std::process::id(),
972            std::time::SystemTime::now()
973                .duration_since(UNIX_EPOCH)
974                .unwrap()
975                .as_nanos()
976        ));
977        fs::create_dir_all(&root).unwrap();
978        let db = root.join("state.db");
979        fs::copy(
980            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_home/state.db"),
981            &db,
982        )
983        .unwrap();
984        let query = DiscoveryQuery {
985            harnesses: vec![HarnessId::new(HarnessId::HERMES)],
986            homes: crate::HarnessHomes {
987                hermes: db.clone(),
988                claude_code: root.join("missing-claude"),
989                codex: root.join("missing-codex"),
990                ..crate::HarnessHomes::default()
991            },
992            ..DiscoveryQuery::default()
993        };
994        let (mut subscription, initial) =
995            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
996        assert!(initial.len() >= 2, "{initial:#?}");
997        assert!(initial
998            .iter()
999            .all(|descriptor| descriptor.locator.harness.as_str() == HarnessId::HERMES));
1000        assert!(
1001            subscription.poll().unwrap().is_none(),
1002            "quiet store, quiet index"
1003        );
1004
1005        // Append one message the way Hermes does: a messages row plus the session's count bump.
1006        let target = initial[0].locator.session_id.clone();
1007        std::thread::sleep(Duration::from_millis(20));
1008        {
1009            let conn = rusqlite::Connection::open(&db).unwrap();
1010            conn.execute(
1011                "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'index test append', ?2, 1)",
1012                rusqlite::params![target, 1_800_000_000.0_f64],
1013            )
1014            .unwrap();
1015            conn.execute(
1016                "UPDATE sessions SET message_count = message_count + 1, ended_at = ?2 WHERE id = ?1",
1017                rusqlite::params![target, 1_800_000_000.0_f64],
1018            )
1019            .unwrap();
1020        }
1021        // Wait for the native watcher (bounded), then poll: exactly the appended session changes.
1022        let deadline = Instant::now() + Duration::from_secs(5);
1023        let delta = loop {
1024            if let Some(delta) = subscription.poll().unwrap() {
1025                break delta;
1026            }
1027            assert!(
1028                Instant::now() < deadline,
1029                "no index delta after the store append"
1030            );
1031            std::thread::sleep(Duration::from_millis(50));
1032        };
1033        assert_eq!(delta.changes.len(), 1, "{delta:#?}");
1034        match &delta.changes[0] {
1035            SessionIndexChange::Updated { descriptor } => {
1036                assert_eq!(descriptor.locator.session_id, target);
1037                assert_eq!(
1038                    descriptor.message_count,
1039                    initial[0].message_count.map(|count| count + 1)
1040                );
1041            }
1042            other => panic!("expected an update for {target}, got {other:?}"),
1043        }
1044        assert!(
1045            subscription.poll().unwrap().is_none(),
1046            "one append, one delta"
1047        );
1048        fs::remove_dir_all(&root).ok();
1049    }
1050
1051    #[test]
1052    fn index_delta_is_a_complete_deterministic_replacement_set() {
1053        let before = descriptor_map([descriptor("removed", 1), descriptor("updated", 2)]);
1054        let after = descriptor_map([descriptor("updated", 3), descriptor("added", 4)]);
1055        let changes = diff_descriptors(&before, &after);
1056        assert!(matches!(
1057            &changes[0],
1058            SessionIndexChange::Added { descriptor } if descriptor.locator.session_id == "added"
1059        ));
1060        assert!(matches!(
1061            &changes[1],
1062            SessionIndexChange::Updated { descriptor } if descriptor.locator.session_id == "updated"
1063        ));
1064        assert!(matches!(
1065            &changes[2],
1066            SessionIndexChange::Removed { key } if key.session_id == "removed"
1067        ));
1068    }
1069
1070    #[test]
1071    fn raw_index_projects_child_activity_into_one_root_row() {
1072        let root = descriptor("root", 10);
1073        let mut child = descriptor("child", 20);
1074        child.parent_session_id = Some("root".into());
1075        let query = DiscoveryQuery {
1076            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
1077            limit: Some(100),
1078            ..DiscoveryQuery::default()
1079        };
1080
1081        let projected = HarnessCatalog::new()
1082            .project_index(&query, [root, child])
1083            .unwrap();
1084
1085        assert_eq!(projected.len(), 1);
1086        assert_eq!(projected[0].locator.session_id, "root");
1087        assert_eq!(projected[0].updated_at_ms, Some(20));
1088        assert_eq!(projected[0].child_session_count, 1);
1089    }
1090
1091    #[test]
1092    fn complete_raw_index_backfills_a_bounded_page_without_discovery() {
1093        let query = DiscoveryQuery {
1094            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
1095            limit: Some(2),
1096            ..DiscoveryQuery::default()
1097        };
1098        let catalog = HarnessCatalog::new();
1099        let mut raw = descriptor_map([
1100            descriptor("oldest", 1),
1101            descriptor("middle", 2),
1102            descriptor("newest", 3),
1103        ]);
1104        let initial = catalog
1105            .project_index(&query, raw.values().cloned())
1106            .unwrap();
1107        assert_eq!(
1108            initial
1109                .iter()
1110                .map(|descriptor| descriptor.locator.session_id.as_str())
1111                .collect::<Vec<_>>(),
1112            ["newest", "middle"]
1113        );
1114
1115        raw.remove(&SessionIndexKey {
1116            harness: HarnessId::CODEX.into(),
1117            session_id: "newest".into(),
1118        });
1119        let after = catalog
1120            .project_index(&query, raw.values().cloned())
1121            .unwrap();
1122        assert_eq!(
1123            after
1124                .iter()
1125                .map(|descriptor| descriptor.locator.session_id.as_str())
1126                .collect::<Vec<_>>(),
1127            ["middle", "oldest"]
1128        );
1129    }
1130
1131    #[test]
1132    fn append_reuses_an_immutable_header_but_replacement_does_not() {
1133        let mut existing = descriptor("session", 1);
1134        existing.cwd = Some(PathBuf::from("/workspace"));
1135        existing.model = Some("model".into());
1136        let before = FileFingerprint {
1137            len: 100,
1138            modified_ns: 1,
1139            modified_ms: Some(1),
1140            identity: 7,
1141        };
1142        let append = FileFingerprint {
1143            len: 200,
1144            modified_ns: 2,
1145            modified_ms: Some(2),
1146            identity: 7,
1147        };
1148        let replacement = FileFingerprint {
1149            identity: 8,
1150            ..append
1151        };
1152
1153        assert!(can_reuse_header(&existing, before, append));
1154        assert!(!can_reuse_header(&existing, before, replacement));
1155    }
1156
1157    #[tokio::test]
1158    async fn filesystem_event_wakes_index_without_a_poll_timer() {
1159        let nonce = std::time::SystemTime::now()
1160            .duration_since(UNIX_EPOCH)
1161            .unwrap()
1162            .as_nanos();
1163        let root = std::env::temp_dir().join(format!(
1164            "supercode-session-index-{}-{nonce}",
1165            std::process::id()
1166        ));
1167        let codex = root.join("codex");
1168        fs::create_dir_all(&codex).unwrap();
1169        let query = DiscoveryQuery {
1170            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
1171            homes: crate::HarnessHomes {
1172                codex: codex.clone(),
1173                ..crate::HarnessHomes::default()
1174            },
1175            limit: Some(10),
1176            ..DiscoveryQuery::default()
1177        };
1178        let notifier = Arc::new(Notify::new());
1179        let (mut index, initial) =
1180            SessionIndexSubscription::open(query, Arc::clone(&notifier)).unwrap();
1181        assert!(initial.is_empty());
1182
1183        let session = codex.join("new.jsonl");
1184        fs::write(
1185            &session,
1186            concat!(
1187                "{\"type\":\"session_meta\",\"payload\":{\"id\":\"new\",\"cwd\":\"/workspace\"}}\n",
1188                "{\"type\":\"turn_context\",\"payload\":{\"cwd\":\"/workspace\",\"model\":\"gpt-test\"}}\n"
1189            ),
1190        )
1191        .unwrap();
1192
1193        tokio::time::timeout(Duration::from_secs(5), notifier.notified())
1194            .await
1195            .expect("filesystem invalidation should wake the index");
1196        let delta = index
1197            .poll()
1198            .unwrap()
1199            .expect("the filesystem event should produce a visible delta");
1200        assert!(matches!(
1201            &delta.changes[0],
1202            SessionIndexChange::Added { descriptor }
1203                if descriptor.locator.session_id == "new"
1204        ));
1205
1206        drop(index);
1207        fs::remove_dir_all(root).unwrap();
1208    }
1209}