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