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