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