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