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