Skip to main content

relay_knowledge/api/contracts/
code_repository.rs

1use serde::{Deserialize, Serialize};
2
3use crate::domain::{
4    CodeGraphContextBudget, CodeGraphContextPack, CodeGraphContextRequest, CodeIndexCheckpoint,
5    CodeIndexTaskQueueStatus, CodeIndexTaskRecord, CodeRepositorySelector, CodeRepositoryStatus,
6    CodeRetrievalLayer, FreshnessPolicy,
7};
8
9use super::ApiMetadata;
10
11/// Code repository scope and index metadata attached to code responses.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct CodeRepositoryScopeMetadata {
14    pub scope_id: String,
15    pub repository_id: String,
16    pub alias: String,
17    pub requested_ref: String,
18    pub resolved_commit_sha: String,
19    pub tree_hash: String,
20    pub path_filters: Vec<String>,
21    pub language_filters: Vec<String>,
22    #[serde(default)]
23    pub indexed_file_count: usize,
24    pub index_versions: Vec<String>,
25    pub stale: bool,
26}
27
28impl CodeRepositoryScopeMetadata {
29    /// Builds stable scope metadata from the selected repository snapshot.
30    pub fn from_status(
31        status: &CodeRepositoryStatus,
32        selector: &CodeRepositorySelector,
33        requested_ref: impl Into<String>,
34    ) -> Self {
35        Self {
36            scope_id: status.last_indexed_scope_id.clone().unwrap_or_default(),
37            repository_id: status.repository_id.clone(),
38            alias: status.alias.clone(),
39            requested_ref: requested_ref.into(),
40            resolved_commit_sha: status.last_indexed_commit.clone().unwrap_or_default(),
41            tree_hash: status.tree_hash.clone().unwrap_or_default(),
42            path_filters: merged_filters(&status.path_filters, &selector.path_filters),
43            language_filters: merged_filters(&status.language_filters, &selector.language_filters),
44            indexed_file_count: status.indexed_file_count,
45            index_versions: vec![format!(
46                "code:{}:{}",
47                status
48                    .last_indexed_scope_id
49                    .as_deref()
50                    .unwrap_or("unscoped"),
51                status.tree_hash.as_deref().unwrap_or("unindexed")
52            )],
53            stale: status.stale,
54        }
55    }
56
57    /// Builds scope metadata for a queued or running index task.
58    pub fn from_index_task(task: &CodeIndexTaskRecord, requested_ref: impl Into<String>) -> Self {
59        Self {
60            scope_id: task.source_scope.clone(),
61            repository_id: task.repository_id.clone(),
62            alias: task.alias.clone(),
63            requested_ref: requested_ref.into(),
64            resolved_commit_sha: task.resolved_commit_sha.clone(),
65            tree_hash: task.tree_hash.clone(),
66            path_filters: task.path_filters.clone(),
67            language_filters: task.language_filters.clone(),
68            indexed_file_count: 0,
69            index_versions: vec![format!("code:{}:{}", task.source_scope, task.tree_hash)],
70            stale: true,
71        }
72    }
73}
74
75fn merged_filters(base: &[String], request: &[String]) -> Vec<String> {
76    let mut merged = Vec::new();
77    for value in base.iter().chain(request.iter()) {
78        if !merged.contains(value) {
79            merged.push(value.clone());
80        }
81    }
82
83    merged
84}
85
86/// Freshness state for a code repository graph answer.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum CodeRepositoryFreshnessState {
90    Fresh,
91    Pending,
92    Stale,
93    Degraded,
94}
95
96/// Durable code-index cursor/checkpoint surfaced with graph answers.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct CodeRepositoryFreshnessCursor {
99    pub source_scope: String,
100    pub checkpoint_state: String,
101    pub total_path_count: usize,
102    pub parsed_file_count: usize,
103    pub committed_file_count: usize,
104    pub committed_symbol_count: usize,
105    pub committed_reference_count: usize,
106    pub committed_chunk_count: usize,
107    pub batch_count: usize,
108    pub pending_file_count: usize,
109    pub updated_at_ms: u64,
110}
111
112impl CodeRepositoryFreshnessCursor {
113    pub fn from_checkpoint(checkpoint: &CodeIndexCheckpoint) -> Self {
114        Self {
115            source_scope: checkpoint.source_scope.clone(),
116            checkpoint_state: checkpoint.state.clone(),
117            total_path_count: checkpoint.total_path_count,
118            parsed_file_count: checkpoint.parsed_file_count,
119            committed_file_count: checkpoint.committed_file_count,
120            committed_symbol_count: checkpoint.committed_symbol_count,
121            committed_reference_count: checkpoint.committed_reference_count,
122            committed_chunk_count: checkpoint.committed_chunk_count,
123            batch_count: checkpoint.batch_count,
124            pending_file_count: checkpoint
125                .total_path_count
126                .saturating_sub(checkpoint.committed_file_count),
127            updated_at_ms: checkpoint.updated_at_ms,
128        }
129    }
130}
131
132/// Pending code-index work that can make a graph answer stale.
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134pub struct CodeRepositoryPendingIndexWork {
135    pub active_for_repository: bool,
136    pub active_matches_request: bool,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub active_task_id: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub active_task_state: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub active_task_source_scope: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub active_task_ref_selector: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub active_task_resolved_commit_sha: Option<String>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub active_task_lease_expires_at_ms: Option<u64>,
149    pub queue_depth: usize,
150    pub queued_task_count: usize,
151    pub running_task_count: usize,
152    pub retrying_task_count: usize,
153    pub dead_letter_task_count: usize,
154    pub running_lease_count: usize,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub last_error: Option<String>,
157}
158
159impl CodeRepositoryPendingIndexWork {
160    pub fn from_task_and_queue(
161        task: Option<&CodeIndexTaskRecord>,
162        active_matches_request: bool,
163        queue: CodeIndexTaskQueueStatus,
164    ) -> Self {
165        let queue_depth = queue
166            .queued_task_count
167            .saturating_add(queue.running_task_count)
168            .saturating_add(queue.retrying_task_count);
169
170        Self {
171            active_for_repository: task.is_some(),
172            active_matches_request,
173            active_task_id: task.map(|task| task.task_id.clone()),
174            active_task_state: task.map(|task| task.state.as_str().to_owned()),
175            active_task_source_scope: task.map(|task| task.source_scope.clone()),
176            active_task_ref_selector: task.map(|task| task.ref_selector.clone()),
177            active_task_resolved_commit_sha: task.map(|task| task.resolved_commit_sha.clone()),
178            active_task_lease_expires_at_ms: task.and_then(|task| task.lease_expires_at_ms),
179            queue_depth,
180            queued_task_count: queue.queued_task_count,
181            running_task_count: queue.running_task_count,
182            retrying_task_count: queue.retrying_task_count,
183            dead_letter_task_count: queue.dead_letter_task_count,
184            running_lease_count: queue.running_lease_count,
185            last_error: queue.last_error,
186        }
187    }
188}
189
190/// Ref and file-count lag between requested source and served graph state.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct CodeRepositoryIndexLag {
193    pub requested_ref: String,
194    pub requested_resolved_ref: String,
195    pub served_ref: String,
196    pub requested_ref_indexed: bool,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub pending_file_count: Option<usize>,
199    pub pending_task_count: usize,
200}
201
202/// Freshness governance fields returned with code graph answers.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct CodeRepositoryFreshnessDiagnostics {
205    pub state: CodeRepositoryFreshnessState,
206    pub freshness_policy: FreshnessPolicy,
207    pub graph_version: u64,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub source_scope: Option<String>,
210    pub scope_stale: bool,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub stale_reason: Option<String>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub degraded_reason: Option<String>,
215    pub index_lag: CodeRepositoryIndexLag,
216    pub pending: CodeRepositoryPendingIndexWork,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub cursor: Option<CodeRepositoryFreshnessCursor>,
219    pub direct_source_read_required: bool,
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub direct_source_read_paths: Vec<String>,
222    #[serde(default, skip_serializing_if = "Vec::is_empty")]
223    pub agent_instructions: Vec<String>,
224}
225
226impl CodeRepositoryFreshnessDiagnostics {
227    pub fn legacy_unknown() -> Self {
228        Self {
229            state: CodeRepositoryFreshnessState::Degraded,
230            freshness_policy: FreshnessPolicy::AllowStale,
231            graph_version: 0,
232            source_scope: None,
233            scope_stale: false,
234            stale_reason: None,
235            degraded_reason: Some(
236                "remote response did not include freshness diagnostics".to_owned(),
237            ),
238            index_lag: CodeRepositoryIndexLag {
239                requested_ref: String::new(),
240                requested_resolved_ref: String::new(),
241                served_ref: String::new(),
242                requested_ref_indexed: false,
243                pending_file_count: None,
244                pending_task_count: 0,
245            },
246            pending: CodeRepositoryPendingIndexWork::default(),
247            cursor: None,
248            direct_source_read_required: false,
249            direct_source_read_paths: Vec::new(),
250            agent_instructions: Vec::new(),
251        }
252    }
253
254    pub(crate) fn code_query(input: CodeRepositoryFreshnessInput) -> Self {
255        let requested_ref_indexed =
256            !input.scope_stale && input.requested_resolved_ref == input.served_ref;
257        let pending_file_count = input
258            .cursor
259            .as_ref()
260            .map(|cursor| cursor.pending_file_count);
261        let pending_task_count = input.pending.queue_depth;
262        let direct_source_read_required = !requested_ref_indexed || input.scope_stale;
263        let state = freshness_state(
264            direct_source_read_required,
265            input.pending.active_matches_request,
266            input.scope_stale,
267            input.degraded_reason.as_ref(),
268        );
269        let agent_instructions = source_read_instructions(
270            direct_source_read_required,
271            &input.requested_ref,
272            &input.served_ref,
273            &input.direct_source_read_paths,
274        );
275
276        Self {
277            state,
278            freshness_policy: input.freshness_policy,
279            graph_version: input.graph_version,
280            source_scope: input.source_scope,
281            scope_stale: input.scope_stale,
282            stale_reason: input.stale_reason,
283            degraded_reason: input.degraded_reason,
284            index_lag: CodeRepositoryIndexLag {
285                requested_ref: input.requested_ref,
286                requested_resolved_ref: input.requested_resolved_ref,
287                served_ref: input.served_ref,
288                requested_ref_indexed,
289                pending_file_count,
290                pending_task_count,
291            },
292            pending: input.pending,
293            cursor: input.cursor,
294            direct_source_read_required,
295            direct_source_read_paths: input.direct_source_read_paths,
296            agent_instructions,
297        }
298    }
299
300    pub(crate) fn graph_only(
301        graph_version: u64,
302        freshness_policy: FreshnessPolicy,
303        source_scope: Option<String>,
304        requested_ref: String,
305        degraded_reason: String,
306    ) -> Self {
307        let input = CodeRepositoryFreshnessInput {
308            graph_version,
309            freshness_policy,
310            source_scope,
311            requested_ref: requested_ref.clone(),
312            requested_resolved_ref: requested_ref.clone(),
313            served_ref: requested_ref,
314            scope_stale: false,
315            stale_reason: None,
316            degraded_reason: Some(degraded_reason),
317            pending: CodeRepositoryPendingIndexWork::default(),
318            cursor: None,
319            direct_source_read_paths: Vec::new(),
320        };
321
322        Self::code_query(input)
323    }
324
325    pub(crate) fn merge_direct_source_read_paths(
326        &mut self,
327        paths: impl IntoIterator<Item = String>,
328    ) {
329        let mut merged = self
330            .direct_source_read_paths
331            .iter()
332            .cloned()
333            .collect::<std::collections::BTreeSet<_>>();
334        merged.extend(paths);
335        self.direct_source_read_paths = merged.into_iter().collect();
336        self.agent_instructions = source_read_instructions(
337            self.direct_source_read_required,
338            &self.index_lag.requested_ref,
339            &self.index_lag.served_ref,
340            &self.direct_source_read_paths,
341        );
342    }
343}
344
345pub(crate) struct CodeRepositoryFreshnessInput {
346    pub graph_version: u64,
347    pub freshness_policy: FreshnessPolicy,
348    pub source_scope: Option<String>,
349    pub requested_ref: String,
350    pub requested_resolved_ref: String,
351    pub served_ref: String,
352    pub scope_stale: bool,
353    pub stale_reason: Option<String>,
354    pub degraded_reason: Option<String>,
355    pub pending: CodeRepositoryPendingIndexWork,
356    pub cursor: Option<CodeRepositoryFreshnessCursor>,
357    pub direct_source_read_paths: Vec<String>,
358}
359
360/// Agent-facing one-call code graph context response.
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct CodeGraphContextResponse {
363    pub metadata: ApiMetadata,
364    pub query: String,
365    pub repository_scope: CodeRepositoryScopeMetadata,
366    #[serde(default = "CodeRepositoryFreshnessDiagnostics::legacy_unknown")]
367    pub freshness: CodeRepositoryFreshnessDiagnostics,
368    pub budget: CodeGraphContextBudget,
369    pub truncated: bool,
370    pub retrieval_layers: Vec<CodeRetrievalLayer>,
371    pub request: CodeGraphContextRequest,
372    #[serde(flatten)]
373    pub pack: CodeGraphContextPack,
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    pub diagnostics: Vec<String>,
376}
377
378fn freshness_state(
379    direct_source_read_required: bool,
380    active_matches_request: bool,
381    scope_stale: bool,
382    degraded_reason: Option<&String>,
383) -> CodeRepositoryFreshnessState {
384    if direct_source_read_required && active_matches_request {
385        CodeRepositoryFreshnessState::Pending
386    } else if scope_stale || direct_source_read_required {
387        CodeRepositoryFreshnessState::Stale
388    } else if degraded_reason.is_some() {
389        CodeRepositoryFreshnessState::Degraded
390    } else {
391        CodeRepositoryFreshnessState::Fresh
392    }
393}
394
395fn source_read_instructions(
396    required: bool,
397    requested_ref: &str,
398    served_ref: &str,
399    paths: &[String],
400) -> Vec<String> {
401    if !required {
402        return Vec::new();
403    }
404    let mut instructions = vec![format!(
405        "Code graph results were served from indexed ref {served_ref}; read direct source before relying on files changed at requested ref {requested_ref}."
406    )];
407    if !paths.is_empty() {
408        instructions.push(format!(
409            "Verify returned paths from direct source before editing or citing them: {}.",
410            paths.join(", ")
411        ));
412    }
413
414    instructions
415}
416
417#[cfg(test)]
418#[path = "code_repository_tests.rs"]
419mod tests;