1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Mutex;
9
10use super::adapter::*;
11
12pub(crate) const META_CACHE_VERSION: u32 = 1;
16
17pub(crate) const META_CACHE_WRITE_INTERVAL_MS: i64 = 10 * 60 * 1000;
19
20#[derive(serde::Deserialize)]
21struct MetaCacheFile {
22 version: u32,
23 entries: HashMap<String, (i64, Option<SessionMeta>)>,
24}
25
26#[derive(serde::Serialize)]
27struct MetaCacheFileRef<'a> {
28 version: u32,
29 entries: &'a HashMap<String, (i64, Option<SessionMeta>)>,
30}
31
32const SNIPPET_MAX_CHARS: usize = 400;
42const SNIPPET_LEAD_CHARS: usize = 120;
44
45#[derive(Debug, Clone, Default)]
47pub struct HistorySearchOptions {
48 pub query: String,
51 pub role: Option<String>,
53 pub tool: Option<String>,
60 pub limit: usize,
62 pub since_ms: Option<i64>,
64 pub until_ms: Option<i64>,
66 pub max_sessions: Option<usize>,
69 pub include_inferred: bool,
72}
73
74#[derive(Debug, Clone, serde::Serialize)]
76pub struct HistorySearchHit {
77 pub session_id: String,
78 pub file_path: String,
79 pub timestamp: i64,
80 pub role: String,
81 pub snippet: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub tool_name: Option<String>,
85 pub source: &'static str,
88 pub attribution: &'static str,
92}
93
94#[derive(Debug, Clone)]
96pub(crate) enum Source {
97 Transcript,
98 Record(super::record::RecordSession),
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub(crate) enum Attribution {
104 AgentRecord,
106 Inferred,
108}
109
110#[derive(Debug, Clone)]
112pub(crate) struct SearchCandidate {
113 pub meta: SessionMeta,
114 pub source: Source,
115 pub attribution: Attribution,
116}
117
118impl SearchCandidate {
119 fn source_label(&self) -> &'static str {
120 match self.source {
121 Source::Transcript => "provider_transcript",
122 Source::Record(_) => "agentmux_record",
123 }
124 }
125 fn attribution_label(&self) -> &'static str {
126 match self.attribution {
127 Attribution::AgentRecord => "agent_record",
128 Attribution::Inferred => "inferred",
129 }
130 }
131}
132
133#[derive(Debug, Clone, serde::Serialize)]
135pub struct HistorySearchOutcome {
136 pub hits: Vec<HistorySearchHit>,
137 pub sessions_scanned: u32,
139 pub total_sessions: u32,
141 pub truncated: bool,
145 pub complete: bool,
151 pub incomplete_reasons: Vec<&'static str>,
154 pub sessions_skipped: u32,
156 pub sessions_unreadable: Vec<UnreadableSession>,
158 pub sessions_partly_read: Vec<UnreadableSession>,
160 pub discovery_errors: Vec<String>,
163 pub ledger_sessions: u32,
165 pub record_only_sessions: u32,
168 pub inferred_sessions_excluded: u32,
172}
173
174const DISCOVERY_ERRORS_LISTED: usize = 20;
176
177impl HistorySearchOutcome {
178 pub fn add_discovery_errors(&mut self, problems: Vec<String>) {
180 if problems.is_empty() {
181 return;
182 }
183 let total = problems.len();
184 self.discovery_errors = problems.into_iter().take(DISCOVERY_ERRORS_LISTED).collect();
185 if total > DISCOVERY_ERRORS_LISTED {
186 self.discovery_errors.push(format!("… and {} more", total - DISCOVERY_ERRORS_LISTED));
187 }
188 self.incomplete_reasons.push("discovery_errors");
189 self.complete = false;
190 }
191}
192
193#[derive(Debug, Clone, serde::Serialize)]
195pub struct UnreadableSession {
196 pub session_id: String,
197 pub reason: String,
198}
199
200fn tool_name_matches(recorded: &str, wanted: &str) -> bool {
205 let recorded = recorded.to_ascii_lowercase();
206 let wanted = wanted.to_ascii_lowercase();
207 recorded == wanted
208 || (recorded.starts_with("mcp__") && recorded.ends_with(&format!("__{wanted}")))
209}
210
211fn within_window(timestamp_ms: i64, opts: &HistorySearchOptions) -> bool {
215 timestamp_ms == 0
216 || (opts.since_ms.is_none_or(|since| timestamp_ms >= since)
217 && opts.until_ms.is_none_or(|until| timestamp_ms <= until))
218}
219
220fn find_case_insensitive(haystack: &str, needle_lower: &str) -> Option<usize> {
234 if needle_lower.is_empty() {
235 return None;
236 }
237 let mut lowered = String::with_capacity(haystack.len());
238 let mut origin: Vec<usize> = Vec::with_capacity(haystack.len() + 1);
240 for (orig_idx, ch) in haystack.char_indices() {
241 for lc in ch.to_lowercase() {
242 let mut buf = [0u8; 4];
243 let encoded = lc.encode_utf8(&mut buf);
244 for _ in 0..encoded.len() {
245 origin.push(orig_idx);
246 }
247 lowered.push_str(encoded);
248 }
249 }
250 origin.push(haystack.len());
251 let at = lowered.find(needle_lower)?;
252 origin.get(at).copied()
255}
256
257fn snippet_around(haystack: &str, match_at: usize) -> String {
260 let start = haystack[..match_at]
261 .char_indices()
262 .rev()
263 .take(SNIPPET_LEAD_CHARS)
264 .last()
265 .map(|(i, _)| i)
266 .unwrap_or(match_at);
267 let tail: String = haystack[start..].chars().take(SNIPPET_MAX_CHARS).collect();
268 let mut out = String::new();
269 if start > 0 {
270 out.push('…');
271 }
272 out.push_str(&tail);
273 if start + tail.len() < haystack.len() {
274 out.push('…');
275 }
276 out
277}
278
279fn match_message(
281 candidate: &SearchCandidate,
282 msg: &HistoryMessage,
283 needle: &str,
284 opts: &HistorySearchOptions,
285) -> Vec<HistorySearchHit> {
286 let meta = &candidate.meta;
287 let mut hits = Vec::new();
288
289 for tu in &msg.tool_uses {
301 if let Some(want) = opts.tool.as_deref() {
302 if !tool_name_matches(&tu.name, want) {
303 continue;
304 }
305 let at = if needle.is_empty() {
308 Some(0)
309 } else {
310 find_case_insensitive(&tu.argument_summary, needle)
311 };
312 if let Some(at) = at {
313 hits.push(HistorySearchHit {
314 session_id: meta.session_id.clone(),
315 file_path: meta.file_path.clone(),
316 timestamp: msg.timestamp,
317 role: msg.role.clone(),
318 snippet: snippet_around(&tu.argument_summary, at),
319 tool_name: Some(tu.name.clone()),
320 source: candidate.source_label(),
321 attribution: candidate.attribution_label(),
322 });
323 }
324 continue;
325 }
326 if needle.is_empty() {
327 continue;
328 }
329 let at = if find_case_insensitive(&tu.name, needle).is_some() {
333 Some(0)
334 } else {
335 find_case_insensitive(&tu.argument_summary, needle)
336 };
337 if let Some(at) = at {
338 hits.push(HistorySearchHit {
339 session_id: meta.session_id.clone(),
340 file_path: meta.file_path.clone(),
341 timestamp: msg.timestamp,
342 role: msg.role.clone(),
343 snippet: snippet_around(&tu.argument_summary, at),
344 tool_name: Some(tu.name.clone()),
345 source: candidate.source_label(),
346 attribution: candidate.attribution_label(),
347 });
348 }
349 }
350
351 if opts.tool.is_none() && !needle.is_empty() {
354 if let Some(at) = find_case_insensitive(&msg.content, needle) {
355 hits.push(HistorySearchHit {
356 session_id: meta.session_id.clone(),
357 file_path: meta.file_path.clone(),
358 timestamp: msg.timestamp,
359 role: msg.role.clone(),
360 snippet: snippet_around(&msg.content, at),
361 tool_name: None,
362 source: candidate.source_label(),
363 attribution: candidate.attribution_label(),
364 });
365 }
366 }
367
368 hits
369}
370
371pub(crate) fn search_candidates(
376 candidates: &[SearchCandidate],
377 opts: &HistorySearchOptions,
378 load: &dyn Fn(&SearchCandidate) -> Result<Option<HistorySession>, HistoryError>,
379) -> HistorySearchOutcome {
380 let needle = opts.query.to_lowercase();
381 let mut hits: Vec<HistorySearchHit> = Vec::new();
382 let mut sessions_scanned = 0u32;
383 let mut sessions_unreadable: Vec<UnreadableSession> = Vec::new();
384 let mut sessions_partly_read: Vec<UnreadableSession> = Vec::new();
385 let mut truncated = false;
386
387 let opened = opts.max_sessions.map_or(candidates.len(), |max| max.min(candidates.len()));
388 let sessions_skipped = (candidates.len() - opened) as u32;
389
390 for candidate in &candidates[..opened] {
391 let meta = &candidate.meta;
392 if hits.len() >= opts.limit {
393 truncated = true;
395 break;
396 }
397 let session = match load(candidate) {
401 Ok(Some(session)) => session,
402 Ok(None) => {
403 sessions_unreadable.push(UnreadableSession {
404 session_id: meta.session_id.clone(),
405 reason: "no longer readable (moved, deleted or emptied)".into(),
406 });
407 continue;
408 }
409 Err(e) => {
410 sessions_unreadable.push(UnreadableSession {
411 session_id: meta.session_id.clone(),
412 reason: e.to_string(),
413 });
414 continue;
415 }
416 };
417 sessions_scanned += 1;
418 if session.skipped_records > 0 {
419 sessions_partly_read.push(UnreadableSession {
420 session_id: meta.session_id.clone(),
421 reason: format!(
422 "{} record(s) could not be read and were not searched",
423 session.skipped_records
424 ),
425 });
426 }
427 for msg in &session.messages {
428 if let Some(role) = opts.role.as_deref() {
429 if msg.role != role {
430 continue;
431 }
432 }
433 if !within_window(msg.timestamp, opts) {
434 continue;
435 }
436 if hits.len() >= opts.limit {
437 truncated = true;
438 break;
439 }
440 for hit in match_message(candidate, msg, &needle, opts) {
441 if hits.len() >= opts.limit {
442 truncated = true;
443 break;
444 }
445 hits.push(hit);
446 }
447 }
448 }
449
450 let mut incomplete_reasons = Vec::new();
451 if truncated {
452 incomplete_reasons.push("hit_limit");
453 }
454 if sessions_skipped > 0 {
455 incomplete_reasons.push("max_sessions");
456 }
457 if !sessions_unreadable.is_empty() {
458 incomplete_reasons.push("unreadable_sessions");
459 }
460 if !sessions_partly_read.is_empty() {
461 incomplete_reasons.push("unreadable_records");
462 }
463 HistorySearchOutcome {
464 hits,
465 sessions_scanned,
466 total_sessions: candidates.len() as u32,
467 truncated,
468 complete: incomplete_reasons.is_empty(),
469 incomplete_reasons,
470 sessions_skipped,
471 sessions_unreadable,
472 sessions_partly_read,
473 discovery_errors: Vec::new(),
474 ledger_sessions: 0,
475 record_only_sessions: 0,
476 inferred_sessions_excluded: 0,
477 }
478}
479
480fn default_isolated_roots() -> Vec<PathBuf> {
485 let shared = std::env::var_os("AGENTMUX_SHARED_DIR")
486 .map(PathBuf::from)
487 .or_else(|| agentmux_common::data_paths::agentmux_root().ok().map(|root| root.join("shared")));
488 match shared {
489 Some(s) => vec![s.join("providers"), s.join("identities")],
490 None => Vec::new(),
491 }
492}
493
494pub(crate) fn normalize_working_dir(dir: &str) -> String {
503 let unified = dir.replace('\\', "/");
504 let trimmed = unified.trim_end_matches('/');
505 if cfg!(windows) {
506 trimmed.to_lowercase()
507 } else {
508 trimmed.to_string()
509 }
510}
511
512#[derive(Debug, PartialEq, Eq)]
514pub enum FirstBuild {
515 Built { discovered: u32 },
517 AlreadyBuilt,
519 Busy,
521}
522
523pub struct SessionIndex {
525 sessions: Mutex<HashMap<String, SessionMeta>>,
527 by_working_dir: Mutex<HashMap<String, Vec<String>>>,
552 by_identity: Mutex<HashMap<String, Vec<String>>>,
560 meta_cache: Mutex<HashMap<String, (i64, Option<SessionMeta>)>>,
565 cache_file: Option<PathBuf>,
571 cache_loaded: std::sync::atomic::AtomicBool,
573 cache_written_at_ms: std::sync::atomic::AtomicI64,
575 pub(super) refresh_lock: Mutex<()>,
578 refreshed_at_ms: std::sync::atomic::AtomicI64,
580 refreshes_started: std::sync::atomic::AtomicU64,
583 last_finished_refresh: std::sync::atomic::AtomicU64,
587 discovery_problems: Mutex<Vec<String>>,
590 adapters: Vec<Box<dyn HistoryAdapter>>,
592 isolated_roots: Vec<PathBuf>,
594}
595
596impl SessionIndex {
597 pub fn new(adapters: Vec<Box<dyn HistoryAdapter>>) -> Self {
598 Self::with_isolated_roots(adapters, default_isolated_roots())
599 }
600
601 pub fn with_isolated_roots(
603 adapters: Vec<Box<dyn HistoryAdapter>>,
604 isolated_roots: Vec<PathBuf>,
605 ) -> Self {
606 SessionIndex {
607 sessions: Mutex::new(HashMap::new()),
608 by_working_dir: Mutex::new(HashMap::new()),
609 by_identity: Mutex::new(HashMap::new()),
610 meta_cache: Mutex::new(HashMap::new()),
611 cache_file: None,
612 cache_loaded: std::sync::atomic::AtomicBool::new(false),
613 cache_written_at_ms: std::sync::atomic::AtomicI64::new(0),
614 refresh_lock: Mutex::new(()),
615 refreshed_at_ms: std::sync::atomic::AtomicI64::new(0),
616 refreshes_started: std::sync::atomic::AtomicU64::new(0),
617 last_finished_refresh: std::sync::atomic::AtomicU64::new(0),
618 discovery_problems: Mutex::new(Vec::new()),
619 adapters,
620 isolated_roots,
621 }
622 }
623
624 pub fn with_cache_file(mut self, path: PathBuf) -> Self {
629 self.cache_file = Some(path);
630 self
631 }
632
633 pub fn refreshed_at_ms(&self) -> i64 {
635 self.refreshed_at_ms.load(std::sync::atomic::Ordering::Acquire)
636 }
637
638 pub fn try_refresh(&self) -> Option<(u32, u32, u32)> {
641 let guard = match self.refresh_lock.try_lock() {
642 Ok(g) => g,
643 Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
644 Err(std::sync::TryLockError::WouldBlock) => return None,
645 };
646 Some(self.refresh_holding(guard))
647 }
648
649 pub fn try_first_build(&self) -> FirstBuild {
654 let guard = match self.refresh_lock.try_lock() {
655 Ok(g) => g,
656 Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
657 Err(std::sync::TryLockError::WouldBlock) => return FirstBuild::Busy,
658 };
659 if self.refreshed_at_ms() > 0 {
660 return FirstBuild::AlreadyBuilt;
661 }
662 let (discovered, _, _) = self.refresh_holding(guard);
663 FirstBuild::Built { discovered }
664 }
665
666 fn load_cache_file_once(&self) {
670 use std::sync::atomic::Ordering;
671 let Some(path) = &self.cache_file else { return };
672 if self.cache_loaded.swap(true, Ordering::AcqRel) {
673 return;
674 }
675 let Ok(bytes) = std::fs::read(path) else { return };
676 match serde_json::from_slice::<MetaCacheFile>(&bytes) {
677 Ok(file) if file.version == META_CACHE_VERSION => {
678 let mut cache = self.meta_cache.lock().unwrap();
679 if cache.is_empty() {
680 tracing::info!(entries = file.entries.len(), "history: loaded the saved metadata cache");
681 *cache = file.entries;
682 }
683 }
684 Ok(file) => tracing::info!(version = file.version, "history: saved metadata cache is another version; rebuilding"),
685 Err(e) => tracing::warn!(error = %e, "history: saved metadata cache unreadable; rebuilding"),
686 }
687 }
688
689 fn write_cache_file(&self, cache: &HashMap<String, (i64, Option<SessionMeta>)>, changed: bool) {
695 use std::sync::atomic::Ordering;
696 let Some(path) = &self.cache_file else { return };
697 let now = chrono::Utc::now().timestamp_millis();
698 let last = self.cache_written_at_ms.load(Ordering::Acquire);
699 if !changed && last > 0 {
700 return;
701 }
702 if last > 0 && now - last < META_CACHE_WRITE_INTERVAL_MS {
703 return;
704 }
705 let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
706 let write = || -> std::io::Result<()> {
707 if let Some(dir) = path.parent() {
708 std::fs::create_dir_all(dir)?;
709 }
710 let body = serde_json::to_vec(&MetaCacheFileRef { version: META_CACHE_VERSION, entries: cache })
711 .map_err(std::io::Error::other)?;
712 std::fs::write(&tmp, body)?;
713 std::fs::rename(&tmp, path)
714 };
715 self.cache_written_at_ms.store(now.max(1), Ordering::Release);
720 match write() {
721 Ok(()) => tracing::debug!(entries = cache.len(), "history: saved the metadata cache"),
722 Err(e) => {
723 let _ = std::fs::remove_file(&tmp);
726 tracing::warn!(error = %e, path = %path.display(), "history: couldn't save the metadata cache");
727 }
728 }
729 }
730
731 fn is_isolated(&self, path: &Path) -> bool {
734 self.isolated_roots.iter().any(|r| path.starts_with(r))
735 }
736
737 pub fn refresh(&self) -> (u32, u32, u32) {
740 let guard = self.refresh_lock.lock().unwrap_or_else(|p| p.into_inner());
741 self.refresh_holding(guard)
742 }
743
744 pub fn refresh_covering_now(&self) {
752 use std::sync::atomic::Ordering;
753 let started_before_this_call = self.refreshes_started.load(Ordering::Acquire);
754 let guard = self.refresh_lock.lock().unwrap_or_else(|p| p.into_inner());
755 if self.last_finished_refresh.load(Ordering::Acquire) > started_before_this_call {
756 return;
757 }
758 self.refresh_holding(guard);
759 }
760
761 pub fn discovery_problems(&self) -> Vec<String> {
764 self.discovery_problems.lock().unwrap().clone()
765 }
766
767 fn refresh_holding(&self, _refreshing: std::sync::MutexGuard<'_, ()>) -> (u32, u32, u32) {
768 use std::sync::atomic::Ordering;
769 let this_refresh = self.refreshes_started.fetch_add(1, Ordering::AcqRel) + 1;
770 let mut problems: Vec<String> = Vec::new();
771 let mut discovered: u32 = 0;
772 let mut updated: u32 = 0;
773 let mut new_count: u32 = 0;
774 self.load_cache_file_once();
775 let old_cache = std::mem::take(&mut *self.meta_cache.lock().unwrap());
776 let mut parsed: u32 = 0;
777 let mut reused: usize = 0;
778 let mut new_cache: HashMap<String, (i64, Option<SessionMeta>)> = HashMap::with_capacity(old_cache.len());
779
780 let mut new_sessions: HashMap<String, SessionMeta> = HashMap::new();
781 let mut new_by_identity: HashMap<String, Vec<String>> = HashMap::new();
782 let mut new_by_working_dir: HashMap<String, Vec<String>> = HashMap::new();
783
784 for adapter in &self.adapters {
785 let files = match adapter.discover() {
786 Ok(found) => {
787 problems.extend(found.problems);
788 found.files
789 }
790 Err(e) => {
791 tracing::warn!(
792 "history: failed to discover {} files: {}",
793 adapter.provider(),
794 e
795 );
796 problems.push(format!("{} discovery failed: {e}", adapter.provider()));
797 continue;
798 }
799 };
800
801 discovered += files.len() as u32;
802
803 for file in &files {
804 let extracted = match old_cache.get(&file.file_path) {
805 Some((mtime, cached)) if *mtime == file.mtime_ms => {
806 reused += 1;
807 Ok(cached.clone())
808 }
809 _ => {
810 parsed += 1;
811 adapter.extract_meta(&file.file_path)
812 }
813 };
814 if let Ok(ref meta) = extracted {
815 new_cache.insert(file.file_path.clone(), (file.mtime_ms, meta.clone()));
816 }
817 match extracted {
818 Ok(Some(meta)) => {
819 if !meta.identity_id.is_empty() {
820 new_by_identity
821 .entry(meta.identity_id.clone())
822 .or_default()
823 .push(meta.session_id.clone());
824 }
825 if !meta.working_directory.is_empty() {
826 new_by_working_dir
827 .entry(normalize_working_dir(&meta.working_directory))
828 .or_default()
829 .push(meta.session_id.clone());
830 }
831 new_sessions.insert(meta.session_id.clone(), meta);
832 }
833 Ok(None) => {} Err(e) => {
835 tracing::debug!(
836 "history: failed to extract meta from {}: {}",
837 file.file_path,
838 e
839 );
840 problems.push(format!("{}: {e}", file.file_path));
843 }
844 }
845 }
846 }
847
848 let mut sessions = self.sessions.lock().unwrap();
850 for (id, _meta) in &new_sessions {
851 if sessions.contains_key(id) {
852 updated += 1;
853 } else {
854 new_count += 1;
855 }
856 }
857
858 *sessions = new_sessions;
859 *self.by_identity.lock().unwrap() = new_by_identity;
860 *self.by_working_dir.lock().unwrap() = new_by_working_dir;
861 drop(sessions);
862 let changed = parsed > 0 || reused < old_cache.len();
864 self.write_cache_file(&new_cache, changed);
865 tracing::debug!(parsed, reused, "history: refresh reused cached metadata");
866 *self.meta_cache.lock().unwrap() = new_cache;
867 if !problems.is_empty() {
868 tracing::warn!(count = problems.len(), first = %problems[0], "history: discovery couldn't read everything");
869 }
870 *self.discovery_problems.lock().unwrap() = problems;
871 self.refreshed_at_ms.store(
872 chrono::Utc::now().timestamp_millis().max(1),
873 Ordering::Release,
874 );
875 self.last_finished_refresh.store(this_refresh, Ordering::Release);
876
877 (discovered, updated, new_count)
878 }
879
880 pub fn list_for_working_directory(&self, working_directory: &str) -> Vec<SessionMeta> {
887 if working_directory.is_empty() {
888 return Vec::new();
889 }
890 let key = normalize_working_dir(working_directory);
891 let sessions = self.sessions.lock().unwrap();
893 let by_working_dir = self.by_working_dir.lock().unwrap();
894 let Some(session_ids) = by_working_dir.get(&key) else {
895 return Vec::new();
896 };
897 session_ids.iter().filter_map(|id| sessions.get(id)).cloned().collect()
898 }
899
900 pub fn list_for_identity(
909 &self,
910 identity_id: &str,
911 offset: usize,
912 limit: usize,
913 sort_by: &str,
914 sort_dir: &str,
915 ) -> (Vec<SessionMeta>, u32, bool) {
916 let sessions = self.sessions.lock().unwrap();
923 let by_identity = self.by_identity.lock().unwrap();
924 let Some(session_ids) = by_identity.get(identity_id) else {
925 return (Vec::new(), 0, false);
926 };
927 let mut filtered: Vec<&SessionMeta> =
928 session_ids.iter().filter_map(|id| sessions.get(id)).collect();
929 Self::sort_sessions(&mut filtered, sort_by, sort_dir);
930
931 let total = filtered.len() as u32;
932 let has_more = offset + limit < filtered.len();
933 let page: Vec<SessionMeta> = filtered.into_iter().skip(offset).take(limit).cloned().collect();
934 (page, total, has_more)
935 }
936
937 pub(crate) fn sort_sessions(filtered: &mut [&SessionMeta], sort_by: &str, sort_dir: &str) {
941 let desc = sort_dir != "asc";
942 match sort_by {
943 "created_at" | "created" => {
944 filtered.sort_by(|a, b| {
945 if desc {
946 b.created_at.cmp(&a.created_at)
947 } else {
948 a.created_at.cmp(&b.created_at)
949 }
950 });
951 }
952 "messages" => {
953 filtered.sort_by(|a, b| {
954 if desc {
955 b.message_count.cmp(&a.message_count)
956 } else {
957 a.message_count.cmp(&b.message_count)
958 }
959 });
960 }
961 "tokens" => {
962 filtered.sort_by(|a, b| {
963 if desc {
964 b.total_tokens.cmp(&a.total_tokens)
965 } else {
966 a.total_tokens.cmp(&b.total_tokens)
967 }
968 });
969 }
970 _ => {
971 filtered.sort_by(|a, b| {
973 if desc {
974 b.modified_at.cmp(&a.modified_at)
975 } else {
976 a.modified_at.cmp(&b.modified_at)
977 }
978 });
979 }
980 }
981 }
982
983 pub fn list(
985 &self,
986 provider: Option<&str>,
987 project: Option<&str>,
988 offset: usize,
989 limit: usize,
990 sort_by: &str,
991 sort_dir: &str,
992 ) -> (Vec<SessionMeta>, u32, bool) {
993 let sessions = self.sessions.lock().unwrap();
994
995 let mut filtered: Vec<&SessionMeta> = sessions
996 .values()
997 .filter(|s| {
998 if let Some(p) = provider {
999 if s.provider != p {
1000 return false;
1001 }
1002 }
1003 if let Some(proj) = project {
1004 if !s.working_directory.contains(proj) {
1005 return false;
1006 }
1007 }
1008 true
1009 })
1010 .collect();
1011
1012 Self::sort_sessions(&mut filtered, sort_by, sort_dir);
1013
1014 let total = filtered.len() as u32;
1015 let has_more = offset + limit < filtered.len();
1016 let page: Vec<SessionMeta> = filtered
1017 .into_iter()
1018 .skip(offset)
1019 .take(limit)
1020 .cloned()
1021 .collect();
1022
1023 (page, total, has_more)
1024 }
1025
1026 pub fn get_meta(&self, session_id: &str) -> Option<SessionMeta> {
1028 let sessions = self.sessions.lock().unwrap();
1029 sessions.get(session_id).cloned()
1030 }
1031
1032 pub fn get_full(&self, session_id: &str) -> Result<Option<HistorySession>, HistoryError> {
1034 let meta = match self.get_meta(session_id) {
1035 Some(m) => m,
1036 None => return Ok(None),
1037 };
1038
1039 for adapter in &self.adapters {
1041 if adapter.provider() == meta.provider {
1042 return adapter.parse_file(&meta.file_path);
1043 }
1044 }
1045
1046 Err(HistoryError::Other(format!(
1047 "no adapter for provider: {}",
1048 meta.provider
1049 )))
1050 }
1051
1052 #[cfg(test)]
1071 pub fn search_sessions(
1072 &self,
1073 candidates: &[SessionMeta],
1074 opts: &HistorySearchOptions,
1075 ) -> HistorySearchOutcome {
1076 let candidates: Vec<SearchCandidate> = candidates
1077 .iter()
1078 .map(|meta| SearchCandidate {
1079 meta: meta.clone(),
1080 source: Source::Transcript,
1081 attribution: Attribution::AgentRecord,
1082 })
1083 .collect();
1084 search_candidates(&candidates, opts, &|c| self.get_full(&c.meta.session_id))
1085 }
1086
1087 #[cfg(test)]
1089 pub fn is_empty(&self) -> bool {
1090 self.sessions.lock().unwrap().is_empty()
1091 }
1092
1093 pub fn delete(&self, session_id: &str) -> Result<bool, HistoryError> {
1098 let meta = match self.get_meta(session_id) {
1099 Some(m) => m,
1100 None => return Ok(false),
1101 };
1102 let path = Path::new(&meta.file_path);
1103 if !self.is_isolated(path) {
1106 return Err(HistoryError::Other(format!(
1107 "refusing to delete '{}' — it lives in your personal {} home, not AgentMux's",
1108 meta.file_path, meta.provider
1109 )));
1110 }
1111 match std::fs::remove_file(path) {
1115 Ok(()) => {}
1116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1117 Err(e) => return Err(e.into()),
1118 }
1119 if let Some(parent) = path.parent() {
1122 let sidecar = parent.join(session_id);
1123 if sidecar.is_dir() {
1124 let _ = std::fs::remove_dir_all(&sidecar);
1125 }
1126 }
1127 self.sessions.lock().unwrap().remove(session_id);
1128 Ok(true)
1129 }
1130
1131 pub fn clear(&self, provider: Option<&str>, project: Option<&str>) -> u32 {
1134 let ids: Vec<String> = {
1137 let sessions = self.sessions.lock().unwrap();
1138 sessions
1139 .values()
1140 .filter(|s| provider.map_or(true, |p| s.provider == p))
1141 .filter(|s| project.map_or(true, |proj| s.working_directory.contains(proj)))
1142 .filter(|s| self.is_isolated(Path::new(&s.file_path)))
1144 .map(|s| s.session_id.clone())
1145 .collect()
1146 };
1147 let mut deleted = 0;
1148 for id in ids {
1149 if matches!(self.delete(&id), Ok(true)) {
1150 deleted += 1;
1151 }
1152 }
1153 deleted
1154 }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::*;
1160 use std::fs;
1161 use std::path::PathBuf;
1162
1163 struct MockAdapter {
1166 files: Vec<DiscoveredFile>,
1167 provider: String,
1168 working_directory: String,
1169 identity_id: String,
1170 }
1171 impl HistoryAdapter for MockAdapter {
1172 fn provider(&self) -> &str {
1173 &self.provider
1174 }
1175 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1176 Ok(self
1177 .files
1178 .iter()
1179 .map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms })
1180 .collect())
1181 }
1182 fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1183 let id = PathBuf::from(file_path)
1184 .file_stem()
1185 .map(|s| s.to_string_lossy().into_owned())
1186 .unwrap_or_default();
1187 Ok(Some(SessionMeta {
1188 session_id: id,
1189 file_path: file_path.to_string(),
1190 provider: self.provider.clone(),
1191 model: String::new(),
1192 slug: String::new(),
1193 working_directory: self.working_directory.clone(),
1194 created_at: 0,
1195 modified_at: 0,
1196 message_count: 0,
1197 first_user_message: String::new(),
1198 file_size_bytes: 0,
1199 git_branch: String::new(),
1200 total_tokens: 0,
1201 subagent_count: 0,
1202 identity_id: self.identity_id.clone(),
1203 starts_undated: false,
1204 }))
1205 }
1206 fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
1207 Ok(None)
1208 }
1209 }
1210
1211 fn write_session(dir: &std::path::Path, id: &str) -> String {
1212 let f = dir.join(format!("{id}.jsonl"));
1213 fs::write(&f, b"{}").unwrap();
1214 f.to_string_lossy().into_owned()
1215 }
1216
1217 #[test]
1218 fn delete_removes_file_and_index_entry() {
1219 let dir = std::env::temp_dir().join(format!("amux-hist-del-{}", std::process::id()));
1220 fs::create_dir_all(&dir).unwrap();
1221 let fp = write_session(&dir, "sess-abc");
1222 let sidecar = dir.join("sess-abc");
1224 fs::create_dir_all(sidecar.join("subagents")).unwrap();
1225
1226 let idx = SessionIndex::with_isolated_roots(
1227 vec![Box::new(MockAdapter {
1228 files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
1229 provider: "mock".into(),
1230 working_directory: "/proj".into(),
1231 identity_id: String::new(),
1232 })],
1233 vec![dir.clone()],
1234 );
1235 idx.refresh();
1236 assert!(idx.get_meta("sess-abc").is_some());
1237
1238 assert!(matches!(idx.delete("sess-abc"), Ok(true)));
1239 assert!(!std::path::Path::new(&fp).exists(), "transcript removed");
1240 assert!(!sidecar.exists(), "subagents sidecar removed");
1241 assert!(idx.get_meta("sess-abc").is_none(), "dropped from index");
1242 assert!(matches!(idx.delete("sess-abc"), Ok(false)), "second delete is a no-op");
1243
1244 let _ = fs::remove_dir_all(&dir);
1245 }
1246
1247 #[test]
1248 fn clear_respects_provider_filter() {
1249 let dir = std::env::temp_dir().join(format!("amux-hist-clr-{}", std::process::id()));
1250 fs::create_dir_all(&dir).unwrap();
1251 let a = write_session(&dir, "a");
1252 let b = write_session(&dir, "b");
1253
1254 let idx = SessionIndex::with_isolated_roots(
1255 vec![Box::new(MockAdapter {
1256 files: vec![
1257 DiscoveredFile { file_path: a.clone(), mtime_ms: 0 },
1258 DiscoveredFile { file_path: b.clone(), mtime_ms: 0 },
1259 ],
1260 provider: "mock".into(),
1261 working_directory: "/proj".into(),
1262 identity_id: String::new(),
1263 })],
1264 vec![dir.clone()],
1265 );
1266 idx.refresh();
1267
1268 assert_eq!(idx.clear(Some("other"), None), 0);
1270 assert!(std::path::Path::new(&a).exists());
1271 assert_eq!(idx.clear(Some("mock"), None), 2);
1272 assert!(!std::path::Path::new(&a).exists() && !std::path::Path::new(&b).exists());
1273 assert!(idx.is_empty());
1274
1275 let _ = fs::remove_dir_all(&dir);
1276 }
1277
1278 #[test]
1279 fn refuses_to_delete_outside_isolated_roots() {
1280 let dir = std::env::temp_dir().join(format!("amux-hist-guard-{}", std::process::id()));
1283 fs::create_dir_all(&dir).unwrap();
1284 let fp = write_session(&dir, "personal-sess");
1285
1286 let idx = SessionIndex::with_isolated_roots(
1287 vec![Box::new(MockAdapter {
1288 files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 0 }],
1289 provider: "claude".into(),
1290 working_directory: "/home/me/.claude".into(),
1291 identity_id: String::new(),
1292 })],
1293 vec![std::env::temp_dir().join("amux-some-other-isolated-root")],
1295 );
1296 idx.refresh();
1297
1298 assert!(idx.delete("personal-sess").is_err(), "single delete must refuse");
1299 assert!(std::path::Path::new(&fp).exists(), "file must be intact");
1300 assert_eq!(idx.clear(None, None), 0, "clear-all must skip it");
1301 assert!(std::path::Path::new(&fp).exists(), "still intact after clear");
1302
1303 let _ = fs::remove_dir_all(&dir);
1304 }
1305
1306 #[test]
1312 fn list_for_identity_returns_only_that_identitys_sessions() {
1313 let dir = std::env::temp_dir().join(format!("amux-hist-idx-{}", std::process::id()));
1314 fs::create_dir_all(&dir).unwrap();
1315 let a1 = write_session(&dir, "a1");
1316 let a2 = write_session(&dir, "a2");
1317 let b1 = write_session(&dir, "b1");
1318 let unattributed = write_session(&dir, "unattributed");
1319
1320 let idx = SessionIndex::with_isolated_roots(
1321 vec![
1322 Box::new(MockAdapter {
1323 files: vec![
1324 DiscoveredFile { file_path: a1, mtime_ms: 1 },
1325 DiscoveredFile { file_path: a2, mtime_ms: 2 },
1326 ],
1327 provider: "mock".into(),
1328 working_directory: "/proj".into(),
1329 identity_id: "identity-a".into(),
1330 }),
1331 Box::new(MockAdapter {
1332 files: vec![DiscoveredFile { file_path: b1, mtime_ms: 1 }],
1333 provider: "mock".into(),
1334 working_directory: "/proj".into(),
1335 identity_id: "identity-b".into(),
1336 }),
1337 Box::new(MockAdapter {
1338 files: vec![DiscoveredFile { file_path: unattributed, mtime_ms: 1 }],
1339 provider: "mock".into(),
1340 working_directory: "/proj".into(),
1341 identity_id: String::new(),
1342 }),
1343 ],
1344 vec![dir.clone()],
1345 );
1346 idx.refresh();
1347
1348 let (sessions_a, total_a, _) = idx.list_for_identity("identity-a", 0, 10, "created_at", "desc");
1349 assert_eq!(total_a, 2);
1350 let ids_a: std::collections::HashSet<_> = sessions_a.iter().map(|s| s.session_id.clone()).collect();
1351 assert_eq!(ids_a, std::collections::HashSet::from(["a1".to_string(), "a2".to_string()]));
1352
1353 let (sessions_b, total_b, _) = idx.list_for_identity("identity-b", 0, 10, "created_at", "desc");
1354 assert_eq!(total_b, 1);
1355 assert_eq!(sessions_b[0].session_id, "b1");
1356
1357 let _ = fs::remove_dir_all(&dir);
1358 }
1359
1360 #[test]
1361 fn list_for_identity_returns_empty_for_an_unknown_identity() {
1362 let idx = SessionIndex::with_isolated_roots(vec![], vec![]);
1363 idx.refresh();
1364 let (sessions, total, has_more) = idx.list_for_identity("no-such-identity", 0, 10, "created_at", "desc");
1365 assert!(sessions.is_empty());
1366 assert_eq!(total, 0);
1367 assert!(!has_more);
1368 }
1369
1370 #[test]
1371 fn list_for_identity_paginates_and_respects_sort_direction() {
1372 let dir = std::env::temp_dir().join(format!("amux-hist-idx-page-{}", std::process::id()));
1373 fs::create_dir_all(&dir).unwrap();
1374 let files: Vec<DiscoveredFile> = (0..3)
1375 .map(|i| DiscoveredFile { file_path: write_session(&dir, &format!("s{i}")), mtime_ms: i })
1376 .collect();
1377
1378 let idx = SessionIndex::with_isolated_roots(
1379 vec![Box::new(MockAdapter {
1380 files,
1381 provider: "mock".into(),
1382 working_directory: "/proj".into(),
1383 identity_id: "identity-a".into(),
1384 })],
1385 vec![dir.clone()],
1386 );
1387 idx.refresh();
1388
1389 let (page1, total, has_more) = idx.list_for_identity("identity-a", 0, 2, "created_at", "desc");
1390 assert_eq!(total, 3);
1391 assert_eq!(page1.len(), 2);
1392 assert!(has_more);
1393 let (page2, _, has_more2) = idx.list_for_identity("identity-a", 2, 2, "created_at", "desc");
1394 assert_eq!(page2.len(), 1);
1395 assert!(!has_more2);
1396
1397 let _ = fs::remove_dir_all(&dir);
1398 }
1399
1400 #[test]
1414 fn concurrent_refresh_and_lookups_do_not_deadlock() {
1415 use std::sync::mpsc;
1416 use std::sync::Arc;
1417 use std::time::Duration;
1418
1419 let dir = std::env::temp_dir().join(format!("amux-hist-lockorder-{}", std::process::id()));
1420 std::fs::create_dir_all(&dir).unwrap();
1421 let f = write_session(&dir, "lock-order-session");
1422
1423 let index = Arc::new(SessionIndex::with_isolated_roots(
1424 vec![Box::new(MockAdapter {
1425 files: vec![DiscoveredFile { file_path: f, mtime_ms: 1 }],
1426 provider: "mock".into(),
1427 working_directory: "/proj".into(),
1428 identity_id: "acct-lock".into(),
1429 })],
1430 vec![dir.clone()],
1431 ));
1432
1433 const ITERATIONS: usize = 300;
1434 let (tx, rx) = mpsc::channel();
1435
1436 for worker in 0..3 {
1437 let idx = Arc::clone(&index);
1438 let tx = tx.clone();
1439 std::thread::spawn(move || {
1440 for _ in 0..ITERATIONS {
1441 match worker {
1442 0 => {
1443 idx.refresh();
1444 }
1445 1 => {
1446 idx.list_for_working_directory("/proj");
1447 }
1448 _ => {
1449 idx.list_for_identity("acct-lock", 0, 10, "created_at", "desc");
1450 }
1451 }
1452 }
1453 let _ = tx.send(worker);
1454 });
1455 }
1456 drop(tx);
1457
1458 for _ in 0..3 {
1459 rx.recv_timeout(Duration::from_secs(30)).expect(
1460 "deadlock: a worker never finished. `refresh()` holds `sessions` while \
1461 swapping the index maps, so every reader must take `sessions` FIRST — \
1462 see the lock-order invariant on `SessionIndex::by_working_dir`",
1463 );
1464 }
1465
1466 std::fs::remove_dir_all(&dir).ok();
1467 }
1468
1469 #[test]
1470 fn refresh_rebuilds_the_identity_index_from_scratch_each_time() {
1471 let dir = std::env::temp_dir().join(format!("amux-hist-idx-rebuild-{}", std::process::id()));
1475 fs::create_dir_all(&dir).unwrap();
1476 let fp = write_session(&dir, "moved");
1477
1478 let idx = SessionIndex::with_isolated_roots(
1479 vec![Box::new(MockAdapter {
1480 files: vec![DiscoveredFile { file_path: fp.clone(), mtime_ms: 1 }],
1481 provider: "mock".into(),
1482 working_directory: "/proj".into(),
1483 identity_id: "identity-old".into(),
1484 })],
1485 vec![dir.clone()],
1486 );
1487 idx.refresh();
1488 assert_eq!(idx.list_for_identity("identity-old", 0, 10, "created_at", "desc").1, 1);
1489
1490 let idx2 = SessionIndex::with_isolated_roots(
1491 vec![Box::new(MockAdapter {
1492 files: vec![DiscoveredFile { file_path: fp, mtime_ms: 1 }],
1493 provider: "mock".into(),
1494 working_directory: "/proj".into(),
1495 identity_id: "identity-new".into(),
1496 })],
1497 vec![dir.clone()],
1498 );
1499 idx2.refresh();
1500 assert_eq!(idx2.list_for_identity("identity-old", 0, 10, "created_at", "desc").1, 0, "stale identity must not linger");
1501 assert_eq!(idx2.list_for_identity("identity-new", 0, 10, "created_at", "desc").1, 1);
1502
1503 let _ = fs::remove_dir_all(&dir);
1504 }
1505
1506 struct SearchMockAdapter {
1512 sessions: HashMap<String, Vec<HistoryMessage>>,
1513 parsed: Mutex<Vec<String>>,
1514 }
1515
1516 impl HistoryAdapter for SearchMockAdapter {
1517 fn provider(&self) -> &str {
1518 "mock"
1519 }
1520 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1521 Ok(self
1522 .sessions
1523 .keys()
1524 .map(|id| DiscoveredFile { file_path: format!("/tmp/{id}.jsonl"), mtime_ms: 0 })
1525 .collect())
1526 }
1527 fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1528 let id = PathBuf::from(file_path)
1529 .file_stem()
1530 .map(|s| s.to_string_lossy().into_owned())
1531 .unwrap_or_default();
1532 Ok(Some(SessionMeta {
1533 session_id: id,
1534 file_path: file_path.to_string(),
1535 provider: "mock".into(),
1536 model: String::new(),
1537 slug: String::new(),
1538 working_directory: "/proj".into(),
1539 created_at: 0,
1540 modified_at: 0,
1541 message_count: 0,
1542 first_user_message: String::new(),
1543 file_size_bytes: 0,
1544 git_branch: String::new(),
1545 total_tokens: 0,
1546 subagent_count: 0,
1547 identity_id: String::new(),
1548 starts_undated: false,
1549 }))
1550 }
1551 fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
1552 self.parsed.lock().unwrap().push(file_path.to_string());
1553 let id = PathBuf::from(file_path)
1554 .file_stem()
1555 .map(|s| s.to_string_lossy().into_owned())
1556 .unwrap_or_default();
1557 if id.starts_with("corrupt") {
1558 return Err(HistoryError::Other("unexpected end of JSON".into()));
1559 }
1560 let Some(messages) = self.sessions.get(&id) else { return Ok(None) };
1561 let meta = self.extract_meta(file_path)?.unwrap();
1562 let skipped_records = if id.starts_with("partial") { 2 } else { 0 };
1563 Ok(Some(HistorySession { meta, messages: messages.clone(), skipped_records }))
1564 }
1565 }
1566
1567 fn msg(role: &str, content: &str, tools: Vec<(&str, &str)>) -> HistoryMessage {
1568 HistoryMessage {
1569 role: role.to_string(),
1570 content: content.to_string(),
1571 timestamp: 100,
1572 tool_uses: tools
1573 .into_iter()
1574 .map(|(n, a)| ToolUseSummary { name: n.to_string(), argument_summary: a.to_string() })
1575 .collect(),
1576 }
1577 }
1578
1579 fn search_index(sessions: Vec<(&str, Vec<HistoryMessage>)>) -> SessionIndex {
1580 let map: HashMap<String, Vec<HistoryMessage>> =
1581 sessions.into_iter().map(|(id, m)| (id.to_string(), m)).collect();
1582 let idx = SessionIndex::new(vec![Box::new(SearchMockAdapter {
1583 sessions: map,
1584 parsed: Mutex::new(Vec::new()),
1585 })]);
1586 idx.refresh();
1587 idx
1588 }
1589
1590 fn opts(query: &str) -> HistorySearchOptions {
1591 HistorySearchOptions { query: query.into(), limit: 50, ..Default::default() }
1592 }
1593
1594 fn at(timestamp: i64, content: &str) -> HistoryMessage {
1595 HistoryMessage { timestamp, ..msg("assistant", content, vec![]) }
1596 }
1597
1598 #[test]
1599 fn a_bare_tool_name_matches_the_mcp_prefixed_call() {
1600 let idx = search_index(vec![(
1604 "s1",
1605 vec![msg("assistant", "sent", vec![("mcp__agentmux__SendMessage", r#"{"to":"Agent4"}"#)])],
1606 )]);
1607 let cands = vec![idx.get_meta("s1").unwrap()];
1608 for wanted in ["SendMessage", "sendmessage", "mcp__agentmux__SendMessage"] {
1609 let mut o = opts("");
1610 o.tool = Some(wanted.into());
1611 let out = idx.search_sessions(&cands, &o);
1612 assert_eq!(out.hits.len(), 1, "tool: {wanted}");
1613 assert_eq!(out.hits[0].tool_name.as_deref(), Some("mcp__agentmux__SendMessage"));
1614 }
1615 let mut other = opts("");
1616 other.tool = Some("Message".into());
1617 assert!(idx.search_sessions(&cands, &other).hits.is_empty(), "a suffix alone is not a name");
1618 }
1619
1620 #[test]
1621 fn messages_outside_the_time_window_are_not_hits() {
1622 let idx = search_index(vec![(
1623 "s1",
1624 vec![at(1_000, "deploy early"), at(5_000, "deploy late"), at(0, "deploy undated")],
1625 )]);
1626 let cands = vec![idx.get_meta("s1").unwrap()];
1627 let mut o = opts("deploy");
1628 o.since_ms = Some(2_000);
1629 let snippets: Vec<String> = idx.search_sessions(&cands, &o).hits.into_iter().map(|h| h.snippet).collect();
1630 assert_eq!(snippets, vec!["deploy late", "deploy undated"]);
1633
1634 let mut o = opts("deploy");
1635 o.until_ms = Some(2_000);
1636 let snippets: Vec<String> = idx.search_sessions(&cands, &o).hits.into_iter().map(|h| h.snippet).collect();
1637 assert_eq!(snippets, vec!["deploy early", "deploy undated"]);
1638 }
1639
1640 #[test]
1641 fn a_full_search_says_it_is_complete() {
1642 let idx = search_index(vec![("s1", vec![msg("assistant", "nothing relevant", vec![])])]);
1643 let cands = vec![idx.get_meta("s1").unwrap()];
1644 let out = idx.search_sessions(&cands, &opts("never said"));
1645 assert!(out.hits.is_empty());
1646 assert!(out.complete, "every candidate was read to the end");
1647 assert!(out.incomplete_reasons.is_empty());
1648 }
1649
1650 #[test]
1651 fn a_max_sessions_cut_is_counted_and_makes_the_answer_incomplete() {
1652 let idx = search_index(vec![
1653 ("a", vec![msg("assistant", "x", vec![])]),
1654 ("b", vec![msg("assistant", "x", vec![])]),
1655 ("c", vec![msg("assistant", "x", vec![])]),
1656 ]);
1657 let cands: Vec<SessionMeta> = ["a", "b", "c"].iter().map(|id| idx.get_meta(id).unwrap()).collect();
1658 let mut o = opts("absent");
1659 o.max_sessions = Some(1);
1660 let out = idx.search_sessions(&cands, &o);
1661 assert_eq!(out.total_sessions, 3, "counted before the cut");
1662 assert_eq!(out.sessions_scanned, 1);
1663 assert_eq!(out.sessions_skipped, 2);
1664 assert!(!out.complete);
1665 assert_eq!(out.incomplete_reasons, vec!["max_sessions"]);
1666 }
1667
1668 #[test]
1669 fn a_session_with_unreadable_records_makes_the_answer_incomplete() {
1670 let idx = search_index(vec![("partial-1", vec![msg("assistant", "deploy done", vec![])])]);
1674 let cands = vec![idx.get_meta("partial-1").unwrap()];
1675 let out = idx.search_sessions(&cands, &opts("deploy"));
1676 assert_eq!(out.hits.len(), 1);
1677 assert_eq!(out.sessions_scanned, 1, "it was read, partly");
1678 assert_eq!(out.sessions_partly_read.len(), 1);
1679 assert_eq!(out.sessions_partly_read[0].session_id, "partial-1");
1680 assert!(out.sessions_partly_read[0].reason.contains("2 record"), "{}", out.sessions_partly_read[0].reason);
1681 assert!(!out.complete);
1682 assert_eq!(out.incomplete_reasons, vec!["unreadable_records"]);
1683 }
1684
1685 #[test]
1686 fn an_unreadable_session_is_reported_not_counted_as_scanned() {
1687 let idx = search_index(vec![
1688 ("good", vec![msg("assistant", "x", vec![])]),
1689 ("corrupt-1", vec![]),
1690 ]);
1691 let cands: Vec<SessionMeta> =
1692 ["good", "corrupt-1"].iter().map(|id| idx.get_meta(id).unwrap()).collect();
1693 let out = idx.search_sessions(&cands, &opts("absent"));
1694 assert_eq!(out.sessions_scanned, 1, "only the session actually read");
1695 assert_eq!(out.sessions_unreadable.len(), 1);
1696 assert_eq!(out.sessions_unreadable[0].session_id, "corrupt-1");
1697 assert!(out.sessions_unreadable[0].reason.contains("unexpected end of JSON"));
1698 assert!(!out.complete);
1699 assert_eq!(out.incomplete_reasons, vec!["unreadable_sessions"]);
1700 }
1701
1702 #[test]
1703 fn search_matches_message_content() {
1704 let idx = search_index(vec![("s1", vec![msg("assistant", "I merged the WAN signing PR", vec![])])]);
1705 let cands = vec![idx.get_meta("s1").unwrap()];
1706 let out = idx.search_sessions(&cands, &opts("wan signing"));
1707 assert_eq!(out.hits.len(), 1);
1708 assert!(out.hits[0].snippet.contains("WAN signing"), "snippet carries the match");
1709 assert!(!out.truncated);
1710 }
1711
1712 #[test]
1713 fn search_matches_a_tool_call_structurally_not_just_prose() {
1714 let args = r#"{"to":"Agent4","message":"following up"}"#;
1718 let idx = search_index(vec![(
1719 "s1",
1720 vec![msg("assistant", "sure, sending now", vec![("SendMessage", args)])],
1721 )]);
1722 let cands = vec![idx.get_meta("s1").unwrap()];
1723
1724 let mut o = opts("");
1725 o.tool = Some("sendmessage".into()); let out = idx.search_sessions(&cands, &o);
1727 assert_eq!(out.hits.len(), 1);
1728 assert_eq!(out.hits[0].tool_name.as_deref(), Some("SendMessage"));
1729 assert!(out.hits[0].snippet.contains("Agent4"));
1730
1731 let mut o2 = opts("agent4");
1733 o2.tool = Some("SendMessage".into());
1734 assert_eq!(idx.search_sessions(&cands, &o2).hits.len(), 1);
1735 let mut o3 = opts("agent9");
1736 o3.tool = Some("SendMessage".into());
1737 assert_eq!(idx.search_sessions(&cands, &o3).hits.len(), 0);
1738 }
1739
1740 #[test]
1741 fn an_exhausted_budget_is_distinguishable_from_no_matches() {
1742 let many: Vec<HistoryMessage> =
1747 (0..10).map(|i| msg("assistant", &format!("hit number {i}"), vec![])).collect();
1748 let idx = search_index(vec![("s1", many)]);
1749 let cands = vec![idx.get_meta("s1").unwrap()];
1750
1751 let mut limited = opts("hit number");
1752 limited.limit = 3;
1753 let out = idx.search_sessions(&cands, &limited);
1754 assert_eq!(out.hits.len(), 3);
1755 assert!(out.truncated, "stopping on budget must be reported");
1756 assert!(!out.complete);
1757 assert_eq!(out.incomplete_reasons, vec!["hit_limit"]);
1758
1759 let absent = idx.search_sessions(&cands, &opts("never appears anywhere"));
1760 assert!(absent.hits.is_empty());
1761 assert!(!absent.truncated, "genuinely-no-matches must NOT look truncated");
1762 assert!(absent.complete);
1763 }
1764
1765 #[test]
1766 fn search_only_opens_the_sessions_it_is_handed() {
1767 let idx = search_index(vec![
1770 ("keep", vec![msg("assistant", "findme", vec![])]),
1771 ("skip", vec![msg("assistant", "findme", vec![])]),
1772 ]);
1773 let cands = vec![idx.get_meta("keep").unwrap()];
1774 let out = idx.search_sessions(&cands, &opts("findme"));
1775 assert_eq!(out.hits.len(), 1);
1776 assert_eq!(out.sessions_scanned, 1);
1777 assert_eq!(out.total_sessions, 1);
1778 assert_eq!(out.hits[0].session_id, "keep");
1779 }
1780
1781 #[test]
1782 fn role_filter_narrows_to_one_side_of_the_conversation() {
1783 let idx = search_index(vec![(
1784 "s1",
1785 vec![msg("user", "deploy it", vec![]), msg("assistant", "deploy it? confirming first", vec![])],
1786 )]);
1787 let cands = vec![idx.get_meta("s1").unwrap()];
1788 let mut o = opts("deploy it");
1789 o.role = Some("user".into());
1790 let out = idx.search_sessions(&cands, &o);
1791 assert_eq!(out.hits.len(), 1);
1792 assert_eq!(out.hits[0].role, "user");
1793 }
1794
1795 #[test]
1796 fn a_snippet_is_bounded_even_for_a_pathologically_large_message() {
1797 let huge = format!("{}NEEDLE{}", "x".repeat(50_000), "y".repeat(50_000));
1799 let idx = search_index(vec![("s1", vec![msg("assistant", &huge, vec![])])]);
1800 let cands = vec![idx.get_meta("s1").unwrap()];
1801 let out = idx.search_sessions(&cands, &opts("needle"));
1802 assert_eq!(out.hits.len(), 1);
1803 assert!(out.hits[0].snippet.chars().count() <= SNIPPET_MAX_CHARS + 2, "snippet stays bounded");
1804 assert!(out.hits[0].snippet.contains("NEEDLE"), "and still shows the match");
1805 }
1806
1807 #[test]
1814 fn a_tool_hit_snippet_contains_the_match_even_far_into_a_long_argument() {
1815 let args = format!("{}NEEDLE_TARGET{}", "p".repeat(3_000), "q".repeat(100));
1820 let idx = search_index(vec![("s1", vec![msg("assistant", "sent", vec![("SendMessage", &args)])])]);
1821 let cands = vec![idx.get_meta("s1").unwrap()];
1822
1823 let mut o = opts("needle_target");
1824 o.tool = Some("SendMessage".into());
1825 let out = idx.search_sessions(&cands, &o);
1826 assert_eq!(out.hits.len(), 1);
1827 assert!(
1828 out.hits[0].snippet.contains("NEEDLE_TARGET"),
1829 "snippet must show the match, not the start of the arguments: {}",
1830 out.hits[0].snippet
1831 );
1832 }
1833
1834 #[test]
1835 fn an_untooled_tool_hit_offsets_against_the_arguments_not_name_plus_arguments() {
1836 let args = format!("{}FIND_ME{}", "z".repeat(1_000), "w".repeat(1_000));
1840 let idx = search_index(vec![(
1841 "s1",
1842 vec![msg("assistant", "no prose match here", vec![("SomeVeryLongToolNameIndeed", &args)])],
1843 )]);
1844 let cands = vec![idx.get_meta("s1").unwrap()];
1845
1846 let out = idx.search_sessions(&cands, &opts("find_me"));
1847 assert_eq!(out.hits.len(), 1);
1848 assert_eq!(out.hits[0].tool_name.as_deref(), Some("SomeVeryLongToolNameIndeed"));
1849 assert!(
1850 out.hits[0].snippet.contains("FIND_ME"),
1851 "snippet window must be centred on the real match: {}",
1852 out.hits[0].snippet
1853 );
1854 }
1855
1856 #[test]
1857 fn matching_is_safe_when_lowercasing_changes_byte_length() {
1858 let content = format!("{}NEEDLE tail", "İ".repeat(200));
1864 let idx = search_index(vec![("s1", vec![msg("assistant", &content, vec![])])]);
1865 let cands = vec![idx.get_meta("s1").unwrap()];
1866
1867 let out = idx.search_sessions(&cands, &opts("needle"));
1868 assert_eq!(out.hits.len(), 1);
1869 assert!(
1870 out.hits[0].snippet.contains("NEEDLE"),
1871 "snippet must still contain the match: {}",
1872 out.hits[0].snippet
1873 );
1874 }
1875
1876 #[test]
1877 fn case_insensitive_find_reports_offsets_into_the_original_string() {
1878 assert_eq!(find_case_insensitive("Hello World", "world"), Some(6));
1880 assert_eq!(find_case_insensitive("abc", "zzz"), None);
1881 assert_eq!(find_case_insensitive("abc", ""), None);
1882
1883 let s = "İX";
1886 assert_eq!(find_case_insensitive(s, "x"), Some(2));
1887 assert!(s.is_char_boundary(find_case_insensitive(s, "x").unwrap()));
1888 }
1889}
1890
1891#[cfg(test)]
1892mod refresh_tests {
1893 use super::*;
1897 use std::sync::Arc;
1898
1899 struct ChangingAdapter {
1902 files: Mutex<Vec<DiscoveredFile>>,
1903 extracted: Mutex<Vec<String>>,
1904 }
1905
1906 impl ChangingAdapter {
1907 fn new(files: &[(&str, i64)]) -> Arc<Self> {
1908 Arc::new(Self {
1909 files: Mutex::new(
1910 files
1911 .iter()
1912 .map(|(p, m)| DiscoveredFile { file_path: p.to_string(), mtime_ms: *m })
1913 .collect(),
1914 ),
1915 extracted: Mutex::new(Vec::new()),
1916 })
1917 }
1918 fn set(&self, files: &[(&str, i64)]) {
1919 *self.files.lock().unwrap() = files
1920 .iter()
1921 .map(|(p, m)| DiscoveredFile { file_path: p.to_string(), mtime_ms: *m })
1922 .collect();
1923 }
1924 fn take_extracted(&self) -> Vec<String> {
1925 let mut v = std::mem::take(&mut *self.extracted.lock().unwrap());
1926 v.sort();
1927 v
1928 }
1929 }
1930
1931 impl HistoryAdapter for Arc<ChangingAdapter> {
1932 fn provider(&self) -> &str {
1933 "mock"
1934 }
1935 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
1936 Ok(self
1937 .files
1938 .lock()
1939 .unwrap()
1940 .iter()
1941 .map(|f| DiscoveredFile { file_path: f.file_path.clone(), mtime_ms: f.mtime_ms })
1942 .collect())
1943 }
1944 fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
1945 self.extracted.lock().unwrap().push(file_path.to_string());
1946 let id = PathBuf::from(file_path)
1947 .file_stem()
1948 .map(|s| s.to_string_lossy().into_owned())
1949 .unwrap_or_default();
1950 Ok(Some(SessionMeta {
1951 session_id: id,
1952 file_path: file_path.to_string(),
1953 provider: "mock".into(),
1954 model: String::new(),
1955 slug: String::new(),
1956 working_directory: "/proj".into(),
1957 created_at: 0,
1958 modified_at: 0,
1959 message_count: 0,
1960 first_user_message: String::new(),
1961 file_size_bytes: 0,
1962 git_branch: String::new(),
1963 total_tokens: 0,
1964 subagent_count: 0,
1965 identity_id: String::new(),
1966 starts_undated: false,
1967 }))
1968 }
1969 fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
1970 Ok(None)
1971 }
1972 }
1973
1974 fn index_over(adapter: &Arc<ChangingAdapter>) -> SessionIndex {
1975 SessionIndex::with_isolated_roots(vec![Box::new(adapter.clone())], vec![])
1976 }
1977
1978 #[test]
1981 fn a_new_index_with_the_saved_cache_reparses_only_what_changed() {
1982 let dir = tempfile::tempdir().unwrap();
1983 let cache = dir.path().join("history").join("session-meta-cache.json");
1984 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 1)]);
1985 let first = index_over(&a).with_cache_file(cache.clone());
1986 first.refresh();
1987 assert_eq!(a.take_extracted(), vec!["/h/a.jsonl", "/h/b.jsonl"]);
1988 assert!(cache.exists(), "the first build saves the cache");
1989
1990 a.set(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 2), ("/h/c.jsonl", 1)]);
1992 let second = index_over(&a).with_cache_file(cache.clone());
1993 second.refresh();
1994 assert_eq!(a.take_extracted(), vec!["/h/b.jsonl", "/h/c.jsonl"], "a came from the saved cache");
1995 assert!(second.get_meta("a").is_some() && second.get_meta("c").is_some());
1996 }
1997
1998 #[test]
2001 fn an_unwritable_cache_waits_out_the_interval_and_leaves_no_temp_file() {
2002 let dir = tempfile::tempdir().unwrap();
2003 let blocker = dir.path().join("not-a-dir");
2004 std::fs::write(&blocker, b"a file where the cache dir would be").unwrap();
2005 let cache = blocker.join("session-meta-cache.json");
2006 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2007 let idx = index_over(&a).with_cache_file(cache.clone());
2008 idx.refresh();
2009 let first_try = idx.cache_written_at_ms.load(std::sync::atomic::Ordering::Acquire);
2010 assert!(first_try > 0, "the failed attempt is recorded");
2011 a.set(&[("/h/a.jsonl", 2)]);
2012 idx.refresh();
2013 assert_eq!(idx.cache_written_at_ms.load(std::sync::atomic::Ordering::Acquire), first_try, "not retried within the interval");
2014 let leftovers: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect();
2015 assert_eq!(leftovers.len(), 1, "only the blocker file: {leftovers:?}");
2016 }
2017
2018 #[test]
2019 fn a_saved_cache_of_another_version_is_rebuilt_not_trusted() {
2020 let dir = tempfile::tempdir().unwrap();
2021 let cache = dir.path().join("session-meta-cache.json");
2022 std::fs::write(&cache, br#"{"version":999,"entries":{"/h/a.jsonl":[1,null]}}"#).unwrap();
2023 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2024 let idx = index_over(&a).with_cache_file(cache);
2025 idx.refresh();
2026 assert_eq!(a.take_extracted(), vec!["/h/a.jsonl"]);
2027 assert!(idx.get_meta("a").is_some(), "the stale entry (no meta) wasn't used");
2028 }
2029
2030 #[test]
2031 fn an_unreadable_saved_cache_is_ignored() {
2032 let dir = tempfile::tempdir().unwrap();
2033 let cache = dir.path().join("session-meta-cache.json");
2034 std::fs::write(&cache, b"not json").unwrap();
2035 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2036 let idx = index_over(&a).with_cache_file(cache.clone());
2037 idx.refresh();
2038 assert_eq!(a.take_extracted(), vec!["/h/a.jsonl"]);
2039 let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache).unwrap()).unwrap();
2040 assert_eq!(saved["version"], serde_json::json!(META_CACHE_VERSION), "rewritten after the build");
2041 }
2042
2043 #[test]
2044 fn a_second_refresh_reparses_only_what_changed() {
2045 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 1)]);
2046 let idx = index_over(&a);
2047 idx.refresh();
2048 assert_eq!(a.take_extracted(), vec!["/h/a.jsonl", "/h/b.jsonl"]);
2049
2050 idx.refresh();
2051 assert!(a.take_extracted().is_empty(), "unchanged files must not be re-parsed");
2052
2053 a.set(&[("/h/a.jsonl", 2), ("/h/b.jsonl", 1), ("/h/c.jsonl", 1)]);
2054 idx.refresh();
2055 assert_eq!(a.take_extracted(), vec!["/h/a.jsonl", "/h/c.jsonl"]);
2056 assert!(idx.get_meta("c").is_some());
2057 }
2058
2059 #[test]
2060 fn a_file_gone_from_disk_leaves_the_index() {
2061 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1), ("/h/b.jsonl", 1)]);
2062 let idx = index_over(&a);
2063 idx.refresh();
2064 a.set(&[("/h/a.jsonl", 1)]);
2065 idx.refresh();
2066 assert!(idx.get_meta("b").is_none());
2067 assert!(idx.get_meta("a").is_some());
2068 assert!(a.take_extracted().len() == 2, "only the first refresh parsed");
2069 }
2070
2071 #[test]
2072 fn refreshed_at_is_zero_until_the_first_refresh_completes() {
2073 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2074 let idx = index_over(&a);
2075 assert_eq!(idx.refreshed_at_ms(), 0);
2076 idx.refresh();
2077 assert!(idx.refreshed_at_ms() > 0);
2078 }
2079
2080 #[test]
2083 fn try_refresh_does_not_wait_for_a_refresh_in_progress() {
2084 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2085 let idx = index_over(&a);
2086 let _running = idx.refresh_lock.lock().unwrap();
2087 assert!(idx.try_refresh().is_none());
2088 assert!(a.take_extracted().is_empty());
2089 }
2090
2091 #[test]
2092 fn try_refresh_refreshes_when_nothing_else_is() {
2093 let a = ChangingAdapter::new(&[("/h/a.jsonl", 1)]);
2094 let idx = index_over(&a);
2095 assert!(idx.try_refresh().is_some());
2096 assert!(idx.get_meta("a").is_some());
2097 }
2098
2099 struct SlowAdapter {
2101 walks: std::sync::atomic::AtomicUsize,
2102 }
2103
2104 impl HistoryAdapter for Arc<SlowAdapter> {
2105 fn provider(&self) -> &str {
2106 "mock"
2107 }
2108 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
2109 self.walks.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2110 std::thread::sleep(std::time::Duration::from_millis(150));
2111 Ok(Vec::new())
2112 }
2113 fn extract_meta(&self, _: &str) -> Result<Option<SessionMeta>, HistoryError> {
2114 Ok(None)
2115 }
2116 fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
2117 Ok(None)
2118 }
2119 }
2120
2121 #[test]
2127 fn concurrent_searches_share_refreshes() {
2128 let a = Arc::new(SlowAdapter { walks: Default::default() });
2129 let idx = Arc::new(SessionIndex::with_isolated_roots(vec![Box::new(a.clone())], vec![]));
2130 let barrier = Arc::new(std::sync::Barrier::new(8));
2131 let threads: Vec<_> = (0..8)
2132 .map(|_| {
2133 let (idx, barrier) = (idx.clone(), barrier.clone());
2134 std::thread::spawn(move || {
2135 barrier.wait();
2136 idx.refresh_covering_now();
2137 })
2138 })
2139 .collect();
2140 for t in threads {
2141 t.join().unwrap();
2142 }
2143 let walks = a.walks.load(std::sync::atomic::Ordering::SeqCst);
2144 assert!((1..=2).contains(&walks), "8 simultaneous searches walked {walks} times");
2145 }
2146
2147 #[test]
2150 fn a_refresh_finished_before_the_request_is_not_reused() {
2151 let a = Arc::new(SlowAdapter { walks: Default::default() });
2152 let idx = SessionIndex::with_isolated_roots(vec![Box::new(a.clone())], vec![]);
2153 idx.refresh();
2154 idx.refresh_covering_now();
2155 assert_eq!(a.walks.load(std::sync::atomic::Ordering::SeqCst), 2);
2156 }
2157
2158 struct ProblemAdapter {
2160 problems: Mutex<Vec<String>>,
2161 }
2162
2163 impl HistoryAdapter for Arc<ProblemAdapter> {
2164 fn provider(&self) -> &str {
2165 "mock"
2166 }
2167 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
2168 Ok(vec![DiscoveredFile { file_path: "/h/locked.jsonl".into(), mtime_ms: 1 }])
2169 }
2170 fn discover(&self) -> Result<Discovery, HistoryError> {
2171 Ok(Discovery { files: self.discover_files()?, problems: self.problems.lock().unwrap().clone() })
2172 }
2173 fn extract_meta(&self, _: &str) -> Result<Option<SessionMeta>, HistoryError> {
2174 Err(HistoryError::Other("sharing violation".into()))
2175 }
2176 fn parse_file(&self, _: &str) -> Result<Option<HistorySession>, HistoryError> {
2177 Ok(None)
2178 }
2179 }
2180
2181 #[test]
2186 fn what_discovery_could_not_read_is_kept_with_the_snapshot() {
2187 let a = Arc::new(ProblemAdapter { problems: Mutex::new(vec!["/h/private: Access is denied".into()]) });
2188 let idx = SessionIndex::with_isolated_roots(vec![Box::new(a.clone())], vec![]);
2189 idx.refresh();
2190 let problems = idx.discovery_problems();
2191 assert!(problems.iter().any(|p| p.contains("/h/private")), "{problems:?}");
2192 assert!(
2193 problems.iter().any(|p| p.contains("locked.jsonl") && p.contains("sharing violation")),
2194 "a transcript that couldn't be indexed is a problem too: {problems:?}"
2195 );
2196
2197 a.problems.lock().unwrap().clear();
2198 idx.refresh();
2199 assert_eq!(idx.discovery_problems().len(), 1, "each refresh replaces the last one's problems");
2200 }
2201
2202 #[test]
2205 fn try_first_build_skips_when_busy_or_already_built() {
2206 let idx = SessionIndex::with_isolated_roots(vec![], vec![]);
2207 {
2208 let _running = idx.refresh_lock.lock().unwrap();
2209 assert_eq!(idx.try_first_build(), FirstBuild::Busy);
2210 }
2211 assert_eq!(idx.try_first_build(), FirstBuild::Built { discovered: 0 });
2212 let built_at = idx.refreshed_at_ms();
2213 assert!(built_at > 0);
2214 assert_eq!(idx.try_first_build(), FirstBuild::AlreadyBuilt);
2215 assert_eq!(idx.refreshed_at_ms(), built_at);
2216 }
2217}