1use crate::{
2 api::{
3 ApiError, ApiMetadata, CodeRepositorySetAddResponse, CodeRepositorySetCreateResponse,
4 CodeRepositorySetQueryResponse, CodeRepositorySetRefreshResponse,
5 CodeRepositorySetStatusResponse, RequestContext,
6 },
7 code::{CodeIndexError, resolve_repository_snapshot},
8 domain::{
9 CodeRepositorySelector, CodeRepositorySetAddMemberRequest, CodeRepositorySetCreateRequest,
10 CodeRepositorySetMemberStatus, CodeRepositorySetQueryHit, CodeRepositorySetQueryRequest,
11 CodeRepositorySetStatus, CodeRetrievalHit, CodeRetrievalRequest, FreshnessPolicy,
12 },
13 storage::{
14 CodeRepositorySetMemberSeed, CodeRepositorySetRefreshTaskClaimRequest,
15 CodeRepositorySetRefreshTaskCompletion, CodeRepositorySetRefreshTaskFailure,
16 CodeRepositorySetRefreshTaskSeed, CodeRepositorySetSeed, StorageError,
17 },
18};
19use std::{collections::BTreeMap, path::PathBuf};
20
21use super::RelayKnowledgeService;
22
23const REPOSITORY_SET_REFRESH_TASK_LEASE_MS: u64 = 10 * 60 * 1000;
24const REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS: u32 = 3;
25const REPOSITORY_SET_REFRESH_TASK_RETRY_BACKOFF_MS: u64 = 60_000;
26
27impl RelayKnowledgeService {
28 pub async fn create_code_repository_set(
30 &self,
31 request: CodeRepositorySetCreateRequest,
32 context: RequestContext,
33 ) -> Result<CodeRepositorySetCreateResponse, ApiError> {
34 let store = self.store().await.map_err(storage_api_error)?;
35 let repository_set = store
36 .create_code_repository_set(CodeRepositorySetSeed {
37 alias: request.alias.clone(),
38 description: request.description.clone(),
39 default_ref_policy_json: request.default_ref_policy_json.clone(),
40 now_ms: now_millis(),
41 })
42 .await
43 .map_err(storage_api_error)?;
44 let graph_version = store
45 .current_graph_version()
46 .await
47 .map_err(storage_api_error)?;
48
49 Ok(CodeRepositorySetCreateResponse {
50 metadata: ApiMetadata::graph_only(&context, graph_version),
51 request,
52 repository_set,
53 })
54 }
55
56 pub async fn add_code_repository_set_member(
58 &self,
59 request: CodeRepositorySetAddMemberRequest,
60 context: RequestContext,
61 ) -> Result<CodeRepositorySetAddResponse, ApiError> {
62 let store = self.store().await.map_err(storage_api_error)?;
63 let repository = store
64 .code_repository_status(request.repository_alias.clone())
65 .await
66 .map_err(storage_api_error)?
67 .ok_or_else(|| {
68 ApiError::invalid_argument(format!(
69 "code repository '{}' is not registered",
70 request.repository_alias
71 ))
72 })?;
73 let root_path = repository.root_path.clone();
74 let ref_selector = request.ref_selector.clone();
75 let (resolved_commit_sha, _tree_hash) =
76 run_blocking_code(move || resolve_repository_snapshot(root_path, &ref_selector))
77 .await?;
78 let path_filters = merged_filters(&repository.path_filters, &request.path_filters);
79 let language_filters =
80 merged_filters(&repository.language_filters, &request.language_filters);
81 let scope = store
82 .code_repository_scope_status(
83 request.repository_alias.clone(),
84 resolved_commit_sha.clone(),
85 path_filters.clone(),
86 language_filters.clone(),
87 )
88 .await
89 .map_err(storage_api_error)?
90 .ok_or_else(|| {
91 ApiError::invalid_argument(format!(
92 "code repository '{}' has no indexed scope for ref {} and requested filters",
93 request.repository_alias, request.ref_selector
94 ))
95 })?;
96 let source_scope = scope.last_indexed_scope_id.clone().ok_or_else(|| {
97 ApiError::invalid_argument(format!(
98 "code repository '{}' matching scope has no source scope",
99 request.repository_alias
100 ))
101 })?;
102 let member = store
103 .add_code_repository_set_member(CodeRepositorySetMemberSeed {
104 set_alias: request.set_alias.clone(),
105 repository_id: repository.repository_id,
106 repository_alias: request.repository_alias.clone(),
107 ref_selector: request.ref_selector.clone(),
108 resolved_commit_sha,
109 source_scope,
110 path_filters,
111 language_filters,
112 priority: request.priority,
113 })
114 .await
115 .map_err(storage_api_error)?;
116 let status = required_set_status(&store, &request.set_alias).await?;
117 let graph_version = store
118 .current_graph_version()
119 .await
120 .map_err(storage_api_error)?;
121
122 Ok(CodeRepositorySetAddResponse {
123 metadata: ApiMetadata::graph_only(&context, graph_version),
124 request,
125 member,
126 status,
127 })
128 }
129
130 pub async fn query_code_repository_set(
132 &self,
133 request: CodeRepositorySetQueryRequest,
134 context: RequestContext,
135 ) -> Result<CodeRepositorySetQueryResponse, ApiError> {
136 let store = self.store().await.map_err(storage_api_error)?;
137 let status = required_set_status(&store, &request.set_alias).await?;
138 let graph_version = store
139 .current_graph_version()
140 .await
141 .map_err(storage_api_error)?;
142 if request.freshness_policy == FreshnessPolicy::GraphOnly {
143 return Ok(CodeRepositorySetQueryResponse {
144 metadata: ApiMetadata::graph_only(&context, graph_version),
145 request,
146 status,
147 results: Vec::new(),
148 truncated: false,
149 degraded_reason: Some("graph_only freshness policy selected".to_owned()),
150 });
151 }
152 if let Some(error) = unfresh_set_error_for_wait_policy(&request, &status) {
153 return Err(error);
154 }
155 let edges = store
156 .code_repository_set_cross_edges(status.repository_set.set_id.clone())
157 .await
158 .map_err(storage_api_error)?;
159 let mut results = Vec::new();
160 let candidate_limit = per_member_candidate_limit(request.limit);
161 for member_status in &status.members {
162 let member = &member_status.member;
163 let selector = CodeRepositorySelector::new(
164 member.repository_alias.clone(),
165 member.resolved_commit_sha.clone(),
166 request.path_filters.clone(),
167 request.language_filters.clone(),
168 )
169 .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
170 let search_request = CodeRetrievalRequest::new(
171 request.query.clone(),
172 selector,
173 request.code_query_kind,
174 candidate_limit,
175 FreshnessPolicy::AllowStale,
176 )
177 .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
178 let hits = store
179 .search_code_scope(member.source_scope.clone(), search_request)
180 .await
181 .map_err(storage_api_error)?;
182 for hit in hits {
183 let overlay_evidence = overlay_evidence_for_hit(&edges, &hit);
184 let score = repository_set_score(&hit, member_status, &overlay_evidence);
185 results.push(CodeRepositorySetQueryHit {
186 member: member.clone(),
187 hit,
188 overlay_evidence,
189 score,
190 });
191 }
192 }
193 let truncated = dedupe_sort_truncate(&mut results, request.limit);
194 let degraded_reason = status.degraded_reason.clone().or_else(|| {
195 status
196 .overlay
197 .stale
198 .then(|| "repository set overlay is stale".to_owned())
199 });
200
201 Ok(CodeRepositorySetQueryResponse {
202 metadata: ApiMetadata::graph_only(&context, graph_version),
203 request,
204 status,
205 results,
206 truncated,
207 degraded_reason,
208 })
209 }
210
211 pub async fn code_repository_set_status(
213 &self,
214 set_alias: String,
215 context: RequestContext,
216 ) -> Result<CodeRepositorySetStatusResponse, ApiError> {
217 let store = self.store().await.map_err(storage_api_error)?;
218 let status = required_set_status(&store, &set_alias).await?;
219 let graph_version = store
220 .current_graph_version()
221 .await
222 .map_err(storage_api_error)?;
223
224 Ok(CodeRepositorySetStatusResponse {
225 metadata: ApiMetadata::graph_only(&context, graph_version),
226 status,
227 })
228 }
229
230 pub async fn refresh_code_repository_set(
232 &self,
233 set_alias: String,
234 context: RequestContext,
235 ) -> Result<CodeRepositorySetRefreshResponse, ApiError> {
236 let store = self.store().await.map_err(storage_api_error)?;
237 let summary = store
238 .refresh_code_repository_set_overlay(set_alias.clone(), now_millis())
239 .await
240 .map_err(storage_api_error)?;
241 let status = required_set_status(&store, &set_alias).await?;
242 let graph_version = store
243 .current_graph_version()
244 .await
245 .map_err(storage_api_error)?;
246
247 Ok(CodeRepositorySetRefreshResponse {
248 metadata: ApiMetadata::graph_only(&context, graph_version),
249 status,
250 summary: Some(summary),
251 task: None,
252 })
253 }
254
255 pub async fn start_code_repository_set_refresh(
257 &self,
258 set_alias: String,
259 context: RequestContext,
260 ) -> Result<CodeRepositorySetRefreshResponse, ApiError> {
261 let store = self.store().await.map_err(storage_api_error)?;
262 let status = required_set_status(&store, &set_alias).await?;
263 let fingerprint = repository_set_refresh_fingerprint(&status);
264 let task = store
265 .queue_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskSeed {
266 set_id: status.repository_set.set_id.clone(),
267 set_alias: status.repository_set.alias.clone(),
268 input_fingerprint: fingerprint,
269 now_ms: now_millis(),
270 })
271 .await
272 .map_err(storage_api_error)?;
273 let graph_version = store
274 .current_graph_version()
275 .await
276 .map_err(storage_api_error)?;
277
278 Ok(CodeRepositorySetRefreshResponse {
279 metadata: ApiMetadata::graph_only(&context, graph_version),
280 status,
281 summary: None,
282 task: Some(task),
283 })
284 }
285
286 pub async fn run_code_repository_set_refresh_task_once(
288 &self,
289 task_id: Option<String>,
290 context: RequestContext,
291 ) -> Result<Option<crate::domain::CodeRepositorySetRefreshTaskRecord>, ApiError> {
292 let store = self.store().await.map_err(storage_api_error)?;
293 let lease_owner = format!("code-repository-set-refresh-worker-{}", std::process::id());
294 let Some(task) = store
295 .claim_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskClaimRequest {
296 task_id,
297 lease_owner: lease_owner.clone(),
298 lease_duration_ms: REPOSITORY_SET_REFRESH_TASK_LEASE_MS,
299 max_attempts: REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS,
300 now_ms: now_millis(),
301 })
302 .await
303 .map_err(storage_api_error)?
304 else {
305 return Ok(None);
306 };
307 let result = self
308 .refresh_code_repository_set(task.set_alias.clone(), context)
309 .await;
310 match result {
311 Ok(_) => store
312 .complete_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskCompletion {
313 task_id: task.task_id,
314 lease_owner,
315 attempt_count: task.attempt_count,
316 now_ms: now_millis(),
317 })
318 .await
319 .map(Some)
320 .map_err(storage_api_error),
321 Err(error) => {
322 let _ = store
323 .fail_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskFailure {
324 task_id: task.task_id,
325 lease_owner,
326 attempt_count: task.attempt_count,
327 error_kind: "repository_set_overlay".to_owned(),
328 error_message: error.message.clone(),
329 retry_backoff_ms: REPOSITORY_SET_REFRESH_TASK_RETRY_BACKOFF_MS,
330 max_attempts: REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS,
331 now_ms: now_millis(),
332 })
333 .await;
334 Err(error)
335 }
336 }
337 }
338
339 pub(crate) async fn code_repository_set_member_scopes(
340 &self,
341 set_alias: String,
342 ) -> Result<Option<Vec<(String, String)>>, ApiError> {
343 let store = self.store().await.map_err(storage_api_error)?;
344 store
345 .code_repository_set_status(set_alias)
346 .await
347 .map(|status| {
348 status.map(|status| {
349 status
350 .members
351 .into_iter()
352 .map(|member| (member.member.repository_alias, member.member.source_scope))
353 .collect()
354 })
355 })
356 .map_err(storage_api_error)
357 }
358}
359
360pub(super) async fn required_set_status(
361 store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
362 set_alias: &str,
363) -> Result<CodeRepositorySetStatus, ApiError> {
364 let mut status = store
365 .code_repository_set_status(set_alias.to_owned())
366 .await
367 .map_err(storage_api_error)?
368 .ok_or_else(|| {
369 ApiError::invalid_argument(format!(
370 "code repository set '{set_alias}' is not registered"
371 ))
372 })?;
373 refresh_moving_member_freshness(store, &mut status).await?;
374 refresh_repository_set_freshness(&mut status);
375
376 Ok(status)
377}
378
379async fn refresh_moving_member_freshness(
380 store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
381 status: &mut CodeRepositorySetStatus,
382) -> Result<(), ApiError> {
383 for index in 0..status.members.len() {
384 let member = status.members[index].member.clone();
385 let Some(reason) = moving_member_stale_reason(store, &member).await? else {
386 continue;
387 };
388 status.members[index].stale = true;
389 status.members[index].freshness_state = "stale".to_owned();
390 status.members[index].degraded_reason = Some(reason);
391 }
392
393 Ok(())
394}
395
396async fn moving_member_stale_reason(
397 store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
398 member: &crate::domain::CodeRepositorySetMember,
399) -> Result<Option<String>, ApiError> {
400 if !member_ref_tracks_repository(&member.ref_selector, &member.resolved_commit_sha) {
401 return Ok(None);
402 }
403 let repository = store
404 .code_repository_status(member.repository_id.clone())
405 .await
406 .map_err(storage_api_error)?
407 .ok_or_else(|| {
408 ApiError::invalid_argument(format!(
409 "code repository '{}' is not registered",
410 member.repository_alias
411 ))
412 })?;
413 let root_path = PathBuf::from(repository.root_path);
414 let ref_selector = member.ref_selector.clone();
415 let resolved =
416 tokio::task::spawn_blocking(move || resolve_repository_snapshot(root_path, &ref_selector))
417 .await
418 .map_err(|error| ApiError::storage_unavailable(error.to_string()))?;
419
420 match resolved {
421 Ok((current_commit, _)) if current_commit == member.resolved_commit_sha => Ok(None),
422 Ok((current_commit, _)) => Ok(Some(format!(
423 "repository set member '{}' ref '{}' now resolves to {}, not stored snapshot {}",
424 member.repository_alias,
425 member.ref_selector,
426 current_commit,
427 member.resolved_commit_sha
428 ))),
429 Err(error) => Ok(Some(format!(
430 "repository set member '{}' ref '{}' could not be resolved: {error}",
431 member.repository_alias, member.ref_selector
432 ))),
433 }
434}
435
436fn member_ref_tracks_repository(ref_selector: &str, resolved_commit_sha: &str) -> bool {
437 let ref_selector = ref_selector.trim();
438 !(ref_selector == resolved_commit_sha
439 || (is_git_oid_prefix(ref_selector) && resolved_commit_sha.starts_with(ref_selector)))
440}
441
442fn is_git_oid_prefix(value: &str) -> bool {
443 (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
444}
445
446fn refresh_repository_set_freshness(status: &mut CodeRepositorySetStatus) {
447 let member_stale = status.members.iter().any(|member| member.stale);
448 if member_stale && !status.overlay.stale {
449 status.overlay.stale = true;
450 status.overlay.state = "overlay_stale".to_owned();
451 }
452 status.freshness_state = if status.members.is_empty() {
453 "incomplete"
454 } else if member_stale {
455 "stale"
456 } else if status.overlay.stale {
457 "overlay_stale"
458 } else {
459 "fresh"
460 }
461 .to_owned();
462 status.degraded_reason = status
463 .members
464 .iter()
465 .find_map(|member| member.degraded_reason.clone())
466 .or_else(|| status.overlay.degraded_reason.clone());
467}
468
469fn unfresh_set_error_for_wait_policy(
470 request: &CodeRepositorySetQueryRequest,
471 status: &CodeRepositorySetStatus,
472) -> Option<ApiError> {
473 if request.freshness_policy != FreshnessPolicy::WaitUntilFresh {
474 return None;
475 }
476 if status.members.is_empty() {
477 return Some(ApiError::invalid_argument(format!(
478 "code repository set '{}' has no members",
479 status.repository_set.alias
480 )));
481 }
482 if let Some(member) = status.members.iter().find(|member| member.stale) {
483 return Some(ApiError::invalid_argument(format!(
484 "code repository set '{}' member '{}' scope '{}' is stale",
485 status.repository_set.alias, member.member.repository_alias, member.member.source_scope
486 )));
487 }
488 if status.overlay.stale {
489 return Some(ApiError::invalid_argument(format!(
490 "code repository set '{}' overlay is stale; run repo-set refresh before querying with wait_until_fresh",
491 status.repository_set.alias
492 )));
493 }
494
495 None
496}
497
498fn per_member_candidate_limit(limit: usize) -> usize {
499 std::cmp::min(
500 50,
501 std::cmp::max(limit.saturating_mul(3), limit.saturating_add(5)),
502 )
503}
504
505fn repository_set_score(
506 hit: &CodeRetrievalHit,
507 member: &CodeRepositorySetMemberStatus,
508 overlay_evidence: &[crate::domain::CodeRepositoryCrossEdge],
509) -> f64 {
510 let priority_bonus = f64::from(member.member.priority) * 0.01;
511 let freshness_penalty = if hit.stale || member.stale { 0.5 } else { 0.0 };
512 let edge_bonus = overlay_evidence
513 .iter()
514 .map(|edge| f64::from(edge.confidence_basis_points) / 10_000.0)
515 .fold(0.0, f64::max);
516
517 hit.score + priority_bonus + edge_bonus - freshness_penalty
518}
519
520fn overlay_evidence_for_hit(
521 edges: &[crate::domain::CodeRepositoryCrossEdge],
522 hit: &CodeRetrievalHit,
523) -> Vec<crate::domain::CodeRepositoryCrossEdge> {
524 edges
525 .iter()
526 .filter(|edge| edge_originates_from_hit(edge, hit) || edge_targets_hit(edge, hit))
527 .take(5)
528 .cloned()
529 .collect()
530}
531
532fn edge_originates_from_hit(
533 edge: &crate::domain::CodeRepositoryCrossEdge,
534 hit: &CodeRetrievalHit,
535) -> bool {
536 if edge.from_source_scope != hit.scope_id
537 || edge.from_record_kind != "module_reference"
538 || hit.edge_kind.as_deref() != Some("import")
539 {
540 return false;
541 }
542
543 evidence_origin(edge.evidence_json.as_str()).is_some_and(|(path, line_start, line_end)| {
544 path == hit.path && line_start == hit.line_range.start && line_end == hit.line_range.end
545 })
546}
547
548fn edge_targets_hit(edge: &crate::domain::CodeRepositoryCrossEdge, hit: &CodeRetrievalHit) -> bool {
549 if edge.to_source_scope.as_deref() != Some(hit.scope_id.as_str()) {
550 return false;
551 }
552
553 match edge.to_record_kind.as_str() {
554 "code_symbol_snapshot" => edge
555 .to_record_id
556 .as_deref()
557 .is_some_and(|target| hit.symbol_snapshot_id.as_deref() == Some(target)),
558 "code_file" => edge
559 .to_record_id
560 .as_deref()
561 .is_some_and(|target| hit.file_id.as_deref() == Some(target)),
562 _ => false,
563 }
564}
565
566fn evidence_origin(evidence_json: &str) -> Option<(String, u32, u32)> {
567 serde_json::from_str::<serde_json::Value>(evidence_json)
568 .ok()
569 .and_then(|value| {
570 let path = value
571 .get("from_path")
572 .and_then(|path| path.as_str())
573 .map(str::to_owned)?;
574 let line_start = value
575 .get("from_line_start")
576 .and_then(|line| line.as_u64())
577 .and_then(|line| u32::try_from(line).ok())?;
578 let line_end = value
579 .get("from_line_end")
580 .and_then(|line| line.as_u64())
581 .and_then(|line| u32::try_from(line).ok())?;
582
583 Some((path, line_start, line_end))
584 })
585}
586
587fn dedupe_sort_truncate(results: &mut Vec<CodeRepositorySetQueryHit>, limit: usize) -> bool {
588 let mut best =
589 BTreeMap::<(String, String, String, u32, u32, String), CodeRepositorySetQueryHit>::new();
590 for result in results.drain(..) {
591 let key = (
592 result.hit.repository_id.clone(),
593 result.hit.scope_id.clone(),
594 result.hit.path.clone(),
595 result.hit.line_range.start,
596 result.hit.line_range.end,
597 result.hit.excerpt.clone(),
598 );
599 match best.get(&key) {
600 Some(existing) if existing.score >= result.score => {}
601 _ => {
602 best.insert(key, result);
603 }
604 }
605 }
606 results.extend(best.into_values());
607 results.sort_by(|left, right| {
608 right
609 .score
610 .total_cmp(&left.score)
611 .then_with(|| {
612 left.member
613 .repository_alias
614 .cmp(&right.member.repository_alias)
615 })
616 .then_with(|| left.hit.path.cmp(&right.hit.path))
617 .then_with(|| left.hit.line_range.start.cmp(&right.hit.line_range.start))
618 });
619 let truncated = results.len() > limit;
620 results.truncate(limit);
621 truncated
622}
623
624fn repository_set_refresh_fingerprint(status: &CodeRepositorySetStatus) -> String {
625 let mut parts = vec![status.repository_set.set_id.clone()];
626 parts.extend(status.members.iter().map(|member| {
627 format!(
628 "{}:{}:{}:{}:{}",
629 member.member.repository_id,
630 member.member.source_scope,
631 member.member.resolved_commit_sha,
632 member.tree_hash,
633 member.stale
634 )
635 }));
636 parts.join("|")
637}
638
639fn merged_filters(left: &[String], right: &[String]) -> Vec<String> {
640 let mut merged = Vec::new();
641 for value in left.iter().chain(right.iter()) {
642 if !merged.contains(value) {
643 merged.push(value.clone());
644 }
645 }
646
647 merged
648}
649
650fn now_millis() -> u64 {
651 std::time::SystemTime::now()
652 .duration_since(std::time::UNIX_EPOCH)
653 .map_or(0, |duration| {
654 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
655 })
656}
657
658async fn run_blocking_code<T, F>(operation: F) -> Result<T, ApiError>
659where
660 T: Send + 'static,
661 F: FnOnce() -> Result<T, CodeIndexError> + Send + 'static,
662{
663 tokio::task::spawn_blocking(operation)
664 .await
665 .map_err(|error| ApiError::storage_unavailable(error.to_string()))?
666 .map_err(code_api_error)
667}
668
669fn code_api_error(error: CodeIndexError) -> ApiError {
670 match error {
671 CodeIndexError::InvalidInput(message) => ApiError::invalid_argument(message),
672 CodeIndexError::Git { .. } | CodeIndexError::Io(_) | CodeIndexError::TreeSitter(_) => {
673 ApiError::storage_unavailable(error.to_string())
674 }
675 }
676}
677
678pub(super) fn storage_api_error(error: StorageError) -> ApiError {
679 match error {
680 StorageError::InvalidInput(message) => ApiError::invalid_argument(message),
681 other => ApiError::storage_unavailable(other.to_string()),
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::{
689 api::ErrorKind,
690 domain::{
691 CodeRepositoryCrossEdge, CodeRepositorySet, CodeRepositorySetMember,
692 CodeRepositorySetOverlayStatus, CodeRetrievalLayer, RepositoryCodeRange,
693 },
694 storage::SqliteGraphStore,
695 };
696 use std::sync::Arc;
697
698 #[test]
699 fn helper_policy_reports_wait_until_fresh_blockers() {
700 let request = CodeRepositorySetQueryRequest::new(
701 "workspace",
702 "serve",
703 crate::domain::CodeQueryKind::Definition,
704 5,
705 FreshnessPolicy::WaitUntilFresh,
706 Vec::new(),
707 Vec::new(),
708 )
709 .expect("request should validate");
710 let empty = status_with_members(Vec::new(), overlay(true));
711 assert!(
712 unfresh_set_error_for_wait_policy(&request, &empty)
713 .expect("empty set should block")
714 .message
715 .contains("has no members")
716 );
717
718 let mut stale_member = member_status("app", "scope-app", 0);
719 stale_member.stale = true;
720 let stale_status = status_with_members(vec![stale_member], overlay(false));
721 assert!(
722 unfresh_set_error_for_wait_policy(&request, &stale_status)
723 .expect("stale member should block")
724 .message
725 .contains("member 'app'")
726 );
727
728 let overlay_status =
729 status_with_members(vec![member_status("app", "scope-app", 0)], overlay(true));
730 assert!(
731 unfresh_set_error_for_wait_policy(&request, &overlay_status)
732 .expect("stale overlay should block")
733 .message
734 .contains("overlay is stale")
735 );
736
737 let allow_stale = CodeRepositorySetQueryRequest::new(
738 "workspace",
739 "serve",
740 crate::domain::CodeQueryKind::Definition,
741 5,
742 FreshnessPolicy::AllowStale,
743 Vec::new(),
744 Vec::new(),
745 )
746 .expect("request should validate");
747 assert!(unfresh_set_error_for_wait_policy(&allow_stale, &overlay_status).is_none());
748 }
749
750 #[test]
751 fn helper_ranking_dedupes_and_attaches_overlay_evidence() {
752 let member = member_status("app", "scope-app", 7);
753 let base_hit = hit("repo-a", "scope-app", "src/client.rs", 1, 0.75, false);
754 let inbound = edge(
755 "edge-in",
756 "scope-service",
757 Some("scope-app"),
758 r#"{"from_path":"src/service.rs"}"#,
759 9_000,
760 );
761 let outbound = edge(
762 "edge-out",
763 "scope-app",
764 Some("scope-service"),
765 r#"{"from_path":"src/client.rs","from_line_start":1,"from_line_end":1}"#,
766 6_000,
767 );
768 let unrelated = edge(
769 "edge-other",
770 "scope-other",
771 Some("scope-service"),
772 r#"{"from_path":"src/other.rs"}"#,
773 10_000,
774 );
775 let mut wrong_target = edge(
776 "edge-wrong",
777 "scope-service",
778 Some("scope-app"),
779 "{}",
780 8_000,
781 );
782 wrong_target.to_record_id = Some("symbol-other".to_owned());
783 let evidence = overlay_evidence_for_hit(
784 &[inbound.clone(), outbound.clone(), unrelated, wrong_target],
785 &base_hit,
786 );
787 assert_eq!(evidence, vec![inbound]);
788 let mut import_hit = hit("repo-a", "scope-app", "src/client.rs", 1, 0.75, false);
789 import_hit.symbol_snapshot_id = None;
790 import_hit.retrieval_layers = vec![CodeRetrievalLayer::ImportGraph];
791 import_hit.edge_kind = Some("import".to_owned());
792 let outbound_evidence =
793 overlay_evidence_for_hit(std::slice::from_ref(&outbound), &import_hit);
794 assert_eq!(outbound_evidence, vec![outbound]);
795 assert!(repository_set_score(&base_hit, &member, &evidence) > base_hit.score);
796 assert!(
797 repository_set_score(
798 &hit("repo-a", "scope-app", "src/client.rs", 1, 0.75, true),
799 &member,
800 &[]
801 ) < base_hit.score
802 );
803
804 let mut results = vec![
805 CodeRepositorySetQueryHit {
806 member: member.member.clone(),
807 hit: hit("repo-a", "scope-app", "src/client.rs", 1, 0.50, false),
808 overlay_evidence: Vec::new(),
809 score: 0.50,
810 },
811 CodeRepositorySetQueryHit {
812 member: member.member.clone(),
813 hit: hit("repo-a", "scope-app", "src/client.rs", 1, 0.90, false),
814 overlay_evidence: evidence,
815 score: 0.90,
816 },
817 CodeRepositorySetQueryHit {
818 member: member.member.clone(),
819 hit: hit("repo-a", "scope-app", "src/client.rs", 2, 0.80, false),
820 overlay_evidence: Vec::new(),
821 score: 0.80,
822 },
823 ];
824 assert!(dedupe_sort_truncate(&mut results, 1));
825 assert_eq!(results.len(), 1);
826 assert_eq!(results[0].score, 0.90);
827
828 assert_eq!(per_member_candidate_limit(1), 6);
829 assert_eq!(per_member_candidate_limit(20), 50);
830 assert_eq!(
831 merged_filters(&["src".to_owned()], &["src".to_owned(), "tests".to_owned()]),
832 ["src".to_owned(), "tests".to_owned()]
833 );
834 assert!(evidence_origin("not-json").is_none());
835 assert!(evidence_origin("{}").is_none());
836 }
837
838 #[test]
839 fn helper_fingerprint_and_error_mapping_are_stable() {
840 let status = status_with_members(
841 vec![
842 member_status("app", "scope-app", 1),
843 member_status("svc", "scope-svc", 0),
844 ],
845 overlay(false),
846 );
847 let fingerprint = repository_set_refresh_fingerprint(&status);
848 assert!(fingerprint.contains("set-workspace"));
849 assert!(fingerprint.contains("repo-app:scope-app:commit-scope-app:tree-scope-app:false"));
850 assert_eq!(
851 code_api_error(CodeIndexError::InvalidInput("bad ref".to_owned())).error_kind,
852 ErrorKind::InvalidArgument
853 );
854 assert_eq!(
855 code_api_error(CodeIndexError::Io(std::io::Error::other("disk"))).error_kind,
856 ErrorKind::StorageUnavailable
857 );
858 assert_eq!(
859 storage_api_error(StorageError::InvalidInput("bad storage".to_owned())).error_kind,
860 ErrorKind::InvalidArgument
861 );
862 }
863
864 #[tokio::test]
865 async fn helper_required_status_reports_missing_sets() {
866 let store: Arc<dyn crate::storage::KnowledgeStore> =
867 Arc::new(SqliteGraphStore::open_in_memory().expect("store should open"));
868 let error = required_set_status(&store, "missing")
869 .await
870 .expect_err("missing set should fail");
871
872 assert_eq!(error.error_kind, ErrorKind::InvalidArgument);
873 assert!(error.message.contains("is not registered"));
874 }
875
876 fn status_with_members(
877 members: Vec<CodeRepositorySetMemberStatus>,
878 overlay: CodeRepositorySetOverlayStatus,
879 ) -> CodeRepositorySetStatus {
880 CodeRepositorySetStatus {
881 repository_set: CodeRepositorySet {
882 set_id: "set-workspace".to_owned(),
883 alias: "workspace".to_owned(),
884 description: None,
885 default_ref_policy_json: "{\"default_ref\":\"HEAD\"}".to_owned(),
886 created_at_ms: 1,
887 updated_at_ms: 1,
888 },
889 members,
890 overlay,
891 freshness_state: "fresh".to_owned(),
892 degraded_reason: None,
893 }
894 }
895
896 fn member_status(
897 repository_alias: &str,
898 source_scope: &str,
899 priority: i32,
900 ) -> CodeRepositorySetMemberStatus {
901 CodeRepositorySetMemberStatus {
902 member: CodeRepositorySetMember {
903 set_id: "set-workspace".to_owned(),
904 repository_id: format!("repo-{repository_alias}"),
905 repository_alias: repository_alias.to_owned(),
906 ref_selector: "HEAD".to_owned(),
907 resolved_commit_sha: format!("commit-{source_scope}"),
908 source_scope: source_scope.to_owned(),
909 path_filters: vec!["src".to_owned()],
910 language_filters: vec!["rust".to_owned()],
911 priority,
912 },
913 tree_hash: format!("tree-{source_scope}"),
914 freshness_state: "fresh".to_owned(),
915 stale: false,
916 indexed_file_count: 1,
917 symbol_count: 1,
918 reference_count: 0,
919 chunk_count: 1,
920 degraded_reason: None,
921 }
922 }
923
924 fn overlay(stale: bool) -> CodeRepositorySetOverlayStatus {
925 CodeRepositorySetOverlayStatus {
926 state: if stale { "overlay_stale" } else { "fresh" }.to_owned(),
927 stale,
928 edge_count: usize::from(!stale),
929 refreshed_at_ms: (!stale).then_some(10),
930 degraded_reason: None,
931 }
932 }
933
934 fn hit(
935 repository_id: &str,
936 scope_id: &str,
937 path: &str,
938 line: u32,
939 score: f64,
940 stale: bool,
941 ) -> CodeRetrievalHit {
942 CodeRetrievalHit {
943 repository_id: repository_id.to_owned(),
944 scope_id: scope_id.to_owned(),
945 resolved_commit_sha: format!("commit-{scope_id}"),
946 tree_hash: format!("tree-{scope_id}"),
947 path: path.to_owned(),
948 language_id: "rust".to_owned(),
949 byte_range: RepositoryCodeRange { start: 0, end: 10 },
950 line_range: RepositoryCodeRange {
951 start: line,
952 end: line,
953 },
954 symbol_snapshot_id: Some(format!("symbol-{line}")),
955 canonical_symbol_id: None,
956 file_id: Some("file-1".to_owned()),
957 retrieval_layers: vec![CodeRetrievalLayer::Symbol],
958 index_versions: vec!["code:1".to_owned()],
959 stale,
960 degraded_reason: None,
961 edge_kind: None,
962 edge_resolution_state: None,
963 edge_target_hint: None,
964 edge_confidence_basis_points: None,
965 edge_confidence_tier: None,
966 score,
967 excerpt: format!("excerpt {line}"),
968 }
969 }
970
971 fn edge(
972 edge_id: &str,
973 from_scope: &str,
974 to_scope: Option<&str>,
975 evidence_json: &str,
976 confidence: u16,
977 ) -> CodeRepositoryCrossEdge {
978 CodeRepositoryCrossEdge {
979 edge_id: edge_id.to_owned(),
980 set_id: "set-workspace".to_owned(),
981 from_source_scope: from_scope.to_owned(),
982 from_repository_id: "repo-from".to_owned(),
983 from_record_kind: "module_reference".to_owned(),
984 from_record_id: "import-1".to_owned(),
985 to_source_scope: to_scope.map(str::to_owned),
986 to_repository_id: to_scope.map(|_| "repo-to".to_owned()),
987 to_record_kind: "code_symbol_snapshot".to_owned(),
988 to_record_id: to_scope.map(|_| "symbol-1".to_owned()),
989 edge_kind: "imports".to_owned(),
990 resolution_state: "resolved".to_owned(),
991 confidence_basis_points: confidence,
992 confidence_tier: "explicit".to_owned(),
993 evidence_json: evidence_json.to_owned(),
994 created_at_ms: 10,
995 }
996 }
997}