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