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