Skip to main content

relay_knowledge/api/
code_repository.rs

1use serde::{Deserialize, Serialize};
2
3use crate::domain::{
4    CodeIndexCheckpoint, CodeIndexTaskQueueStatus, CodeIndexTaskRecord, FreshnessPolicy,
5};
6
7/// Freshness state for a code repository graph answer.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum CodeRepositoryFreshnessState {
11    Fresh,
12    Pending,
13    Stale,
14    Degraded,
15}
16
17/// Durable code-index cursor/checkpoint surfaced with graph answers.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct CodeRepositoryFreshnessCursor {
20    pub source_scope: String,
21    pub checkpoint_state: String,
22    pub total_path_count: usize,
23    pub parsed_file_count: usize,
24    pub committed_file_count: usize,
25    pub committed_symbol_count: usize,
26    pub committed_reference_count: usize,
27    pub committed_chunk_count: usize,
28    pub batch_count: usize,
29    pub pending_file_count: usize,
30    pub updated_at_ms: u64,
31}
32
33impl CodeRepositoryFreshnessCursor {
34    pub fn from_checkpoint(checkpoint: &CodeIndexCheckpoint) -> Self {
35        Self {
36            source_scope: checkpoint.source_scope.clone(),
37            checkpoint_state: checkpoint.state.clone(),
38            total_path_count: checkpoint.total_path_count,
39            parsed_file_count: checkpoint.parsed_file_count,
40            committed_file_count: checkpoint.committed_file_count,
41            committed_symbol_count: checkpoint.committed_symbol_count,
42            committed_reference_count: checkpoint.committed_reference_count,
43            committed_chunk_count: checkpoint.committed_chunk_count,
44            batch_count: checkpoint.batch_count,
45            pending_file_count: checkpoint
46                .total_path_count
47                .saturating_sub(checkpoint.committed_file_count),
48            updated_at_ms: checkpoint.updated_at_ms,
49        }
50    }
51}
52
53/// Pending code-index work that can make a graph answer stale.
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct CodeRepositoryPendingIndexWork {
56    pub active_for_repository: bool,
57    pub active_matches_request: bool,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub active_task_id: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub active_task_state: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub active_task_source_scope: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub active_task_ref_selector: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub active_task_resolved_commit_sha: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub active_task_lease_expires_at_ms: Option<u64>,
70    pub queue_depth: usize,
71    pub queued_task_count: usize,
72    pub running_task_count: usize,
73    pub retrying_task_count: usize,
74    pub dead_letter_task_count: usize,
75    pub running_lease_count: usize,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub last_error: Option<String>,
78}
79
80impl CodeRepositoryPendingIndexWork {
81    pub fn from_task_and_queue(
82        task: Option<&CodeIndexTaskRecord>,
83        active_matches_request: bool,
84        queue: CodeIndexTaskQueueStatus,
85    ) -> Self {
86        let queue_depth = queue
87            .queued_task_count
88            .saturating_add(queue.running_task_count)
89            .saturating_add(queue.retrying_task_count);
90
91        Self {
92            active_for_repository: task.is_some(),
93            active_matches_request,
94            active_task_id: task.map(|task| task.task_id.clone()),
95            active_task_state: task.map(|task| task.state.as_str().to_owned()),
96            active_task_source_scope: task.map(|task| task.source_scope.clone()),
97            active_task_ref_selector: task.map(|task| task.ref_selector.clone()),
98            active_task_resolved_commit_sha: task.map(|task| task.resolved_commit_sha.clone()),
99            active_task_lease_expires_at_ms: task.and_then(|task| task.lease_expires_at_ms),
100            queue_depth,
101            queued_task_count: queue.queued_task_count,
102            running_task_count: queue.running_task_count,
103            retrying_task_count: queue.retrying_task_count,
104            dead_letter_task_count: queue.dead_letter_task_count,
105            running_lease_count: queue.running_lease_count,
106            last_error: queue.last_error,
107        }
108    }
109}
110
111/// Ref and file-count lag between requested source and served graph state.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct CodeRepositoryIndexLag {
114    pub requested_ref: String,
115    pub requested_resolved_ref: String,
116    pub served_ref: String,
117    pub requested_ref_indexed: bool,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub pending_file_count: Option<usize>,
120    pub pending_task_count: usize,
121}
122
123/// Freshness governance fields returned with code graph answers.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct CodeRepositoryFreshnessDiagnostics {
126    pub state: CodeRepositoryFreshnessState,
127    pub freshness_policy: FreshnessPolicy,
128    pub graph_version: u64,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub source_scope: Option<String>,
131    pub scope_stale: bool,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub stale_reason: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub degraded_reason: Option<String>,
136    pub index_lag: CodeRepositoryIndexLag,
137    pub pending: CodeRepositoryPendingIndexWork,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub cursor: Option<CodeRepositoryFreshnessCursor>,
140    pub direct_source_read_required: bool,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub direct_source_read_paths: Vec<String>,
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub agent_instructions: Vec<String>,
145}
146
147impl CodeRepositoryFreshnessDiagnostics {
148    pub fn legacy_unknown() -> Self {
149        Self {
150            state: CodeRepositoryFreshnessState::Degraded,
151            freshness_policy: FreshnessPolicy::AllowStale,
152            graph_version: 0,
153            source_scope: None,
154            scope_stale: false,
155            stale_reason: None,
156            degraded_reason: Some(
157                "remote response did not include freshness diagnostics".to_owned(),
158            ),
159            index_lag: CodeRepositoryIndexLag {
160                requested_ref: String::new(),
161                requested_resolved_ref: String::new(),
162                served_ref: String::new(),
163                requested_ref_indexed: false,
164                pending_file_count: None,
165                pending_task_count: 0,
166            },
167            pending: CodeRepositoryPendingIndexWork::default(),
168            cursor: None,
169            direct_source_read_required: false,
170            direct_source_read_paths: Vec::new(),
171            agent_instructions: Vec::new(),
172        }
173    }
174
175    pub(crate) fn code_query(input: CodeRepositoryFreshnessInput) -> Self {
176        let requested_ref_indexed =
177            !input.scope_stale && input.requested_resolved_ref == input.served_ref;
178        let pending_file_count = input
179            .cursor
180            .as_ref()
181            .map(|cursor| cursor.pending_file_count);
182        let pending_task_count = input.pending.queue_depth;
183        let direct_source_read_required = !requested_ref_indexed || input.scope_stale;
184        let state = freshness_state(
185            direct_source_read_required,
186            input.pending.active_matches_request,
187            input.scope_stale,
188            input.degraded_reason.as_ref(),
189        );
190        let agent_instructions = source_read_instructions(
191            direct_source_read_required,
192            &input.requested_ref,
193            &input.served_ref,
194            &input.direct_source_read_paths,
195        );
196
197        Self {
198            state,
199            freshness_policy: input.freshness_policy,
200            graph_version: input.graph_version,
201            source_scope: input.source_scope,
202            scope_stale: input.scope_stale,
203            stale_reason: input.stale_reason,
204            degraded_reason: input.degraded_reason,
205            index_lag: CodeRepositoryIndexLag {
206                requested_ref: input.requested_ref,
207                requested_resolved_ref: input.requested_resolved_ref,
208                served_ref: input.served_ref,
209                requested_ref_indexed,
210                pending_file_count,
211                pending_task_count,
212            },
213            pending: input.pending,
214            cursor: input.cursor,
215            direct_source_read_required,
216            direct_source_read_paths: input.direct_source_read_paths,
217            agent_instructions,
218        }
219    }
220
221    pub(crate) fn graph_only(
222        graph_version: u64,
223        freshness_policy: FreshnessPolicy,
224        source_scope: Option<String>,
225        requested_ref: String,
226        degraded_reason: String,
227    ) -> Self {
228        let input = CodeRepositoryFreshnessInput {
229            graph_version,
230            freshness_policy,
231            source_scope,
232            requested_ref: requested_ref.clone(),
233            requested_resolved_ref: requested_ref.clone(),
234            served_ref: requested_ref,
235            scope_stale: false,
236            stale_reason: None,
237            degraded_reason: Some(degraded_reason),
238            pending: CodeRepositoryPendingIndexWork::default(),
239            cursor: None,
240            direct_source_read_paths: Vec::new(),
241        };
242
243        Self::code_query(input)
244    }
245}
246
247pub(crate) struct CodeRepositoryFreshnessInput {
248    pub graph_version: u64,
249    pub freshness_policy: FreshnessPolicy,
250    pub source_scope: Option<String>,
251    pub requested_ref: String,
252    pub requested_resolved_ref: String,
253    pub served_ref: String,
254    pub scope_stale: bool,
255    pub stale_reason: Option<String>,
256    pub degraded_reason: Option<String>,
257    pub pending: CodeRepositoryPendingIndexWork,
258    pub cursor: Option<CodeRepositoryFreshnessCursor>,
259    pub direct_source_read_paths: Vec<String>,
260}
261
262fn freshness_state(
263    direct_source_read_required: bool,
264    active_matches_request: bool,
265    scope_stale: bool,
266    degraded_reason: Option<&String>,
267) -> CodeRepositoryFreshnessState {
268    if direct_source_read_required && active_matches_request {
269        CodeRepositoryFreshnessState::Pending
270    } else if scope_stale || direct_source_read_required {
271        CodeRepositoryFreshnessState::Stale
272    } else if degraded_reason.is_some() {
273        CodeRepositoryFreshnessState::Degraded
274    } else {
275        CodeRepositoryFreshnessState::Fresh
276    }
277}
278
279fn source_read_instructions(
280    required: bool,
281    requested_ref: &str,
282    served_ref: &str,
283    paths: &[String],
284) -> Vec<String> {
285    if !required {
286        return Vec::new();
287    }
288    let mut instructions = vec![format!(
289        "Code graph results were served from indexed ref {served_ref}; read direct source before relying on files changed at requested ref {requested_ref}."
290    )];
291    if !paths.is_empty() {
292        instructions.push(format!(
293            "Verify returned paths from direct source before editing or citing them: {}.",
294            paths.join(", ")
295        ));
296    }
297
298    instructions
299}