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, DiscoveryQuery, HarnessCatalog, HarnessId, SessionDescriptor,
21 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)]
71pub struct SessionIndexDelta {
72 pub revision: u64,
74 pub changes: Vec<SessionIndexChange>,
76}
77
78pub(crate) struct SessionIndexSubscription {
81 query: DiscoveryQuery,
82 raw: BTreeMap<SessionIndexKey, SessionDescriptor>,
83 paths: BTreeMap<PathBuf, SessionIndexKey>,
84 current: BTreeMap<SessionIndexKey, SessionDescriptor>,
85 fingerprints: BTreeMap<PathBuf, FileFingerprint>,
86 store_fingerprints: BTreeMap<PathBuf, Option<FileFingerprint>>,
90 codex_history: Option<CodexHistoryTopicIndex>,
91 revision: u64,
92 receiver: mpsc::Receiver<notify::Result<Event>>,
93 overflowed: Arc<AtomicBool>,
94 _watcher: RecommendedWatcher,
95 last_reconcile: Instant,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99struct FileFingerprint {
100 len: u64,
101 modified_ns: u128,
102 modified_ms: Option<u64>,
103 identity: u128,
104}
105
106impl SessionIndexSubscription {
107 pub(crate) fn homes(&self) -> &crate::HarnessHomes {
108 &self.query.homes
109 }
110
111 pub(crate) fn open(
112 mut query: DiscoveryQuery,
113 notifier: Arc<Notify>,
114 ) -> Result<(Self, Vec<SessionDescriptor>), String> {
115 validate_query(&query)?;
116 query.cursor = None;
117 query.limit = Some(query.limit.unwrap_or(100));
118
119 let catalog = HarnessCatalog::new();
120 let raw = descriptor_map(catalog.discover_raw_index(&query));
121 let projected = catalog
122 .project_index(&query, raw.values().cloned())
123 .map_err(|error| error.to_string())?;
124 let mut codex_history = (query.include_topic_candidates
125 && query
126 .harnesses
127 .iter()
128 .any(|harness| harness.as_str() == HarnessId::CODEX))
129 .then(|| CodexHistoryTopicIndex::new(&query.homes.codex));
130 if let Some(history) = &mut codex_history {
131 let _ = history.refresh();
134 }
135 let initial = match &codex_history {
136 Some(history) => {
137 catalog.enrich_index_page_with_codex_history(&query, projected, history)
138 }
139 None => catalog.enrich_index_page(&query, projected),
140 }
141 .map_err(|error| error.to_string())?;
142 let paths = descriptor_path_map(&raw);
143 let current = descriptor_map(initial.iter().cloned());
144 let fingerprints = scan_file_fingerprints(&query);
145 let store_fingerprints = scan_store_fingerprints(&query);
146 let (sender, receiver) = mpsc::sync_channel(INVALIDATION_QUEUE_CAPACITY);
147 let overflowed = Arc::new(AtomicBool::new(false));
148 let callback_overflowed = Arc::clone(&overflowed);
149 let callback_notifier = Arc::clone(¬ifier);
150 let mut watcher = notify::recommended_watcher(move |event| {
151 if sender.try_send(event).is_err() {
152 callback_overflowed.store(true, Ordering::Release);
153 }
154 callback_notifier.notify_one();
155 })
156 .map_err(|error| error.to_string())?;
157 for root in watch_roots(&query) {
158 if let Some(watched) = existing_watch_root(&root) {
159 watcher
160 .watch(&watched, RecursiveMode::Recursive)
161 .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
162 }
163 }
164 for store in store_paths(&query) {
165 let Some(dir) = store.parent() else { continue };
168 if let Some(watched) = existing_watch_root(dir) {
169 watcher
170 .watch(&watched, RecursiveMode::NonRecursive)
171 .map_err(|error| format!("cannot watch {}: {error}", watched.display()))?;
172 }
173 }
174 if let Some(history) = &codex_history {
175 let target = if history.path().is_file() {
176 history.path()
177 } else {
178 history.path().parent().unwrap_or(history.path())
179 };
180 if target.exists() {
181 watcher
182 .watch(target, RecursiveMode::NonRecursive)
183 .map_err(|error| format!("cannot watch {}: {error}", target.display()))?;
184 }
185 }
186
187 Ok((
188 Self {
189 query,
190 raw,
191 paths,
192 current,
193 fingerprints,
194 store_fingerprints,
195 codex_history,
196 revision: 1,
197 receiver,
198 overflowed,
199 _watcher: watcher,
200 last_reconcile: Instant::now(),
201 },
202 initial,
203 ))
204 }
205
206 pub(crate) fn poll(&mut self) -> Result<Option<SessionIndexDelta>, String> {
209 let mut paths = BTreeSet::new();
210 let mut sweep = self.overflowed.swap(false, Ordering::AcqRel);
211 let mut stores = false;
212 while let Ok(event) = self.receiver.try_recv() {
213 match event {
214 Ok(event) => {
215 if event.paths.is_empty() {
216 sweep = true;
217 }
218 for path in event.paths {
219 if path.extension().and_then(|value| value.to_str()) == Some("jsonl") {
220 paths.insert(path);
221 } else if self
222 .store_fingerprints
223 .contains_key(&normalized_store_path(&path))
224 {
225 stores = true;
226 } else {
227 sweep = true;
228 }
229 }
230 }
231 Err(_) => sweep = true,
232 }
233 }
234 if self.last_reconcile.elapsed() >= RECONCILE_INTERVAL {
235 sweep = true;
236 }
237 if paths.is_empty() && !sweep && !stores {
238 return Ok(None);
239 }
240
241 let before = self.current.clone();
242 let mut content_dirty = BTreeSet::new();
243 let history_path = self
244 .codex_history
245 .as_ref()
246 .map(|history| normalized_path(history.path()));
247 if let Some(history) = &mut self.codex_history {
248 if let Ok(changed) = history.refresh() {
249 content_dirty.extend(changed.into_iter().map(|session_id| SessionIndexKey {
250 harness: HarnessId::CODEX.to_string(),
251 session_id,
252 }));
253 }
254 }
255 if sweep {
256 self.reconcile_filesystem(&mut content_dirty)?;
257 }
258 if sweep || stores {
259 self.reconcile_stores(&mut content_dirty)?;
260 }
261 for path in paths {
262 if history_path
263 .as_ref()
264 .is_some_and(|history_path| normalized_path(&path) == *history_path)
265 {
266 continue;
267 }
268 self.refresh_path(&path, &mut content_dirty)?;
269 }
270 self.rebuild_current(&content_dirty)?;
271 let changes = diff_descriptors(&before, &self.current);
272 if changes.is_empty() {
273 return Ok(None);
274 }
275 self.revision = self.revision.saturating_add(1);
276 Ok(Some(SessionIndexDelta {
277 revision: self.revision,
278 changes,
279 }))
280 }
281
282 fn reconcile_filesystem(
283 &mut self,
284 content_dirty: &mut BTreeSet<SessionIndexKey>,
285 ) -> Result<(), String> {
286 self.last_reconcile = Instant::now();
287 let next = scan_file_fingerprints(&self.query);
288 let changed = self
289 .fingerprints
290 .keys()
291 .chain(next.keys())
292 .filter(|path| self.fingerprints.get(*path) != next.get(*path))
293 .cloned()
294 .collect::<BTreeSet<_>>();
295 for path in changed {
296 self.refresh_path(&path, content_dirty)?;
297 }
298 self.fingerprints = next;
299 Ok(())
300 }
301
302 fn reconcile_stores(
306 &mut self,
307 content_dirty: &mut BTreeSet<SessionIndexKey>,
308 ) -> Result<(), String> {
309 let next = scan_store_fingerprints(&self.query);
310 if next == self.store_fingerprints {
311 return Ok(());
312 }
313 self.store_fingerprints = next;
314 let mut query = self.query.clone();
315 query
316 .harnesses
317 .retain(|harness| harness.as_str() == HarnessId::HERMES);
318 if query.harnesses.is_empty() {
319 return Ok(());
320 }
321 let fresh = descriptor_map(HarnessCatalog::new().discover_raw_index(&query));
322 let stale = self
323 .raw
324 .keys()
325 .filter(|key| key.harness == HarnessId::HERMES)
326 .cloned()
327 .collect::<Vec<_>>();
328 for key in stale {
329 if !fresh.contains_key(&key) {
330 self.raw.remove(&key);
331 content_dirty.insert(key);
332 }
333 }
334 for (key, descriptor) in fresh {
335 if self.raw.get(&key) != Some(&descriptor) {
336 self.raw.insert(key.clone(), descriptor);
337 content_dirty.insert(key);
338 }
339 }
340 Ok(())
341 }
342
343 fn refresh_path(
344 &mut self,
345 path: &Path,
346 content_dirty: &mut BTreeSet<SessionIndexKey>,
347 ) -> Result<(), String> {
348 if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
349 return Ok(());
350 }
351 let event_path = normalized_path(path);
352 let previous_key = self.paths.get(&event_path).cloned();
353 let previous = previous_key
354 .as_ref()
355 .and_then(|key| self.raw.get(key))
356 .cloned();
357 let previous_fingerprint = self.fingerprints.get(&event_path).copied();
358 let fingerprint = file_fingerprint(&event_path);
359
360 let Some(fingerprint) = fingerprint else {
361 self.fingerprints.remove(&event_path);
362 if let Some(key) = previous_key {
363 self.paths.remove(&event_path);
364 self.raw.remove(&key);
365 content_dirty.insert(key);
366 }
367 return Ok(());
368 };
369 self.fingerprints.insert(event_path.clone(), fingerprint);
370
371 let locator = previous
372 .as_ref()
373 .map(|descriptor| descriptor.locator.clone())
374 .or_else(|| locator_for_path(&self.query, &event_path));
375 let Some(locator) = locator else {
376 return Ok(());
377 };
378 let refreshed =
379 if let (Some(descriptor), Some(old)) = (previous.as_ref(), previous_fingerprint) {
380 if can_reuse_header(descriptor, old, fingerprint) {
381 let mut descriptor = descriptor.clone();
382 descriptor.updated_at_ms = fingerprint.modified_ms;
383 Some(descriptor)
384 } else {
385 HarnessCatalog::new()
386 .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
387 .map_err(|error| error.to_string())?
388 }
389 } else {
390 HarnessCatalog::new()
391 .refresh_file_index_descriptor(&locator, self.query.workspace.as_deref())
392 .map_err(|error| error.to_string())?
393 };
394 let Some(descriptor) = refreshed else {
395 return Ok(());
396 };
397 let key = SessionIndexKey::from_locator(&descriptor.locator);
398 if let Some(previous_key) = previous_key {
399 if previous_key != key {
400 self.raw.remove(&previous_key);
401 content_dirty.insert(previous_key);
402 }
403 }
404 self.paths.insert(event_path, key.clone());
405 self.raw.insert(key.clone(), descriptor);
406 content_dirty.insert(key);
407 Ok(())
408 }
409
410 fn rebuild_current(&mut self, content_dirty: &BTreeSet<SessionIndexKey>) -> Result<(), String> {
411 let catalog = HarnessCatalog::new();
412 let projected = catalog
413 .project_index(&self.query, self.raw.values().cloned())
414 .map_err(|error| error.to_string())?;
415 let mut next = Vec::with_capacity(projected.len());
416 for mut descriptor in projected {
417 let key = SessionIndexKey::from_locator(&descriptor.locator);
418 if let Some(previous) = self.current.get(&key) {
419 descriptor.preview_candidates = previous.preview_candidates.clone();
420 descriptor.latest_message_candidates = previous.latest_message_candidates.clone();
421 }
422 if !self.current.contains_key(&key) || content_dirty.contains(&key) {
423 let enriched = match &self.codex_history {
424 Some(history) => catalog.enrich_index_page_with_codex_history(
425 &self.query,
426 vec![descriptor],
427 history,
428 ),
429 None => catalog.enrich_index_page(&self.query, vec![descriptor]),
430 };
431 descriptor = enriched
432 .map_err(|error| error.to_string())?
433 .pop()
434 .expect("one descriptor remains one descriptor");
435 }
436 next.push(descriptor);
437 }
438 self.current = descriptor_map(next);
439 Ok(())
440 }
441}
442
443pub(crate) fn validate_query(query: &DiscoveryQuery) -> Result<(), String> {
444 if query.cursor.is_some() {
445 return Err("sessions.index.subscribe does not accept a cursor".into());
446 }
447 let limit = query.limit.unwrap_or(100);
448 if limit == 0 || limit > MAX_SUBSCRIPTION_ROWS {
449 return Err(format!(
450 "sessions.index.subscribe limit must be between 1 and {MAX_SUBSCRIPTION_ROWS}"
451 ));
452 }
453 if query.harnesses.is_empty()
454 || query.harnesses.iter().any(|harness| {
455 !matches!(
456 harness.as_str(),
457 HarnessId::CLAUDE_CODE | HarnessId::CODEX | HarnessId::HERMES
458 )
459 })
460 {
461 return Err(
462 "sessions.index.subscribe currently requires explicit claude-code, codex and/or hermes harnesses"
463 .into(),
464 );
465 }
466 Ok(())
467}
468
469fn watch_roots(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
470 query
471 .harnesses
472 .iter()
473 .filter_map(|harness| match harness.as_str() {
474 HarnessId::CLAUDE_CODE => Some(query.homes.claude_code.clone()),
475 HarnessId::CODEX => Some(query.homes.codex.clone()),
476 _ => None,
477 })
478 .collect()
479}
480
481fn existing_watch_root(root: &Path) -> Option<PathBuf> {
482 if root.is_dir() {
483 return Some(root.to_path_buf());
484 }
485 root.parent()
489 .filter(|parent| parent.is_dir())
490 .map(Path::to_path_buf)
491}
492
493fn store_paths(query: &DiscoveryQuery) -> BTreeSet<PathBuf> {
495 query
496 .harnesses
497 .iter()
498 .filter_map(|harness| match harness.as_str() {
499 HarnessId::HERMES => Some(query.homes.hermes.clone()),
500 _ => None,
501 })
502 .collect()
503}
504
505fn scan_store_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, Option<FileFingerprint>> {
508 let mut stamps = BTreeMap::new();
509 for store in store_paths(query) {
510 for path in store_sibling_paths(&store) {
511 let stamp = file_fingerprint(&path);
512 stamps.insert(normalized_store_path(&path), stamp);
513 }
514 }
515 stamps
516}
517
518fn store_sibling_paths(store: &Path) -> [PathBuf; 3] {
519 let name = store
520 .file_name()
521 .and_then(|value| value.to_str())
522 .unwrap_or("state.db");
523 [
524 store.to_path_buf(),
525 store.with_file_name(format!("{name}-wal")),
526 store.with_file_name(format!("{name}-shm")),
527 ]
528}
529
530fn normalized_store_path(path: &Path) -> PathBuf {
532 match (path.parent(), path.file_name()) {
533 (Some(dir), Some(name)) => normalized_path(dir).join(name),
534 _ => path.to_path_buf(),
535 }
536}
537
538fn locator_for_path(query: &DiscoveryQuery, path: &Path) -> Option<SessionLocator> {
539 let claude_root = normalized_path(&query.homes.claude_code);
540 let codex_root = normalized_path(&query.homes.codex);
541 let harness = if query
542 .harnesses
543 .iter()
544 .any(|harness| harness.as_str() == HarnessId::CLAUDE_CODE)
545 && path.starts_with(&claude_root)
546 {
547 HarnessId::CLAUDE_CODE
548 } else if query
549 .harnesses
550 .iter()
551 .any(|harness| harness.as_str() == HarnessId::CODEX)
552 && path.starts_with(&codex_root)
553 {
554 HarnessId::CODEX
555 } else {
556 return None;
557 };
558 Some(SessionLocator {
559 harness: HarnessId::new(harness),
560 session_id: path
561 .file_stem()
562 .and_then(|value| value.to_str())
563 .unwrap_or("unknown")
564 .to_string(),
565 storage: StorageLocator::File {
566 path: path.to_path_buf(),
567 },
568 })
569}
570
571fn normalized_path(path: &Path) -> PathBuf {
572 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
573}
574
575fn descriptor_path_map(
576 descriptors: &BTreeMap<SessionIndexKey, SessionDescriptor>,
577) -> BTreeMap<PathBuf, SessionIndexKey> {
578 descriptors
579 .iter()
580 .map(|(key, descriptor)| {
581 (
582 normalized_path(descriptor.locator.storage.path()),
583 key.clone(),
584 )
585 })
586 .collect()
587}
588
589fn scan_file_fingerprints(query: &DiscoveryQuery) -> BTreeMap<PathBuf, FileFingerprint> {
590 let mut paths = Vec::new();
591 for root in watch_roots(query) {
592 collect_jsonl_paths(&root, &mut paths);
593 }
594 paths
595 .into_iter()
596 .filter_map(|path| {
597 let path = normalized_path(&path);
598 file_fingerprint(&path).map(|fingerprint| (path, fingerprint))
599 })
600 .collect()
601}
602
603fn collect_jsonl_paths(root: &Path, paths: &mut Vec<PathBuf>) {
604 let Ok(entries) = fs::read_dir(root) else {
605 return;
606 };
607 for entry in entries.flatten() {
608 let Ok(file_type) = entry.file_type() else {
609 continue;
610 };
611 let path = entry.path();
612 if file_type.is_dir() {
613 collect_jsonl_paths(&path, paths);
614 } else if file_type.is_file()
615 && path.extension().and_then(|value| value.to_str()) == Some("jsonl")
616 {
617 paths.push(path);
618 }
619 }
620}
621
622fn file_fingerprint(path: &Path) -> Option<FileFingerprint> {
623 let metadata = fs::metadata(path).ok()?;
624 let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
625 #[cfg(unix)]
626 let identity = {
627 use std::os::unix::fs::MetadataExt;
628 (u128::from(metadata.dev()) << 64) | u128::from(metadata.ino())
629 };
630 #[cfg(not(unix))]
631 let identity = 0;
632 Some(FileFingerprint {
633 len: metadata.len(),
634 modified_ns: modified.as_nanos(),
635 modified_ms: u64::try_from(modified.as_millis()).ok(),
636 identity,
637 })
638}
639
640fn can_reuse_header(
641 descriptor: &SessionDescriptor,
642 previous: FileFingerprint,
643 current: FileFingerprint,
644) -> bool {
645 previous.identity == current.identity
646 && previous.len <= current.len
647 && descriptor.cwd.is_some()
648 && descriptor.model.is_some()
649 && !descriptor.locator.session_id.is_empty()
650}
651
652fn descriptor_map(
653 descriptors: impl IntoIterator<Item = SessionDescriptor>,
654) -> BTreeMap<SessionIndexKey, SessionDescriptor> {
655 descriptors
656 .into_iter()
657 .map(|descriptor| {
658 (
659 SessionIndexKey::from_locator(&descriptor.locator),
660 descriptor,
661 )
662 })
663 .collect()
664}
665
666fn diff_descriptors(
667 before: &BTreeMap<SessionIndexKey, SessionDescriptor>,
668 after: &BTreeMap<SessionIndexKey, SessionDescriptor>,
669) -> Vec<SessionIndexChange> {
670 let mut changes = Vec::new();
671 for (key, descriptor) in after {
672 match before.get(key) {
673 None => changes.push(SessionIndexChange::Added {
674 descriptor: descriptor.clone(),
675 }),
676 Some(previous) if previous != descriptor => {
677 changes.push(SessionIndexChange::Updated {
678 descriptor: descriptor.clone(),
679 });
680 }
681 Some(_) => {}
682 }
683 }
684 for key in before.keys() {
685 if !after.contains_key(key) {
686 changes.push(SessionIndexChange::Removed { key: key.clone() });
687 }
688 }
689 changes
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 fn descriptor(id: &str, updated_at_ms: u64) -> SessionDescriptor {
697 SessionDescriptor {
698 locator: SessionLocator {
699 harness: HarnessId::new(HarnessId::CODEX),
700 session_id: id.into(),
701 storage: StorageLocator::File {
702 path: PathBuf::from(format!("/{id}.jsonl")),
703 },
704 },
705 cwd: None,
706 title: None,
707 preview_candidates: Vec::new(),
708 latest_message_candidates: Vec::new(),
709 updated_at_ms: Some(updated_at_ms),
710 message_count: None,
711 model: None,
712 parent_session_id: None,
713 child_session_count: 0,
714 nouns: Default::default(),
715 }
716 }
717
718 #[test]
719 fn hermes_store_appends_surface_as_index_updates() {
720 let root = std::env::temp_dir().join(format!(
723 "supercode-index-hermes-{}-{}",
724 std::process::id(),
725 std::time::SystemTime::now()
726 .duration_since(UNIX_EPOCH)
727 .unwrap()
728 .as_nanos()
729 ));
730 fs::create_dir_all(&root).unwrap();
731 let db = root.join("state.db");
732 fs::copy(
733 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hermes_home/state.db"),
734 &db,
735 )
736 .unwrap();
737 let query = DiscoveryQuery {
738 harnesses: vec![HarnessId::new(HarnessId::HERMES)],
739 homes: crate::HarnessHomes {
740 hermes: db.clone(),
741 claude_code: root.join("missing-claude"),
742 codex: root.join("missing-codex"),
743 ..crate::HarnessHomes::default()
744 },
745 ..DiscoveryQuery::default()
746 };
747 let (mut subscription, initial) =
748 SessionIndexSubscription::open(query, Arc::new(Notify::new())).unwrap();
749 assert!(initial.len() >= 2, "{initial:#?}");
750 assert!(initial
751 .iter()
752 .all(|descriptor| descriptor.locator.harness.as_str() == HarnessId::HERMES));
753 assert!(
754 subscription.poll().unwrap().is_none(),
755 "quiet store, quiet index"
756 );
757
758 let target = initial[0].locator.session_id.clone();
760 std::thread::sleep(Duration::from_millis(20));
761 {
762 let conn = rusqlite::Connection::open(&db).unwrap();
763 conn.execute(
764 "INSERT INTO messages (session_id, role, content, timestamp, active) VALUES (?1, 'assistant', 'index test append', ?2, 1)",
765 rusqlite::params![target, 1_800_000_000.0_f64],
766 )
767 .unwrap();
768 conn.execute(
769 "UPDATE sessions SET message_count = message_count + 1, ended_at = ?2 WHERE id = ?1",
770 rusqlite::params![target, 1_800_000_000.0_f64],
771 )
772 .unwrap();
773 }
774 let deadline = Instant::now() + Duration::from_secs(5);
776 let delta = loop {
777 if let Some(delta) = subscription.poll().unwrap() {
778 break delta;
779 }
780 assert!(
781 Instant::now() < deadline,
782 "no index delta after the store append"
783 );
784 std::thread::sleep(Duration::from_millis(50));
785 };
786 assert_eq!(delta.changes.len(), 1, "{delta:#?}");
787 match &delta.changes[0] {
788 SessionIndexChange::Updated { descriptor } => {
789 assert_eq!(descriptor.locator.session_id, target);
790 assert_eq!(
791 descriptor.message_count,
792 initial[0].message_count.map(|count| count + 1)
793 );
794 }
795 other => panic!("expected an update for {target}, got {other:?}"),
796 }
797 assert!(
798 subscription.poll().unwrap().is_none(),
799 "one append, one delta"
800 );
801 fs::remove_dir_all(&root).ok();
802 }
803
804 #[test]
805 fn index_delta_is_a_complete_deterministic_replacement_set() {
806 let before = descriptor_map([descriptor("removed", 1), descriptor("updated", 2)]);
807 let after = descriptor_map([descriptor("updated", 3), descriptor("added", 4)]);
808 let changes = diff_descriptors(&before, &after);
809 assert!(matches!(
810 &changes[0],
811 SessionIndexChange::Added { descriptor } if descriptor.locator.session_id == "added"
812 ));
813 assert!(matches!(
814 &changes[1],
815 SessionIndexChange::Updated { descriptor } if descriptor.locator.session_id == "updated"
816 ));
817 assert!(matches!(
818 &changes[2],
819 SessionIndexChange::Removed { key } if key.session_id == "removed"
820 ));
821 }
822
823 #[test]
824 fn raw_index_projects_child_activity_into_one_root_row() {
825 let root = descriptor("root", 10);
826 let mut child = descriptor("child", 20);
827 child.parent_session_id = Some("root".into());
828 let query = DiscoveryQuery {
829 harnesses: vec![HarnessId::new(HarnessId::CODEX)],
830 limit: Some(100),
831 ..DiscoveryQuery::default()
832 };
833
834 let projected = HarnessCatalog::new()
835 .project_index(&query, [root, child])
836 .unwrap();
837
838 assert_eq!(projected.len(), 1);
839 assert_eq!(projected[0].locator.session_id, "root");
840 assert_eq!(projected[0].updated_at_ms, Some(20));
841 assert_eq!(projected[0].child_session_count, 1);
842 }
843
844 #[test]
845 fn complete_raw_index_backfills_a_bounded_page_without_discovery() {
846 let query = DiscoveryQuery {
847 harnesses: vec![HarnessId::new(HarnessId::CODEX)],
848 limit: Some(2),
849 ..DiscoveryQuery::default()
850 };
851 let catalog = HarnessCatalog::new();
852 let mut raw = descriptor_map([
853 descriptor("oldest", 1),
854 descriptor("middle", 2),
855 descriptor("newest", 3),
856 ]);
857 let initial = catalog
858 .project_index(&query, raw.values().cloned())
859 .unwrap();
860 assert_eq!(
861 initial
862 .iter()
863 .map(|descriptor| descriptor.locator.session_id.as_str())
864 .collect::<Vec<_>>(),
865 ["newest", "middle"]
866 );
867
868 raw.remove(&SessionIndexKey {
869 harness: HarnessId::CODEX.into(),
870 session_id: "newest".into(),
871 });
872 let after = catalog
873 .project_index(&query, raw.values().cloned())
874 .unwrap();
875 assert_eq!(
876 after
877 .iter()
878 .map(|descriptor| descriptor.locator.session_id.as_str())
879 .collect::<Vec<_>>(),
880 ["middle", "oldest"]
881 );
882 }
883
884 #[test]
885 fn append_reuses_an_immutable_header_but_replacement_does_not() {
886 let mut existing = descriptor("session", 1);
887 existing.cwd = Some(PathBuf::from("/workspace"));
888 existing.model = Some("model".into());
889 let before = FileFingerprint {
890 len: 100,
891 modified_ns: 1,
892 modified_ms: Some(1),
893 identity: 7,
894 };
895 let append = FileFingerprint {
896 len: 200,
897 modified_ns: 2,
898 modified_ms: Some(2),
899 identity: 7,
900 };
901 let replacement = FileFingerprint {
902 identity: 8,
903 ..append
904 };
905
906 assert!(can_reuse_header(&existing, before, append));
907 assert!(!can_reuse_header(&existing, before, replacement));
908 }
909
910 #[tokio::test]
911 async fn filesystem_event_wakes_index_without_a_poll_timer() {
912 let nonce = std::time::SystemTime::now()
913 .duration_since(UNIX_EPOCH)
914 .unwrap()
915 .as_nanos();
916 let root = std::env::temp_dir().join(format!(
917 "supercode-session-index-{}-{nonce}",
918 std::process::id()
919 ));
920 let codex = root.join("codex");
921 fs::create_dir_all(&codex).unwrap();
922 let query = DiscoveryQuery {
923 harnesses: vec![HarnessId::new(HarnessId::CODEX)],
924 homes: crate::HarnessHomes {
925 codex: codex.clone(),
926 ..crate::HarnessHomes::default()
927 },
928 limit: Some(10),
929 ..DiscoveryQuery::default()
930 };
931 let notifier = Arc::new(Notify::new());
932 let (mut index, initial) =
933 SessionIndexSubscription::open(query, Arc::clone(¬ifier)).unwrap();
934 assert!(initial.is_empty());
935
936 let session = codex.join("new.jsonl");
937 fs::write(
938 &session,
939 concat!(
940 "{\"type\":\"session_meta\",\"payload\":{\"id\":\"new\",\"cwd\":\"/workspace\"}}\n",
941 "{\"type\":\"turn_context\",\"payload\":{\"cwd\":\"/workspace\",\"model\":\"gpt-test\"}}\n"
942 ),
943 )
944 .unwrap();
945
946 tokio::time::timeout(Duration::from_secs(5), notifier.notified())
947 .await
948 .expect("filesystem invalidation should wake the index");
949 let delta = index
950 .poll()
951 .unwrap()
952 .expect("the filesystem event should produce a visible delta");
953 assert!(matches!(
954 &delta.changes[0],
955 SessionIndexChange::Added { descriptor }
956 if descriptor.locator.session_id == "new"
957 ));
958
959 drop(index);
960 fs::remove_dir_all(root).unwrap();
961 }
962}