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#[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#[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#[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#[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#[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#[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}