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, DiscoveryQuery, HarnessCatalog, HarnessId, SessionDescriptor,
21    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.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct SessionIndexDelta {
72    /// Monotonic subscription-local revision.
73    pub revision: u64,
74    /// Complete replacement changes in deterministic identity order.
75    pub changes: Vec<SessionIndexChange>,
76}
77
78/// Filesystem-backed index subscription. Dropping it drops the platform
79/// watcher and callback channel, so unsubscribe has deterministic cleanup.
80pub(crate) struct SessionIndexSubscription {
81    query: DiscoveryQuery,
82    raw: BTreeMap<SessionIndexKey, SessionDescriptor>,
83    paths: BTreeMap<PathBuf, SessionIndexKey>,
84    current: BTreeMap<SessionIndexKey, SessionDescriptor>,
85    fingerprints: BTreeMap<PathBuf, FileFingerprint>,
86    /// Whole-store SQLite files (Hermes `state.db` + its `-wal`/`-shm`), keyed by path. A store
87    /// holds every session in one file, so a stamp change means "re-enumerate this store", not
88    /// "this one path is one session". `None` = the file is absent.
89    store_fingerprints: BTreeMap<PathBuf, Option<FileFingerprint>>,
90    codex_history: Option<CodexHistoryTopicIndex>,
91    revision: u64,
92    receiver: mpsc::Receiver<notify::Result<Event>>,
93    overflowed: Arc<AtomicBool>,
94    _watcher: RecommendedWatcher,
95    last_reconcile: Instant,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99struct FileFingerprint {
100    len: u64,
101    modified_ns: u128,
102    modified_ms: Option<u64>,
103    identity: u128,
104}
105
106impl SessionIndexSubscription {
107    pub(crate) fn homes(&self) -> &crate::HarnessHomes {
108        &self.query.homes
109    }
110
111    pub(crate) fn open(
112        mut query: DiscoveryQuery,
113        notifier: Arc<Notify>,
114    ) -> Result<(Self, Vec<SessionDescriptor>), String> {
115        validate_query(&query)?;
116        query.cursor = None;
117        query.limit = Some(query.limit.unwrap_or(100));
118
119        let catalog = HarnessCatalog::new();
120        let raw = descriptor_map(catalog.discover_raw_index(&query));
121        let projected = catalog
122            .project_index(&query, raw.values().cloned())
123            .map_err(|error| error.to_string())?;
124        let mut codex_history = (query.include_topic_candidates
125            && query
126                .harnesses
127                .iter()
128                .any(|harness| harness.as_str() == HarnessId::CODEX))
129        .then(|| CodexHistoryTopicIndex::new(&query.homes.codex));
130        if let Some(history) = &mut codex_history {
131            // Discovery has always treated an unavailable history file as a
132            // soft fallback to transcript topics. Preserve that behavior.
133            let _ = history.refresh();
134        }
135        let initial = match &codex_history {
136            Some(history) => {
137                catalog.enrich_index_page_with_codex_history(&query, projected, history)
138            }
139            None => catalog.enrich_index_page(&query, projected),
140        }
141        .map_err(|error| error.to_string())?;
142        let paths = descriptor_path_map(&raw);
143        let current = descriptor_map(initial.iter().cloned());
144        let fingerprints = scan_file_fingerprints(&query);
145        let store_fingerprints = scan_store_fingerprints(&query);
146        let (sender, receiver) = mpsc::sync_channel(INVALIDATION_QUEUE_CAPACITY);
147        let overflowed = Arc::new(AtomicBool::new(false));
148        let callback_overflowed = Arc::clone(&overflowed);
149        let callback_notifier = Arc::clone(&notifier);
150        let mut watcher = notify::recommended_watcher(move |event| {
151            if sender.try_send(event).is_err() {
152                callback_overflowed.store(true, Ordering::Release);
153            }
154            callback_notifier.notify_one();
155        })
156        .map_err(|error| error.to_string())?;
157        for root in watch_roots(&query) {
158            if let Some(watched) = existing_watch_root(&root) {
159                watcher
160                    .watch(&watched, RecursiveMode::Recursive)
161                    .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
162            }
163        }
164        for store in store_paths(&query) {
165            // The store's directory, not the store file: a WAL-mode writer creates and removes the
166            // `-wal`/`-shm` siblings, and a first run creates the store itself.
167            let Some(dir) = store.parent() else { continue };
168            if let Some(watched) = existing_watch_root(dir) {
169                watcher
170                    .watch(&watched, RecursiveMode::NonRecursive)
171                    .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
172            }
173        }
174        if let Some(history) = &codex_history {
175            let target = if history.path().is_file() {
176                history.path()
177            } else {
178                history.path().parent().unwrap_or(history.path())
179            };
180            if target.exists() {
181                watcher
182                    .watch(target, RecursiveMode::NonRecursive)
183                    .map_err(|error| format!("cannot watch {}: {error}", target.display()))?;
184            }
185        }
186
187        Ok((
188            Self {
189                query,
190                raw,
191                paths,
192                current,
193                fingerprints,
194                store_fingerprints,
195                codex_history,
196                revision: 1,
197                receiver,
198                overflowed,
199                _watcher: watcher,
200                last_reconcile: Instant::now(),
201            },
202            initial,
203        ))
204    }
205
206    /// Drain and coalesce native invalidations once. No events means no I/O
207    /// until the minute-scale metadata-only recovery sweep becomes due.
208    pub(crate) fn poll(&mut self) -> Result<Option<SessionIndexDelta>, String> {
209        let mut paths = BTreeSet::new();
210        let mut sweep = self.overflowed.swap(false, Ordering::AcqRel);
211        let mut stores = false;
212        while let Ok(event) = self.receiver.try_recv() {
213            match event {
214                Ok(event) => {
215                    if event.paths.is_empty() {
216                        sweep = true;
217                    }
218                    for path in event.paths {
219                        if path.extension().and_then(|value| value.to_str()) == Some("jsonl") {
220                            paths.insert(path);
221                        } else if self
222                            .store_fingerprints
223                            .contains_key(&normalized_store_path(&path))
224                        {
225                            stores = true;
226                        } else {
227                            sweep = true;
228                        }
229                    }
230                }
231                Err(_) => sweep = true,
232            }
233        }
234        if self.last_reconcile.elapsed() >= RECONCILE_INTERVAL {
235            sweep = true;
236        }
237        if paths.is_empty() && !sweep && !stores {
238            return Ok(None);
239        }
240
241        let before = self.current.clone();
242        let mut content_dirty = BTreeSet::new();
243        let history_path = self
244            .codex_history
245            .as_ref()
246            .map(|history| normalized_path(history.path()));
247        if let Some(history) = &mut self.codex_history {
248            if let Ok(changed) = history.refresh() {
249                content_dirty.extend(changed.into_iter().map(|session_id| SessionIndexKey {
250                    harness: HarnessId::CODEX.to_string(),
251                    session_id,
252                }));
253            }
254        }
255        if sweep {
256            self.reconcile_filesystem(&mut content_dirty)?;
257        }
258        if sweep || stores {
259            self.reconcile_stores(&mut content_dirty)?;
260        }
261        for path in paths {
262            if history_path
263                .as_ref()
264                .is_some_and(|history_path| normalized_path(&path) == *history_path)
265            {
266                continue;
267            }
268            self.refresh_path(&path, &mut content_dirty)?;
269        }
270        self.rebuild_current(&content_dirty)?;
271        let changes = diff_descriptors(&before, &self.current);
272        if changes.is_empty() {
273            return Ok(None);
274        }
275        self.revision = self.revision.saturating_add(1);
276        Ok(Some(SessionIndexDelta {
277            revision: self.revision,
278            changes,
279        }))
280    }
281
282    fn reconcile_filesystem(
283        &mut self,
284        content_dirty: &mut BTreeSet<SessionIndexKey>,
285    ) -> Result<(), String> {
286        self.last_reconcile = Instant::now();
287        let next = scan_file_fingerprints(&self.query);
288        let changed = self
289            .fingerprints
290            .keys()
291            .chain(next.keys())
292            .filter(|path| self.fingerprints.get(*path) != next.get(*path))
293            .cloned()
294            .collect::<BTreeSet<_>>();
295        for path in changed {
296            self.refresh_path(&path, content_dirty)?;
297        }
298        self.fingerprints = next;
299        Ok(())
300    }
301
302    /// Re-enumerate every whole-store harness whose store stamps moved. Rows are diffed by value:
303    /// a store keeps its sessions' `message_count`/`ended_at` current on every append, so a
304    /// descriptor that compares equal is unchanged and one that differs is content-dirty.
305    fn reconcile_stores(
306        &mut self,
307        content_dirty: &mut BTreeSet<SessionIndexKey>,
308    ) -> Result<(), String> {
309        let next = scan_store_fingerprints(&self.query);
310        if next == self.store_fingerprints {
311            return Ok(());
312        }
313        self.store_fingerprints = next;
314        let mut query = self.query.clone();
315        query
316            .harnesses
317            .retain(|harness| harness.as_str() == HarnessId::HERMES);
318        if query.harnesses.is_empty() {
319            return Ok(());
320        }
321        let fresh = descriptor_map(HarnessCatalog::new().discover_raw_index(&query));
322        let stale = self
323            .raw
324            .keys()
325            .filter(|key| key.harness == HarnessId::HERMES)
326            .cloned()
327            .collect::<Vec<_>>();
328        for key in stale {
329            if !fresh.contains_key(&key) {
330                self.raw.remove(&key);
331                content_dirty.insert(key);
332            }
333        }
334        for (key, descriptor) in fresh {
335            if self.raw.get(&key) != Some(&descriptor) {
336                self.raw.insert(key.clone(), descriptor);
337                content_dirty.insert(key);
338            }
339        }
340        Ok(())
341    }
342
343    fn refresh_path(
344        &mut self,
345        path: &Path,
346        content_dirty: &mut BTreeSet<SessionIndexKey>,
347    ) -> Result<(), String> {
348        if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
349            return Ok(());
350        }
351        let event_path = normalized_path(path);
352        let previous_key = self.paths.get(&event_path).cloned();
353        let previous = previous_key
354            .as_ref()
355            .and_then(|key| self.raw.get(key))
356            .cloned();
357        let previous_fingerprint = self.fingerprints.get(&event_path).copied();
358        let fingerprint = file_fingerprint(&event_path);
359
360        let Some(fingerprint) = fingerprint else {
361            self.fingerprints.remove(&event_path);
362            if let Some(key) = previous_key {
363                self.paths.remove(&event_path);
364                self.raw.remove(&key);
365                content_dirty.insert(key);
366            }
367            return Ok(());
368        };
369        self.fingerprints.insert(event_path.clone(), fingerprint);
370
371        let locator = previous
372            .as_ref()
373            .map(|descriptor| descriptor.locator.clone())
374            .or_else(|| locator_for_path(&self.query, &event_path));
375        let Some(locator) = locator else {
376            return Ok(());
377        };
378        let refreshed =
379            if let (Some(descriptor), Some(old)) = (previous.as_ref(), previous_fingerprint) {
380                if can_reuse_header(descriptor, old, fingerprint) {
381                    let mut descriptor = descriptor.clone();
382                    descriptor.updated_at_ms = fingerprint.modified_ms;
383                    Some(descriptor)
384                } else {
385                    HarnessCatalog::new()
386                        .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
387                        .map_err(|error| error.to_string())?
388                }
389            } else {
390                HarnessCatalog::new()
391                    .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
392                    .map_err(|error| error.to_string())?
393            };
394        let Some(descriptor) = refreshed else {
395            return Ok(());
396        };
397        let key = SessionIndexKey::from_locator(&descriptor.locator);
398        if let Some(previous_key) = previous_key {
399            if previous_key != key {
400                self.raw.remove(&previous_key);
401                content_dirty.insert(previous_key);
402            }
403        }
404        self.paths.insert(event_path, key.clone());
405        self.raw.insert(key.clone(), descriptor);
406        content_dirty.insert(key);
407        Ok(())
408    }
409
410    fn rebuild_current(&mut self, content_dirty: &BTreeSet<SessionIndexKey>) -> Result<(), String> {
411        let catalog = HarnessCatalog::new();
412        let projected = catalog
413            .project_index(&self.query, self.raw.values().cloned())
414            .map_err(|error| error.to_string())?;
415        let mut next = Vec::with_capacity(projected.len());
416        for mut descriptor in projected {
417            let key = SessionIndexKey::from_locator(&descriptor.locator);
418            if let Some(previous) = self.current.get(&key) {
419                descriptor.preview_candidates = previous.preview_candidates.clone();
420                descriptor.latest_message_candidates = previous.latest_message_candidates.clone();
421            }
422            if !self.current.contains_key(&key) || content_dirty.contains(&key) {
423                let enriched = match &self.codex_history {
424                    Some(history) => catalog.enrich_index_page_with_codex_history(
425                        &self.query,
426                        vec![descriptor],
427                        history,
428                    ),
429                    None => catalog.enrich_index_page(&self.query, vec![descriptor]),
430                };
431                descriptor = enriched
432                    .map_err(|error| error.to_string())?
433                    .pop()
434                    .expect("one descriptor remains one descriptor");
435            }
436            next.push(descriptor);
437        }
438        self.current = descriptor_map(next);
439        Ok(())
440    }
441}
442
443pub(crate) fn validate_query(query: &DiscoveryQuery) -> Result<(), String> {
444    if query.cursor.is_some() {
445        return Err("sessions.index.subscribe does not accept a cursor".into());
446    }
447    let limit = query.limit.unwrap_or(100);
448    if limit == 0 || limit > MAX_SUBSCRIPTION_ROWS {
449        return Err(format!(
450            "sessions.index.subscribe limit must be between 1 and {MAX_SUBSCRIPTION_ROWS}"
451        ));
452    }
453    if query.harnesses.is_empty()
454        || query.harnesses.iter().any(|harness| {
455            !matches!(
456                harness.as_str(),
457                HarnessId::CLAUDE_CODE | HarnessId::CODEX | HarnessId::HERMES
458            )
459        })
460    {
461        return Err(
462            "sessions.index.subscribe currently requires explicit claude-code, codex and/or hermes harnesses"
463                .into(),
464        );
465    }
466    Ok(())
467}
468
469fn watch_roots(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
470    query
471        .harnesses
472        .iter()
473        .filter_map(|harness| match harness.as_str() {
474            HarnessId::CLAUDE_CODE => Some(query.homes.claude_code.clone()),
475            HarnessId::CODEX => Some(query.homes.codex.clone()),
476            _ => None,
477        })
478        .collect()
479}
480
481fn existing_watch_root(root: &Path) -> Option<PathBuf> {
482    if root.is_dir() {
483        return Some(root.to_path_buf());
484    }
485    // Watching an entire home directory because a harness has never created
486    // its store is disproportionate. One parent level catches the ordinary
487    // first-run mkdir; the recovery reconciliation handles rarer deeper gaps.
488    root.parent()
489        .filter(|parent| parent.is_dir())
490        .map(Path::to_path_buf)
491}
492
493/// Whole-store SQLite files named by the query (one file = every session of that harness).
494fn store_paths(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
495    query
496        .harnesses
497        .iter()
498        .filter_map(|harness| match harness.as_str() {
499            HarnessId::HERMES => Some(query.homes.hermes.clone()),
500            _ => None,
501        })
502        .collect()
503}
504
505/// A store's stamp set: the file itself and its WAL-mode siblings, which is where a live writer's
506/// appends land until a checkpoint. Absent files are kept as `None` so their creation is a change.
507fn scan_store_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, Option<FileFingerprint>> {
508    let mut stamps = BTreeMap::new();
509    for store in store_paths(query) {
510        for path in store_sibling_paths(&store) {
511            let stamp = file_fingerprint(&path);
512            stamps.insert(normalized_store_path(&path), stamp);
513        }
514    }
515    stamps
516}
517
518fn store_sibling_paths(store: &Path) -> [PathBuf; 3] {
519    let name = store
520        .file_name()
521        .and_then(|value| value.to_str())
522        .unwrap_or("state.db");
523    [
524        store.to_path_buf(),
525        store.with_file_name(format!("{name}-wal")),
526        store.with_file_name(format!("{name}-shm")),
527    ]
528}
529
530/// Store siblings come and go, so canonicalize through the (stable) directory rather than the file.
531fn normalized_store_path(path: &Path) -> PathBuf {
532    match (path.parent(), path.file_name()) {
533        (Some(dir), Some(name)) => normalized_path(dir).join(name),
534        _ => path.to_path_buf(),
535    }
536}
537
538fn locator_for_path(query: &DiscoveryQuery, path: &Path) -> Option<SessionLocator> {
539    let claude_root = normalized_path(&query.homes.claude_code);
540    let codex_root = normalized_path(&query.homes.codex);
541    let harness = if query
542        .harnesses
543        .iter()
544        .any(|harness| harness.as_str() == HarnessId::CLAUDE_CODE)
545        && path.starts_with(&claude_root)
546    {
547        HarnessId::CLAUDE_CODE
548    } else if query
549        .harnesses
550        .iter()
551        .any(|harness| harness.as_str() == HarnessId::CODEX)
552        && path.starts_with(&codex_root)
553    {
554        HarnessId::CODEX
555    } else {
556        return None;
557    };
558    Some(SessionLocator {
559        harness: HarnessId::new(harness),
560        session_id: path
561            .file_stem()
562            .and_then(|value| value.to_str())
563            .unwrap_or("unknown")
564            .to_string(),
565        storage: StorageLocator::File {
566            path: path.to_path_buf(),
567        },
568    })
569}
570
571fn normalized_path(path: &Path) -> PathBuf {
572    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
573}
574
575fn descriptor_path_map(
576    descriptors: &BTreeMap<SessionIndexKey, SessionDescriptor>,
577) -> BTreeMap<PathBuf, SessionIndexKey> {
578    descriptors
579        .iter()
580        .map(|(key, descriptor)| {
581            (
582                normalized_path(descriptor.locator.storage.path()),
583                key.clone(),
584            )
585        })
586        .collect()
587}
588
589fn scan_file_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, FileFingerprint> {
590    let mut paths = Vec::new();
591    for root in watch_roots(query) {
592        collect_jsonl_paths(&root, &mut paths);
593    }
594    paths
595        .into_iter()
596        .filter_map(|path| {
597            let path = normalized_path(&path);
598            file_fingerprint(&path).map(|fingerprint| (path, fingerprint))
599        })
600        .collect()
601}
602
603fn collect_jsonl_paths(root: &Path, paths: &mut Vec<PathBuf>) {
604    let Ok(entries) = fs::read_dir(root) else {
605        return;
606    };
607    for entry in entries.flatten() {
608        let Ok(file_type) = entry.file_type() else {
609            continue;
610        };
611        let path = entry.path();
612        if file_type.is_dir() {
613            collect_jsonl_paths(&path, paths);
614        } else if file_type.is_file()
615            && path.extension().and_then(|value| value.to_str()) == Some("jsonl")
616        {
617            paths.push(path);
618        }
619    }
620}
621
622fn file_fingerprint(path: &Path) -> Option<FileFingerprint> {
623    let metadata = fs::metadata(path).ok()?;
624    let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
625    #[cfg(unix)]
626    let identity = {
627        use std::os::unix::fs::MetadataExt;
628        (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
629    };
630    #[cfg(not(unix))]
631    let identity = 0;
632    Some(FileFingerprint {
633        len: metadata.len(),
634        modified_ns: modified.as_nanos(),
635        modified_ms: u64::try_from(modified.as_millis()).ok(),
636        identity,
637    })
638}
639
640fn can_reuse_header(
641    descriptor: &SessionDescriptor,
642    previous: FileFingerprint,
643    current: FileFingerprint,
644) -> bool {
645    previous.identity == current.identity
646        && previous.len <= current.len
647        && descriptor.cwd.is_some()
648        && descriptor.model.is_some()
649        && !descriptor.locator.session_id.is_empty()
650}
651
652fn descriptor_map(
653    descriptors: impl IntoIterator<Item = SessionDescriptor>,
654) -> BTreeMap<SessionIndexKey, SessionDescriptor> {
655    descriptors
656        .into_iter()
657        .map(|descriptor| {
658            (
659                SessionIndexKey::from_locator(&descriptor.locator),
660                descriptor,
661            )
662        })
663        .collect()
664}
665
666fn diff_descriptors(
667    before: &BTreeMap<SessionIndexKey, SessionDescriptor>,
668    after: &BTreeMap<SessionIndexKey, SessionDescriptor>,
669) -> Vec<SessionIndexChange> {
670    let mut changes = Vec::new();
671    for (key, descriptor) in after {
672        match before.get(key) {
673            None => changes.push(SessionIndexChange::Added {
674                descriptor: descriptor.clone(),
675            }),
676            Some(previous) if previous != descriptor => {
677                changes.push(SessionIndexChange::Updated {
678                    descriptor: descriptor.clone(),
679                });
680            }
681            Some(_) => {}
682        }
683    }
684    for key in before.keys() {
685        if !after.contains_key(key) {
686            changes.push(SessionIndexChange::Removed { key: key.clone() });
687        }
688    }
689    changes
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695
696    fn descriptor(id: &str, updated_at_ms: u64) -> SessionDescriptor {
697        SessionDescriptor {
698            locator: SessionLocator {
699                harness: HarnessId::new(HarnessId::CODEX),
700                session_id: id.into(),
701                storage: StorageLocator::File {
702                    path: PathBuf::from(format!("/{id}.jsonl")),
703                },
704            },
705            cwd: None,
706            title: None,
707            preview_candidates: Vec::new(),
708            latest_message_candidates: Vec::new(),
709            updated_at_ms: Some(updated_at_ms),
710            message_count: None,
711            model: None,
712            parent_session_id: None,
713            child_session_count: 0,
714            nouns: Default::default(),
715        }
716    }
717
718    #[test]
719    fn hermes_store_appends_surface_as_index_updates() {
720        // A private copy of the committed Hermes fixture store, in its own directory, so the
721        // subscription watches exactly one store and the test may write to it.
722        let root = std::env::temp_dir().join(format!(
723            "supercode-index-hermes-{}-{}",
724            std::process::id(),
725            std::time::SystemTime::now()
726                .duration_since(UNIX_EPOCH)
727                .unwrap()
728                .as_nanos()
729        ));
730        fs::create_dir_all(&root).unwrap();
731        let db = root.join("state.db");
732        fs::copy(
733            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_home/state.db"),
734            &db,
735        )
736        .unwrap();
737        let query = DiscoveryQuery {
738            harnesses: vec![HarnessId::new(HarnessId::HERMES)],
739            homes: crate::HarnessHomes {
740                hermes: db.clone(),
741                claude_code: root.join("missing-claude"),
742                codex: root.join("missing-codex"),
743                ..crate::HarnessHomes::default()
744            },
745            ..DiscoveryQuery::default()
746        };
747        let (mut subscription, initial) =
748            SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
749        assert!(initial.len() >= 2, "{initial:#?}");
750        assert!(initial
751            .iter()
752            .all(|descriptor| descriptor.locator.harness.as_str() == HarnessId::HERMES));
753        assert!(
754            subscription.poll().unwrap().is_none(),
755            "quiet store, quiet index"
756        );
757
758        // Append one message the way Hermes does: a messages row plus the session's count bump.
759        let target = initial[0].locator.session_id.clone();
760        std::thread::sleep(Duration::from_millis(20));
761        {
762            let conn = rusqlite::Connection::open(&db).unwrap();
763            conn.execute(
764                "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'index test append', ?2, 1)",
765                rusqlite::params![target, 1_800_000_000.0_f64],
766            )
767            .unwrap();
768            conn.execute(
769                "UPDATE sessions SET message_count = message_count + 1, ended_at = ?2 WHERE id = ?1",
770                rusqlite::params![target, 1_800_000_000.0_f64],
771            )
772            .unwrap();
773        }
774        // Wait for the native watcher (bounded), then poll: exactly the appended session changes.
775        let deadline = Instant::now() + Duration::from_secs(5);
776        let delta = loop {
777            if let Some(delta) = subscription.poll().unwrap() {
778                break delta;
779            }
780            assert!(
781                Instant::now() < deadline,
782                "no index delta after the store append"
783            );
784            std::thread::sleep(Duration::from_millis(50));
785        };
786        assert_eq!(delta.changes.len(), 1, "{delta:#?}");
787        match &delta.changes[0] {
788            SessionIndexChange::Updated { descriptor } => {
789                assert_eq!(descriptor.locator.session_id, target);
790                assert_eq!(
791                    descriptor.message_count,
792                    initial[0].message_count.map(|count| count + 1)
793                );
794            }
795            other => panic!("expected an update for {target}, got {other:?}"),
796        }
797        assert!(
798            subscription.poll().unwrap().is_none(),
799            "one append, one delta"
800        );
801        fs::remove_dir_all(&root).ok();
802    }
803
804    #[test]
805    fn index_delta_is_a_complete_deterministic_replacement_set() {
806        let before = descriptor_map([descriptor("removed", 1), descriptor("updated", 2)]);
807        let after = descriptor_map([descriptor("updated", 3), descriptor("added", 4)]);
808        let changes = diff_descriptors(&before, &after);
809        assert!(matches!(
810            &changes[0],
811            SessionIndexChange::Added { descriptor } if descriptor.locator.session_id == "added"
812        ));
813        assert!(matches!(
814            &changes[1],
815            SessionIndexChange::Updated { descriptor } if descriptor.locator.session_id == "updated"
816        ));
817        assert!(matches!(
818            &changes[2],
819            SessionIndexChange::Removed { key } if key.session_id == "removed"
820        ));
821    }
822
823    #[test]
824    fn raw_index_projects_child_activity_into_one_root_row() {
825        let root = descriptor("root", 10);
826        let mut child = descriptor("child", 20);
827        child.parent_session_id = Some("root".into());
828        let query = DiscoveryQuery {
829            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
830            limit: Some(100),
831            ..DiscoveryQuery::default()
832        };
833
834        let projected = HarnessCatalog::new()
835            .project_index(&query, [root, child])
836            .unwrap();
837
838        assert_eq!(projected.len(), 1);
839        assert_eq!(projected[0].locator.session_id, "root");
840        assert_eq!(projected[0].updated_at_ms, Some(20));
841        assert_eq!(projected[0].child_session_count, 1);
842    }
843
844    #[test]
845    fn complete_raw_index_backfills_a_bounded_page_without_discovery() {
846        let query = DiscoveryQuery {
847            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
848            limit: Some(2),
849            ..DiscoveryQuery::default()
850        };
851        let catalog = HarnessCatalog::new();
852        let mut raw = descriptor_map([
853            descriptor("oldest", 1),
854            descriptor("middle", 2),
855            descriptor("newest", 3),
856        ]);
857        let initial = catalog
858            .project_index(&query, raw.values().cloned())
859            .unwrap();
860        assert_eq!(
861            initial
862                .iter()
863                .map(|descriptor| descriptor.locator.session_id.as_str())
864                .collect::<Vec<_>>(),
865            ["newest", "middle"]
866        );
867
868        raw.remove(&SessionIndexKey {
869            harness: HarnessId::CODEX.into(),
870            session_id: "newest".into(),
871        });
872        let after = catalog
873            .project_index(&query, raw.values().cloned())
874            .unwrap();
875        assert_eq!(
876            after
877                .iter()
878                .map(|descriptor| descriptor.locator.session_id.as_str())
879                .collect::<Vec<_>>(),
880            ["middle", "oldest"]
881        );
882    }
883
884    #[test]
885    fn append_reuses_an_immutable_header_but_replacement_does_not() {
886        let mut existing = descriptor("session", 1);
887        existing.cwd = Some(PathBuf::from("/workspace"));
888        existing.model = Some("model".into());
889        let before = FileFingerprint {
890            len: 100,
891            modified_ns: 1,
892            modified_ms: Some(1),
893            identity: 7,
894        };
895        let append = FileFingerprint {
896            len: 200,
897            modified_ns: 2,
898            modified_ms: Some(2),
899            identity: 7,
900        };
901        let replacement = FileFingerprint {
902            identity: 8,
903            ..append
904        };
905
906        assert!(can_reuse_header(&existing, before, append));
907        assert!(!can_reuse_header(&existing, before, replacement));
908    }
909
910    #[tokio::test]
911    async fn filesystem_event_wakes_index_without_a_poll_timer() {
912        let nonce = std::time::SystemTime::now()
913            .duration_since(UNIX_EPOCH)
914            .unwrap()
915            .as_nanos();
916        let root = std::env::temp_dir().join(format!(
917            "supercode-session-index-{}-{nonce}",
918            std::process::id()
919        ));
920        let codex = root.join("codex");
921        fs::create_dir_all(&codex).unwrap();
922        let query = DiscoveryQuery {
923            harnesses: vec![HarnessId::new(HarnessId::CODEX)],
924            homes: crate::HarnessHomes {
925                codex: codex.clone(),
926                ..crate::HarnessHomes::default()
927            },
928            limit: Some(10),
929            ..DiscoveryQuery::default()
930        };
931        let notifier = Arc::new(Notify::new());
932        let (mut index, initial) =
933            SessionIndexSubscription::open(query, Arc::clone(&notifier)).unwrap();
934        assert!(initial.is_empty());
935
936        let session = codex.join("new.jsonl");
937        fs::write(
938            &session,
939            concat!(
940                "{\"type\":\"session_meta\",\"payload\":{\"id\":\"new\",\"cwd\":\"/workspace\"}}\n",
941                "{\"type\":\"turn_context\",\"payload\":{\"cwd\":\"/workspace\",\"model\":\"gpt-test\"}}\n"
942            ),
943        )
944        .unwrap();
945
946        tokio::time::timeout(Duration::from_secs(5), notifier.notified())
947            .await
948            .expect("filesystem invalidation should wake the index");
949        let delta = index
950            .poll()
951            .unwrap()
952            .expect("the filesystem event should produce a visible delta");
953        assert!(matches!(
954            &delta.changes[0],
955            SessionIndexChange::Added { descriptor }
956                if descriptor.locator.session_id == "new"
957        ));
958
959        drop(index);
960        fs::remove_dir_all(root).unwrap();
961    }
962}