Skip to main content

relay_knowledge/application/
code_repository_set_service.rs

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        CodeRepositorySetQueryHit, CodeRepositorySetQueryRequest, CodeRepositorySetStatus,
11        CodeRetrievalRequest, FreshnessPolicy,
12    },
13    storage::{
14        CodeRepositorySetMemberSeed, CodeRepositorySetRefreshTaskClaimRequest,
15        CodeRepositorySetRefreshTaskCompletion, CodeRepositorySetRefreshTaskFailure,
16        CodeRepositorySetRefreshTaskSeed, CodeRepositorySetSeed, StorageError,
17    },
18};
19use std::path::PathBuf;
20
21use super::{
22    RelayKnowledgeService,
23    code_repository_set_plan::{
24        dependency_symbol_plan_needs_hybrid_fallback, repository_set_member_query_kind,
25    },
26    code_repository_set_query::{
27        OverlayEvidenceIndex, apply_bridge_support_bonus, dedupe_sort_truncate,
28        per_member_candidate_limit, prune_returned_overlay_evidence, repository_set_score,
29    },
30};
31
32const REPOSITORY_SET_REFRESH_TASK_LEASE_MS: u64 = 10 * 60 * 1000;
33const REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS: u32 = 3;
34const REPOSITORY_SET_REFRESH_TASK_RETRY_BACKOFF_MS: u64 = 60_000;
35
36impl RelayKnowledgeService {
37    /// Creates or updates a thin repository set.
38    pub async fn create_code_repository_set(
39        &self,
40        request: CodeRepositorySetCreateRequest,
41        context: RequestContext,
42    ) -> Result<CodeRepositorySetCreateResponse, ApiError> {
43        let store = self.store().await.map_err(storage_api_error)?;
44        let repository_set = store
45            .create_code_repository_set(CodeRepositorySetSeed {
46                alias: request.alias.clone(),
47                description: request.description.clone(),
48                default_ref_policy_json: request.default_ref_policy_json.clone(),
49                now_ms: now_millis(),
50            })
51            .await
52            .map_err(storage_api_error)?;
53        let graph_version = store
54            .current_graph_version()
55            .await
56            .map_err(storage_api_error)?;
57
58        Ok(CodeRepositorySetCreateResponse {
59            metadata: ApiMetadata::graph_only(&context, graph_version),
60            request,
61            repository_set,
62        })
63    }
64
65    /// Adds one already-indexed repository snapshot to a repository set.
66    pub async fn add_code_repository_set_member(
67        &self,
68        request: CodeRepositorySetAddMemberRequest,
69        context: RequestContext,
70    ) -> Result<CodeRepositorySetAddResponse, ApiError> {
71        let store = self.store().await.map_err(storage_api_error)?;
72        let repository = store
73            .code_repository_status(request.repository_alias.clone())
74            .await
75            .map_err(storage_api_error)?
76            .ok_or_else(|| {
77                ApiError::invalid_argument(format!(
78                    "code repository '{}' is not registered",
79                    request.repository_alias
80                ))
81            })?;
82        let root_path = repository.root_path.clone();
83        let ref_selector = request.ref_selector.clone();
84        let (resolved_commit_sha, _tree_hash) =
85            run_blocking_code(move || resolve_repository_snapshot(root_path, &ref_selector))
86                .await?;
87        let path_filters = merged_filters(&repository.path_filters, &request.path_filters);
88        let language_filters =
89            merged_filters(&repository.language_filters, &request.language_filters);
90        let scope = store
91            .code_repository_scope_status(
92                request.repository_alias.clone(),
93                resolved_commit_sha.clone(),
94                path_filters.clone(),
95                language_filters.clone(),
96            )
97            .await
98            .map_err(storage_api_error)?
99            .ok_or_else(|| {
100                ApiError::invalid_argument(format!(
101                    "code repository '{}' has no indexed scope for ref {} and requested filters",
102                    request.repository_alias, request.ref_selector
103                ))
104            })?;
105        let source_scope = scope.last_indexed_scope_id.clone().ok_or_else(|| {
106            ApiError::invalid_argument(format!(
107                "code repository '{}' matching scope has no source scope",
108                request.repository_alias
109            ))
110        })?;
111        let member = store
112            .add_code_repository_set_member(CodeRepositorySetMemberSeed {
113                set_alias: request.set_alias.clone(),
114                repository_id: repository.repository_id,
115                repository_alias: request.repository_alias.clone(),
116                ref_selector: request.ref_selector.clone(),
117                resolved_commit_sha,
118                source_scope,
119                path_filters,
120                language_filters,
121                priority: request.priority,
122            })
123            .await
124            .map_err(storage_api_error)?;
125        let status = required_set_status(&store, &request.set_alias).await?;
126        let graph_version = store
127            .current_graph_version()
128            .await
129            .map_err(storage_api_error)?;
130
131        Ok(CodeRepositorySetAddResponse {
132            metadata: ApiMetadata::graph_only(&context, graph_version),
133            request,
134            member,
135            status,
136        })
137    }
138
139    /// Queries every member scope and merges ranked candidates without changing single-repo search.
140    pub async fn query_code_repository_set(
141        &self,
142        request: CodeRepositorySetQueryRequest,
143        context: RequestContext,
144    ) -> Result<CodeRepositorySetQueryResponse, ApiError> {
145        let store = self.store().await.map_err(storage_api_error)?;
146        let status = required_set_status(&store, &request.set_alias).await?;
147        let graph_version = store
148            .current_graph_version()
149            .await
150            .map_err(storage_api_error)?;
151        if request.freshness_policy == FreshnessPolicy::GraphOnly {
152            return Ok(CodeRepositorySetQueryResponse {
153                metadata: ApiMetadata::graph_only(&context, graph_version),
154                request,
155                status,
156                results: Vec::new(),
157                truncated: false,
158                degraded_reason: Some("graph_only freshness policy selected".to_owned()),
159            });
160        }
161        if let Some(error) = unfresh_set_error_for_wait_policy(&request, &status) {
162            return Err(error);
163        }
164        let edges = store
165            .code_repository_set_cross_edges(status.repository_set.set_id.clone())
166            .await
167            .map_err(storage_api_error)?;
168        let edge_index = OverlayEvidenceIndex::new(&edges);
169        let mut results = Vec::new();
170        let candidate_limit = per_member_candidate_limit(request.limit, status.members.len());
171        let highest_priority = status
172            .members
173            .iter()
174            .map(|member| member.member.priority)
175            .max()
176            .unwrap_or(0);
177        for member_status in &status.members {
178            let member = &member_status.member;
179            let selector = CodeRepositorySelector::new(
180                member.repository_alias.clone(),
181                member.resolved_commit_sha.clone(),
182                request.path_filters.clone(),
183                request.language_filters.clone(),
184            )
185            .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
186            let member_query_kind =
187                repository_set_member_query_kind(&request, member_status, highest_priority);
188            let search_request = CodeRetrievalRequest::new(
189                request.query.clone(),
190                selector.clone(),
191                member_query_kind,
192                candidate_limit,
193                FreshnessPolicy::AllowStale,
194            )
195            .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
196            let mut hits = store
197                .search_code_scope(member.source_scope.clone(), search_request)
198                .await
199                .map_err(storage_api_error)?;
200            if dependency_symbol_plan_needs_hybrid_fallback(&request, member_query_kind, &hits) {
201                let fallback_request = CodeRetrievalRequest::new(
202                    request.query.clone(),
203                    selector,
204                    request.code_query_kind,
205                    candidate_limit,
206                    FreshnessPolicy::AllowStale,
207                )
208                .map_err(|error| ApiError::invalid_argument(error.to_string()))?;
209                hits = store
210                    .search_code_scope(member.source_scope.clone(), fallback_request)
211                    .await
212                    .map_err(storage_api_error)?;
213            }
214            for hit in hits {
215                let overlay_evidence = edge_index.evidence_for_hit(&hit);
216                let score = repository_set_score(&hit, member_status, &overlay_evidence);
217                results.push(CodeRepositorySetQueryHit {
218                    member: member.clone(),
219                    hit,
220                    overlay_evidence,
221                    score,
222                });
223            }
224        }
225        apply_bridge_support_bonus(&mut results);
226        let truncated = dedupe_sort_truncate(&mut results, request.limit);
227        prune_returned_overlay_evidence(&mut results);
228        let degraded_reason = status.degraded_reason.clone().or_else(|| {
229            status
230                .overlay
231                .stale
232                .then(|| "repository set overlay is stale".to_owned())
233        });
234
235        Ok(CodeRepositorySetQueryResponse {
236            metadata: ApiMetadata::graph_only(&context, graph_version),
237            request,
238            status,
239            results,
240            truncated,
241            degraded_reason,
242        })
243    }
244
245    /// Returns repository-set freshness and member diagnostics.
246    pub async fn code_repository_set_status(
247        &self,
248        set_alias: String,
249        context: RequestContext,
250    ) -> Result<CodeRepositorySetStatusResponse, ApiError> {
251        let store = self.store().await.map_err(storage_api_error)?;
252        let status = required_set_status(&store, &set_alias).await?;
253        let graph_version = store
254            .current_graph_version()
255            .await
256            .map_err(storage_api_error)?;
257
258        Ok(CodeRepositorySetStatusResponse {
259            metadata: ApiMetadata::graph_only(&context, graph_version),
260            status,
261        })
262    }
263
264    /// Rebuilds cross-repository import/module overlay edges.
265    pub async fn refresh_code_repository_set(
266        &self,
267        set_alias: String,
268        context: RequestContext,
269    ) -> Result<CodeRepositorySetRefreshResponse, ApiError> {
270        let store = self.store().await.map_err(storage_api_error)?;
271        let summary = store
272            .refresh_code_repository_set_overlay(set_alias.clone(), now_millis())
273            .await
274            .map_err(storage_api_error)?;
275        let status = required_set_status(&store, &set_alias).await?;
276        let graph_version = store
277            .current_graph_version()
278            .await
279            .map_err(storage_api_error)?;
280
281        Ok(CodeRepositorySetRefreshResponse {
282            metadata: ApiMetadata::graph_only(&context, graph_version),
283            status,
284            summary: Some(summary),
285            task: None,
286        })
287    }
288
289    /// Queues a repository-set overlay refresh task.
290    pub async fn start_code_repository_set_refresh(
291        &self,
292        set_alias: String,
293        context: RequestContext,
294    ) -> Result<CodeRepositorySetRefreshResponse, ApiError> {
295        let store = self.store().await.map_err(storage_api_error)?;
296        let status = required_set_status(&store, &set_alias).await?;
297        let fingerprint = repository_set_refresh_fingerprint(&status);
298        let task = store
299            .queue_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskSeed {
300                set_id: status.repository_set.set_id.clone(),
301                set_alias: status.repository_set.alias.clone(),
302                input_fingerprint: fingerprint,
303                now_ms: now_millis(),
304            })
305            .await
306            .map_err(storage_api_error)?;
307        let graph_version = store
308            .current_graph_version()
309            .await
310            .map_err(storage_api_error)?;
311
312        Ok(CodeRepositorySetRefreshResponse {
313            metadata: ApiMetadata::graph_only(&context, graph_version),
314            status,
315            summary: None,
316            task: Some(task),
317        })
318    }
319
320    /// Runs one queued repository-set overlay refresh task under a lease.
321    pub async fn run_code_repository_set_refresh_task_once(
322        &self,
323        task_id: Option<String>,
324        context: RequestContext,
325    ) -> Result<Option<crate::domain::CodeRepositorySetRefreshTaskRecord>, ApiError> {
326        let store = self.store().await.map_err(storage_api_error)?;
327        let lease_owner = format!("code-repository-set-refresh-worker-{}", std::process::id());
328        let Some(task) = store
329            .claim_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskClaimRequest {
330                task_id,
331                lease_owner: lease_owner.clone(),
332                lease_duration_ms: REPOSITORY_SET_REFRESH_TASK_LEASE_MS,
333                max_attempts: REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS,
334                now_ms: now_millis(),
335            })
336            .await
337            .map_err(storage_api_error)?
338        else {
339            return Ok(None);
340        };
341        let result = self
342            .refresh_code_repository_set(task.set_alias.clone(), context)
343            .await;
344        match result {
345            Ok(_) => store
346                .complete_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskCompletion {
347                    task_id: task.task_id,
348                    lease_owner,
349                    attempt_count: task.attempt_count,
350                    now_ms: now_millis(),
351                })
352                .await
353                .map(Some)
354                .map_err(storage_api_error),
355            Err(error) => {
356                let _ = store
357                    .fail_code_repository_set_refresh_task(CodeRepositorySetRefreshTaskFailure {
358                        task_id: task.task_id,
359                        lease_owner,
360                        attempt_count: task.attempt_count,
361                        error_kind: "repository_set_overlay".to_owned(),
362                        error_message: error.message.clone(),
363                        retry_backoff_ms: REPOSITORY_SET_REFRESH_TASK_RETRY_BACKOFF_MS,
364                        max_attempts: REPOSITORY_SET_REFRESH_TASK_MAX_ATTEMPTS,
365                        now_ms: now_millis(),
366                    })
367                    .await;
368                Err(error)
369            }
370        }
371    }
372
373    pub(crate) async fn code_repository_set_member_scopes(
374        &self,
375        set_alias: String,
376    ) -> Result<Option<Vec<(String, String)>>, ApiError> {
377        let store = self.store().await.map_err(storage_api_error)?;
378        store
379            .code_repository_set_status(set_alias)
380            .await
381            .map(|status| {
382                status.map(|status| {
383                    status
384                        .members
385                        .into_iter()
386                        .map(|member| (member.member.repository_alias, member.member.source_scope))
387                        .collect()
388                })
389            })
390            .map_err(storage_api_error)
391    }
392}
393
394pub(super) async fn required_set_status(
395    store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
396    set_alias: &str,
397) -> Result<CodeRepositorySetStatus, ApiError> {
398    let mut status = store
399        .code_repository_set_status(set_alias.to_owned())
400        .await
401        .map_err(storage_api_error)?
402        .ok_or_else(|| {
403            ApiError::invalid_argument(format!(
404                "code repository set '{set_alias}' is not registered"
405            ))
406        })?;
407    refresh_moving_member_freshness(store, &mut status).await?;
408    refresh_repository_set_freshness(&mut status);
409
410    Ok(status)
411}
412
413async fn refresh_moving_member_freshness(
414    store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
415    status: &mut CodeRepositorySetStatus,
416) -> Result<(), ApiError> {
417    for index in 0..status.members.len() {
418        let member = status.members[index].member.clone();
419        let Some(reason) = moving_member_stale_reason(store, &member).await? else {
420            continue;
421        };
422        status.members[index].stale = true;
423        status.members[index].freshness_state = "stale".to_owned();
424        status.members[index].degraded_reason = Some(reason);
425    }
426
427    Ok(())
428}
429
430async fn moving_member_stale_reason(
431    store: &std::sync::Arc<dyn crate::storage::KnowledgeStore>,
432    member: &crate::domain::CodeRepositorySetMember,
433) -> Result<Option<String>, ApiError> {
434    if !member_ref_tracks_repository(&member.ref_selector, &member.resolved_commit_sha) {
435        return Ok(None);
436    }
437    let repository = store
438        .code_repository_status(member.repository_id.clone())
439        .await
440        .map_err(storage_api_error)?
441        .ok_or_else(|| {
442            ApiError::invalid_argument(format!(
443                "code repository '{}' is not registered",
444                member.repository_alias
445            ))
446        })?;
447    let root_path = PathBuf::from(repository.root_path);
448    let ref_selector = member.ref_selector.clone();
449    let resolved =
450        tokio::task::spawn_blocking(move || resolve_repository_snapshot(root_path, &ref_selector))
451            .await
452            .map_err(|error| ApiError::storage_unavailable(error.to_string()))?;
453
454    match resolved {
455        Ok((current_commit, _)) if current_commit == member.resolved_commit_sha => Ok(None),
456        Ok((current_commit, _)) => Ok(Some(format!(
457            "repository set member '{}' ref '{}' now resolves to {}, not stored snapshot {}",
458            member.repository_alias,
459            member.ref_selector,
460            current_commit,
461            member.resolved_commit_sha
462        ))),
463        Err(error) => Ok(Some(format!(
464            "repository set member '{}' ref '{}' could not be resolved: {error}",
465            member.repository_alias, member.ref_selector
466        ))),
467    }
468}
469
470fn member_ref_tracks_repository(ref_selector: &str, resolved_commit_sha: &str) -> bool {
471    let ref_selector = ref_selector.trim();
472    !(ref_selector == resolved_commit_sha
473        || (is_git_oid_prefix(ref_selector) && resolved_commit_sha.starts_with(ref_selector)))
474}
475
476fn is_git_oid_prefix(value: &str) -> bool {
477    (7..=64).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
478}
479
480fn refresh_repository_set_freshness(status: &mut CodeRepositorySetStatus) {
481    let member_stale = status.members.iter().any(|member| member.stale);
482    if member_stale && !status.overlay.stale {
483        status.overlay.stale = true;
484        status.overlay.state = "overlay_stale".to_owned();
485    }
486    status.freshness_state = if status.members.is_empty() {
487        "incomplete"
488    } else if member_stale {
489        "stale"
490    } else if status.overlay.stale {
491        "overlay_stale"
492    } else {
493        "fresh"
494    }
495    .to_owned();
496    status.degraded_reason = status
497        .members
498        .iter()
499        .find_map(|member| member.degraded_reason.clone())
500        .or_else(|| status.overlay.degraded_reason.clone());
501}
502
503fn unfresh_set_error_for_wait_policy(
504    request: &CodeRepositorySetQueryRequest,
505    status: &CodeRepositorySetStatus,
506) -> Option<ApiError> {
507    if request.freshness_policy != FreshnessPolicy::WaitUntilFresh {
508        return None;
509    }
510    if status.members.is_empty() {
511        return Some(ApiError::invalid_argument(format!(
512            "code repository set '{}' has no members",
513            status.repository_set.alias
514        )));
515    }
516    if let Some(member) = status.members.iter().find(|member| member.stale) {
517        return Some(ApiError::invalid_argument(format!(
518            "code repository set '{}' member '{}' scope '{}' is stale",
519            status.repository_set.alias, member.member.repository_alias, member.member.source_scope
520        )));
521    }
522    if status.overlay.stale {
523        return Some(ApiError::invalid_argument(format!(
524            "code repository set '{}' overlay is stale; run repo-set refresh before querying with wait_until_fresh",
525            status.repository_set.alias
526        )));
527    }
528
529    None
530}
531
532fn repository_set_refresh_fingerprint(status: &CodeRepositorySetStatus) -> String {
533    let mut parts = vec![status.repository_set.set_id.clone()];
534    parts.extend(status.members.iter().map(|member| {
535        format!(
536            "{}:{}:{}:{}:{}",
537            member.member.repository_id,
538            member.member.source_scope,
539            member.member.resolved_commit_sha,
540            member.tree_hash,
541            member.stale
542        )
543    }));
544    parts.join("|")
545}
546
547fn merged_filters(left: &[String], right: &[String]) -> Vec<String> {
548    let mut merged = Vec::new();
549    for value in left.iter().chain(right.iter()) {
550        if !merged.contains(value) {
551            merged.push(value.clone());
552        }
553    }
554
555    merged
556}
557
558fn now_millis() -> u64 {
559    std::time::SystemTime::now()
560        .duration_since(std::time::UNIX_EPOCH)
561        .map_or(0, |duration| {
562            u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
563        })
564}
565
566async fn run_blocking_code<T, F>(operation: F) -> Result<T, ApiError>
567where
568    T: Send + 'static,
569    F: FnOnce() -> Result<T, CodeIndexError> + Send + 'static,
570{
571    tokio::task::spawn_blocking(operation)
572        .await
573        .map_err(|error| ApiError::storage_unavailable(error.to_string()))?
574        .map_err(code_api_error)
575}
576
577fn code_api_error(error: CodeIndexError) -> ApiError {
578    match error {
579        CodeIndexError::InvalidInput(message) => ApiError::invalid_argument(message),
580        CodeIndexError::Git { .. } | CodeIndexError::Io(_) | CodeIndexError::TreeSitter(_) => {
581            ApiError::storage_unavailable(error.to_string())
582        }
583    }
584}
585
586pub(super) fn storage_api_error(error: StorageError) -> ApiError {
587    match error {
588        StorageError::InvalidInput(message) => ApiError::invalid_argument(message),
589        other => ApiError::storage_unavailable(other.to_string()),
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::{
597        api::ErrorKind,
598        domain::CodeRepositorySetMemberStatus,
599        domain::{CodeRepositorySet, CodeRepositorySetMember, CodeRepositorySetOverlayStatus},
600        storage::SqliteGraphStore,
601    };
602    use std::sync::Arc;
603
604    #[test]
605    fn helper_policy_reports_wait_until_fresh_blockers() {
606        let request = CodeRepositorySetQueryRequest::new(
607            "workspace",
608            "serve",
609            crate::domain::CodeQueryKind::Definition,
610            5,
611            FreshnessPolicy::WaitUntilFresh,
612            Vec::new(),
613            Vec::new(),
614        )
615        .expect("request should validate");
616        let empty = status_with_members(Vec::new(), overlay(true));
617        assert!(
618            unfresh_set_error_for_wait_policy(&request, &empty)
619                .expect("empty set should block")
620                .message
621                .contains("has no members")
622        );
623
624        let mut stale_member = member_status("app", "scope-app", 0);
625        stale_member.stale = true;
626        let stale_status = status_with_members(vec![stale_member], overlay(false));
627        assert!(
628            unfresh_set_error_for_wait_policy(&request, &stale_status)
629                .expect("stale member should block")
630                .message
631                .contains("member 'app'")
632        );
633
634        let overlay_status =
635            status_with_members(vec![member_status("app", "scope-app", 0)], overlay(true));
636        assert!(
637            unfresh_set_error_for_wait_policy(&request, &overlay_status)
638                .expect("stale overlay should block")
639                .message
640                .contains("overlay is stale")
641        );
642
643        let allow_stale = CodeRepositorySetQueryRequest::new(
644            "workspace",
645            "serve",
646            crate::domain::CodeQueryKind::Definition,
647            5,
648            FreshnessPolicy::AllowStale,
649            Vec::new(),
650            Vec::new(),
651        )
652        .expect("request should validate");
653        assert!(unfresh_set_error_for_wait_policy(&allow_stale, &overlay_status).is_none());
654    }
655
656    #[test]
657    fn helper_fingerprint_and_error_mapping_are_stable() {
658        let status = status_with_members(
659            vec![
660                member_status("app", "scope-app", 1),
661                member_status("svc", "scope-svc", 0),
662            ],
663            overlay(false),
664        );
665        let fingerprint = repository_set_refresh_fingerprint(&status);
666        assert!(fingerprint.contains("set-workspace"));
667        assert!(fingerprint.contains("repo-app:scope-app:commit-scope-app:tree-scope-app:false"));
668        assert_eq!(
669            merged_filters(&["src".to_owned()], &["src".to_owned(), "tests".to_owned()]),
670            ["src".to_owned(), "tests".to_owned()]
671        );
672        assert_eq!(
673            code_api_error(CodeIndexError::InvalidInput("bad ref".to_owned())).error_kind,
674            ErrorKind::InvalidArgument
675        );
676        assert_eq!(
677            code_api_error(CodeIndexError::Io(std::io::Error::other("disk"))).error_kind,
678            ErrorKind::StorageUnavailable
679        );
680        assert_eq!(
681            storage_api_error(StorageError::InvalidInput("bad storage".to_owned())).error_kind,
682            ErrorKind::InvalidArgument
683        );
684    }
685
686    #[tokio::test]
687    async fn helper_required_status_reports_missing_sets() {
688        let store: Arc<dyn crate::storage::KnowledgeStore> =
689            Arc::new(SqliteGraphStore::open_in_memory().expect("store should open"));
690        let error = required_set_status(&store, "missing")
691            .await
692            .expect_err("missing set should fail");
693
694        assert_eq!(error.error_kind, ErrorKind::InvalidArgument);
695        assert!(error.message.contains("is not registered"));
696    }
697
698    fn status_with_members(
699        members: Vec<CodeRepositorySetMemberStatus>,
700        overlay: CodeRepositorySetOverlayStatus,
701    ) -> CodeRepositorySetStatus {
702        CodeRepositorySetStatus {
703            repository_set: CodeRepositorySet {
704                set_id: "set-workspace".to_owned(),
705                alias: "workspace".to_owned(),
706                description: None,
707                default_ref_policy_json: "{\"default_ref\":\"HEAD\"}".to_owned(),
708                created_at_ms: 1,
709                updated_at_ms: 1,
710            },
711            members,
712            overlay,
713            freshness_state: "fresh".to_owned(),
714            degraded_reason: None,
715        }
716    }
717
718    fn member_status(
719        repository_alias: &str,
720        source_scope: &str,
721        priority: i32,
722    ) -> CodeRepositorySetMemberStatus {
723        CodeRepositorySetMemberStatus {
724            member: CodeRepositorySetMember {
725                set_id: "set-workspace".to_owned(),
726                repository_id: format!("repo-{repository_alias}"),
727                repository_alias: repository_alias.to_owned(),
728                ref_selector: "HEAD".to_owned(),
729                resolved_commit_sha: format!("commit-{source_scope}"),
730                source_scope: source_scope.to_owned(),
731                path_filters: vec!["src".to_owned()],
732                language_filters: vec!["rust".to_owned()],
733                priority,
734            },
735            tree_hash: format!("tree-{source_scope}"),
736            freshness_state: "fresh".to_owned(),
737            stale: false,
738            indexed_file_count: 1,
739            symbol_count: 1,
740            reference_count: 0,
741            chunk_count: 1,
742            degraded_reason: None,
743        }
744    }
745
746    fn overlay(stale: bool) -> CodeRepositorySetOverlayStatus {
747        CodeRepositorySetOverlayStatus {
748            state: if stale { "overlay_stale" } else { "fresh" }.to_owned(),
749            stale,
750            edge_count: usize::from(!stale),
751            refreshed_at_ms: (!stale).then_some(10),
752            degraded_reason: None,
753        }
754    }
755}