1use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::fs;
11use std::num::NonZeroUsize;
12use std::sync::atomic::AtomicBool;
13use std::sync::Arc;
14
15use anyhow::{anyhow, bail, Context, Result};
16
17use crate::config::{Config, IndexRefresh, ScoringConfig};
18use crate::db::{
19 Db, MessageBatchControl, QueryCancellation, SchemaState, MIN_READABLE_SCHEMA_VERSION,
20};
21use crate::indexer::{self, AutoReindexOutcome, IndexCoordinator};
22use crate::message_search::{
23 apply_message_presentation_cancellable, attach_match_evidence_cancellable, ContextWindow,
24 DetailLevel, ExecutionOrder, FieldViewBudget, LineWindow, MatchViewBudget, MatchWindow,
25 MessageResponsePlan, MessageRetrievalPlan, MessageSearchError, MessageSearchInclude,
26 MessageSearchIncludedData, MessageSearchOrderedDigest, MessageSearchOrigins, MessageSearchPlan,
27 MessageSearchRequest, MessageSearchResponse, MessageSearchRuntimeDiagnostics,
28 MessageSearchSpecification, MessageTarget, PageInfo, ReceiptLevel, ResolvedExtent,
29 ResolvedMessagePredicates, ResolvedMessagePresentation, ResolvedMessageSearchRequest,
30 SearchSurface, ValueOrigin, DEFAULT_MATCH_EVIDENCE_MAX_CHARS,
31};
32use crate::message_search_batches::{
33 ensure_message_search_active, MessageSearchBatch, MessageSearchBatches,
34};
35use crate::models::{
36 DiagnosticStatus, FileCrossRef, FileEditSummary, FileQuery, FileVersion, IndexStatus,
37 MessageFilters, MessageHit, SearchExplain, SearchFilters, SearchHit, SessionMeta,
38 SessionRecord,
39};
40use crate::runtime::ExecutionRuntime;
41use crate::search_scope::{EffectiveAccessScope, TrustedAccessInputs};
42
43pub struct SessionSearch {
48 config: Config,
49 access: EffectiveAccessScope,
50 db: Db,
51}
52
53#[cfg(test)]
54mod analysis_service_tests {
55 use super::*;
56 use crate::models::{
57 Message, MessageAuthorship, MessageKind, MessageProvenance, MessageRecordRelation,
58 Provider, Role,
59 };
60 use crate::util::minimal_record;
61
62 fn human_original_provenance() -> MessageProvenance {
63 MessageProvenance {
64 authorship: MessageAuthorship::Human,
65 record_relation: MessageRecordRelation::Original,
66 ..MessageProvenance::default()
67 }
68 }
69
70 fn app_with_user_messages(
73 dir: &std::path::Path,
74 config: Config,
75 texts: &[&str],
76 ) -> SessionSearch {
77 let mut config = config;
78 config.index.db_path = Some(dir.join("index.db").to_string_lossy().into_owned());
79 config.index.cache_dir = Some(dir.join("cache").to_string_lossy().into_owned());
80 let app = SessionSearch::open(config).unwrap();
81 let mut parsed = minimal_record(
82 Provider::Claude,
83 std::path::Path::new("/fixture/selection.jsonl"),
84 String::new(),
85 );
86 parsed.session.id = "claude:selection".into();
87 parsed.session.provider_session_id = "selection".into();
88 parsed.messages = texts
89 .iter()
90 .enumerate()
91 .map(|(seq, text)| Message {
92 seq: seq as i64,
93 role: Role::User,
94 ts: None,
95 tool_name: None,
96 kind: MessageKind::Conversation,
97 tool_call_id: None,
98 is_compaction: false,
99 content: (*text).to_string(),
100 provenance: human_original_provenance(),
101 })
102 .collect();
103 app.database().upsert_session(&parsed, 0, 0).unwrap();
104 app
105 }
106
107 fn write_skill(search_root: &std::path::Path, name: &str, category: &str, pattern: &str) {
109 let root = search_root.join(name);
110 std::fs::create_dir_all(&root).unwrap();
111 std::fs::write(
112 root.join("SKILL.md"),
113 format!(
114 "---\nname: {name}\ndescription: test skill\nmetadata:\n version: 2.1.0\n---\n\nbody\n"
115 ),
116 )
117 .unwrap();
118 std::fs::write(
119 root.join("aise-capability.toml"),
120 format!(
121 "schema_version = 1\nkind = \"message-classification\"\n\n\
122 [[categories]]\nname = \"{category}\"\npatterns = [\'\'\'{pattern}\'\'\']\n"
123 ),
124 )
125 .unwrap();
126 }
127
128 fn classify_messages(
129 analysis: &AnalysisService<'_>,
130 skill: crate::skill_catalog::SkillSelector,
131 filters: MessageFilters,
132 ) -> crate::corrections::MessageClassificationReport {
133 let report = analysis
134 .run_skill(&crate::skill_run::SkillRunQuery {
135 skill,
136 definition: None,
137 input: crate::skill_run::SkillCapabilityInput::MessageClassification(
138 crate::skill_run::MessageClassificationQuery {
139 filters,
140 additional_skills: Vec::new(),
141 },
142 ),
143 })
144 .unwrap();
145 let crate::skill_run::SkillCapabilityOutput::MessageClassification(output) = report.output;
146 output.report
147 }
148
149 #[test]
150 fn typed_skill_run_resolves_one_descriptor_and_returns_tagged_provenance() {
151 let dir = tempfile::tempdir().unwrap();
152 let skills_root = dir.path().join("skills");
153 let skill_root = skills_root.join("my-review");
154 std::fs::create_dir_all(&skill_root).unwrap();
155 std::fs::write(
156 skill_root.join("SKILL.md"),
157 "---\nname: my-review\ndescription: test classification\nmetadata:\n version: \
158 2.1.0\n---\n\ninstructions\n",
159 )
160 .unwrap();
161 std::fs::write(
162 skill_root.join("aise-capability.toml"),
163 "schema_version = 1\nkind = \"message-classification\"\n\n\
164 [[categories]]\nname = \"clobber\"\npatterns = ['''\\byou overwrote\\b''']\n",
165 )
166 .unwrap();
167
168 let mut config = Config::default();
169 config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
170 let app = app_with_user_messages(dir.path(), config, &["you overwrote the notes"]);
171 let report = app
172 .analysis()
173 .run_skill(&crate::skill_run::SkillRunQuery {
174 skill: crate::skill_catalog::SkillSelector::Name(
175 crate::skill_catalog::SkillNameSelector {
176 name: crate::skill_catalog::SkillName::try_from("my-review".to_string())
177 .unwrap(),
178 },
179 ),
180 definition: None,
181 input: crate::skill_run::SkillCapabilityInput::MessageClassification(
182 crate::skill_run::MessageClassificationQuery {
183 filters: MessageFilters::default(),
184 additional_skills: Vec::new(),
185 },
186 ),
187 })
188 .unwrap();
189
190 let value = serde_json::to_value(&report).unwrap();
191 assert_eq!(value["requested_selector"]["name"], "my-review");
192 assert_eq!(value["resolved_skill"]["name"], "my-review");
193 assert_eq!(value["output"]["capability"], "message-classification");
194 assert_eq!(
195 value["output"]["result"]["report"]["matches"][0]["category"],
196 "clobber"
197 );
198 assert_eq!(
199 value["output"]["result"]["receipt"]["sha256"]
200 .as_str()
201 .unwrap()
202 .len(),
203 64
204 );
205 assert_eq!(
206 value["output"]["result"]["receipt"],
207 value["output"]["result"]["report"]["policies"][0],
208 "the primary envelope receipt is derived from the first evaluated policy"
209 );
210
211 let duplicate = app
212 .analysis()
213 .run_skill(&crate::skill_run::SkillRunQuery {
214 skill: crate::skill_catalog::SkillSelector::Name(
215 crate::skill_catalog::SkillNameSelector {
216 name: crate::skill_catalog::SkillName::try_from("my-review".to_string())
217 .unwrap(),
218 },
219 ),
220 definition: None,
221 input: crate::skill_run::SkillCapabilityInput::MessageClassification(
222 crate::skill_run::MessageClassificationQuery {
223 filters: MessageFilters::default(),
224 additional_skills: vec![crate::skill_catalog::SkillSelector::Path(
225 crate::skill_catalog::SkillPathSelector { path: skill_root },
226 )],
227 },
228 ),
229 })
230 .expect_err("the same canonical skill selected by name and path must fail");
231 assert!(
232 duplicate.to_string().contains("selected more than once"),
233 "{duplicate:#}"
234 );
235 }
236
237 #[test]
245 fn a_named_skill_replaces_the_built_in_rules_and_names_itself_in_the_report() {
246 let dir = tempfile::tempdir().unwrap();
247 let skills_root = dir.path().join("skills");
248 write_skill(
249 &skills_root,
250 "my-corrections",
251 "clobber",
252 r"\byou overwrote\b",
253 );
254
255 let mut config = Config::default();
256 config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
257 let app = app_with_user_messages(
258 dir.path(),
259 config,
260 &["you overwrote my notes", "you forgot the tests"],
261 );
262 let analysis = app.analysis();
263
264 let selected = classify_messages(
265 &analysis,
266 crate::skill_catalog::SkillSelector::name("my-corrections").unwrap(),
267 MessageFilters::default(),
268 );
269 assert_eq!(
270 selected
271 .matches
272 .iter()
273 .map(|hit| (
274 hit.policy_name.as_str(),
275 hit.category.as_str(),
276 hit.matched_text.as_str()
277 ))
278 .collect::<Vec<_>>(),
279 vec![("my-corrections", "clobber", "you overwrote")],
280 "only the selected skill's categories may apply"
281 );
282 assert_eq!(
283 selected
284 .policies
285 .iter()
286 .map(|receipt| (receipt.name.as_str(), receipt.version.as_str()))
287 .collect::<Vec<_>>(),
288 vec![("my-corrections", "2.1.0")],
289 "the receipt reports the policy's own version, not the aise version"
290 );
291 assert_eq!(
292 selected.policies[0].sha256.len(),
293 64,
294 "a receipt without a digest cannot reproduce a run"
295 );
296
297 let defaulted = classify_messages(
298 &analysis,
299 crate::skill_catalog::SkillSelector::name("corrections").unwrap(),
300 MessageFilters::default(),
301 );
302 assert_eq!(
303 defaulted
304 .matches
305 .iter()
306 .map(|hit| (hit.policy_name.as_str(), hit.category.as_str()))
307 .collect::<Vec<_>>(),
308 vec![(crate::corrections::EMBEDDED_POLICY_NAME, "skip_step")],
309 "omitting --skill must evaluate the embedded policy, and ONLY it"
310 );
311 }
312
313 #[test]
317 fn a_direct_definition_replaces_file_rules_without_replacing_skill_identity() {
318 let dir = tempfile::tempdir().unwrap();
319 let skills_root = dir.path().join("skills");
320 write_skill(
321 &skills_root,
322 "my-corrections",
323 "file-rule",
324 r"\byou overwrote\b",
325 );
326
327 let mut config = Config::default();
328 config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
329 let app = app_with_user_messages(
330 dir.path(),
331 config,
332 &["you overwrote my notes", "that answer is incorrect"],
333 );
334 let query = crate::skill_run::SkillRunQuery {
335 skill: crate::skill_catalog::SkillSelector::name("my-corrections").unwrap(),
336 definition: Some(crate::skill_run::MessageClassificationDefinition {
337 categories: vec![crate::corrections::CorrectionCategorySpec {
338 name: "direct-rule".to_string(),
339 patterns: vec![r"\bincorrect\b".to_string()],
340 }],
341 }),
342 input: crate::skill_run::SkillCapabilityInput::MessageClassification(
343 crate::skill_run::MessageClassificationQuery::default(),
344 ),
345 };
346
347 let first = app.analysis().run_skill(&query).unwrap();
348 let second = app.analysis().run_skill(&query).unwrap();
349 assert_eq!(first.resolved_skill.name.as_str(), "my-corrections");
350 assert_eq!(
351 first.resolved_skill.package_version.as_deref(),
352 Some("2.1.0")
353 );
354 assert!(matches!(
355 first.resolved_skill.selected_location,
356 crate::skill_run::SelectedSkillLocation::Path { .. }
357 ));
358 assert!(matches!(
359 first.resolved_skill.execution_source,
360 crate::skill_run::CapabilityExecutionSource::Inline
361 ));
362 let crate::skill_run::SkillCapabilityOutput::MessageClassification(first_output) =
363 first.output;
364 let crate::skill_run::SkillCapabilityOutput::MessageClassification(second_output) =
365 second.output;
366 assert_eq!(
367 first_output
368 .report
369 .matches
370 .iter()
371 .map(|matched| (
372 matched.policy_name.as_str(),
373 matched.category.as_str(),
374 matched.matched_text.as_str()
375 ))
376 .collect::<Vec<_>>(),
377 vec![("my-corrections", "direct-rule", "incorrect")],
378 "the direct definition must replace, rather than merge with, the file rules"
379 );
380 assert_eq!(first_output.receipt, first_output.report.policies[0]);
381 assert_eq!(
382 first_output.receipt.sha256, second_output.receipt.sha256,
383 "the same ordered definition must produce the same canonical digest"
384 );
385
386 let invalid = crate::skill_run::SkillRunQuery {
387 definition: Some(crate::skill_run::MessageClassificationDefinition {
388 categories: Vec::new(),
389 }),
390 ..query
391 };
392 let error = app
393 .analysis()
394 .run_skill(&invalid)
395 .expect_err("an empty direct definition must use the shared policy validator");
396 assert!(
397 error.to_string().contains("defines no categories"),
398 "the validation error must say how the definition is invalid: {error:#}"
399 );
400 }
401
402 #[test]
403 fn a_direct_definition_and_additional_package_share_one_byte_budget() {
404 let dir = tempfile::tempdir().unwrap();
405 let skills_root = dir.path().join("skills");
406 write_skill(&skills_root, "primary", "unused", r"\bunused\b");
407 write_skill(&skills_root, "additional", "file-rule", r"\bwrong\b");
408 let additional_capability = skills_root.join("additional/aise-capability.toml");
409 let mut file = std::fs::read_to_string(&additional_capability).unwrap();
410 file.push_str(&format!("\n# {}\n", "x".repeat(500 * 1024)));
411 std::fs::write(&additional_capability, file).unwrap();
412
413 let mut config = Config::default();
414 config.skills.search_paths = vec![skills_root.to_string_lossy().into_owned()];
415 let app = app_with_user_messages(dir.path(), config, &["wrong"]);
416 let error = app
417 .analysis()
418 .run_skill(&crate::skill_run::SkillRunQuery {
419 skill: crate::skill_catalog::SkillSelector::name("primary").unwrap(),
420 definition: Some(crate::skill_run::MessageClassificationDefinition {
421 categories: vec![crate::corrections::CorrectionCategorySpec {
422 name: "inline".to_string(),
423 patterns: vec![format!("(?x){}needle", " ".repeat(600 * 1024))],
424 }],
425 }),
426 input: crate::skill_run::SkillCapabilityInput::MessageClassification(
427 crate::skill_run::MessageClassificationQuery {
428 filters: MessageFilters::default(),
429 additional_skills: vec![crate::skill_catalog::SkillSelector::name(
430 "additional",
431 )
432 .unwrap()],
433 },
434 ),
435 })
436 .expect_err("direct and additional package definitions must share one budget");
437 let message = error.to_string();
438 assert!(
439 message.contains("aggregate")
440 && message.contains("additional")
441 && message.contains("aise-capability.toml")
442 && message.contains("bytes were consumed by earlier selections"),
443 "{message}"
444 );
445 }
446
447 #[test]
452 fn an_unknown_skill_name_fails_instead_of_silently_using_the_defaults() {
453 let dir = tempfile::tempdir().unwrap();
454 let app = app_with_user_messages(dir.path(), Config::default(), &["you forgot the tests"]);
455 let error = app
456 .analysis()
457 .run_skill(&crate::skill_run::SkillRunQuery {
458 skill: crate::skill_catalog::SkillSelector::name("not-installed").unwrap(),
459 definition: None,
460 input: crate::skill_run::SkillCapabilityInput::MessageClassification(
461 crate::skill_run::MessageClassificationQuery::default(),
462 ),
463 })
464 .expect_err("an unknown skill must not resolve to the defaults");
465 let message = format!("{error:#}");
466 assert!(
467 message.contains("not-installed") && message.contains("aise skills list"),
468 "the error must name the value and how to find valid ones: {message}"
469 );
470 }
471
472 #[test]
473 fn analysis_service_reuses_indexed_correction_planning_and_role_queries() {
474 let dir = tempfile::tempdir().unwrap();
475 let mut config = Config::default();
476 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
477 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
478 let app = SessionSearch::open(config).unwrap();
479 let mut parsed = minimal_record(
480 Provider::Claude,
481 std::path::Path::new("/fixture/session.jsonl"),
482 String::new(),
483 );
484 parsed.session.id = "claude:analysis".into();
485 parsed.session.provider_session_id = "analysis".into();
486 parsed.messages = vec![
487 Message {
488 seq: 0,
489 role: Role::User,
490 ts: None,
491 tool_name: None,
492 kind: MessageKind::Conversation,
493 tool_call_id: None,
494 is_compaction: false,
495 content: "actually, that is wrong".into(),
496 provenance: human_original_provenance(),
497 },
498 Message {
499 seq: 1,
500 role: Role::Slash,
501 ts: None,
502 tool_name: None,
503 kind: MessageKind::Conversation,
504 tool_call_id: None,
505 is_compaction: false,
506 content: "/plan verify migration".into(),
507 provenance: Default::default(),
508 },
509 ];
510 app.database().upsert_session(&parsed, 0, 0).unwrap();
511 let mut other = minimal_record(
512 Provider::Codex,
513 std::path::Path::new("/fixture/codex.jsonl"),
514 String::new(),
515 );
516 other.session.id = "codex:analysis".into();
517 other.session.provider_session_id = "analysis".into();
518 other.messages = vec![Message {
519 seq: 0,
520 role: Role::User,
521 ts: None,
522 tool_name: None,
523 kind: MessageKind::Conversation,
524 tool_call_id: None,
525 is_compaction: false,
526 content: "unrelated provider message".into(),
527 provenance: human_original_provenance(),
528 }];
529 app.database().upsert_session(&other, 0, 0).unwrap();
530
531 let analysis = app.analysis();
532 let filters = MessageFilters::default();
533 let corrections = classify_messages(
534 &analysis,
535 crate::skill_catalog::SkillSelector::name("corrections").unwrap(),
536 filters.clone(),
537 );
538 assert_eq!(corrections.matches.len(), 1);
539 assert_eq!(
541 corrections
542 .policies
543 .iter()
544 .map(|receipt| receipt.name.as_str())
545 .collect::<Vec<_>>(),
546 vec![crate::corrections::EMBEDDED_POLICY_NAME]
547 );
548 assert_eq!(
549 corrections.matches[0].policy_name,
550 crate::corrections::EMBEDDED_POLICY_NAME
551 );
552 let planning = analysis.planning(&filters, &["^/plan$".into()]).unwrap();
553 assert_eq!(planning.len(), 1);
554 assert_eq!(planning[0].command, "/plan");
555 let roles = analysis.role_statistics(&filters).unwrap();
556 assert_eq!(roles.len(), 2);
557 assert_eq!(roles.iter().map(|row| row.count).sum::<i64>(), 3);
558 let provider_roles = analysis
559 .role_statistics(&MessageFilters {
560 providers: Some(vec![Provider::Claude]),
561 ..Default::default()
562 })
563 .unwrap();
564 assert_eq!(provider_roles.iter().map(|row| row.count).sum::<i64>(), 2);
565 }
566
567 #[test]
568 fn analysis_run_matches_paged_documents_reference_on_indexed_sessions() {
569 use crate::analysis_pipeline::{
570 AnalysisPolicySpec, ClassificationRuleSpec, ClassificationTarget, PhraseTextMode,
571 PhraseVocabularyPolicySpec,
572 };
573
574 let dir = tempfile::tempdir().unwrap();
575 let mut config = Config::default();
576 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
577 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
578 let app = SessionSearch::open(config).unwrap();
579 for (id, provider, texts) in [
582 (
583 "claude:multi",
584 Provider::Claude,
585 vec!["use tdd across chunks", "tdd again across chunks"],
586 ),
587 (
588 "codex:fence",
589 Provider::Codex,
590 vec!["prose one\n```", "code hidden\n```\nprose two"],
591 ),
592 ("gemini-cli:empty", Provider::GeminiCli, vec![]),
593 ] {
594 let mut parsed = minimal_record(
595 Provider::Claude,
596 std::path::Path::new("/fixture/session.jsonl"),
597 String::new(),
598 );
599 parsed.session.provider = provider;
600 parsed.session.id = id.into();
601 parsed.session.provider_session_id = id.into();
602 parsed.messages = texts
603 .iter()
604 .enumerate()
605 .map(|(seq, text)| Message {
606 seq: seq as i64,
607 role: Role::User,
608 ts: None,
609 tool_name: None,
610 kind: MessageKind::Conversation,
611 tool_call_id: None,
612 is_compaction: false,
613 content: (*text).into(),
614 provenance: human_original_provenance(),
615 })
616 .collect();
617 app.database().upsert_session(&parsed, 0, 0).unwrap();
618 }
619 let policy = AnalysisPolicySpec {
620 classification_rules: vec![ClassificationRuleSpec {
621 dimension: "technique".into(),
622 label: "tdd".into(),
623 target: ClassificationTarget::UserText,
624 pattern: "(?i)\\btdd\\b".into(),
625 weight: 1,
626 }],
627 relationship_rules: vec![],
628 phrase_vocabulary: Some(PhraseVocabularyPolicySpec {
629 widths: vec![2],
630 max_unique_phrases: 1000,
631 min_document_tokens: 0,
632 excluded_tokens: vec![],
633 exclude_numeric_tokens: true,
634 text_mode: PhraseTextMode::ProseOnly,
635 }),
636 max_classification_chars: None,
637 }
638 .compile()
639 .unwrap();
640 let filters = SearchFilters::default();
641
642 let request = crate::models::AnalysisRequest::new(
643 filters.clone(),
644 crate::models::AnalysisSessionSelection::AllEligible,
645 )
646 .unwrap();
647 let streamed = app.analysis().run(&request, &policy).unwrap();
648
649 let limited = SearchFilters {
652 limit: 2,
653 ..filters
654 };
655 let mut documents = Vec::new();
656 let mut cursor = None;
657 loop {
658 let page = app.analysis().documents(&limited, cursor.as_ref()).unwrap();
659 documents.extend(page.documents);
660 match page.next_cursor {
661 Some(next) => cursor = Some(next),
662 None => break,
663 }
664 }
665 let reference = policy.analyze(documents).unwrap();
666
667 assert_eq!(
668 serde_json::to_value(&streamed.result).unwrap(),
669 serde_json::to_value(&reference).unwrap()
670 );
671 assert!(!streamed.result.vocabulary.is_empty());
672 assert!(streamed.result.sessions["claude:multi"].has_user_text);
673 assert!(!streamed.result.sessions["gemini-cli:empty"].has_user_text);
674 }
675
676 #[test]
677 fn analysis_run_streams_one_snapshot_and_resolves_across_pages() {
678 use crate::analysis_pipeline::{
679 AnalysisPolicy, ClassificationRuleSpec, ClassificationTarget, RelationshipKind,
680 RelationshipResolution, RelationshipRuleSpec,
681 };
682
683 let dir = tempfile::tempdir().unwrap();
684 let mut config = Config::default();
685 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
686 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
687 let app = SessionSearch::open(config).unwrap();
688 for (id, provider, title, text) in [
689 ("claude:root", Provider::Claude, "Root", "use TDD"),
690 ("codex:root", Provider::Codex, "Root", ""),
691 (
692 "gemini-cli:child",
693 Provider::GeminiCli,
694 "Branch of Root",
695 "use TDD",
696 ),
697 ] {
698 let mut parsed = minimal_record(
699 provider,
700 std::path::Path::new("/fixture/session.jsonl"),
701 String::new(),
702 );
703 parsed.session.id = id.into();
704 parsed.session.provider_session_id = id.into();
705 parsed.session.title = Some(title.into());
706 if !text.is_empty() {
707 parsed.messages.push(Message {
708 seq: 0,
709 role: Role::User,
710 ts: None,
711 tool_name: None,
712 kind: MessageKind::Conversation,
713 tool_call_id: None,
714 is_compaction: false,
715 content: text.into(),
716 provenance: human_original_provenance(),
717 });
718 }
719 app.database().upsert_session(&parsed, 0, 0).unwrap();
720 }
721 let policy = AnalysisPolicy::compile(
722 vec![ClassificationRuleSpec {
723 dimension: "technique".into(),
724 label: "tdd".into(),
725 target: ClassificationTarget::UserText,
726 pattern: "(?i)\\btdd\\b".into(),
727 weight: 1,
728 }],
729 vec![RelationshipRuleSpec {
730 id: "branch_of".into(),
731 kind: RelationshipKind::Branch,
732 pattern: "^Branch of (?P<parent>.+)$".into(),
733 }],
734 )
735 .unwrap();
736 let request = crate::models::AnalysisRequest::new(
737 SearchFilters::default(),
738 crate::models::AnalysisSessionSelection::AllEligible,
739 )
740 .unwrap();
741 let automatic = app.analysis().run(&request, &policy).unwrap();
742 for batch_size in [1, 2, 50] {
743 let batched = app
744 .analysis()
745 .run_with_session_batch_size(
746 &request,
747 std::num::NonZeroUsize::new(batch_size).unwrap(),
748 &policy,
749 )
750 .unwrap();
751 assert_eq!(
752 serde_json::to_value(&batched).unwrap(),
753 serde_json::to_value(&automatic).unwrap(),
754 "internal batch size {batch_size} must not alter results, counts, or digests"
755 );
756 }
757 let run = app
758 .analysis()
759 .run_with_session_batch_size(&request, std::num::NonZeroUsize::new(1).unwrap(), &policy)
760 .unwrap();
761
762 assert_eq!(run.result.sessions.len(), 3);
763 assert_eq!(run.result.sessions["gemini-cli:child"].score, 1);
764 assert_eq!(
765 run.result.sessions["gemini-cli:child"].relationship_hints[0].resolution,
766 RelationshipResolution::Ambiguous {
767 session_ids: vec!["claude:root".into(), "codex:root".into()]
768 }
769 );
770
771 let limited = crate::models::AnalysisRequest::new(
772 SearchFilters::default(),
773 crate::models::AnalysisSessionSelection::FirstCanonicalSessions {
774 max_sessions: std::num::NonZeroUsize::new(2).unwrap(),
775 },
776 )
777 .unwrap();
778 assert_eq!(
779 app.analysis()
780 .run_with_session_batch_size(
781 &limited,
782 std::num::NonZeroUsize::new(1).unwrap(),
783 &policy,
784 )
785 .unwrap()
786 .result
787 .sessions
788 .len(),
789 2
790 );
791 }
792
793 #[test]
794 fn receipted_analysis_names_canonical_session_selection_and_exact_count_units() {
795 use std::num::NonZeroUsize;
796
797 use crate::analysis_pipeline::AnalysisPolicySpec;
798 use crate::models::{AnalysisRequest, AnalysisSessionSelection};
799
800 let dir = tempfile::tempdir().unwrap();
801 let mut config = Config::default();
802 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().into_owned());
803 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().into_owned());
804 let app = SessionSearch::open(config).unwrap();
805 for (id, updated_at) in [
806 ("claude:a-newer", "2026-03-01T00:00:00Z"),
807 ("claude:z-older", "2026-01-01T00:00:00Z"),
808 ] {
809 let mut parsed = minimal_record(
810 Provider::Claude,
811 std::path::Path::new(if id == "claude:a-newer" {
812 "/fixture/a-newer.jsonl"
813 } else {
814 "/fixture/z-older.jsonl"
815 }),
816 String::new(),
817 );
818 parsed.session.id = id.into();
819 parsed.session.provider_session_id = id.into();
820 parsed.session.updated_at = Some(updated_at.parse().unwrap());
821 parsed.messages = vec![
822 Message {
823 seq: 0,
824 role: Role::User,
825 ts: None,
826 tool_name: None,
827 kind: MessageKind::Conversation,
828 tool_call_id: None,
829 is_compaction: false,
830 content: "human input".into(),
831 provenance: human_original_provenance(),
832 },
833 Message {
834 seq: 1,
835 role: Role::Assistant,
836 ts: None,
837 tool_name: None,
838 kind: MessageKind::Conversation,
839 tool_call_id: None,
840 is_compaction: false,
841 content: "assistant output".into(),
842 provenance: Default::default(),
843 },
844 Message {
845 seq: 2,
846 role: Role::Tool,
847 ts: None,
848 tool_name: Some("test".into()),
849 kind: MessageKind::ToolResult,
850 tool_call_id: None,
851 is_compaction: false,
852 content: "tool output".into(),
853 provenance: Default::default(),
854 },
855 ];
856 app.database().upsert_session(&parsed, 0, 0).unwrap();
857 }
858 let policy = AnalysisPolicySpec::default().compile().unwrap();
859 let request = AnalysisRequest::new(
860 SearchFilters::default(),
861 AnalysisSessionSelection::FirstCanonicalSessions {
862 max_sessions: NonZeroUsize::new(1).unwrap(),
863 },
864 )
865 .unwrap();
866
867 let run = app.analysis().run(&request, &policy).unwrap();
868 assert_eq!(
869 run.result.sessions.keys().cloned().collect::<Vec<_>>(),
870 ["claude:a-newer"]
871 );
872 assert_eq!(run.receipt.selected_sessions, 1);
873 assert_eq!(run.receipt.messages_in_selected_sessions, 3);
874 assert_eq!(run.receipt.analyzed_user_messages, 1);
875 assert!(run.receipt.has_more);
876 assert_eq!(
877 run.receipt.last_selected_session_id.as_deref(),
878 Some("claude:a-newer")
879 );
880 assert!(run.receipt.policy_digest.starts_with("sha256:"));
881 assert!(run.receipt.corpus_digest.starts_with("sha256:"));
882 assert!(run.receipt.result_digest.starts_with("sha256:"));
883 assert_ne!(run.receipt.policy_digest, run.receipt.corpus_digest);
884 assert_ne!(run.receipt.corpus_digest, run.receipt.result_digest);
885 assert_eq!(run.receipt.policy_digest, policy.policy_digest());
886 assert_eq!(run.receipt.result_digest, run.result.result_digest());
887 assert_eq!(
888 serde_json::to_value(run.receipt.selection).unwrap(),
889 serde_json::json!({
890 "kind": "first_canonical_sessions",
891 "max_sessions": 1
892 })
893 );
894
895 let all = app
896 .analysis()
897 .run(
898 &AnalysisRequest::new(
899 SearchFilters::default(),
900 AnalysisSessionSelection::AllEligible,
901 )
902 .unwrap(),
903 &policy,
904 )
905 .unwrap();
906 assert_eq!(all.receipt.selected_sessions, 2);
907 assert_eq!(all.receipt.messages_in_selected_sessions, 6);
908 assert_eq!(all.receipt.analyzed_user_messages, 2);
909 assert!(!all.receipt.has_more);
910 assert_ne!(all.receipt.corpus_digest, run.receipt.corpus_digest);
911 }
912
913 #[test]
914 fn analysis_request_rejects_generic_session_limit_with_actionable_replacement() {
915 use crate::models::{AnalysisRequest, AnalysisSessionSelection};
916
917 let error = AnalysisRequest::new(
918 SearchFilters {
919 limit: 5,
920 ..SearchFilters::default()
921 },
922 AnalysisSessionSelection::AllEligible,
923 )
924 .unwrap_err();
925
926 assert!(error.to_string().contains("SearchFilters.limit"));
927 assert!(error.to_string().contains("FirstCanonicalSessions"));
928 }
929}
930
931impl SessionSearch {
932 pub fn load() -> Result<Self> {
934 Self::open(Config::load()?)
935 }
936
937 pub fn open(config: Config) -> Result<Self> {
946 let inputs = TrustedAccessInputs::capture(&config.search.scope, Vec::new())?;
947 Self::open_with_access_inputs(config, inputs)
948 }
949
950 pub fn open_with_access_inputs(
952 config: Config,
953 access_inputs: TrustedAccessInputs,
954 ) -> Result<Self> {
955 Self::open_with_access_inputs_and_runtime(config, access_inputs, None)
956 }
957
958 pub(crate) fn open_prepared_reader(
964 config: Config,
965 access_inputs: TrustedAccessInputs,
966 runtime: Arc<ExecutionRuntime>,
967 ) -> Result<Self> {
968 if config.index.refresh != IndexRefresh::ExistingOnly {
969 anyhow::bail!(
970 "prepared readers require index.refresh=existing-only after the freshness coordinator completes"
971 );
972 }
973 Self::open_with_access_inputs_and_runtime(config, access_inputs, Some(runtime))
974 }
975
976 fn open_with_access_inputs_and_runtime(
977 config: Config,
978 access_inputs: TrustedAccessInputs,
979 runtime: Option<Arc<ExecutionRuntime>>,
980 ) -> Result<Self> {
981 let access = EffectiveAccessScope::resolve(&config.search.scope, access_inputs)?;
982 let schema_state = IndexCoordinator::new(&config).inspect_schema()?;
983 match schema_state {
984 SchemaState::Missing if config.index.refresh == IndexRefresh::ExistingOnly => {
985 anyhow::bail!(
986 "existing-only index {} does not exist; run `aise reindex --full` without --index-refresh existing-only",
987 config.db_path().display()
988 )
989 }
990 SchemaState::Older { current, .. }
991 if config.index.refresh == IndexRefresh::ExistingOnly
992 && current < MIN_READABLE_SCHEMA_VERSION =>
993 {
994 anyhow::bail!(
995 "existing-only index {} uses unreadable schema generation {current}; run `aise reindex --full` without --index-refresh existing-only",
996 config.db_path().display()
997 )
998 }
999 SchemaState::Missing | SchemaState::Current | SchemaState::Older { .. } => {}
1000 SchemaState::Newer { current, supported } => anyhow::bail!(
1001 "index {} uses schema generation {current}, newer than this aise build supports ({supported}); upgrade aise before opening it",
1002 config.db_path().display()
1003 ),
1004 SchemaState::UnknownProviders { providers } => anyhow::bail!(
1005 "{}",
1006 SchemaState::unknown_providers_message(&config.db_path(), &providers)
1007 ),
1008 SchemaState::RepairableLayout { reason }
1009 if config.index.refresh == IndexRefresh::ExistingOnly =>
1010 {
1011 anyhow::bail!(
1012 "existing-only index {} needs a one-time message-search index rebuild ({reason}); run a writable aise command (without --index-refresh existing-only) to self-heal it in place",
1013 config.db_path().display()
1014 )
1015 }
1016 SchemaState::RepairableLayout { .. } => {
1017 let heal_threads = NonZeroUsize::new(config.resolve_threads())
1022 .expect("Config::resolve_threads always returns at least one");
1023 let heal_busy_timeout = config.index.busy_timeout_ms;
1024 crate::indexer::with_index_update_lock(&config, || {
1025 if matches!(
1026 IndexCoordinator::new(&config).inspect_schema()?,
1027 SchemaState::RepairableLayout { .. }
1028 ) {
1029 let db = Db::open_with_threads(
1030 &config.db_path(),
1031 heal_busy_timeout,
1032 heal_threads,
1033 )?;
1034 db.migrate_message_search_schema_exclusive()?;
1035 }
1036 Ok(())
1037 })?;
1038 }
1039 SchemaState::RecoveryRequired { reason } => anyhow::bail!(
1040 "index {} requires offline recovery: {reason}; stop AISE processes, then run `aise reindex --full`",
1041 config.db_path().display()
1042 ),
1043 }
1044 if config.index.refresh != IndexRefresh::ExistingOnly {
1045 fs::create_dir_all(config.cache_dir())?;
1046 }
1047 let worker_threads = NonZeroUsize::new(config.resolve_threads())
1048 .expect("Config::resolve_threads always returns at least one");
1049 let mut db = if config.index.refresh == IndexRefresh::ExistingOnly {
1050 if let Some(runtime) = runtime {
1051 Db::open_existing_read_only_with_runtime(
1052 &config.db_path(),
1053 config.index.busy_timeout_ms,
1054 runtime,
1055 )?
1056 } else {
1057 Db::open_existing_read_only_with_threads(
1058 &config.db_path(),
1059 config.index.busy_timeout_ms,
1060 worker_threads,
1061 )?
1062 }
1063 } else {
1064 Db::open_with_threads(
1065 &config.db_path(),
1066 config.index.busy_timeout_ms,
1067 worker_threads,
1068 )?
1069 };
1070 db.set_access_scope(access.clone());
1071 db.set_implicit_index_maintenance(config.index.refresh != IndexRefresh::ExistingOnly);
1072 Ok(Self { config, access, db })
1073 }
1074
1075 pub(crate) fn open_for_maintenance(mut config: Config) -> Result<Self> {
1080 let refresh = config.index.refresh;
1081 config.index.refresh = IndexRefresh::BeforeQuery;
1082 let mut app = Self::open(config)?;
1083 app.config.index.refresh = refresh;
1084 Ok(app)
1085 }
1086
1087 pub const fn config(&self) -> &Config {
1089 &self.config
1090 }
1091
1092 pub const fn access_scope(&self) -> &EffectiveAccessScope {
1094 &self.access
1095 }
1096
1097 pub const fn catalog(&self) -> CatalogService<'_> {
1099 CatalogService::new(&self.db)
1100 }
1101
1102 pub const fn messages(&self) -> MessageService<'_> {
1104 MessageService::new(&self.config, &self.db, SearchSurface::Rust)
1105 }
1106
1107 #[doc(hidden)]
1109 pub const fn messages_for_surface(&self, surface: SearchSurface) -> MessageService<'_> {
1110 MessageService::new(&self.config, &self.db, surface)
1111 }
1112
1113 pub fn message_search_batches(
1127 &self,
1128 request: MessageSearchRequest,
1129 batch_rows: NonZeroUsize,
1130 ) -> Result<MessageSearchBatches> {
1131 self.message_search_batches_for_surface(SearchSurface::Rust, request, batch_rows)
1132 }
1133
1134 #[doc(hidden)]
1136 pub fn message_search_batches_for_surface(
1137 &self,
1138 surface: SearchSurface,
1139 request: MessageSearchRequest,
1140 batch_rows: NonZeroUsize,
1141 ) -> Result<MessageSearchBatches> {
1142 MessageSearchBatches::spawn(
1143 self.config.clone(),
1144 self.access.clone(),
1145 surface,
1146 request,
1147 batch_rows,
1148 )
1149 }
1150
1151 pub const fn files(&self) -> FileService<'_> {
1153 FileService::new(&self.db)
1154 }
1155
1156 pub const fn exports(&self) -> ExportService<'_> {
1158 ExportService::new(&self.db)
1159 }
1160
1161 pub const fn index(&self) -> IndexService<'_> {
1163 IndexService::new(&self.config, &self.db)
1164 }
1165
1166 pub const fn maintenance(&self) -> MaintenanceService<'_> {
1168 MaintenanceService::new(&self.config, &self.db)
1169 }
1170
1171 pub const fn sources(&self) -> SourceService<'_> {
1173 SourceService::new(&self.config)
1174 }
1175
1176 pub const fn analysis(&self) -> AnalysisService<'_> {
1178 AnalysisService::new(&self.config, &self.db)
1179 }
1180
1181 pub(crate) const fn database(&self) -> &Db {
1186 &self.db
1187 }
1188
1189 pub fn set_progress_reporter(&mut self, reporter: impl Fn(&str) + Send + Sync + 'static) {
1191 self.db.set_progress_reporter(reporter);
1192 }
1193}
1194
1195#[cfg(test)]
1196mod execution_runtime_tests {
1197 use super::*;
1198 use crate::config::{SearchScopeConfig, SearchScopeMode};
1199 use crate::message_search::{MessageQuery, MessageTarget};
1200 use crate::models::{
1201 FileEdit, FileQuery, Message, MessageFilters, MessageKind, MessageSearchMode, Provider,
1202 Role, SearchFilters,
1203 };
1204 use crate::util::minimal_record;
1205 use std::sync::atomic::{AtomicU32, Ordering};
1206 use std::sync::Arc;
1207
1208 fn all_session_filters(limit: usize) -> SearchFilters {
1209 SearchFilters {
1210 limit,
1211 ..SearchFilters::default()
1212 }
1213 }
1214
1215 #[test]
1216 fn ordinary_open_refuses_newer_schema_before_initialization_can_mutate_it() {
1217 let root = tempfile::tempdir().unwrap();
1218 let db_path = root.path().join("index.db");
1219 drop(Db::open(&db_path).unwrap());
1220 let conn = rusqlite::Connection::open(&db_path).unwrap();
1221 conn.pragma_update(None, "user_version", crate::db::SCHEMA_VERSION + 1)
1222 .unwrap();
1223 let schema_before: String = conn
1224 .query_row(
1225 "select group_concat(coalesce(sql, ''), char(10))
1226 from (select sql from sqlite_schema order by type, name)",
1227 [],
1228 |row| row.get(0),
1229 )
1230 .unwrap();
1231 drop(conn);
1232
1233 let mut config = Config::default();
1234 config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1235 let error = SessionSearch::open(config)
1236 .err()
1237 .expect("newer schema must be rejected")
1238 .to_string();
1239 assert!(error.contains("newer than this aise build"), "{error}");
1240
1241 let conn = rusqlite::Connection::open_with_flags(
1242 &db_path,
1243 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1244 )
1245 .unwrap();
1246 let schema_after: String = conn
1247 .query_row(
1248 "select group_concat(coalesce(sql, ''), char(10))
1249 from (select sql from sqlite_schema order by type, name)",
1250 [],
1251 |row| row.get(0),
1252 )
1253 .unwrap();
1254 assert_eq!(schema_after, schema_before);
1255 }
1256
1257 #[test]
1265 fn ordinary_open_refuses_an_index_carrying_a_provider_this_build_does_not_know() {
1266 let root = tempfile::tempdir().unwrap();
1267 let db_path = root.path().join("index.db");
1268 {
1269 let db = Db::open(&db_path).unwrap();
1270 let parsed = minimal_record(
1271 Provider::Codex,
1272 std::path::Path::new("/tmp/known-session.jsonl"),
1273 String::new(),
1274 );
1275 db.replace_session(&parsed, 0, 0).unwrap();
1276 }
1277 let conn = rusqlite::Connection::open(&db_path).unwrap();
1278 conn.execute(
1279 "update sessions set provider = 'future-provider' where id = 'codex:known-session'",
1280 [],
1281 )
1282 .unwrap();
1283 drop(conn);
1284
1285 let mut config = Config::default();
1286 config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1287 for refresh in [IndexRefresh::Auto, IndexRefresh::ExistingOnly] {
1288 let mut config = config.clone();
1289 config.index.refresh = refresh;
1290 let error = SessionSearch::open(config)
1291 .err()
1292 .unwrap_or_else(|| panic!("{refresh:?}: unknown provider must be rejected"))
1293 .to_string();
1294 assert!(error.contains("future-provider"), "{refresh:?}: {error}");
1295 assert!(error.contains("upgrade aise"), "{refresh:?}: {error}");
1296 assert!(
1297 error.contains(env!("CARGO_PKG_VERSION")),
1298 "{refresh:?}: names this build so the reader can compare: {error}"
1299 );
1300 assert!(
1301 !error.contains("reindex --full"),
1302 "{refresh:?}: reindex is the command that fails; advising it is circular: {error}"
1303 );
1304 }
1305 }
1306
1307 #[test]
1308 fn restricted_open_without_authoritative_roots_fails_before_database_creation() {
1309 let root = tempfile::tempdir().unwrap();
1310 let db_path = root.path().join("must-not-exist.db");
1311 let mut config = Config::default();
1312 config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1313 config.search.scope = SearchScopeConfig {
1314 mode: SearchScopeMode::AllowedRoots,
1315 roots: Vec::new(),
1316 include_invocation_directory: false,
1317 };
1318
1319 let error = SessionSearch::open_with_access_inputs(config, TrustedAccessInputs::default())
1320 .err()
1321 .expect("restricted open must fail")
1322 .to_string();
1323
1324 assert!(error.contains("resolved no authoritative roots"), "{error}");
1325 assert!(!db_path.exists());
1326 }
1327
1328 #[test]
1329 fn allowed_roots_scope_is_consistent_across_read_services_and_exact_ids() {
1330 let root = tempfile::tempdir().unwrap();
1331 let allowed = root.path().join("allowed");
1332 let outside = root.path().join("outside");
1333 let sibling = root.path().join("allowed-sibling");
1334 fs::create_dir_all(&allowed).unwrap();
1335 fs::create_dir_all(&outside).unwrap();
1336 fs::create_dir_all(&sibling).unwrap();
1337
1338 let mut config = Config::default();
1339 config.index.db_path = Some(root.path().join("index.db").to_string_lossy().into_owned());
1340 config.index.cache_dir = Some(root.path().join("cache").to_string_lossy().into_owned());
1341 config.search.scope = SearchScopeConfig {
1342 mode: SearchScopeMode::AllowedRoots,
1343 roots: vec![allowed.to_string_lossy().into_owned()],
1344 include_invocation_directory: false,
1345 };
1346 let app =
1347 SessionSearch::open_with_access_inputs(config, TrustedAccessInputs::default()).unwrap();
1348
1349 let insert = |id: &str, workspace: &std::path::Path, transcript: &std::path::Path| {
1350 let mut parsed = minimal_record(Provider::Claude, transcript, String::new());
1351 parsed.session.id = id.into();
1352 parsed.session.provider_session_id = id.replace(':', "-");
1353 parsed.session.cwd = Some(workspace.to_string_lossy().into_owned());
1354 parsed.session.repo_root = Some(workspace.join("repo").to_string_lossy().into_owned());
1355 if id != "claude:allowed" {
1356 parsed.session.title = Some("scope needle scope needle scope needle".into());
1357 }
1358 parsed.session.preview_text = "scope needle".into();
1359 parsed.transcript_text = "scope needle transcript".into();
1360 parsed.messages = vec![Message {
1361 seq: 0,
1362 role: Role::User,
1363 ts: None,
1364 tool_name: None,
1365 kind: MessageKind::Conversation,
1366 tool_call_id: None,
1367 is_compaction: false,
1368 content: "scope needle message".into(),
1369 provenance: Default::default(),
1370 }];
1371 parsed.file_edits = vec![FileEdit {
1372 seq: 0,
1373 ts: None,
1374 tool: "Write".into(),
1375 file_path: workspace.join("scope.txt").to_string_lossy().into_owned(),
1376 file_name: "scope.txt".into(),
1377 new_content: Some(format!("content from {id}")),
1378 edits: Vec::new(),
1379 }];
1380 app.database().upsert_session(&parsed, 0, 0).unwrap();
1381 };
1382
1383 insert(
1384 "claude:allowed",
1385 &allowed.join("project"),
1386 &outside.join("allowed-transcript.jsonl"),
1387 );
1388 insert(
1390 "claude:hidden",
1391 &outside.join("project"),
1392 &allowed.join("hidden-transcript.jsonl"),
1393 );
1394 insert(
1396 "claude:sibling",
1397 &sibling.join("project"),
1398 &outside.join("sibling-transcript.jsonl"),
1399 );
1400
1401 let sessions = app
1402 .catalog()
1403 .list_sessions(&all_session_filters(0))
1404 .unwrap();
1405 assert_eq!(sessions.len(), 1);
1406 assert_eq!(sessions[0].id, "claude:allowed");
1407 let session_hits = app
1408 .catalog()
1409 .search_sessions(
1410 "scope needle",
1411 &all_session_filters(0),
1412 None,
1413 &ScoringConfig::default(),
1414 )
1415 .unwrap();
1416 assert_eq!(session_hits.len(), 1);
1417 assert_eq!(session_hits[0].session.id, "claude:allowed");
1418 let narrow_scoring = ScoringConfig::default();
1419 let bounded_hits = app
1420 .catalog()
1421 .search_sessions(
1422 "scope needle",
1423 &all_session_filters(1),
1424 None,
1425 &narrow_scoring,
1426 )
1427 .unwrap();
1428 assert_eq!(bounded_hits.len(), 1);
1429 assert_eq!(bounded_hits[0].session.id, "claude:allowed");
1430
1431 let message_response = app
1432 .messages()
1433 .search(
1434 MessageSearchRequest::builder(
1435 MessageQuery::literal("scope needle").unwrap(),
1436 MessageTarget::content(),
1437 )
1438 .build()
1439 .unwrap(),
1440 )
1441 .unwrap();
1442 assert_eq!(message_response.hits().len(), 1);
1443 assert_eq!(message_response.hits()[0].session_id, "claude:allowed");
1444 assert!(app
1445 .messages()
1446 .context("claude:hidden", 0, 1, 1)
1447 .unwrap()
1448 .is_empty());
1449 assert!(app
1450 .messages()
1451 .session_metadata(&["claude:hidden".into()])
1452 .unwrap()
1453 .is_empty());
1454
1455 let files = app.files().search(&FileQuery::default()).unwrap();
1456 assert_eq!(files.len(), 1);
1457 assert!(std::path::Path::new(&files[0].file_path).starts_with(&allowed));
1458 let cross_reference = app.files().cross_reference(&FileQuery::default()).unwrap();
1459 assert_eq!(cross_reference.len(), 1);
1460 assert_eq!(cross_reference[0].session_id, "claude:allowed");
1461 let history = app
1462 .files()
1463 .history("scope.txt", &FileQuery::default())
1464 .unwrap();
1465 assert_eq!(history.len(), 1);
1466 assert_eq!(history[0].session_id, "claude:allowed");
1467 let reconstructed = app
1468 .files()
1469 .reconstruct("scope.txt", &FileQuery::default(), None)
1470 .unwrap();
1471 assert_eq!(reconstructed.session_id, "claude:allowed");
1472 assert_eq!(reconstructed.content, "content from claude:allowed");
1473 let analysis = app
1474 .analysis()
1475 .documents(&all_session_filters(10), None)
1476 .unwrap();
1477 assert_eq!(analysis.documents.len(), 1);
1478 assert_eq!(analysis.documents[0].session.id, "claude:allowed");
1479
1480 assert!(app
1481 .exports()
1482 .render_full("claude:allowed", crate::export::ExportFormat::Json)
1483 .is_ok());
1484 let hidden_error = app
1485 .catalog()
1486 .resolve_session("claude:hidden")
1487 .unwrap_err()
1488 .to_string();
1489 assert!(hidden_error.contains("no session matches"));
1490 assert!(!hidden_error.contains("allowed-sibling"));
1491 assert!(!hidden_error.contains("outside"));
1492 assert!(app
1493 .exports()
1494 .render_full("claude:hidden", crate::export::ExportFormat::Json)
1495 .unwrap_err()
1496 .to_string()
1497 .contains("no session matches"));
1498 }
1499
1500 #[test]
1501 fn applications_own_independent_worker_counts() {
1502 let root = tempfile::tempdir().unwrap();
1503 let mut one_config = Config::default();
1504 one_config.index.db_path = Some(root.path().join("one.db").to_string_lossy().into_owned());
1505 one_config.performance.threads = 1;
1506 let mut two_config = Config::default();
1507 two_config.index.db_path = Some(root.path().join("two.db").to_string_lossy().into_owned());
1508 two_config.performance.threads = 2;
1509
1510 let one = SessionSearch::open(one_config).unwrap();
1511 let two = SessionSearch::open(two_config).unwrap();
1512
1513 assert_eq!(one.database().worker_threads(), 1);
1514 assert_eq!(two.database().worker_threads(), 2);
1515 }
1516
1517 #[test]
1518 fn prepared_readers_require_existing_only_and_reuse_the_shared_worker_budget() {
1519 let root = tempfile::tempdir().unwrap();
1520 let mut config = Config::default();
1521 config.index.db_path = Some(root.path().join("index.db").to_string_lossy().into_owned());
1522 drop(SessionSearch::open(config.clone()).unwrap());
1523 let runtime = Arc::new(ExecutionRuntime::new(
1524 NonZeroUsize::new(2).expect("nonzero worker count"),
1525 ));
1526
1527 let error = SessionSearch::open_prepared_reader(
1528 config.clone(),
1529 TrustedAccessInputs::default(),
1530 Arc::clone(&runtime),
1531 )
1532 .err()
1533 .expect("writable policy must be rejected")
1534 .to_string();
1535 assert!(error.contains("existing-only"), "{error}");
1536
1537 config.index.refresh = IndexRefresh::ExistingOnly;
1538 let first = SessionSearch::open_prepared_reader(
1539 config.clone(),
1540 TrustedAccessInputs::default(),
1541 Arc::clone(&runtime),
1542 )
1543 .unwrap();
1544 let second =
1545 SessionSearch::open_prepared_reader(config, TrustedAccessInputs::default(), runtime)
1546 .unwrap();
1547 assert_eq!(first.database().worker_threads(), 2);
1548 assert_eq!(second.database().worker_threads(), 2);
1549 }
1550
1551 #[test]
1552 fn existing_only_search_never_builds_the_lazy_trigram_index() {
1553 let root = tempfile::tempdir().unwrap();
1554 let mut config = Config::default();
1555 config.index.db_path = Some(root.path().join("index.db").to_string_lossy().into_owned());
1556 config.index.cache_dir = Some(root.path().join("cache").to_string_lossy().into_owned());
1557 let mut parsed = minimal_record(
1558 Provider::Claude,
1559 std::path::Path::new("/fixture/session.jsonl"),
1560 String::new(),
1561 );
1562 parsed.session.id = "claude:existing-only".into();
1563 parsed.session.provider_session_id = "existing-only".into();
1564 parsed.messages = vec![Message {
1565 seq: 0,
1566 role: Role::User,
1567 ts: None,
1568 tool_name: None,
1569 kind: MessageKind::Conversation,
1570 tool_call_id: None,
1571 is_compaction: false,
1572 content: "request failed with ECONNRESET".into(),
1573 provenance: Default::default(),
1574 }];
1575 let writer = SessionSearch::open(config.clone()).unwrap();
1576 writer.database().upsert_session(&parsed, 0, 0).unwrap();
1577 drop(writer);
1578 let conn = rusqlite::Connection::open_with_flags(
1579 config.db_path(),
1580 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1581 )
1582 .unwrap();
1583 let schema_before: String = conn
1584 .query_row(
1585 "select group_concat(coalesce(sql, ''), char(10))
1586 from (select sql from sqlite_schema order by type, name)",
1587 [],
1588 |row| row.get(0),
1589 )
1590 .unwrap();
1591 drop(conn);
1592
1593 config.index.refresh = IndexRefresh::ExistingOnly;
1594 let mut app = SessionSearch::open(config).unwrap();
1595 let write_error = app
1596 .database()
1597 .upsert_session(&parsed, 1, 1)
1598 .expect_err("existing-only database authority must reject writes")
1599 .to_string();
1600 assert!(
1601 write_error.contains("readonly") || write_error.contains("read-only"),
1602 "{write_error}"
1603 );
1604
1605 let progress_calls = Arc::new(AtomicU32::new(0));
1606 let observed_calls = Arc::clone(&progress_calls);
1607 app.set_progress_reporter(move |_message| {
1608 observed_calls.fetch_add(1, Ordering::Relaxed);
1609 });
1610 let hits = app
1611 .messages()
1612 .search_legacy(
1613 "ECONNRESET",
1614 &MessageFilters {
1615 match_mode: MessageSearchMode::Regex,
1616 ..Default::default()
1617 },
1618 )
1619 .unwrap();
1620
1621 assert_eq!(
1622 hits.len(),
1623 1,
1624 "existing-only still scans the unindexed delta"
1625 );
1626 assert_eq!(
1627 progress_calls.load(Ordering::Relaxed),
1628 0,
1629 "existing-only must not start implicit trigram maintenance"
1630 );
1631 assert!(
1632 app.database()
1633 .vocabulary(true, 0, None)
1634 .unwrap()
1635 .iter()
1636 .any(|(term, _, _)| term == "eco" || term == "con" || term == "res"),
1637 "v4 trigram vocabulary remains readable without legacy maintenance"
1638 );
1639 drop(app);
1640 let conn = rusqlite::Connection::open_with_flags(
1641 root.path().join("index.db"),
1642 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
1643 )
1644 .unwrap();
1645 let schema_after: String = conn
1646 .query_row(
1647 "select group_concat(coalesce(sql, ''), char(10))
1648 from (select sql from sqlite_schema order by type, name)",
1649 [],
1650 |row| row.get(0),
1651 )
1652 .unwrap();
1653 assert_eq!(schema_after, schema_before);
1654 }
1655
1656 #[test]
1657 fn existing_only_open_refuses_missing_index_without_creating_paths() {
1658 let root = tempfile::tempdir().unwrap();
1659 let db_path = root.path().join("missing/index.db");
1660 let cache_path = root.path().join("missing/cache");
1661 let mut config = Config::default();
1662 config.index.db_path = Some(db_path.to_string_lossy().into_owned());
1663 config.index.cache_dir = Some(cache_path.to_string_lossy().into_owned());
1664 config.index.refresh = IndexRefresh::ExistingOnly;
1665
1666 let error = SessionSearch::open(config)
1667 .err()
1668 .expect("missing existing-only index must fail")
1669 .to_string();
1670
1671 assert!(error.contains("does not exist"), "{error}");
1672 assert!(error.contains("aise reindex --full"), "{error}");
1673 assert!(!db_path.exists());
1674 assert!(!cache_path.exists());
1675 assert!(!root.path().join("missing").exists());
1676 }
1677}
1678
1679#[derive(Clone, Copy)]
1685pub struct AnalysisService<'app> {
1686 config: &'app Config,
1687 db: &'app Db,
1688}
1689
1690#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1692pub struct AnalysisReceipt {
1693 pub selection: crate::models::AnalysisSessionSelection,
1695 pub database_schema_version: i64,
1697 pub selected_sessions: u64,
1699 pub messages_in_selected_sessions: u64,
1701 pub analyzed_user_messages: u64,
1703 pub has_more: bool,
1705 pub last_selected_session_id: Option<String>,
1707 pub max_selected_session_updated_at: Option<String>,
1709 pub policy_digest: String,
1711 pub corpus_digest: String,
1713 pub result_digest: String,
1715}
1716
1717#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1719pub struct ReceiptedAnalysis {
1720 pub result: crate::analysis_pipeline::AnalysisResult,
1722 pub receipt: AnalysisReceipt,
1724}
1725
1726const DEFAULT_ANALYSIS_SESSION_BATCH_SIZE: std::num::NonZeroUsize =
1727 std::num::NonZeroUsize::new(50).expect("analysis session batch constant is nonzero");
1728
1729impl<'app> AnalysisService<'app> {
1730 pub const fn new(config: &'app Config, db: &'app Db) -> Self {
1732 Self { config, db }
1733 }
1734
1735 pub(crate) fn corrections_with_resolved_policies(
1736 &self,
1737 filters: &crate::models::MessageFilters,
1738 policies: &crate::corrections::ResolvedCorrectionPolicySet,
1739 ) -> Result<crate::corrections::MessageClassificationReport> {
1740 let matches = self.db.find_corrections(policies, filters)?;
1741 Ok(crate::corrections::MessageClassificationReport {
1742 policies: policies.receipts(),
1743 matches,
1744 })
1745 }
1746
1747 pub fn run_skill(
1754 &self,
1755 query: &crate::skill_run::SkillRunQuery,
1756 ) -> Result<crate::skill_run::SkillRunReport> {
1757 let crate::skill_run::SkillCapabilityInput::MessageClassification(arguments) = &query.input;
1758 let embedded_primary = matches!(
1759 &query.skill,
1760 crate::skill_catalog::SkillSelector::Name(selector)
1761 if selector.name.as_str() == crate::corrections::EMBEDDED_POLICY_NAME
1762 );
1763 let roots = || {
1764 self.config
1765 .skills
1766 .search_paths
1767 .iter()
1768 .map(|root| crate::util::expand_tilde(root))
1769 .collect::<Vec<_>>()
1770 };
1771
1772 let (resolved_skill, policies) = if embedded_primary {
1773 if arguments.additional_skills.iter().any(|selector| {
1774 matches!(
1775 selector,
1776 crate::skill_catalog::SkillSelector::Name(selector)
1777 if selector.name.as_str() == crate::corrections::EMBEDDED_POLICY_NAME
1778 )
1779 }) {
1780 bail!("skill \"corrections\" was selected more than once");
1781 }
1782 let mut capability_budget = crate::message_classification::CapabilityLoadBudget::new();
1783 let mut compiled = vec![match &query.definition {
1784 Some(definition) => crate::message_classification::compile_inline_definition(
1785 definition.clone(),
1786 crate::corrections::EMBEDDED_POLICY_NAME.to_string(),
1787 env!("CARGO_PKG_VERSION").to_string(),
1788 &mut capability_budget,
1789 )?,
1790 None => crate::corrections::embedded_policy()?,
1791 }];
1792 if !arguments.additional_skills.is_empty() {
1793 let catalog = crate::skill_catalog::load_skill_catalog(&roots());
1794 let descriptors = crate::skill_catalog::resolve_skill_selectors(
1795 &arguments.additional_skills,
1796 &catalog,
1797 )?;
1798 if descriptors.iter().any(|descriptor| {
1799 descriptor.frontmatter.as_ref().is_some_and(|frontmatter| {
1800 frontmatter.name == crate::corrections::EMBEDDED_POLICY_NAME
1801 })
1802 }) {
1803 bail!(
1804 "the embedded corrections skill and an installed corrections package \
1805 identify the same capability; remove the duplicate --skill selector"
1806 );
1807 }
1808 let additional = crate::message_classification::compile_skill_descriptors(
1809 descriptors,
1810 &mut capability_budget,
1811 )?;
1812 compiled.extend(additional.policies().iter().cloned());
1813 }
1814 (
1815 crate::skill_run::ResolvedSkillReceipt {
1816 name: crate::skill_catalog::SkillName::try_from(
1817 crate::corrections::EMBEDDED_POLICY_NAME.to_string(),
1818 )
1819 .map_err(anyhow::Error::msg)?,
1820 package_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1821 selected_location: crate::skill_run::SelectedSkillLocation::Embedded,
1822 execution_source: if query.definition.is_some() {
1823 crate::skill_run::CapabilityExecutionSource::Inline
1824 } else {
1825 crate::skill_run::CapabilityExecutionSource::Embedded
1826 },
1827 },
1828 crate::corrections::ResolvedCorrectionPolicySet::from_policies(compiled),
1829 )
1830 } else {
1831 let catalog = crate::skill_catalog::load_skill_catalog(&roots());
1832 let selectors = std::iter::once(query.skill.clone())
1833 .chain(arguments.additional_skills.iter().cloned())
1834 .collect::<Vec<_>>();
1835 let descriptors = crate::skill_catalog::resolve_skill_selectors(&selectors, &catalog)?;
1836 let primary = descriptors
1837 .first()
1838 .context("a skill run requires one primary resolved skill")?;
1839 let frontmatter = primary
1840 .frontmatter
1841 .as_ref()
1842 .context("resolved skill has no valid frontmatter")?;
1843 let package_version = frontmatter
1844 .metadata
1845 .get("version")
1846 .cloned()
1847 .context("runnable skill must declare metadata.version in SKILL.md")?;
1848 let resolved_skill = crate::skill_run::ResolvedSkillReceipt {
1849 name: crate::skill_catalog::SkillName::try_from(frontmatter.name.clone())
1850 .map_err(anyhow::Error::msg)?,
1851 package_version: Some(package_version.clone()),
1852 selected_location: crate::skill_run::SelectedSkillLocation::Path {
1853 canonical_skill_md: primary.root.join("SKILL.md"),
1854 },
1855 execution_source: if query.definition.is_some() {
1856 crate::skill_run::CapabilityExecutionSource::Inline
1857 } else {
1858 crate::skill_run::CapabilityExecutionSource::Path {
1859 canonical_capability_toml: primary
1860 .capability
1861 .clone()
1862 .require_path(&frontmatter.name)?,
1863 }
1864 },
1865 };
1866 let policies = match &query.definition {
1867 None => crate::message_classification::compile_skill_descriptors(
1868 descriptors,
1869 &mut crate::message_classification::CapabilityLoadBudget::new(),
1870 )?,
1871 Some(definition) => {
1872 let mut capability_budget =
1873 crate::message_classification::CapabilityLoadBudget::new();
1874 let mut compiled =
1875 vec![crate::message_classification::compile_inline_definition(
1876 definition.clone(),
1877 frontmatter.name.clone(),
1878 package_version,
1879 &mut capability_budget,
1880 )?];
1881 let additional = crate::message_classification::compile_skill_descriptors(
1882 descriptors.into_iter().skip(1).collect(),
1883 &mut capability_budget,
1884 )?;
1885 compiled.extend(additional.policies().iter().cloned());
1886 crate::corrections::ResolvedCorrectionPolicySet::from_policies(compiled)
1887 }
1888 };
1889 (resolved_skill, policies)
1890 };
1891 let report = self.corrections_with_resolved_policies(&arguments.filters, &policies)?;
1892 let receipt = report
1893 .policies
1894 .first()
1895 .cloned()
1896 .context("a primary capability produced no policy receipt")?;
1897 Ok(crate::skill_run::SkillRunReport {
1898 requested_selector: query.skill.clone(),
1899 resolved_skill,
1900 output: crate::skill_run::SkillCapabilityOutput::MessageClassification(
1901 crate::skill_run::MessageClassificationResult { receipt, report },
1902 ),
1903 })
1904 }
1905
1906 pub fn planning(
1917 &self,
1918 filters: &MessageFilters,
1919 command_patterns: &[String],
1920 ) -> Result<Vec<crate::models::PlanningCount>> {
1921 let command_filters =
1922 crate::analytics::compile_planning_filters(self.config, command_patterns)?;
1923 self.db.planning_usage(filters, &command_filters)
1924 }
1925
1926 pub fn role_statistics(
1932 &self,
1933 filters: &MessageFilters,
1934 ) -> Result<Vec<crate::analytics::RoleStat>> {
1935 let rows: Vec<_> = self
1936 .db
1937 .message_role_counts(filters)?
1938 .into_iter()
1939 .map(|(role, count)| crate::analytics::RoleStat { role, count })
1940 .collect();
1941 let limit = if filters.limit == 0 {
1942 usize::MAX
1943 } else {
1944 filters.limit
1945 };
1946 Ok(rows.into_iter().skip(filters.offset).take(limit).collect())
1947 }
1948
1949 pub fn documents(
1957 &self,
1958 filters: &SearchFilters,
1959 cursor: Option<&crate::models::AnalysisCursor>,
1960 ) -> Result<crate::models::AnalysisDocumentPage> {
1961 self.db.analysis_documents(filters, cursor)
1962 }
1963
1964 pub fn run(
1979 &self,
1980 request: &crate::models::AnalysisRequest,
1981 policy: &crate::analysis_pipeline::AnalysisPolicy,
1982 ) -> Result<ReceiptedAnalysis> {
1983 self.run_with_session_batch_size(request, DEFAULT_ANALYSIS_SESSION_BATCH_SIZE, policy)
1984 }
1985
1986 pub(crate) fn run_with_session_batch_size(
1989 &self,
1990 request: &crate::models::AnalysisRequest,
1991 session_batch_size: std::num::NonZeroUsize,
1992 policy: &crate::analysis_pipeline::AnalysisPolicy,
1993 ) -> Result<ReceiptedAnalysis> {
1994 let mut accumulator = policy.accumulator();
1995 let traversal = self.db.visit_analysis_sessions(
1996 request,
1997 session_batch_size,
1998 |session, message_count, user_message_count, chunks| {
1999 let mut content_chunks = chunks.map(|chunk| chunk.map(|(_seq, content)| content));
2000 accumulator.push_session_text_stream(
2001 session,
2002 message_count,
2003 user_message_count,
2004 &mut content_chunks,
2005 )
2006 },
2007 )?;
2008 let result = accumulator.finish()?;
2009 let receipt = AnalysisReceipt {
2010 selection: request.selection(),
2011 database_schema_version: crate::db::SCHEMA_VERSION,
2012 selected_sessions: traversal.selected_sessions,
2013 messages_in_selected_sessions: traversal.messages_in_selected_sessions,
2014 analyzed_user_messages: traversal.analyzed_user_messages,
2015 has_more: traversal.has_more,
2016 last_selected_session_id: traversal.last_selected_session_id,
2017 max_selected_session_updated_at: traversal
2018 .max_selected_session_updated_at
2019 .map(|value| value.to_rfc3339()),
2020 policy_digest: policy.policy_digest(),
2021 corpus_digest: traversal.corpus_digest,
2022 result_digest: result.result_digest(),
2023 };
2024 Ok(ReceiptedAnalysis { result, receipt })
2025 }
2026}
2027
2028#[derive(Clone, Copy)]
2030pub struct ExportService<'db> {
2031 db: &'db Db,
2032}
2033
2034impl<'db> ExportService<'db> {
2035 pub const fn new(db: &'db Db) -> Self {
2037 Self { db }
2038 }
2039
2040 pub fn render_full(
2050 &self,
2051 id_or_prefix: &str,
2052 format: crate::export::ExportFormat,
2053 ) -> Result<crate::export::ExportDocument> {
2054 let session = self.db.resolve_session(id_or_prefix)?;
2055 crate::export::render_full(&session, format)
2056 }
2057
2058 pub fn publish_bundle(
2065 &self,
2066 filters: &SearchFilters,
2067 plan: &crate::export::ExportPublicationPlan,
2068 ) -> Result<crate::export::ExportPublicationReceipt> {
2069 let sessions = self.db.list_recent(filters)?;
2070 let format = plan.format();
2071 let documents = sessions.into_iter().map(|session| {
2072 self.render_full(&session.id, format)
2073 .map(|document| (session.id, document))
2074 });
2075 plan.publish(documents)
2076 }
2077}
2078
2079#[derive(Clone, Copy)]
2080pub struct SourceService<'config> {
2082 config: &'config Config,
2083}
2084
2085impl<'config> SourceService<'config> {
2086 pub const fn new(config: &'config Config) -> Self {
2088 Self { config }
2089 }
2090
2091 pub fn inventory(&self) -> Vec<crate::source::ProviderSourceStatus> {
2093 crate::source::inventory(self.config)
2094 }
2095}
2096
2097#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2099pub struct CompactOutcome {
2100 pub before_bytes: u64,
2101 pub after_bytes: u64,
2102}
2103
2104impl CompactOutcome {
2105 pub const fn reclaimed_bytes(self) -> u64 {
2106 self.before_bytes.saturating_sub(self.after_bytes)
2107 }
2108}
2109
2110#[derive(Clone, Copy)]
2112pub struct MaintenanceService<'app> {
2113 config: &'app Config,
2114 db: &'app Db,
2115}
2116
2117impl<'app> MaintenanceService<'app> {
2118 pub const fn new(config: &'app Config, db: &'app Db) -> Self {
2119 Self { config, db }
2120 }
2121
2122 pub fn diagnostics(&self) -> Result<DiagnosticStatus> {
2123 crate::diagnostics::collect(self.config, self.db)
2124 }
2125
2126 pub fn compact(&self) -> Result<CompactOutcome> {
2129 indexer::with_index_update_lock(self.config, || {
2130 let before_bytes = self.db.storage_allocation()?.total_bytes;
2131 self.db.optimize_fts()?;
2132 self.db.vacuum()?;
2133 self.db.checkpoint_truncate()?;
2134 Ok(CompactOutcome {
2135 before_bytes,
2136 after_bytes: self.db.storage_allocation()?.total_bytes,
2137 })
2138 })
2139 }
2140}
2141
2142#[derive(Clone, Copy)]
2143pub struct IndexService<'app> {
2145 config: &'app Config,
2146 db: &'app Db,
2147}
2148
2149impl<'app> IndexService<'app> {
2150 pub const fn new(config: &'app Config, db: &'app Db) -> Self {
2151 Self { config, db }
2152 }
2153
2154 pub fn refresh(&self) -> Result<AutoReindexOutcome> {
2155 indexer::refresh_index_opportunistically(self.config, self.db, None)
2156 }
2157
2158 pub fn reindex(&self, full: bool) -> Result<(usize, usize)> {
2159 let outcome = self.reindex_report(full)?;
2160 Ok((outcome.files_seen, outcome.sessions_updated))
2161 }
2162
2163 pub fn reindex_report(&self, full: bool) -> Result<indexer::ExplicitReindexOutcome> {
2164 indexer::explicit_reindex_and_migrate(self.config, self.db, full, None)
2165 }
2166
2167 pub fn status(&self) -> Result<IndexStatus> {
2169 crate::diagnostics::index_status(self.config, self.db)
2170 }
2171}
2172
2173#[derive(Clone, Copy)]
2174pub struct CatalogService<'db> {
2176 db: &'db Db,
2177}
2178
2179impl<'db> CatalogService<'db> {
2180 pub const fn new(db: &'db Db) -> Self {
2181 Self { db }
2182 }
2183
2184 pub fn list_sessions(&self, filters: &SearchFilters) -> Result<Vec<SessionRecord>> {
2195 filters.validate()?;
2196 self.db.list_recent(filters)
2197 }
2198
2199 pub fn list_sessions_page(
2203 &self,
2204 filters: &SearchFilters,
2205 offset: usize,
2206 ) -> Result<Vec<SessionRecord>> {
2207 filters.validate()?;
2208 self.db.list_recent_page(filters, offset)
2209 }
2210
2211 pub fn search_sessions(
2227 &self,
2228 query: &str,
2229 filters: &SearchFilters,
2230 current_repo: Option<&str>,
2231 scoring: &ScoringConfig,
2232 ) -> Result<Vec<SearchHit>> {
2233 filters.validate()?;
2234 self.db.search(query, filters, current_repo, scoring)
2235 }
2236
2237 pub(crate) fn search_sessions_cancellable(
2240 &self,
2241 query: &str,
2242 filters: &SearchFilters,
2243 current_repo: Option<&str>,
2244 scoring: &ScoringConfig,
2245 cancellation: &QueryCancellation,
2246 ) -> Result<Vec<SearchHit>> {
2247 filters.validate()?;
2248 self.db
2249 .search_cancellable(query, filters, current_repo, scoring, cancellation)
2250 }
2251
2252 pub fn resolve_session(&self, id_or_prefix: &str) -> Result<SessionRecord> {
2254 self.db.resolve_session_record(id_or_prefix)
2255 }
2256
2257 pub fn inspect(
2259 &self,
2260 id_or_prefix: &str,
2261 options: crate::inspect::InspectionOptions,
2262 ) -> Result<crate::inspect::SessionInspection> {
2263 crate::inspect::inspect_session(self.db, id_or_prefix, options)
2264 }
2265}
2266
2267#[derive(Clone, Copy)]
2268pub struct MessageService<'db> {
2270 config: &'db Config,
2271 db: &'db Db,
2272 surface: SearchSurface,
2273}
2274
2275#[derive(Debug, Clone, Copy, Eq, PartialEq)]
2276pub(crate) enum MessageSearchBatchControl {
2277 Continue,
2278 Stop,
2279}
2280
2281pub(crate) struct MessageSearchBatchVisitOutcome {
2284 pub(crate) exhausted: bool,
2285 pub(crate) page: Option<PageInfo>,
2286 pub(crate) planner: Option<SearchExplain>,
2287 pub(crate) origins: Option<MessageSearchOrigins>,
2288 pub(crate) ordered_digest: Option<String>,
2289}
2290
2291impl<'db> MessageService<'db> {
2292 pub const fn new(config: &'db Config, db: &'db Db, surface: SearchSurface) -> Self {
2293 Self {
2294 config,
2295 db,
2296 surface,
2297 }
2298 }
2299
2300 pub fn message_search_spec(&self) -> Result<MessageSearchSpecification> {
2305 Self::message_search_spec_for_config(self.config, self.surface)
2306 }
2307
2308 pub(crate) fn message_search_spec_for_config(
2314 config: &Config,
2315 surface: SearchSurface,
2316 ) -> Result<MessageSearchSpecification> {
2317 let request = MessageSearchRequest::builder(
2318 crate::message_search::MessageQuery::All,
2319 MessageTarget::content(),
2320 )
2321 .build()?;
2322 let plan = Self::plan_with_session_resolver(config, surface, request, |_| {
2323 bail!(
2324 "configured message-search specification unexpectedly requires session resolution"
2325 )
2326 })?;
2327 Ok(MessageSearchSpecification::new(
2328 ResolvedMessageSearchRequest::from_plan(&plan)?,
2329 ))
2330 }
2331
2332 pub(crate) fn search_batches(
2338 &self,
2339 request: MessageSearchRequest,
2340 batch_rows: NonZeroUsize,
2341 ) -> Result<MessageSearchBatches> {
2342 MessageSearchBatches::spawn(
2343 self.config.clone(),
2344 self.db.access_scope().clone(),
2345 self.surface,
2346 request,
2347 batch_rows,
2348 )
2349 }
2350
2351 pub fn search_legacy(&self, query: &str, filters: &MessageFilters) -> Result<Vec<MessageHit>> {
2372 self.db.search_messages(query, filters)
2373 }
2374
2375 pub fn plan(&self, request: MessageSearchRequest) -> Result<MessageSearchPlan> {
2376 Self::plan_with_session_resolver(self.config, self.surface, request, |value| {
2377 self.catalog().resolve_session(value)
2378 })
2379 }
2380
2381 fn plan_with_session_resolver(
2382 config: &Config,
2383 surface: SearchSurface,
2384 request: MessageSearchRequest,
2385 resolve_session: impl FnOnce(&str) -> Result<SessionRecord>,
2386 ) -> Result<MessageSearchPlan> {
2387 let purpose = request
2388 .purpose()
2389 .map(|selection| -> Result<_> {
2390 let definition = config
2391 .search
2392 .purposes
2393 .get(selection.name())
2394 .ok_or_else(|| MessageSearchError::InvalidParameter {
2395 parameter: "purpose",
2396 reason: format!("unknown message-search purpose {:?}", selection.name()),
2397 })?;
2398 if selection
2399 .version()
2400 .is_some_and(|version| version != definition.version)
2401 {
2402 return Err(MessageSearchError::InvalidParameter {
2403 parameter: "purpose_version",
2404 reason: format!(
2405 "purpose {:?} version {} is unavailable; configured version is {}",
2406 selection.name(),
2407 selection.version().unwrap(),
2408 definition.version
2409 ),
2410 }
2411 .into());
2412 }
2413 Ok((selection.name().to_string(), definition))
2414 })
2415 .transpose()?;
2416 let purpose_origin = || {
2417 purpose
2418 .as_ref()
2419 .map(|(name, definition)| ValueOrigin::Purpose {
2420 name: name.clone(),
2421 version: definition.version,
2422 })
2423 };
2424 let purpose_preferences = purpose
2425 .as_ref()
2426 .map(|(_, definition)| &definition.preferences);
2427
2428 let (requested_limit, offset, explicit_all) = match request.extent() {
2429 crate::message_search::RequestedExtent::Page { limit, offset } => {
2430 (limit, offset, false)
2431 }
2432 crate::message_search::RequestedExtent::AllResults { offset } => (None, offset, true),
2433 };
2434 let (limit, limit_origin) = if explicit_all {
2435 (None, ValueOrigin::Explicit)
2436 } else if let Some(limit) = requested_limit {
2437 (Some(limit), ValueOrigin::Explicit)
2438 } else if let Some(limit) = purpose_preferences.and_then(|value| value.default_limit) {
2439 (Some(limit), purpose_origin().unwrap())
2440 } else if let Some(limit) = config.search.message_search.default_limit {
2441 (Some(limit), ValueOrigin::OperationConfig)
2442 } else {
2443 let surface_limit = match surface {
2444 SearchSurface::Mcp => NonZeroUsize::new(config.mcp.search_messages_limit),
2445 SearchSurface::Rust | SearchSurface::Cli | SearchSurface::Python => None,
2450 };
2451 match surface_limit {
2452 Some(limit) => (Some(limit), ValueOrigin::SurfaceConfig { surface }),
2453 None => (None, ValueOrigin::TypedDefault),
2454 }
2455 };
2456 if let (Some(current), Some(maximum)) = (limit, config.search.budgets.max_hits_per_page) {
2457 if current > maximum {
2458 return Err(MessageSearchError::InvalidParameter {
2459 parameter: "limit",
2460 reason: format!(
2461 "resolved message-search limit {} exceeds search.budgets.max_hits_per_page {}; lower the request, purpose, operation default, or MCP default",
2462 current, maximum
2463 ),
2464 }
2465 .into());
2466 }
2467 }
2468 let extent = if explicit_all {
2469 ResolvedExtent::AllResults { offset }
2470 } else if let Some(limit) = limit {
2471 ResolvedExtent::Page { limit, offset }
2472 } else if matches!(
2473 request.query(),
2474 crate::message_search::MessageQuery::Fuzzy(_)
2475 ) {
2476 return Err(MessageSearchError::InvalidParameter {
2477 parameter: "limit",
2478 reason:
2479 "fuzzy search requires a positive page size from the request or configuration"
2480 .into(),
2481 }
2482 .into());
2483 } else {
2484 ResolvedExtent::AllResults { offset }
2485 };
2486
2487 let (context, context_before_origin, context_after_origin) =
2488 if let Some(context) = request.context() {
2489 (context, ValueOrigin::Explicit, ValueOrigin::Explicit)
2490 } else {
2491 let (before, before_origin) = if let Some(value) =
2492 purpose_preferences.and_then(|preferences| preferences.context_before)
2493 {
2494 (value, purpose_origin().unwrap())
2495 } else if let Some(value) = config.search.message_search.context.context_before {
2496 (value, ValueOrigin::OperationConfig)
2497 } else {
2498 (0, ValueOrigin::TypedDefault)
2499 };
2500 let (after, after_origin) = if let Some(value) =
2501 purpose_preferences.and_then(|preferences| preferences.context_after)
2502 {
2503 (value, purpose_origin().unwrap())
2504 } else if let Some(value) = config.search.message_search.context.context_after {
2505 (value, ValueOrigin::OperationConfig)
2506 } else {
2507 (0, ValueOrigin::TypedDefault)
2508 };
2509 (
2510 ContextWindow::new(before, after),
2511 before_origin,
2512 after_origin,
2513 )
2514 };
2515 if let Some(maximum) = config.search.budgets.max_context_neighbors_per_hit {
2516 let total = context
2517 .messages_before()
2518 .checked_add(context.messages_after())
2519 .ok_or_else(|| MessageSearchError::InvalidParameter {
2520 parameter: "context",
2521 reason: "resolved message-search context total overflows".to_string(),
2522 })?;
2523 if total > maximum.get() {
2524 return Err(MessageSearchError::InvalidParameter {
2525 parameter: "context",
2526 reason: format!(
2527 "resolved message-search context total {} exceeds search.budgets.max_context_neighbors_per_hit {}; lower context_before or context_after",
2528 total, maximum
2529 ),
2530 }
2531 .into());
2532 }
2533 }
2534
2535 let (includes, includes_origin) = if let Some(includes) = request.includes() {
2536 (includes.to_vec(), ValueOrigin::Explicit)
2537 } else if let Some(value) =
2538 purpose_preferences.and_then(|preferences| preferences.include_refs)
2539 {
2540 (
2541 value
2542 .then_some(MessageSearchInclude::ParsedReferences)
2543 .into_iter()
2544 .collect(),
2545 purpose_origin().unwrap(),
2546 )
2547 } else if surface == SearchSurface::Mcp {
2548 (
2549 vec![MessageSearchInclude::NormalizedSessionMetadata],
2550 ValueOrigin::SurfaceConfig { surface },
2551 )
2552 } else {
2553 (Vec::new(), ValueOrigin::TypedDefault)
2554 };
2555 let include_refs = includes.contains(&MessageSearchInclude::ParsedReferences);
2556 let detail = request.presentation().detail();
2557 let detail_origin = if detail.is_some() {
2558 ValueOrigin::Explicit
2559 } else {
2560 ValueOrigin::TypedDefault
2561 };
2562 let (message_lines, message_lines_origin) =
2563 if let Some(value) = request.presentation().message_lines() {
2564 (value, ValueOrigin::Explicit)
2565 } else if detail == Some(DetailLevel::Full) {
2566 (
2567 LineWindow::Full,
2568 ValueOrigin::DetailPreset {
2569 detail: DetailLevel::Full,
2570 },
2571 )
2572 } else if let Some(value) =
2573 purpose_preferences.and_then(|preferences| preferences.lines_per_message)
2574 {
2575 (LineWindow::from_signed(value)?, purpose_origin().unwrap())
2576 } else {
2577 let value = match surface {
2578 SearchSurface::Cli => config.cli.lines_per_message,
2579 SearchSurface::Mcp => config.mcp.lines_per_message,
2580 SearchSurface::Rust | SearchSurface::Python => 0,
2581 };
2582 (
2583 LineWindow::from_signed(value)?,
2584 ValueOrigin::SurfaceConfig { surface },
2585 )
2586 };
2587 let (match_evidence_max_chars, match_evidence_max_chars_origin) = if let Some(value) =
2588 purpose_preferences.and_then(|preferences| preferences.match_evidence_max_chars)
2589 {
2590 (value, purpose_origin().unwrap())
2591 } else if let Some(value) = config.search.message_search.match_evidence_max_chars {
2592 (value, ValueOrigin::OperationConfig)
2593 } else {
2594 (
2595 NonZeroUsize::new(DEFAULT_MATCH_EVIDENCE_MAX_CHARS)
2596 .expect("typed match evidence default is positive"),
2597 ValueOrigin::TypedDefault,
2598 )
2599 };
2600 let compact_boundary_chars = match surface {
2601 SearchSurface::Mcp => config.mcp.preview_chars,
2602 SearchSurface::Cli => config.cli.evidence_preview_chars,
2603 SearchSurface::Rust | SearchSurface::Python => DEFAULT_MATCH_EVIDENCE_MAX_CHARS,
2604 };
2605 let (field_view, field_view_origin) =
2606 if let Some(value) = request.presentation().field_view() {
2607 (value, ValueOrigin::Explicit)
2608 } else {
2609 match detail {
2610 Some(DetailLevel::Compact) => (
2611 FieldViewBudget::MaxChars {
2612 max_chars: NonZeroUsize::new(compact_boundary_chars)
2613 .expect("validated presentation defaults are positive"),
2614 },
2615 ValueOrigin::DetailPreset {
2616 detail: DetailLevel::Compact,
2617 },
2618 ),
2619 Some(DetailLevel::Full) => (
2620 FieldViewBudget::NoCharLimit,
2621 ValueOrigin::DetailPreset {
2622 detail: DetailLevel::Full,
2623 },
2624 ),
2625 None if surface == SearchSurface::Mcp => (
2626 FieldViewBudget::MaxChars {
2627 max_chars: NonZeroUsize::new(config.mcp.preview_chars)
2628 .expect("validated MCP preview_chars is positive"),
2629 },
2630 ValueOrigin::SurfaceConfig { surface },
2631 ),
2632 None => (FieldViewBudget::NoCharLimit, ValueOrigin::TypedDefault),
2633 }
2634 };
2635 let (match_view, match_view_origin) =
2636 if let Some(value) = request.presentation().match_view() {
2637 (value, ValueOrigin::Explicit)
2638 } else {
2639 match detail {
2640 Some(DetailLevel::Full) => (
2641 MatchViewBudget::MinimalSpan,
2642 ValueOrigin::DetailPreset {
2643 detail: DetailLevel::Full,
2644 },
2645 ),
2646 Some(DetailLevel::Compact) => (
2647 MatchViewBudget::MaxChars {
2648 max_chars: match_evidence_max_chars,
2649 },
2650 ValueOrigin::DetailPreset {
2651 detail: DetailLevel::Compact,
2652 },
2653 ),
2654 None => (
2655 MatchViewBudget::MaxChars {
2656 max_chars: match_evidence_max_chars,
2657 },
2658 match_evidence_max_chars_origin.clone(),
2659 ),
2660 }
2661 };
2662 let effective_match_evidence_max_chars = match match_view {
2663 MatchViewBudget::MinimalSpan => match_evidence_max_chars,
2664 MatchViewBudget::MaxChars { max_chars } => max_chars,
2665 };
2666 let (receipt, receipt_origin) = if let Some(value) = request.receipt_level() {
2667 (value, ValueOrigin::Explicit)
2668 } else if let Some(value) =
2669 purpose_preferences.and_then(|preferences| preferences.receipt_level)
2670 {
2671 (value, purpose_origin().unwrap())
2672 } else {
2673 (ReceiptLevel::None, ValueOrigin::TypedDefault)
2674 };
2675
2676 let predicates = request.predicates();
2677 let resolved_session = predicates.session().map(resolve_session).transpose()?;
2678 if let (Some(session), Some(providers)) = (&resolved_session, predicates.providers()) {
2679 if !providers.contains(&session.provider) {
2680 let selected = providers
2681 .iter()
2682 .map(|provider| provider.as_str())
2683 .collect::<Vec<_>>()
2684 .join(", ");
2685 return Err(MessageSearchError::Conflict(format!(
2686 "session_id {:?} resolves to provider {}, outside providers=[{}]; include {} \
2687 in providers or remove session_id",
2688 predicates.session().unwrap_or_default(),
2689 session.provider,
2690 selected,
2691 session.provider
2692 ))
2693 .into());
2694 }
2695 }
2696 let session_id = resolved_session.map(|session| session.id);
2697 let normalize = |value: &str| crate::util::normalize_path_prefix(value);
2698 let resolved_predicates = ResolvedMessagePredicates {
2699 role: predicates.role(),
2700 kinds: predicates.kinds().map(<[_]>::to_vec),
2701 providers: predicates.providers().map(<[_]>::to_vec),
2702 session_id,
2703 workspace_path_prefix: predicates.workspace_path_prefix().map(normalize),
2704 transcript_path_prefix: predicates.transcript_path_prefix().map(normalize),
2705 exclude_workspace_path_prefixes: predicates
2706 .exclude_workspace_path_prefixes()
2707 .map(normalize)
2708 .collect(),
2709 exclude_transcript_path_prefixes: predicates
2710 .exclude_transcript_path_prefixes()
2711 .map(normalize)
2712 .collect(),
2713 exclude_session_ids: predicates
2714 .exclude_session_ids()
2715 .map(str::to_string)
2716 .collect(),
2717 time: predicates.time(),
2718 sequence: predicates.sequence(),
2719 tool_name_contains: predicates.tool_name_contains().map(str::to_string),
2720 include_compaction: predicates.include_compaction(),
2721 };
2722 let ordering = if matches!(
2723 request.query(),
2724 crate::message_search::MessageQuery::Fuzzy(_)
2725 ) {
2726 ExecutionOrder::FuzzyRelevance
2727 } else {
2728 ExecutionOrder::SessionSequence
2729 };
2730 Ok(MessageSearchPlan {
2731 retrieval: MessageRetrievalPlan {
2732 query: request.query().clone(),
2733 target: request.target().clone(),
2734 predicates: resolved_predicates,
2735 match_window: request.match_window(),
2736 ordering,
2737 extent,
2738 },
2739 response: MessageResponsePlan {
2740 context,
2741 includes,
2742 presentation: ResolvedMessagePresentation {
2743 include_refs,
2744 message_lines,
2745 match_evidence_max_chars: effective_match_evidence_max_chars,
2746 detail,
2747 field_view,
2748 match_view,
2749 },
2750 },
2751 receipt,
2752 origins: MessageSearchOrigins {
2753 result_extent: limit_origin,
2754 context_messages_before: context_before_origin,
2755 context_messages_after: context_after_origin,
2756 includes: includes_origin,
2757 detail: detail_origin,
2758 lines_per_message: message_lines_origin,
2759 field_view: field_view_origin,
2760 match_view: match_view_origin,
2761 receipt_level: receipt_origin,
2762 result_order: ValueOrigin::Derived,
2763 },
2764 })
2765 }
2766
2767 pub fn search(&self, request: MessageSearchRequest) -> Result<MessageSearchResponse> {
2775 self.search_cancellable(request, None)
2776 }
2777
2778 pub(crate) fn search_cancellable(
2779 &self,
2780 request: MessageSearchRequest,
2781 cancellation: Option<&AtomicBool>,
2782 ) -> Result<MessageSearchResponse> {
2783 self.db
2784 .with_read_snapshot(|| self.search_in_snapshot(request, cancellation, || {}))
2785 }
2786
2787 #[cfg(test)]
2788 fn search_with_after_retrieval_for_test(
2789 &self,
2790 request: MessageSearchRequest,
2791 after_retrieval: impl FnOnce(),
2792 ) -> Result<MessageSearchResponse> {
2793 self.db
2794 .with_read_snapshot(|| self.search_in_snapshot(request, None, after_retrieval))
2795 }
2796
2797 fn search_in_snapshot(
2798 &self,
2799 request: MessageSearchRequest,
2800 cancellation: Option<&AtomicBool>,
2801 after_retrieval: impl FnOnce(),
2802 ) -> Result<MessageSearchResponse> {
2803 ensure_message_search_active(cancellation)?;
2804 let plan = self.plan(request)?;
2805 let resolved_request = ResolvedMessageSearchRequest::from_plan(&plan)?;
2806 let include_explain = plan.receipt != ReceiptLevel::None;
2807 let (mut hits, planner) = self
2808 .db
2809 .search_message_plan(&plan.retrieval, include_explain)?;
2810 let (next_offset, extent) = match plan.retrieval.extent {
2811 ResolvedExtent::Page { limit, offset } => {
2812 let has_more = hits.len() > limit.get();
2813 hits.truncate(limit.get());
2814 let next_offset = if has_more {
2815 Some(
2816 offset
2817 .checked_add(limit.get())
2818 .ok_or_else(|| anyhow!("message page next offset overflows"))?,
2819 )
2820 } else {
2821 None
2822 };
2823 (next_offset, plan.retrieval.extent)
2824 }
2825 ResolvedExtent::AllResults { .. } => (None, plan.retrieval.extent),
2826 };
2827 if plan.retrieval.match_window == Some(MatchWindow::Latest) {
2828 hits.reverse();
2829 }
2830 after_retrieval();
2831 ensure_message_search_active(cancellation)?;
2832 let runtime_diagnostics = self.runtime_diagnostics_for_plan(&plan)?;
2833 let (hits, context_windows, included) =
2834 self.enrich_hits_cancellable(&plan, hits, cancellation, runtime_diagnostics, None)?;
2835 let origins = (plan.receipt == ReceiptLevel::Full).then(|| plan.origins.clone());
2836 let match_mode = plan.retrieval.query.mode();
2837 let match_details = match_mode.map(|mode| (plan.retrieval.target.clone(), mode));
2838 let response_query = plan.retrieval.query.text().map(str::to_owned);
2839 let returned = hits.len();
2840 Ok(
2841 MessageSearchResponse::new(crate::message_search::MessageSearchResponseParts {
2842 request: resolved_request,
2843 match_details,
2844 hits,
2845 context_windows,
2846 page: PageInfo::new(extent, returned, next_offset, plan.retrieval.ordering),
2847 response: plan.response,
2848 planner,
2849 origins,
2850 included,
2851 })
2852 .with_query(response_query),
2853 )
2854 }
2855
2856 #[cfg(test)]
2864 pub(crate) fn visit_search_batches(
2865 &self,
2866 request: MessageSearchRequest,
2867 batch_rows: NonZeroUsize,
2868 visitor: impl FnMut(MessageSearchBatch) -> Result<MessageSearchBatchControl>,
2869 ) -> Result<MessageSearchBatchVisitOutcome> {
2870 let plan = self.plan(request)?;
2871 self.visit_search_plan_batches(plan, batch_rows, None, visitor)
2872 }
2873
2874 pub(crate) fn visit_search_plan_batches(
2875 &self,
2876 plan: MessageSearchPlan,
2877 batch_rows: NonZeroUsize,
2878 cancellation: Option<&AtomicBool>,
2879 mut visitor: impl FnMut(MessageSearchBatch) -> Result<MessageSearchBatchControl>,
2880 ) -> Result<MessageSearchBatchVisitOutcome> {
2881 Self::validate_batch_plan(&plan)?;
2882 ensure_message_search_active(cancellation)?;
2883
2884 let include_explain = plan.receipt != ReceiptLevel::None;
2885 let match_mode = plan.retrieval.query.mode();
2886 let mut digest = MessageSearchOrderedDigest::new(plan.retrieval.target.clone(), match_mode);
2887 let mut emitted = 0_usize;
2888 let mut emitted_session_metadata = BTreeSet::new();
2889 let visit = self.db.visit_message_plan_batches(
2890 &plan.retrieval,
2891 include_explain,
2892 batch_rows,
2893 |raw_hits| {
2894 ensure_message_search_active(cancellation)?;
2895 let (results, context_windows, included) = self.enrich_hits_cancellable(
2896 &plan,
2897 raw_hits,
2898 cancellation,
2899 None,
2900 Some(&mut emitted_session_metadata),
2901 )?;
2902 for result in &results {
2903 digest.update(result);
2904 }
2905 emitted = emitted
2906 .checked_add(results.len())
2907 .ok_or_else(|| anyhow!("batched message-search emitted count overflows"))?;
2908 let control = visitor(MessageSearchBatch {
2909 results,
2910 context_windows,
2911 included,
2912 })?;
2913 Ok(match control {
2914 MessageSearchBatchControl::Continue => MessageBatchControl::Continue,
2915 MessageSearchBatchControl::Stop => MessageBatchControl::Stop,
2916 })
2917 },
2918 )?;
2919 debug_assert_eq!(visit.rows_visited, emitted);
2920 let exhausted = visit.exhausted;
2921 let page = exhausted.then(|| {
2922 PageInfo::new(
2923 plan.retrieval.extent,
2924 emitted,
2925 None,
2926 plan.retrieval.ordering,
2927 )
2928 });
2929 let origins =
2930 (exhausted && plan.receipt == ReceiptLevel::Full).then(|| plan.origins.clone());
2931 let ordered_digest =
2932 (exhausted && plan.receipt == ReceiptLevel::Full).then(|| digest.finish());
2933 Ok(MessageSearchBatchVisitOutcome {
2934 exhausted,
2935 page,
2936 planner: visit.explain,
2937 origins,
2938 ordered_digest,
2939 })
2940 }
2941
2942 fn validate_batch_selection(
2943 all_results: bool,
2944 fuzzy: bool,
2945 ) -> std::result::Result<(), MessageSearchError> {
2946 if !all_results {
2947 return Err(MessageSearchError::InvalidParameter {
2948 parameter: "all_results",
2949 reason: "batched message-search traversal requires all_results; use search() for a finite materialized page".into(),
2950 });
2951 }
2952 if fuzzy {
2953 return Err(MessageSearchError::InvalidParameter {
2954 parameter: "query_mode",
2955 reason: "batched message-search traversal supports literal, regex, and queryless all-results requests; pass a positive limit to search() for fuzzy results".into(),
2956 });
2957 }
2958 Ok(())
2959 }
2960
2961 pub(crate) fn validate_batch_request(
2962 surface: SearchSurface,
2963 request: &MessageSearchRequest,
2964 ) -> std::result::Result<(), MessageSearchError> {
2965 let resolves_exhaustively_without_a_surface_limit =
2966 matches!(
2967 request.extent(),
2968 crate::message_search::RequestedExtent::Page { limit: None, .. }
2969 ) && !matches!(surface, SearchSurface::Mcp);
2970 Self::validate_batch_selection(
2971 matches!(
2972 request.extent(),
2973 crate::message_search::RequestedExtent::AllResults { .. }
2974 ) || resolves_exhaustively_without_a_surface_limit,
2975 matches!(
2976 request.query(),
2977 crate::message_search::MessageQuery::Fuzzy(_)
2978 ),
2979 )
2980 }
2981
2982 pub(crate) fn validate_batch_plan(
2983 plan: &MessageSearchPlan,
2984 ) -> std::result::Result<(), MessageSearchError> {
2985 Self::validate_batch_selection(
2986 matches!(plan.retrieval.extent, ResolvedExtent::AllResults { .. }),
2987 matches!(
2988 plan.retrieval.query,
2989 crate::message_search::MessageQuery::Fuzzy(_)
2990 ),
2991 )
2992 }
2993
2994 pub(crate) fn runtime_diagnostics_for_plan(
2995 &self,
2996 plan: &MessageSearchPlan,
2997 ) -> Result<Option<MessageSearchRuntimeDiagnostics>> {
2998 plan.response
2999 .includes
3000 .contains(&MessageSearchInclude::RuntimeDiagnostics)
3001 .then(|| {
3002 serde_json::to_vec(self.config).map(|bytes| {
3003 MessageSearchRuntimeDiagnostics::new(
3004 self.surface,
3005 format!("sha256:{}", crate::hashing::sha256(&bytes)),
3006 )
3007 })
3008 })
3009 .transpose()
3010 .map_err(Into::into)
3011 }
3012
3013 fn enrich_hits_cancellable(
3014 &self,
3015 plan: &MessageSearchPlan,
3016 hits: Vec<MessageHit>,
3017 cancellation: Option<&AtomicBool>,
3018 runtime_diagnostics: Option<MessageSearchRuntimeDiagnostics>,
3019 emitted_session_metadata: Option<&mut BTreeSet<String>>,
3020 ) -> Result<(
3021 Vec<crate::message_search::MessageSearchHit>,
3022 Vec<Vec<MessageHit>>,
3023 MessageSearchIncludedData,
3024 )> {
3025 ensure_message_search_active(cancellation)?;
3026 let mut hits = attach_match_evidence_cancellable(
3027 &plan.retrieval.query,
3028 &plan.retrieval.target,
3029 plan.response.presentation.match_view,
3030 hits,
3031 || ensure_message_search_active(cancellation),
3032 )?;
3033 ensure_message_search_active(cancellation)?;
3034 apply_message_presentation_cancellable(
3035 &plan.retrieval.target,
3036 plan.response.presentation,
3037 &mut hits,
3038 || ensure_message_search_active(cancellation),
3039 )?;
3040 if plan
3041 .response
3042 .includes
3043 .contains(&MessageSearchInclude::ParsedReferences)
3044 {
3045 for hit in &mut hits {
3046 ensure_message_search_active(cancellation)?;
3047 hit.set_parsed_references(crate::refs::extract_refs_from_text(
3048 &hit.message().content,
3049 hit.message().tool_name.as_deref(),
3050 ));
3051 }
3052 }
3053 ensure_message_search_active(cancellation)?;
3054 let include_normalized_metadata = plan
3055 .response
3056 .includes
3057 .contains(&MessageSearchInclude::NormalizedSessionMetadata);
3058 let include_raw_metadata = plan
3059 .response
3060 .includes
3061 .contains(&MessageSearchInclude::RawProviderMetadata);
3062 let deduplicate_session_metadata = emitted_session_metadata.is_some();
3063 let session_ids = if include_normalized_metadata || include_raw_metadata {
3064 let already_emitted = emitted_session_metadata.as_deref();
3065 hits.iter()
3066 .map(|hit| hit.message().session_id.clone())
3067 .filter(|session_id| {
3068 already_emitted.is_none_or(|emitted| !emitted.contains(session_id))
3069 })
3070 .collect::<BTreeSet<_>>()
3071 .into_iter()
3072 .collect::<Vec<_>>()
3073 } else {
3074 Vec::new()
3075 };
3076 let emit_session_metadata = !deduplicate_session_metadata || !session_ids.is_empty();
3077 let normalized_session_metadata = (include_normalized_metadata && emit_session_metadata)
3078 .then(|| {
3079 self.db
3080 .session_metadata(&session_ids)
3081 .map(|metadata| metadata.into_iter().collect::<BTreeMap<_, _>>())
3082 })
3083 .transpose()?;
3084 ensure_message_search_active(cancellation)?;
3085 let raw_provider_metadata = (include_raw_metadata && emit_session_metadata)
3086 .then(|| self.db.session_raw_provider_metadata(&session_ids))
3087 .transpose()?;
3088 ensure_message_search_active(cancellation)?;
3089 let included = MessageSearchIncludedData::new(
3090 normalized_session_metadata,
3091 raw_provider_metadata,
3092 runtime_diagnostics,
3093 );
3094 if let Some(emitted) = emitted_session_metadata {
3095 emitted.extend(session_ids);
3096 }
3097 let context_windows = if plan.response.context.messages_before() == 0
3098 && plan.response.context.messages_after() == 0
3099 {
3100 Vec::new()
3101 } else {
3102 let before = i64::try_from(plan.response.context.messages_before())
3103 .map_err(|_| anyhow!("resolved context_before exceeds SQLite's signed range"))?;
3104 let after = i64::try_from(plan.response.context.messages_after())
3105 .map_err(|_| anyhow!("resolved context_after exceeds SQLite's signed range"))?;
3106 let anchors = hits
3107 .iter()
3108 .map(|hit| (hit.message.session_id.clone(), hit.message.seq))
3109 .collect::<Vec<_>>();
3110 self.db.message_context_windows(&anchors, before, after)?
3111 };
3112 ensure_message_search_active(cancellation)?;
3113 Ok((hits, context_windows, included))
3114 }
3115
3116 fn catalog(&self) -> CatalogService<'db> {
3117 CatalogService::new(self.db)
3118 }
3119
3120 pub fn search_with_explain(
3123 &self,
3124 query: &str,
3125 filters: &MessageFilters,
3126 include_explain: bool,
3127 ) -> Result<(Vec<MessageHit>, Option<SearchExplain>)> {
3128 self.db
3129 .search_messages_with_explain(query, filters, include_explain)
3130 }
3131
3132 pub fn session_metadata(&self, session_ids: &[String]) -> Result<HashMap<String, SessionMeta>> {
3134 self.db.session_metadata(session_ids)
3135 }
3136
3137 pub fn context(
3144 &self,
3145 session_id: &str,
3146 seq: i64,
3147 before: i64,
3148 after: i64,
3149 ) -> Result<Vec<MessageHit>> {
3150 self.db.message_context(session_id, seq, before, after)
3151 }
3152
3153 pub fn read_session(
3157 &self,
3158 filters: &MessageFilters,
3159 order: crate::db::MessageOrder,
3160 ) -> Result<Vec<MessageHit>> {
3161 self.db.read_session_messages(filters, order)
3162 }
3163}
3164
3165#[derive(Clone, Copy)]
3166pub struct FileService<'db> {
3168 db: &'db Db,
3169}
3170
3171impl<'db> FileService<'db> {
3172 pub const fn new(db: &'db Db) -> Self {
3173 Self { db }
3174 }
3175
3176 pub fn search(&self, query: &FileQuery) -> Result<Vec<FileEditSummary>> {
3183 self.db.file_search(query)
3184 }
3185
3186 pub fn cross_reference(&self, query: &FileQuery) -> Result<Vec<FileCrossRef>> {
3188 self.db.file_cross_ref(query)
3189 }
3190
3191 pub fn history(&self, file: &str, query: &FileQuery) -> Result<Vec<FileVersion>> {
3199 crate::files::history(self.db, file, query)
3200 }
3201
3202 pub fn reconstruct(
3209 &self,
3210 file: &str,
3211 query: &FileQuery,
3212 version: Option<usize>,
3213 ) -> Result<crate::files::ReconstructedFile> {
3214 crate::files::reconstruct_query(self.db, file, query, version)
3215 }
3216
3217 pub fn reconstruct_versions(
3219 &self,
3220 file: &str,
3221 query: &FileQuery,
3222 ) -> Result<crate::files::ReconstructedFileVersions> {
3223 crate::files::reconstruct_versions_query(self.db, file, query)
3224 }
3225
3226 pub fn publish_versions(
3228 &self,
3229 file: &str,
3230 query: &FileQuery,
3231 destination: &std::path::Path,
3232 ) -> Result<crate::files::RecoveryPublicationReceipt> {
3233 let versions = self.reconstruct_versions(file, query)?;
3234 crate::files::publish_reconstructed_versions(versions, destination)
3235 }
3236
3237 pub fn restore(
3239 &self,
3240 reconstructed: &crate::files::ReconstructedFile,
3241 output_dir: Option<&std::path::Path>,
3242 ) -> Result<std::path::PathBuf> {
3243 crate::files::restore_reconstructed(reconstructed, output_dir)
3244 }
3245}
3246
3247#[cfg(test)]
3248mod message_search_service_tests {
3249 use std::num::{NonZeroU32, NonZeroUsize};
3250 use std::sync::{Arc, Barrier};
3251
3252 use super::*;
3253 use crate::config::{MessagePurposePreferences, PurposeDefinition, SearchOperation};
3254 use crate::message_search::{
3255 MessageQuery, MessageSearchRequestBuilder, MessageTarget, PurposeSelection,
3256 RequestedExtent, ResolvedExtent,
3257 };
3258 use crate::models::{Message, MessageKind, Provider, Role};
3259 use crate::util::minimal_record;
3260
3261 fn disposable_db() -> (tempfile::TempDir, Db) {
3262 let directory = tempfile::tempdir().unwrap();
3263 let db = Db::open(&directory.path().join("message-search.db")).unwrap();
3264 db.mark_schema_current().unwrap();
3265 (directory, db)
3266 }
3267
3268 fn literal_request() -> MessageSearchRequestBuilder {
3269 MessageSearchRequest::builder(
3270 MessageQuery::literal("needle").unwrap(),
3271 MessageTarget::content(),
3272 )
3273 }
3274
3275 #[test]
3276 fn message_search_spec_uses_the_real_surface_and_operation_default_planner() {
3277 let (_directory, db) = disposable_db();
3278 let mut config = Config::default();
3279 config.mcp.search_messages_limit = 9;
3280
3281 for surface in [
3282 SearchSurface::Rust,
3283 SearchSurface::Cli,
3284 SearchSurface::Python,
3285 ] {
3286 let spec = MessageService::new(&config, &db, surface)
3287 .message_search_spec()
3288 .unwrap();
3289 assert_eq!(
3290 spec.configured_default().extent(),
3291 crate::message_search::ResolvedRequestExtent::AllResults { offset: 0 }
3292 );
3293 assert_eq!(
3294 spec.registry(),
3295 crate::message_search::MessageSearchParameterRegistry::current()
3296 );
3297 }
3298 let mcp = MessageService::new(&config, &db, SearchSurface::Mcp)
3299 .message_search_spec()
3300 .unwrap();
3301 assert_eq!(
3302 mcp.configured_default().extent(),
3303 crate::message_search::ResolvedRequestExtent::Page {
3304 limit: NonZeroUsize::new(9).unwrap(),
3305 offset: 0,
3306 }
3307 );
3308
3309 config.search.message_search.default_limit = NonZeroUsize::new(4);
3310 config.search.message_search.context.context_before = Some(2);
3311 config.search.message_search.context.context_after = Some(3);
3312 for surface in SearchSurface::ALL {
3313 let spec = MessageService::new(&config, &db, *surface)
3314 .message_search_spec()
3315 .unwrap();
3316 assert_eq!(
3317 spec.configured_default().extent(),
3318 crate::message_search::ResolvedRequestExtent::Page {
3319 limit: NonZeroUsize::new(4).unwrap(),
3320 offset: 0,
3321 }
3322 );
3323 assert_eq!(
3324 spec.configured_default().context(),
3325 ContextWindow::new(2, 3)
3326 );
3327 }
3328 }
3329
3330 fn limit_of(plan: &MessageSearchPlan) -> Option<usize> {
3331 match plan.extent() {
3332 ResolvedExtent::Page { limit, .. } => Some(limit.get()),
3333 ResolvedExtent::AllResults { .. } => None,
3334 }
3335 }
3336
3337 fn insert_session(
3338 db: &Db,
3339 provider: Provider,
3340 provider_session_id: &str,
3341 workspace: &str,
3342 transcript: &str,
3343 contents: &[&str],
3344 ) {
3345 let mut parsed = minimal_record(provider, std::path::Path::new(transcript), String::new());
3346 parsed.session.id = format!("{}:{provider_session_id}", provider.as_str());
3347 parsed.session.provider_session_id = provider_session_id.to_string();
3348 parsed.session.cwd = Some(workspace.into());
3349 parsed.session.repo_root = Some(format!("{workspace}/repo"));
3350 parsed.session.source_path = transcript.into();
3351 parsed.session.raw_metadata_json =
3352 Some(format!(r#"{{"fixture_provider":"{}"}}"#, provider.as_str()));
3353 parsed.messages = contents
3354 .iter()
3355 .enumerate()
3356 .map(|(sequence, content)| Message {
3357 seq: sequence as i64,
3358 role: Role::User,
3359 ts: None,
3360 tool_name: None,
3361 kind: MessageKind::Conversation,
3362 tool_call_id: None,
3363 is_compaction: false,
3364 content: (*content).into(),
3365 provenance: Default::default(),
3366 })
3367 .collect();
3368 db.upsert_session(&parsed, 0, 0).unwrap();
3369 }
3370
3371 fn existing_only_search_app(
3372 provider: Provider,
3373 provider_session_id: &str,
3374 contents: &[&str],
3375 ) -> (tempfile::TempDir, SessionSearch) {
3376 let directory = tempfile::tempdir().unwrap();
3377 let db_path = directory.path().join("message-search.db");
3378 let db = Db::open(&db_path).unwrap();
3379 db.mark_schema_current().unwrap();
3380 insert_session(
3381 &db,
3382 provider,
3383 provider_session_id,
3384 "/workspace/message-search",
3385 "/transcripts/message-search.jsonl",
3386 contents,
3387 );
3388 drop(db);
3389 let mut config = Config::default();
3390 config.index.db_path = Some(db_path.to_string_lossy().into_owned());
3391 config.index.cache_dir = Some(
3392 directory
3393 .path()
3394 .join("cache")
3395 .to_string_lossy()
3396 .into_owned(),
3397 );
3398 config.index.refresh = IndexRefresh::ExistingOnly;
3399 let app = SessionSearch::open(config).unwrap();
3400 (directory, app)
3401 }
3402
3403 #[test]
3404 fn omitted_limits_keep_rust_cli_and_python_unbounded_while_mcp_stays_bounded() {
3405 let (_directory, db) = disposable_db();
3406 let config = Config::default();
3407 let request = literal_request()
3408 .extent(RequestedExtent::page(None, 7).unwrap())
3409 .build()
3410 .unwrap();
3411
3412 for surface in [
3413 SearchSurface::Rust,
3414 SearchSurface::Cli,
3415 SearchSurface::Python,
3416 ] {
3417 let plan = MessageService::new(&config, &db, surface)
3418 .plan(request.clone())
3419 .unwrap();
3420 assert_eq!(plan.extent(), ResolvedExtent::AllResults { offset: 7 });
3421 assert_eq!(plan.origins().result_extent(), &ValueOrigin::TypedDefault);
3422 }
3423
3424 let mcp = MessageService::new(&config, &db, SearchSurface::Mcp)
3425 .plan(request.clone())
3426 .unwrap();
3427 assert_eq!(limit_of(&mcp), Some(config.mcp.search_messages_limit));
3428 assert_eq!(
3429 mcp.origins().result_extent(),
3430 &ValueOrigin::SurfaceConfig {
3431 surface: SearchSurface::Mcp,
3432 }
3433 );
3434
3435 let fuzzy = MessageSearchRequest::builder(
3436 MessageQuery::fuzzy("needle").unwrap(),
3437 MessageTarget::content(),
3438 )
3439 .build()
3440 .unwrap();
3441 for surface in [
3442 SearchSurface::Rust,
3443 SearchSurface::Cli,
3444 SearchSurface::Python,
3445 ] {
3446 assert!(
3447 MessageService::new(&config, &db, surface)
3448 .plan(fuzzy.clone())
3449 .unwrap_err()
3450 .to_string()
3451 .contains("requires a positive page size"),
3452 "{surface:?} fuzzy search must not acquire an implicit non-MCP page"
3453 );
3454 }
3455 assert_eq!(
3456 limit_of(
3457 &MessageService::new(&config, &db, SearchSurface::Mcp)
3458 .plan(fuzzy)
3459 .unwrap()
3460 ),
3461 Some(config.mcp.search_messages_limit)
3462 );
3463 }
3464
3465 #[test]
3466 fn explicit_all_results_truthfully_bypasses_only_the_finite_page_ceiling() {
3467 let (_directory, db) = disposable_db();
3468 let mut config = Config::default();
3469 config.search.budgets.max_hits_per_page = NonZeroUsize::new(50);
3470 let request = literal_request()
3471 .extent(RequestedExtent::all_results())
3472 .build()
3473 .unwrap();
3474
3475 let plan = MessageService::new(&config, &db, SearchSurface::Rust)
3476 .plan(request)
3477 .expect("an explicit all_results request is not a finite page");
3478 assert_eq!(plan.extent(), ResolvedExtent::AllResults { offset: 0 });
3479 assert_eq!(
3480 plan.origins().result_extent(),
3481 &ValueOrigin::Explicit,
3482 "the receipt must expose the explicit bypass, not an unused default page size"
3483 );
3484 }
3485
3486 #[test]
3487 fn planner_precedence_is_explicit_and_policy_conflicts_are_rejected() {
3488 let (_directory, db) = disposable_db();
3489 let mut config = Config::default();
3490 config.search.message_search.default_limit = NonZeroUsize::new(8);
3491 config.search.message_search.context.context_before = Some(2);
3492 config.search.purposes.insert(
3493 "focused-review".into(),
3494 PurposeDefinition {
3495 version: NonZeroU32::new(1).unwrap(),
3496 operation: SearchOperation::MessageSearch,
3497 preferences: MessagePurposePreferences {
3498 default_limit: NonZeroUsize::new(6),
3499 context_before: Some(3),
3500 context_after: Some(4),
3501 receipt_level: Some(ReceiptLevel::Summary),
3502 include_refs: Some(true),
3503 lines_per_message: Some(-5),
3504 match_evidence_max_chars: NonZeroUsize::new(90),
3505 },
3506 },
3507 );
3508 let purpose = PurposeSelection::new("focused-review", NonZeroU32::new(1)).unwrap();
3509 let service = MessageService::new(&config, &db, SearchSurface::Mcp);
3510
3511 let purpose_plan = service
3512 .plan(literal_request().purpose(purpose.clone()).build().unwrap())
3513 .unwrap();
3514 assert_eq!(limit_of(&purpose_plan), Some(6));
3515 assert!(matches!(
3516 purpose_plan.origins().result_extent(),
3517 ValueOrigin::Purpose { name, version }
3518 if name == "focused-review" && version.get() == 1
3519 ));
3520 assert_eq!(purpose_plan.context(), ContextWindow::new(3, 4));
3521 assert!(purpose_plan.presentation().include_refs());
3522 assert_eq!(
3523 purpose_plan.presentation().message_lines(),
3524 LineWindow::Tail(NonZeroUsize::new(5).unwrap())
3525 );
3526 assert_eq!(
3527 purpose_plan.presentation().match_evidence_max_chars().get(),
3528 90
3529 );
3530 assert!(matches!(
3531 purpose_plan.origins().match_view(),
3532 ValueOrigin::Purpose { name, version }
3533 if name == "focused-review" && version.get() == 1
3534 ));
3535 assert_eq!(purpose_plan.receipt_level(), ReceiptLevel::Summary);
3536
3537 let explicit = service
3538 .plan(
3539 literal_request()
3540 .purpose(purpose)
3541 .extent(RequestedExtent::page(Some(3), 0).unwrap())
3542 .context(ContextWindow::new(9, 10))
3543 .includes([])
3544 .message_lines(LineWindow::Head(NonZeroUsize::new(2).unwrap()))
3545 .match_view(MatchViewBudget::max_chars(44).unwrap())
3546 .receipt_level(ReceiptLevel::Full)
3547 .build()
3548 .unwrap(),
3549 )
3550 .unwrap();
3551 assert_eq!(limit_of(&explicit), Some(3));
3552 assert_eq!(explicit.origins().result_extent(), &ValueOrigin::Explicit);
3553 assert_eq!(
3554 explicit.origins().context_messages_before(),
3555 &ValueOrigin::Explicit
3556 );
3557 assert_eq!(explicit.origins().receipt_level(), &ValueOrigin::Explicit);
3558 assert_eq!(explicit.presentation().match_evidence_max_chars().get(), 44);
3559 assert_eq!(explicit.origins().match_view(), &ValueOrigin::Explicit);
3560
3561 config.search.budgets.max_hits_per_page = NonZeroUsize::new(4);
3562 let limit_error = MessageService::new(&config, &db, SearchSurface::Mcp)
3563 .plan(literal_request().build().unwrap())
3564 .unwrap_err()
3565 .to_string();
3566 assert!(limit_error.contains("max_hits_per_page"), "{limit_error}");
3567 assert!(limit_error.contains("8"), "{limit_error}");
3568 assert!(limit_error.contains("4"), "{limit_error}");
3569
3570 config.search.budgets.max_hits_per_page = None;
3571 config.search.budgets.max_context_neighbors_per_hit = NonZeroUsize::new(4);
3572 let context_error = MessageService::new(&config, &db, SearchSurface::Mcp)
3573 .plan(
3574 literal_request()
3575 .context(ContextWindow::new(9, 10))
3576 .build()
3577 .unwrap(),
3578 )
3579 .unwrap_err()
3580 .to_string();
3581 assert!(
3582 context_error.contains("max_context_neighbors_per_hit"),
3583 "{context_error}"
3584 );
3585 assert!(context_error.contains("19"), "{context_error}");
3586 assert!(context_error.contains("4"), "{context_error}");
3587 }
3588
3589 #[test]
3590 fn planner_resolves_unique_session_prefix_and_rejects_ambiguous_prefix() {
3591 let (_directory, db) = disposable_db();
3592 insert_session(
3593 &db,
3594 Provider::Claude,
3595 "abcdef",
3596 "/workspace/a",
3597 "/transcripts/a.jsonl",
3598 &["needle"],
3599 );
3600 insert_session(
3601 &db,
3602 Provider::Claude,
3603 "abcxyz",
3604 "/workspace/b",
3605 "/transcripts/b.jsonl",
3606 &["needle"],
3607 );
3608 let config = Config::default();
3609 let service = MessageService::new(&config, &db, SearchSurface::Rust);
3610
3611 let unique = service
3612 .plan(
3613 literal_request()
3614 .session_id("claude:abcd")
3615 .unwrap()
3616 .build()
3617 .unwrap(),
3618 )
3619 .unwrap();
3620 assert_eq!(
3621 unique.retrieval.predicates.session_id.as_deref(),
3622 Some("claude:abcdef")
3623 );
3624
3625 assert!(service
3626 .plan(
3627 literal_request()
3628 .session_id("claude:abc")
3629 .unwrap()
3630 .build()
3631 .unwrap(),
3632 )
3633 .unwrap_err()
3634 .to_string()
3635 .contains("ambiguous"));
3636 }
3637
3638 #[test]
3639 fn provider_sets_filter_results_and_reject_a_session_outside_the_selected_sources() {
3640 let (_directory, db) = disposable_db();
3641 for (provider, id) in [
3642 (Provider::Claude, "provider-set-claude"),
3643 (Provider::Codex, "provider-set-codex"),
3644 (Provider::GeminiCli, "provider-set-gemini"),
3645 ] {
3646 insert_session(
3647 &db,
3648 provider,
3649 id,
3650 "/workspace/providers",
3651 &format!("/transcripts/{id}.jsonl"),
3652 &["shared provider-set needle"],
3653 );
3654 }
3655 let config = Config::default();
3656 let service = MessageService::new(&config, &db, SearchSurface::Rust);
3657 let request = literal_request()
3658 .providers(vec![Provider::Codex, Provider::Claude, Provider::Codex])
3659 .unwrap()
3660 .extent(RequestedExtent::all_results())
3661 .build()
3662 .unwrap();
3663 let response = service.search(request).unwrap();
3664 assert_eq!(
3665 response
3666 .hits()
3667 .iter()
3668 .map(|hit| hit.message().provider)
3669 .collect::<Vec<_>>(),
3670 [Provider::Claude, Provider::Codex]
3671 );
3672 assert_eq!(
3673 response.request().provider_scope(),
3674 &crate::message_search::ProviderScope::Selected {
3675 providers: vec![Provider::Claude, Provider::Codex]
3676 }
3677 );
3678
3679 let error = service
3680 .plan(
3681 literal_request()
3682 .providers(vec![Provider::Codex])
3683 .unwrap()
3684 .session_id("claude:provider-set-claude")
3685 .unwrap()
3686 .build()
3687 .unwrap(),
3688 )
3689 .unwrap_err()
3690 .to_string();
3691 assert!(error.contains("session_id"), "{error}");
3692 assert!(error.contains("provider claude"), "{error}");
3693 assert!(error.contains("providers=[codex]"), "{error}");
3694 assert!(error.contains("include claude"), "{error}");
3695 }
3696
3697 #[test]
3698 fn bounded_batches_match_the_simple_materialized_response_and_stop_without_completion() {
3699 let (_directory, db) = disposable_db();
3700 insert_session(
3701 &db,
3702 Provider::Claude,
3703 "bounded-service",
3704 "/workspace/bounded",
3705 "/transcripts/bounded.jsonl",
3706 &[
3707 "needle zero https://example.com/zero",
3708 "context one",
3709 "needle two https://example.com/two",
3710 "needle three",
3711 "needle four",
3712 ],
3713 );
3714 let config = Config::default();
3715 let service = MessageService::new(&config, &db, SearchSurface::Rust);
3716 let request = literal_request()
3717 .session_id("claude:bounded-service")
3718 .unwrap()
3719 .extent(RequestedExtent::all_results())
3720 .context(ContextWindow::new(1, 1))
3721 .includes([
3722 MessageSearchInclude::NormalizedSessionMetadata,
3723 MessageSearchInclude::ParsedReferences,
3724 ])
3725 .receipt_level(ReceiptLevel::Full)
3726 .build()
3727 .unwrap();
3728 let materialized = service.search(request.clone()).unwrap();
3729
3730 let mut batch_lengths = Vec::new();
3731 let mut sequences = Vec::new();
3732 let mut field_views = Vec::new();
3733 let mut context_sequences = Vec::new();
3734 let mut parsed_reference_counts = Vec::new();
3735 let mut included_sessions = BTreeSet::new();
3736 let outcome =
3737 service
3738 .visit_search_batches(request.clone(), NonZeroUsize::new(2).unwrap(), |batch| {
3739 batch_lengths.push(batch.results.len());
3740 sequences.extend(batch.results.iter().map(|result| result.seq));
3741 field_views.extend(
3742 batch
3743 .results
3744 .iter()
3745 .map(|result| result.field_view().text().to_string()),
3746 );
3747 parsed_reference_counts.extend(
3748 batch
3749 .results
3750 .iter()
3751 .map(|result| result.parsed_references().map_or(0, <[_]>::len)),
3752 );
3753 context_sequences.extend(batch.context_windows.iter().map(|window| {
3754 window.iter().map(|message| message.seq).collect::<Vec<_>>()
3755 }));
3756 if let Some(metadata) = batch.included.normalized_session_metadata() {
3757 included_sessions.extend(metadata.keys().cloned());
3758 }
3759 Ok(MessageSearchBatchControl::Continue)
3760 })
3761 .unwrap();
3762
3763 assert_eq!(batch_lengths, vec![2, 2]);
3764 assert_eq!(
3765 sequences,
3766 materialized
3767 .results()
3768 .iter()
3769 .map(|result| result.seq)
3770 .collect::<Vec<_>>()
3771 );
3772 assert_eq!(
3773 field_views,
3774 materialized
3775 .results()
3776 .iter()
3777 .map(|result| result.field_view().text().to_string())
3778 .collect::<Vec<_>>()
3779 );
3780 assert_eq!(
3781 parsed_reference_counts,
3782 materialized
3783 .results()
3784 .iter()
3785 .map(|result| result.parsed_references().map_or(0, <[_]>::len))
3786 .collect::<Vec<_>>()
3787 );
3788 assert_eq!(
3789 context_sequences,
3790 materialized
3791 .context_windows()
3792 .iter()
3793 .map(|window| window.iter().map(|message| message.seq).collect::<Vec<_>>())
3794 .collect::<Vec<_>>()
3795 );
3796 assert_eq!(
3797 included_sessions,
3798 BTreeSet::from(["claude:bounded-service".to_string()])
3799 );
3800 assert!(outcome.exhausted);
3801 assert_eq!(
3802 outcome.page.expect("natural exhaustion has page metadata"),
3803 materialized.page()
3804 );
3805 assert_eq!(
3806 outcome.ordered_digest.as_deref(),
3807 Some(materialized.ordered_digest().as_str())
3808 );
3809 assert!(outcome.planner.is_some());
3810 assert!(outcome.origins.is_some());
3811
3812 let mut stopped_sequences = Vec::new();
3813 let stopped = service
3814 .visit_search_batches(request, NonZeroUsize::new(2).unwrap(), |batch| {
3815 stopped_sequences.extend(batch.results.iter().map(|result| result.seq));
3816 Ok(MessageSearchBatchControl::Stop)
3817 })
3818 .unwrap();
3819 assert_eq!(stopped_sequences, vec![0, 2]);
3820 assert!(!stopped.exhausted);
3821 assert!(stopped.page.is_none());
3822 assert!(stopped.planner.is_none());
3823 assert!(stopped.origins.is_none());
3824 assert!(stopped.ordered_digest.is_none());
3825 }
3826
3827 #[test]
3828 fn bounded_latest_offset_matches_the_materialized_chronological_result() {
3829 let (_directory, db) = disposable_db();
3830 insert_session(
3831 &db,
3832 Provider::Claude,
3833 "bounded-latest",
3834 "/workspace/bounded-latest",
3835 "/transcripts/bounded-latest.jsonl",
3836 &[
3837 "needle zero",
3838 "needle one",
3839 "needle two",
3840 "needle three",
3841 "needle four",
3842 ],
3843 );
3844 let config = Config::default();
3845 let service = MessageService::new(&config, &db, SearchSurface::Rust);
3846 let request = literal_request()
3847 .session_id("claude:bounded-latest")
3848 .unwrap()
3849 .match_window(MatchWindow::Latest)
3850 .extent(RequestedExtent::all_results_from(2))
3851 .receipt_level(ReceiptLevel::Full)
3852 .build()
3853 .unwrap();
3854 let materialized = service.search(request.clone()).unwrap();
3855 let mut streamed = Vec::new();
3856 let outcome = service
3857 .visit_search_batches(request, NonZeroUsize::new(1).unwrap(), |batch| {
3858 streamed.extend(batch.results.into_iter().map(|result| result.seq));
3859 Ok(MessageSearchBatchControl::Continue)
3860 })
3861 .unwrap();
3862
3863 assert_eq!(streamed, vec![0, 1, 2]);
3864 assert_eq!(
3865 streamed,
3866 materialized
3867 .results()
3868 .iter()
3869 .map(|result| result.seq)
3870 .collect::<Vec<_>>()
3871 );
3872 assert_eq!(
3873 outcome.ordered_digest.as_deref(),
3874 Some(materialized.ordered_digest().as_str())
3875 );
3876 }
3877
3878 #[test]
3879 fn exhaustive_batches_match_the_simple_list_and_own_terminal_state() {
3880 let (_directory, app) = existing_only_search_app(
3881 Provider::Codex,
3882 "batch-fixture",
3883 &["needle zero", "context one", "needle two", "needle three"],
3884 );
3885 let request = literal_request()
3886 .session_id("codex:batch-fixture")
3887 .unwrap()
3888 .extent(RequestedExtent::all_results())
3889 .context(ContextWindow::new(1, 1))
3890 .includes([
3891 MessageSearchInclude::NormalizedSessionMetadata,
3892 MessageSearchInclude::RawProviderMetadata,
3893 MessageSearchInclude::RuntimeDiagnostics,
3894 ])
3895 .receipt_level(ReceiptLevel::Full)
3896 .build()
3897 .unwrap();
3898 let materialized = app.messages().search(request.clone()).unwrap();
3899 let mut batches = app
3900 .message_search_batches(request, NonZeroUsize::new(2).unwrap())
3901 .unwrap();
3902 assert_eq!(batches.request().receipt_level(), ReceiptLevel::Full);
3903 assert_eq!(
3904 batches
3905 .runtime_diagnostics()
3906 .expect("requested request-wide diagnostics")
3907 .config_digest(),
3908 materialized
3909 .included()
3910 .runtime_diagnostics()
3911 .expect("materialized response diagnostics")
3912 .config_digest()
3913 );
3914 assert!(batches
3915 .completion()
3916 .unwrap_err()
3917 .to_string()
3918 .contains("unread results"));
3919 let resolved_request = batches.request().clone();
3920 let mut sequences = Vec::new();
3921 let mut contexts = Vec::new();
3922 let mut serialized_results = Vec::new();
3923 let mut normalized_metadata_batches = 0;
3924 let mut raw_metadata_batches = 0;
3925 while let Some(batch) = batches.next_batch().unwrap() {
3926 assert!(batch.results().len() <= 2);
3927 assert!(batch.included().runtime_diagnostics().is_none());
3928 if let Some(metadata) = batch.included().normalized_session_metadata() {
3929 normalized_metadata_batches += 1;
3930 assert_eq!(metadata.len(), 1);
3931 assert!(metadata.contains_key("codex:batch-fixture"));
3932 }
3933 if let Some(metadata) = batch.included().raw_provider_metadata() {
3934 raw_metadata_batches += 1;
3935 assert_eq!(metadata.len(), 1);
3936 assert!(metadata.contains_key("codex:batch-fixture"));
3937 }
3938 serialized_results.extend((0..batch.results().len()).map(|index| {
3939 serde_json::to_value(
3940 batch
3941 .result_document(&resolved_request, index)
3942 .expect("index comes from the batch result length"),
3943 )
3944 .unwrap()
3945 }));
3946 sequences.extend(batch.results().iter().map(|result| result.seq));
3947 contexts.extend(
3948 batch
3949 .context_windows()
3950 .iter()
3951 .map(|window| window.iter().map(|message| message.seq).collect::<Vec<_>>()),
3952 );
3953 }
3954 let completion = batches.completion().unwrap();
3955 assert_eq!(normalized_metadata_batches, 1);
3956 assert_eq!(raw_metadata_batches, 1);
3957 assert_eq!(
3958 sequences,
3959 materialized
3960 .results()
3961 .iter()
3962 .map(|result| result.seq)
3963 .collect::<Vec<_>>()
3964 );
3965 assert_eq!(
3966 contexts,
3967 materialized
3968 .context_windows()
3969 .iter()
3970 .map(|window| window.iter().map(|message| message.seq).collect::<Vec<_>>())
3971 .collect::<Vec<_>>()
3972 );
3973 assert_eq!(completion.page(), materialized.page());
3974 assert_eq!(
3975 serialized_results,
3976 (0..materialized.results().len())
3977 .map(|index| {
3978 serde_json::to_value(materialized.result_document(index).unwrap()).unwrap()
3979 })
3980 .collect::<Vec<_>>()
3981 );
3982 assert_eq!(
3983 serde_json::to_value(completion.page_document()).unwrap(),
3984 serde_json::to_value(materialized.page_document()).unwrap()
3985 );
3986 assert_eq!(
3987 serde_json::to_value(completion.receipt_document(&resolved_request)).unwrap(),
3988 serde_json::to_value(materialized.receipt_document()).unwrap()
3989 );
3990 assert_eq!(
3991 completion.ordered_digest(),
3992 Some(materialized.ordered_digest().as_str())
3993 );
3994 assert!(completion.search_explanation().is_some());
3995 assert!(completion.parameter_origins().is_some());
3996 assert!(batches.next_batch().unwrap().is_none());
3997 batches.close().unwrap();
3998 batches.close().unwrap();
3999 }
4000
4001 #[test]
4002 fn exhaustive_batches_preserve_the_rust_surface_unbounded_default() {
4003 let (_directory, app) = existing_only_search_app(
4004 Provider::Codex,
4005 "batch-default-fixture",
4006 &["needle zero", "needle one", "needle two"],
4007 );
4008 let request = literal_request()
4009 .session_id("codex:batch-default-fixture")
4010 .unwrap()
4011 .build()
4012 .unwrap();
4013 let materialized = app.messages().search(request.clone()).unwrap();
4014 let mut batches = app
4015 .message_search_batches(request, NonZeroUsize::new(2).unwrap())
4016 .unwrap();
4017 let mut sequences = Vec::new();
4018 while let Some(batch) = batches.next_batch().unwrap() {
4019 sequences.extend(batch.results().iter().map(|result| result.seq));
4020 }
4021 assert_eq!(
4022 sequences,
4023 materialized
4024 .results()
4025 .iter()
4026 .map(|result| result.seq)
4027 .collect::<Vec<_>>()
4028 );
4029 assert_eq!(
4030 batches.request().extent(),
4031 crate::message_search::ResolvedRequestExtent::AllResults { offset: 0 }
4032 );
4033 assert_eq!(batches.completion().unwrap().page(), materialized.page());
4034 }
4035
4036 #[test]
4037 fn exhaustive_batches_reject_finite_pages_with_the_simple_api_recovery() {
4038 let (_directory, app) =
4039 existing_only_search_app(Provider::Antigravity, "batch-fixture", &["needle zero"]);
4040 let request = literal_request()
4041 .session_id("antigravity:batch-fixture")
4042 .unwrap()
4043 .extent(RequestedExtent::page(Some(1), 0).unwrap())
4044 .build()
4045 .unwrap();
4046 let error = app
4047 .message_search_batches(request, NonZeroUsize::new(1).unwrap())
4048 .err()
4049 .expect("finite page must be rejected")
4050 .to_string();
4051 assert!(error.contains("requires all_results"));
4052 assert!(error.contains("use search() for a finite materialized page"));
4053 }
4054
4055 #[test]
4056 fn exhaustive_batches_keep_one_snapshot_and_close_does_not_drain() {
4057 let (directory, app) = existing_only_search_app(
4058 Provider::GeminiCli,
4059 "batch-fixture",
4060 &["needle zero", "needle one", "needle two"],
4061 );
4062 let request = literal_request()
4063 .session_id("gemini-cli:batch-fixture")
4064 .unwrap()
4065 .extent(RequestedExtent::all_results())
4066 .build()
4067 .unwrap();
4068 let mut batches = app
4069 .message_search_batches(request, NonZeroUsize::new(1).unwrap())
4070 .unwrap();
4071 let first = batches.next_batch().unwrap().unwrap();
4072 assert_eq!(
4073 first
4074 .results()
4075 .iter()
4076 .map(|result| result.seq)
4077 .collect::<Vec<_>>(),
4078 vec![0]
4079 );
4080
4081 let writer = Db::open(&directory.path().join("message-search.db")).unwrap();
4082 insert_session(
4083 &writer,
4084 Provider::GeminiCli,
4085 "batch-fixture",
4086 "/workspace/message-search",
4087 "/transcripts/message-search.jsonl",
4088 &["needle zero", "needle one", "needle two", "needle late"],
4089 );
4090 let mut remaining = Vec::new();
4091 while let Some(batch) = batches.next_batch().unwrap() {
4092 remaining.extend(batch.results().iter().map(|result| result.seq));
4093 }
4094 assert_eq!(remaining, vec![1, 2]);
4095 assert_eq!(
4096 writer
4097 .search_messages("needle", &MessageFilters::default())
4098 .unwrap()
4099 .len(),
4100 4
4101 );
4102
4103 let request = literal_request()
4104 .session_id("gemini-cli:batch-fixture")
4105 .unwrap()
4106 .extent(RequestedExtent::all_results())
4107 .build()
4108 .unwrap();
4109 for _ in 0..16 {
4110 let mut immediately_stopped = app
4111 .message_search_batches(request.clone(), NonZeroUsize::new(1).unwrap())
4112 .unwrap();
4113 immediately_stopped.close().unwrap();
4114 }
4115 assert_eq!(writer.wal_checkpoint_busy_for_test().unwrap(), 0);
4116
4117 let mut stopped = app
4118 .message_search_batches(request, NonZeroUsize::new(1).unwrap())
4119 .unwrap();
4120 assert!(stopped.next_batch().unwrap().is_some());
4121 stopped.close().unwrap();
4122 stopped.close().unwrap();
4123 assert!(stopped
4124 .next_batch()
4125 .unwrap_err()
4126 .to_string()
4127 .contains("closed"));
4128 assert!(stopped
4129 .completion()
4130 .unwrap_err()
4131 .to_string()
4132 .contains("no terminal metadata"));
4133 assert_eq!(writer.wal_checkpoint_busy_for_test().unwrap(), 0);
4134 }
4135
4136 #[test]
4137 fn materialized_search_keeps_hits_and_context_in_one_snapshot() {
4138 let (directory, app) = existing_only_search_app(
4139 Provider::GeminiCli,
4140 "materialized-snapshot",
4141 &["needle before refresh"],
4142 );
4143 let writer = Db::open(&directory.path().join("message-search.db")).unwrap();
4144 let request = literal_request()
4145 .session_id("gemini-cli:materialized-snapshot")
4146 .unwrap()
4147 .extent(RequestedExtent::all_results())
4148 .context(ContextWindow::new(0, 1))
4149 .build()
4150 .unwrap();
4151
4152 let response = app
4153 .messages()
4154 .search_with_after_retrieval_for_test(request.clone(), || {
4155 insert_session(
4156 &writer,
4157 Provider::GeminiCli,
4158 "materialized-snapshot",
4159 "/workspace/message-search",
4160 "/transcripts/message-search.jsonl",
4161 &["needle before refresh", "context committed during refresh"],
4162 );
4163 })
4164 .unwrap();
4165 assert_eq!(response.results().len(), 1);
4166 assert!(response
4167 .context_windows()
4168 .iter()
4169 .flatten()
4170 .all(|message| message.content != "context committed during refresh"));
4171
4172 let refreshed = app.messages().search(request).unwrap();
4173 assert!(refreshed
4174 .context_windows()
4175 .iter()
4176 .flatten()
4177 .any(|message| message.content == "context committed during refresh"));
4178 }
4179
4180 #[test]
4181 fn exhaustive_batches_resolve_session_prefix_inside_the_result_snapshot() {
4182 let (directory, app) = existing_only_search_app(
4183 Provider::Pi,
4184 "snapshot-original",
4185 &["needle before replacement"],
4186 );
4187 let request = literal_request()
4188 .session_id("pi:snapshot")
4189 .unwrap()
4190 .extent(RequestedExtent::all_results())
4191 .build()
4192 .unwrap();
4193 let ready = Arc::new(Barrier::new(2));
4194 let worker_ready = Arc::clone(&ready);
4195 let mut batches = MessageSearchBatches::spawn_with_before_traversal(
4196 app.config.clone(),
4197 app.access.clone(),
4198 SearchSurface::Rust,
4199 request,
4200 NonZeroUsize::new(1).unwrap(),
4201 move || {
4202 worker_ready.wait();
4203 },
4204 )
4205 .unwrap();
4206
4207 let writer = Db::open(&directory.path().join("message-search.db")).unwrap();
4210 insert_session(
4211 &writer,
4212 Provider::Pi,
4213 "snapshot-replacement",
4214 "/workspace/message-search",
4215 "/transcripts/message-search.jsonl",
4216 &["needle after replacement"],
4217 );
4218 ready.wait();
4219
4220 let mut sessions = Vec::new();
4221 while let Some(batch) = batches.next_batch().unwrap() {
4222 sessions.extend(
4223 batch
4224 .results()
4225 .iter()
4226 .map(|result| result.message.session_id.clone()),
4227 );
4228 }
4229 assert_eq!(sessions, vec!["pi:snapshot-original"]);
4230 assert_eq!(
4231 writer.resolve_session("pi:snapshot").unwrap().session.id,
4232 "pi:snapshot-replacement"
4233 );
4234 }
4235
4236 #[test]
4237 fn latest_literal_and_regex_select_newest_matches_then_present_chronologically() {
4238 let (_directory, db) = disposable_db();
4239 insert_session(
4240 &db,
4241 Provider::Claude,
4242 "latest-window",
4243 "/workspace/latest",
4244 "/transcripts/latest.jsonl",
4245 &["needle zero", "unrelated", "needle two", "needle three"],
4246 );
4247 let config = Config::default();
4248 let service = MessageService::new(&config, &db, SearchSurface::Rust);
4249
4250 for query in [
4251 MessageQuery::literal("needle").unwrap(),
4252 MessageQuery::regex("needle (zero|two|three)").unwrap(),
4253 ] {
4254 let response = service
4255 .search(
4256 MessageSearchRequest::builder(query, MessageTarget::content())
4257 .session_id("claude:latest")
4258 .unwrap()
4259 .match_window(MatchWindow::Latest)
4260 .extent(RequestedExtent::page(Some(2), 0).unwrap())
4261 .build()
4262 .unwrap(),
4263 )
4264 .unwrap();
4265 assert_eq!(
4266 response
4267 .hits()
4268 .iter()
4269 .map(|hit| hit.seq)
4270 .collect::<Vec<_>>(),
4271 vec![2, 3]
4272 );
4273 assert_eq!(response.page().next_offset(), Some(2));
4274 }
4275 }
4276
4277 #[test]
4278 fn latest_window_applies_to_tool_name_and_tool_argument_targets() {
4279 let (_directory, db) = disposable_db();
4280 let mut parsed = minimal_record(
4281 Provider::Claude,
4282 std::path::Path::new("/transcripts/latest-tools.jsonl"),
4283 String::new(),
4284 );
4285 parsed.session.id = "claude:latest-tools".into();
4286 parsed.session.provider_session_id = "latest-tools".into();
4287 parsed.session.cwd = Some("/workspace/latest-tools".into());
4288 parsed.messages = (0..4)
4289 .map(|seq| Message {
4290 seq,
4291 role: Role::Tool,
4292 ts: None,
4293 tool_name: Some(format!("exec-{seq}")),
4294 kind: MessageKind::ToolCall,
4295 tool_call_id: Some(format!("call-{seq}")),
4296 is_compaction: false,
4297 content: format!(r#"{{"args":{{"cmd":"needle {seq}"}}}}"#),
4298 provenance: Default::default(),
4299 })
4300 .collect();
4301 db.upsert_session(&parsed, 0, 0).unwrap();
4302 let config = Config::default();
4303 let service = MessageService::new(&config, &db, SearchSurface::Rust);
4304
4305 for (query, target) in [
4306 (
4307 MessageQuery::literal("exec").unwrap(),
4308 MessageTarget::tool_name(),
4309 ),
4310 (
4311 MessageQuery::regex(r"exec-[0-3]").unwrap(),
4312 MessageTarget::tool_name(),
4313 ),
4314 (
4315 MessageQuery::literal("needle").unwrap(),
4316 MessageTarget::tool_argument("/cmd").unwrap(),
4317 ),
4318 (
4319 MessageQuery::regex(r"needle [0-3]").unwrap(),
4320 MessageTarget::tool_argument("/cmd").unwrap(),
4321 ),
4322 ] {
4323 let response = service
4324 .search(
4325 MessageSearchRequest::builder(query, target)
4326 .session_id("claude:latest-tools")
4327 .unwrap()
4328 .match_window(MatchWindow::Latest)
4329 .extent(RequestedExtent::page(Some(2), 0).unwrap())
4330 .build()
4331 .unwrap(),
4332 )
4333 .unwrap();
4334 assert_eq!(
4335 response
4336 .hits()
4337 .iter()
4338 .map(|hit| hit.seq)
4339 .collect::<Vec<_>>(),
4340 vec![2, 3]
4341 );
4342 }
4343 }
4344
4345 #[test]
4346 fn typed_path_domains_are_separate_while_legacy_path_remains_broad() {
4347 let (_directory, db) = disposable_db();
4348 insert_session(
4349 &db,
4350 Provider::Claude,
4351 "workspace-domain",
4352 "/domains/shared/workspace",
4353 "/elsewhere/workspace.jsonl",
4354 &["needle"],
4355 );
4356 insert_session(
4357 &db,
4358 Provider::Claude,
4359 "transcript-domain",
4360 "/elsewhere/transcript",
4361 "/domains/shared/transcript/session.jsonl",
4362 &["needle"],
4363 );
4364 let config = Config::default();
4365 let service = MessageService::new(&config, &db, SearchSurface::Rust);
4366
4367 let workspace = service
4368 .search(
4369 literal_request()
4370 .workspace_path_prefix("/domains/shared")
4371 .unwrap()
4372 .build()
4373 .unwrap(),
4374 )
4375 .unwrap();
4376 assert_eq!(workspace.hits().len(), 1);
4377 assert_eq!(workspace.hits()[0].session_id, "claude:workspace-domain");
4378
4379 let transcript = service
4380 .search(
4381 literal_request()
4382 .transcript_path_prefix("/domains/shared")
4383 .unwrap()
4384 .build()
4385 .unwrap(),
4386 )
4387 .unwrap();
4388 assert_eq!(transcript.hits().len(), 1);
4389 assert_eq!(transcript.hits()[0].session_id, "claude:transcript-domain");
4390
4391 let excluded_workspace = service
4392 .search(
4393 literal_request()
4394 .exclude_workspace_path_prefix("/domains/shared")
4395 .unwrap()
4396 .build()
4397 .unwrap(),
4398 )
4399 .unwrap();
4400 assert_eq!(excluded_workspace.hits().len(), 1);
4401 assert_eq!(
4402 excluded_workspace.hits()[0].session_id,
4403 "claude:transcript-domain"
4404 );
4405
4406 let excluded_transcript = service
4407 .search(
4408 literal_request()
4409 .exclude_transcript_path_prefix("/domains/shared")
4410 .unwrap()
4411 .build()
4412 .unwrap(),
4413 )
4414 .unwrap();
4415 assert_eq!(excluded_transcript.hits().len(), 1);
4416 assert_eq!(
4417 excluded_transcript.hits()[0].session_id,
4418 "claude:workspace-domain"
4419 );
4420
4421 let legacy = service
4422 .search_legacy(
4423 "needle",
4424 &MessageFilters {
4425 path_prefix: Some("/domains/shared".into()),
4426 ..Default::default()
4427 },
4428 )
4429 .unwrap();
4430 assert_eq!(legacy.len(), 2);
4431 }
4432}
4433
4434#[cfg(test)]
4435mod tests {
4436 use super::*;
4437
4438 #[test]
4439 fn services_share_one_database_and_return_typed_empty_results() {
4440 let dir = tempfile::tempdir().unwrap();
4441 let db = Db::open(&dir.path().join("index.db")).unwrap();
4442 db.mark_schema_current().unwrap();
4443
4444 let config = Config::default();
4445 let messages = MessageService::new(&config, &db, SearchSurface::Rust)
4446 .search_legacy("missing", &MessageFilters::default())
4447 .unwrap();
4448 let files = FileService::new(&db).search(&FileQuery::default()).unwrap();
4449 let mut config = Config::default();
4450 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4451 let app = SessionSearch::open(config).unwrap();
4452 app.database().mark_schema_current().unwrap();
4453 let status = app.index().status().unwrap();
4454
4455 assert!(messages.is_empty());
4456 assert!(files.is_empty());
4457 assert!(status.parser_health.schema_current);
4458 assert_eq!(status.parser_health.indexed_sessions, 0);
4459 }
4460
4461 #[test]
4462 fn catalog_rejects_a_parent_filter_that_can_match_no_user_session() {
4463 let dir = tempfile::tempdir().unwrap();
4464 let db = Db::open(&dir.path().join("index.db")).unwrap();
4465 db.mark_schema_current().unwrap();
4466
4467 let error = CatalogService::new(&db)
4468 .list_sessions(&SearchFilters {
4469 session_kinds: Some(vec![crate::models::SessionKind::User]),
4470 parent_session_id: Some("claude:parent".to_string()),
4471 ..SearchFilters::default()
4472 })
4473 .expect_err("a user-started session cannot have a parent")
4474 .to_string();
4475
4476 assert!(error.contains("session_kinds"), "{error}");
4477 assert!(error.contains("parent_session_id"), "{error}");
4478 assert!(error.contains("subagent"), "{error}");
4479 assert!(error.contains("omit session_kinds"), "{error}");
4480 }
4481
4482 #[test]
4483 fn application_owns_config_database_and_service_lifecycle() {
4484 let dir = tempfile::tempdir().unwrap();
4485 let mut config = Config::default();
4486 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4487 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().to_string());
4488 config.providers.claude.enabled = false;
4489 config.providers.claude_desktop.enabled = false;
4490 config.providers.codex.enabled = false;
4491 config.providers.cursor.enabled = false;
4492 config.providers.antigravity.enabled = false;
4493 config.providers.pi.enabled = false;
4494 config.providers.prime_agent.enabled = false;
4495 config.providers.aistudio.enabled = false;
4496 config.providers.gemini_cli.enabled = false;
4497
4498 let app = SessionSearch::open(config).unwrap();
4499 let outcome = app.index().refresh().unwrap();
4500
4501 assert!(matches!(outcome, AutoReindexOutcome::Updated { .. }));
4502 assert!(app.config().db_path().exists());
4503 assert!(app
4504 .catalog()
4505 .list_sessions(&SearchFilters {
4506 limit: 1,
4507 ..SearchFilters::default()
4508 })
4509 .unwrap()
4510 .is_empty());
4511
4512 let diagnostics = app.maintenance().diagnostics().unwrap();
4513 assert_eq!(
4514 std::path::Path::new(&diagnostics.db_path),
4515 app.config().db_path()
4516 );
4517 let compacted = app.maintenance().compact().unwrap();
4518 assert!(compacted.after_bytes > 0);
4519 assert_eq!(
4520 compacted.reclaimed_bytes(),
4521 compacted.before_bytes.saturating_sub(compacted.after_bytes)
4522 );
4523 }
4524
4525 #[test]
4526 fn explicit_reindex_owns_lock_on_current_schema() {
4527 let dir = tempfile::tempdir().unwrap();
4528 let mut config = Config::default();
4529 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4530 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().to_string());
4531 config.providers.claude.enabled = false;
4532 config.providers.claude_desktop.enabled = false;
4533 config.providers.codex.enabled = false;
4534 config.providers.cursor.enabled = false;
4535 config.providers.antigravity.enabled = false;
4536 config.providers.pi.enabled = false;
4537 config.providers.prime_agent.enabled = false;
4538 config.providers.aistudio.enabled = false;
4539 config.providers.gemini_cli.enabled = false;
4540
4541 let app = SessionSearch::open(config).unwrap();
4542 assert!(!app.database().needs_backfill().unwrap());
4543
4544 assert_eq!(app.index().reindex(false).unwrap(), (0, 0));
4545
4546 assert!(!app.database().needs_backfill().unwrap());
4547 assert!(app.index().status().unwrap().parser_health.schema_current);
4548 assert!(indexer::index_update_lock_path(&app.config().db_path()).exists());
4549 }
4550
4551 #[test]
4552 fn public_full_reindex_promotes_v3_to_current_search_schema() {
4553 let dir = tempfile::tempdir().unwrap();
4554 let mut config = Config::default();
4555 config.index.db_path = Some(dir.path().join("index.db").to_string_lossy().to_string());
4556 config.index.cache_dir = Some(dir.path().join("cache").to_string_lossy().to_string());
4557 config.providers.claude.enabled = false;
4558 config.providers.claude_desktop.enabled = false;
4559 config.providers.codex.enabled = false;
4560 config.providers.cursor.enabled = false;
4561 config.providers.antigravity.enabled = false;
4562 config.providers.pi.enabled = false;
4563 config.providers.prime_agent.enabled = false;
4564 config.providers.aistudio.enabled = false;
4565 config.providers.gemini_cli.enabled = false;
4566
4567 drop(Db::open(&config.db_path()).unwrap());
4568 let conn = rusqlite::Connection::open(config.db_path()).unwrap();
4569 conn.execute_batch(
4570 "drop trigger messages_ai;
4571 drop trigger messages_ad;
4572 drop trigger messages_au;
4573 drop table messages_trigram_terms;
4574 drop table messages_trigram_vocab;
4575 drop table messages_trigram;
4576 pragma user_version=3;",
4577 )
4578 .unwrap();
4579 crate::fts::install_released_message_word_index(&conn).unwrap();
4580 crate::trigram_index::ensure_schema(&conn).unwrap();
4581 drop(conn);
4582
4583 let app = SessionSearch::open(config).unwrap();
4584
4585 assert_eq!(app.index().reindex(true).unwrap(), (0, 0));
4586 assert_eq!(
4587 app.database().schema_version().unwrap(),
4588 crate::db::SCHEMA_VERSION
4589 );
4590 let conn = rusqlite::Connection::open_with_flags(
4591 app.config().db_path(),
4592 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4593 )
4594 .unwrap();
4595 let layout: (bool, bool, bool) = conn
4596 .query_row(
4597 "select exists(select 1 from sqlite_schema where name='messages_trigram'),
4598 exists(select 1 from sqlite_schema where name='messages_trigram_vocab'),
4599 not exists(select 1 from sqlite_schema where name='trigram_postings')",
4600 [],
4601 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
4602 )
4603 .unwrap();
4604 assert_eq!(layout, (true, true, true));
4605 }
4606}