Skip to main content

relay_knowledge/api/
code_repository.rs

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