Skip to main content

relay_knowledge/domain/code/repository_index/
mod.rs

1//! Defines durable repository-index snapshots, batches, tasks, checkpoints, and progress.
2
3use serde::{Deserialize, Serialize};
4
5use super::{
6    CodeFrameworkEdgeRecord, CodeFrameworkNodeRecord,
7    dependencies::CodeDependencyRecord,
8    error::DomainError,
9    repository::{
10        CodeCallRecord, CodeFeatureFlagRecord, CodeFileDiagnostic, CodeImportRecord, CodeIndexMode,
11        CodePathTombstone, CodeRouteRecord, RepositoryCodeChunkRecord, RepositoryCodeFileRecord,
12        RepositoryCodeReferenceRecord, RepositoryCodeSymbolRecord,
13    },
14    workspace::CodeMonorepoWorkspace,
15};
16
17mod incremental_clone;
18mod reference_resolution;
19mod software_projection;
20
21pub(crate) use self::incremental_clone::{
22    CodeIncrementalClonePhase, code_incremental_clone, code_incremental_clone_state,
23};
24pub(crate) use self::reference_resolution::{
25    CodeReferenceResolution, CodeReferenceResolutionQueryIndexRepair, CodeReferenceResolutionStage,
26    code_reference_resolution, code_reference_resolution_cursor_digest,
27    code_reference_resolution_query_index_repair,
28    code_reference_resolution_query_index_repair_state, code_reference_resolution_state,
29};
30pub(crate) use self::software_projection::{
31    CodeSoftwareProjectionPhase, SOFTWARE_PROJECTION_CHECKPOINT, code_software_projection_phase,
32};
33
34/// Parsed index changes ready to commit into storage.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CodeIndexSnapshot {
37    pub repository_id: String,
38    pub source_scope: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub base_resolved_commit_sha: Option<String>,
41    pub resolved_commit_sha: String,
42    pub tree_hash: String,
43    pub path_filters: Vec<String>,
44    pub language_filters: Vec<String>,
45    pub full_replace: bool,
46    pub changed_path_count: usize,
47    pub skipped_unchanged_count: usize,
48    pub deleted_paths: Vec<String>,
49    pub tombstones: Vec<CodePathTombstone>,
50    pub files: Vec<RepositoryCodeFileRecord>,
51    pub symbols: Vec<RepositoryCodeSymbolRecord>,
52    pub references: Vec<RepositoryCodeReferenceRecord>,
53    pub imports: Vec<CodeImportRecord>,
54    pub calls: Vec<CodeCallRecord>,
55    pub dependencies: Vec<CodeDependencyRecord>,
56    pub feature_flags: Vec<CodeFeatureFlagRecord>,
57    #[serde(default)]
58    pub framework_nodes: Vec<CodeFrameworkNodeRecord>,
59    #[serde(default)]
60    pub framework_edges: Vec<CodeFrameworkEdgeRecord>,
61    pub routes: Vec<CodeRouteRecord>,
62    pub chunks: Vec<RepositoryCodeChunkRecord>,
63    #[serde(default)]
64    pub workspaces: Vec<CodeMonorepoWorkspace>,
65    pub diagnostics: Vec<CodeFileDiagnostic>,
66}
67
68/// Resource budget used to partition repository indexing into bounded batches.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub struct CodeIndexResourceBudget {
71    pub max_files_per_batch: usize,
72    pub max_bytes_per_batch: usize,
73    pub max_rows_per_batch: usize,
74}
75
76impl CodeIndexResourceBudget {
77    pub const DEFAULT_MAX_FILES_PER_BATCH: usize = 512;
78    pub const DEFAULT_MAX_BYTES_PER_BATCH: usize = 16 * 1024 * 1024;
79    pub const DEFAULT_MAX_ROWS_PER_BATCH: usize = 150_000;
80
81    /// Creates a non-zero resource budget for batch parsing and SQLite writes.
82    pub fn new(
83        max_files_per_batch: usize,
84        max_bytes_per_batch: usize,
85        max_rows_per_batch: usize,
86    ) -> Result<Self, DomainError> {
87        if max_files_per_batch == 0 {
88            return Err(DomainError::invalid(
89                "max_files_per_batch",
90                "must be greater than zero",
91            ));
92        }
93        if max_bytes_per_batch == 0 {
94            return Err(DomainError::invalid(
95                "max_bytes_per_batch",
96                "must be greater than zero",
97            ));
98        }
99        if max_rows_per_batch == 0 {
100            return Err(DomainError::invalid(
101                "max_rows_per_batch",
102                "must be greater than zero",
103            ));
104        }
105
106        Ok(Self {
107            max_files_per_batch,
108            max_bytes_per_batch,
109            max_rows_per_batch,
110        })
111    }
112}
113
114impl Default for CodeIndexResourceBudget {
115    fn default() -> Self {
116        Self {
117            max_files_per_batch: Self::DEFAULT_MAX_FILES_PER_BATCH,
118            max_bytes_per_batch: Self::DEFAULT_MAX_BYTES_PER_BATCH,
119            max_rows_per_batch: Self::DEFAULT_MAX_ROWS_PER_BATCH,
120        }
121    }
122}
123
124/// Stable metadata for one resumable repository indexing session.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CodeIndexSession {
127    pub repository_id: String,
128    pub source_scope: String,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub base_resolved_commit_sha: Option<String>,
131    pub resolved_commit_sha: String,
132    pub tree_hash: String,
133    pub path_filters: Vec<String>,
134    pub language_filters: Vec<String>,
135    pub full_replace: bool,
136    pub total_path_count: usize,
137    pub changed_path_count: usize,
138    pub skipped_unchanged_count: usize,
139    pub deleted_paths: Vec<String>,
140    /// Paths that will be re-inserted during an incremental session.
141    /// Used by `begin_session_once` to exclude them from the historical
142    /// scope clone so stale rows are never copied into the new scope.
143    /// Empty for full-replace sessions.
144    #[serde(default)]
145    pub changed_paths: Vec<String>,
146    pub tombstones: Vec<CodePathTombstone>,
147    #[serde(default)]
148    pub workspaces: Vec<CodeMonorepoWorkspace>,
149    pub resource_budget: CodeIndexResourceBudget,
150}
151
152/// One bounded parse result committed under a checkpointed index session.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct CodeIndexBatch {
155    pub repository_id: String,
156    pub source_scope: String,
157    pub batch_index: usize,
158    pub parsed_byte_count: usize,
159    pub files: Vec<RepositoryCodeFileRecord>,
160    pub symbols: Vec<RepositoryCodeSymbolRecord>,
161    pub references: Vec<RepositoryCodeReferenceRecord>,
162    pub imports: Vec<CodeImportRecord>,
163    pub dependencies: Vec<CodeDependencyRecord>,
164    pub feature_flags: Vec<CodeFeatureFlagRecord>,
165    #[serde(default)]
166    pub framework_nodes: Vec<CodeFrameworkNodeRecord>,
167    #[serde(default)]
168    pub framework_edges: Vec<CodeFrameworkEdgeRecord>,
169    pub routes: Vec<CodeRouteRecord>,
170    pub chunks: Vec<RepositoryCodeChunkRecord>,
171    pub diagnostics: Vec<CodeFileDiagnostic>,
172}
173
174impl CodeIndexBatch {
175    pub fn row_count(&self) -> usize {
176        self.files
177            .len()
178            .saturating_add(self.symbols.len())
179            .saturating_add(self.references.len())
180            .saturating_add(self.imports.len())
181            .saturating_add(self.dependencies.len())
182            .saturating_add(self.feature_flags.len())
183            .saturating_add(self.framework_nodes.len())
184            .saturating_add(self.framework_edges.len())
185            .saturating_add(self.routes.len())
186            .saturating_add(self.chunks.len())
187            .saturating_add(self.diagnostics.len())
188    }
189}
190
191/// Version of the stable deferred query-index finalization plan.
192///
193/// Reordering, adding, or removing a storage descriptor requires a version
194/// bump plus an explicit recovery policy for checkpoints written by the old
195/// plan.
196pub(crate) const CODE_QUERY_INDEX_PLAN_VERSION: u32 = 3;
197
198/// Number of stable units in the current deferred query-index plan.
199pub(crate) const CODE_QUERY_INDEX_PLAN_UNIT_COUNT: usize = 17;
200
201const LEGACY_CODE_QUERY_INDEX_PLAN_V1: u32 = 1;
202const LEGACY_CODE_QUERY_INDEX_PLAN_V1_UNIT_COUNT: usize = 16;
203const LEGACY_CODE_QUERY_INDEX_PLAN_V2: u32 = 2;
204const LEGACY_CODE_QUERY_INDEX_PLAN_V2_UNIT_COUNT: usize = 17;
205
206const CODE_QUERY_INDEX_SUBPHASE_PREFIX: &str = "finalizing:build_query_indexes";
207const CODE_QUERY_INDEX_REPAIR_PREFIX: &str = "finalizing:query_index_repair";
208const CODE_REFERENCE_SEARCH_REBUILD_PREFIX: &str = "finalizing:rebuild_reference_search";
209const CODE_REFERENCE_SEARCH_REBUILD_VERSION: u32 = 2;
210
211/// Durable stage within an unpublished reference-search rebuild.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub(crate) enum CodeReferenceSearchRebuildStage {
214    Cleanup,
215    Discover,
216    Build,
217}
218
219impl CodeReferenceSearchRebuildStage {
220    const fn code(self) -> &'static str {
221        match self {
222            Self::Cleanup => "cleanup",
223            Self::Discover => "discover",
224            Self::Build => "build",
225        }
226    }
227
228    fn parse(code: &str) -> Option<Self> {
229        match code {
230            "cleanup" => Some(Self::Cleanup),
231            "discover" => Some(Self::Discover),
232            "build" => Some(Self::Build),
233            _ => None,
234        }
235    }
236}
237
238/// Parsed canonical progress token for a staged reference-search rebuild.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub(crate) struct CodeReferenceSearchRebuild {
241    pub(crate) protocol_version: u32,
242    pub(crate) stage: CodeReferenceSearchRebuildStage,
243    pub(crate) completed_page_ordinal: usize,
244}
245
246impl CodeReferenceSearchRebuild {
247    /// Restores the exact protocol version parsed from durable progress.
248    ///
249    /// Query-index repair must not silently upgrade a version-1 reference
250    /// cursor before the reference-search driver has reconciled its matching
251    /// version-1 progress row.
252    pub(crate) fn checkpoint_state(self) -> Option<String> {
253        (matches!(
254            self.protocol_version,
255            1 | CODE_REFERENCE_SEARCH_REBUILD_VERSION
256        ) && !(self.protocol_version == 1
257            && self.stage == CodeReferenceSearchRebuildStage::Discover))
258            .then(|| {
259                format!(
260                    "{CODE_REFERENCE_SEARCH_REBUILD_PREFIX}:v{}:{}:{}",
261                    self.protocol_version,
262                    self.stage.code(),
263                    self.completed_page_ordinal
264                )
265            })
266    }
267}
268
269/// Durable query-index repair cursor that preserves an exact in-progress
270/// reference-search page boundary across a versioned query-index plan upgrade.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub(crate) struct CodeReferenceSearchQueryIndexRepair {
273    pub(crate) plan_version: u32,
274    pub(crate) completed_unit: usize,
275    pub(crate) reference_search: CodeReferenceSearchRebuild,
276}
277
278impl CodeReferenceSearchQueryIndexRepair {
279    pub(crate) const fn requires_legacy_retired_prefix(self) -> bool {
280        self.plan_version == LEGACY_CODE_QUERY_INDEX_PLAN_V2
281    }
282
283    /// Advances without changing either the query-index plan policy or the
284    /// nested reference-search protocol carried by the durable cursor.
285    pub(crate) fn next_state(self, completed_unit: usize) -> Option<String> {
286        code_reference_search_query_index_repair_state_for_version(
287            self.plan_version,
288            completed_unit,
289            self.reference_search,
290        )
291    }
292}
293
294pub(crate) fn code_reference_search_rebuild_state(
295    stage: CodeReferenceSearchRebuildStage,
296    completed_page_ordinal: usize,
297) -> String {
298    format!(
299        "{CODE_REFERENCE_SEARCH_REBUILD_PREFIX}:v{CODE_REFERENCE_SEARCH_REBUILD_VERSION}:{}:{completed_page_ordinal}",
300        stage.code()
301    )
302}
303
304/// Parses canonical current and legacy staged reference-search tokens while
305/// preserving their protocol version for recovery.
306pub(crate) fn code_reference_search_rebuild(state: &str) -> Option<CodeReferenceSearchRebuild> {
307    let suffix = state.strip_prefix(&format!("{CODE_REFERENCE_SEARCH_REBUILD_PREFIX}:v"))?;
308    let mut parts = suffix.split(':');
309    let version = parts.next()?.parse::<u32>().ok()?;
310    let stage = CodeReferenceSearchRebuildStage::parse(parts.next()?)?;
311    let completed_page_ordinal = parts.next()?.parse::<usize>().ok()?;
312    if !matches!(version, 1 | CODE_REFERENCE_SEARCH_REBUILD_VERSION)
313        || (version == 1 && stage == CodeReferenceSearchRebuildStage::Discover)
314        || parts.next().is_some()
315    {
316        return None;
317    }
318    let canonical = format!(
319        "{CODE_REFERENCE_SEARCH_REBUILD_PREFIX}:v{version}:{}:{completed_page_ordinal}",
320        stage.code()
321    );
322    (canonical == state).then_some(CodeReferenceSearchRebuild {
323        protocol_version: version,
324        stage,
325        completed_page_ordinal,
326    })
327}
328
329pub(crate) fn code_reference_search_query_index_repair_state(
330    unit: usize,
331    reference_search: CodeReferenceSearchRebuild,
332) -> Option<String> {
333    code_reference_search_query_index_repair_state_for_version(
334        CODE_QUERY_INDEX_PLAN_VERSION,
335        unit,
336        reference_search,
337    )
338}
339
340fn code_reference_search_query_index_repair_state_for_version(
341    plan_version: u32,
342    unit: usize,
343    reference_search: CodeReferenceSearchRebuild,
344) -> Option<String> {
345    (matches!(
346        plan_version,
347        LEGACY_CODE_QUERY_INDEX_PLAN_V2 | CODE_QUERY_INDEX_PLAN_VERSION
348    ) && unit < CODE_QUERY_INDEX_PLAN_UNIT_COUNT
349        && matches!(
350            reference_search.protocol_version,
351            1 | CODE_REFERENCE_SEARCH_REBUILD_VERSION
352        )
353        && !(reference_search.protocol_version == 1
354            && reference_search.stage == CodeReferenceSearchRebuildStage::Discover))
355    .then(|| {
356        format!(
357            "{CODE_QUERY_INDEX_REPAIR_PREFIX}:v{plan_version}:{unit}:resume:reference_search:v{}:{}:{}",
358            reference_search.protocol_version,
359            reference_search.stage.code(), reference_search.completed_page_ordinal
360        )
361    })
362}
363
364pub(crate) fn code_reference_search_query_index_repair(
365    state: &str,
366) -> Option<CodeReferenceSearchQueryIndexRepair> {
367    let suffix = state.strip_prefix(&format!("{CODE_QUERY_INDEX_REPAIR_PREFIX}:v"))?;
368    let (version_and_unit, reference) = suffix.split_once(":resume:reference_search:v")?;
369    let (version, unit) = version_and_unit.split_once(':')?;
370    let version = version.parse::<u32>().ok()?;
371    let unit = unit.parse::<usize>().ok()?;
372    let mut reference = reference.split(':');
373    let reference_version = reference.next()?.parse::<u32>().ok()?;
374    let stage = CodeReferenceSearchRebuildStage::parse(reference.next()?)?;
375    let completed_page_ordinal = reference.next()?.parse::<usize>().ok()?;
376    if !matches!(
377        version,
378        CODE_QUERY_INDEX_PLAN_VERSION | LEGACY_CODE_QUERY_INDEX_PLAN_V2
379    ) || unit >= CODE_QUERY_INDEX_PLAN_UNIT_COUNT
380        || !matches!(reference_version, 1 | CODE_REFERENCE_SEARCH_REBUILD_VERSION)
381        || (reference_version == 1 && stage == CodeReferenceSearchRebuildStage::Discover)
382        || reference.next().is_some()
383    {
384        return None;
385    }
386    let reference_search = CodeReferenceSearchRebuild {
387        protocol_version: reference_version,
388        stage,
389        completed_page_ordinal,
390    };
391    let canonical = format!(
392        "{CODE_QUERY_INDEX_REPAIR_PREFIX}:v{version}:{unit}:resume:reference_search:v{reference_version}:{}:{}",
393        reference_search.stage.code(),
394        reference_search.completed_page_ordinal
395    );
396    (canonical == state).then_some(CodeReferenceSearchQueryIndexRepair {
397        plan_version: version,
398        completed_unit: unit,
399        reference_search,
400    })
401}
402
403/// Stable coarse checkpoint restored after a durable query-index repair.
404///
405/// These explicit codes are persisted. Reordering the finalization driver must
406/// not change them; changing their meaning requires a repair-token version
407/// bump and an explicit compatibility policy.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409#[repr(u8)]
410pub(crate) enum CodeQueryIndexRepairResumePhase {
411    BuildQueryIndexes = 0,
412    ResolveReferences = 1,
413    ResolveImports = 2,
414    ResolveCallTargets = 3,
415    RefreshDependencies = 4,
416    RebuildReferenceSearch = 5,
417    RebuildCalls = 6,
418    PublishScope = 7,
419    ResolveWorkspaceImports = 8,
420    SoftwareProjection = 9,
421    PartitionedPublish = 10,
422}
423
424impl CodeQueryIndexRepairResumePhase {
425    pub(crate) const ALL: [Self; 11] = [
426        Self::BuildQueryIndexes,
427        Self::ResolveReferences,
428        Self::ResolveImports,
429        Self::ResolveCallTargets,
430        Self::RefreshDependencies,
431        Self::RebuildReferenceSearch,
432        Self::RebuildCalls,
433        Self::PublishScope,
434        Self::ResolveWorkspaceImports,
435        Self::SoftwareProjection,
436        Self::PartitionedPublish,
437    ];
438
439    pub(crate) const fn checkpoint_state(self) -> &'static str {
440        match self {
441            Self::BuildQueryIndexes => "finalizing:build_query_indexes",
442            Self::ResolveReferences => "finalizing:resolve_references",
443            Self::ResolveImports => "finalizing:resolve_imports",
444            Self::ResolveCallTargets => "finalizing:resolve_call_targets",
445            Self::RefreshDependencies => "finalizing:refresh_dependencies",
446            Self::RebuildReferenceSearch => "finalizing:rebuild_reference_search",
447            Self::RebuildCalls => "finalizing:rebuild_calls",
448            Self::PublishScope => "finalizing:publish_scope",
449            Self::ResolveWorkspaceImports => "finalizing:resolve_workspace_imports",
450            Self::SoftwareProjection => "finalizing:software_projection",
451            Self::PartitionedPublish => "finalizing:partitioned_publish",
452        }
453    }
454
455    pub(crate) fn from_checkpoint_state(state: &str) -> Option<Self> {
456        Self::ALL
457            .into_iter()
458            .find(|phase| phase.checkpoint_state() == state)
459    }
460
461    const fn code(self) -> u8 {
462        self as u8
463    }
464
465    fn from_code(code: u8) -> Option<Self> {
466        Self::ALL.into_iter().find(|phase| phase.code() == code)
467    }
468}
469
470/// Parsed durable cursor for an interrupted query-index repair.
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub(crate) struct CodeQueryIndexRepair {
473    pub(crate) plan_version: u32,
474    pub(crate) completed_unit: usize,
475    pub(crate) resume_phase: CodeQueryIndexRepairResumePhase,
476}
477
478impl CodeQueryIndexRepair {
479    pub(crate) const fn requires_legacy_retired_prefix(self) -> bool {
480        self.plan_version == LEGACY_CODE_QUERY_INDEX_PLAN_V2
481    }
482
483    /// Advances a repair while retaining the parsed plan version and its
484    /// retired-prefix policy across every durable writer quantum.
485    pub(crate) fn next_state(self, completed_unit: usize) -> Option<String> {
486        code_query_index_repair_state_for_version(
487            self.plan_version,
488            completed_unit,
489            self.resume_phase,
490        )
491    }
492}
493
494/// Parsed durable cursor for one completed query-index plan unit.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub(crate) struct CodeQueryIndexSubphase {
497    pub(crate) plan_version: u32,
498    pub(crate) completed_unit: usize,
499}
500
501impl CodeQueryIndexSubphase {
502    pub(crate) const fn requires_legacy_retired_prefix(self) -> bool {
503        matches!(
504            self.plan_version,
505            LEGACY_CODE_QUERY_INDEX_PLAN_V1 | LEGACY_CODE_QUERY_INDEX_PLAN_V2
506        )
507    }
508
509    /// Advances a parsed cursor without reinterpreting a legacy completed
510    /// prefix under the current retired-index policy.
511    pub(crate) fn next_state(self, completed_unit: usize) -> Option<String> {
512        code_query_index_subphase_state_for_version(self.plan_version, completed_unit)
513    }
514}
515
516/// Formats the durable checkpoint token for one completed query-index unit.
517pub(crate) fn code_query_index_subphase_state(unit: usize) -> Option<String> {
518    code_query_index_subphase_state_for_version(CODE_QUERY_INDEX_PLAN_VERSION, unit)
519}
520
521fn code_query_index_subphase_state_for_version(plan_version: u32, unit: usize) -> Option<String> {
522    let unit_count = match plan_version {
523        CODE_QUERY_INDEX_PLAN_VERSION => CODE_QUERY_INDEX_PLAN_UNIT_COUNT,
524        LEGACY_CODE_QUERY_INDEX_PLAN_V2 => LEGACY_CODE_QUERY_INDEX_PLAN_V2_UNIT_COUNT,
525        LEGACY_CODE_QUERY_INDEX_PLAN_V1 => LEGACY_CODE_QUERY_INDEX_PLAN_V1_UNIT_COUNT,
526        _ => return None,
527    };
528    (unit < unit_count)
529        .then(|| format!("{CODE_QUERY_INDEX_SUBPHASE_PREFIX}:v{plan_version}:{unit}"))
530}
531
532/// Parses canonical current-plan tokens and compatible version-1/version-2 tokens.
533///
534/// Version 2 appended unit 16. Version 3 preserves all 17 ordinal identities
535/// while retiring unit 1's creation action. The returned plan version must
536/// reach prefix validation so no older completed ordinal is reinterpreted.
537pub(crate) fn code_query_index_subphase(state: &str) -> Option<CodeQueryIndexSubphase> {
538    let suffix = state.strip_prefix(&format!("{CODE_QUERY_INDEX_SUBPHASE_PREFIX}:v"))?;
539    let (version, unit) = suffix.split_once(':')?;
540    let version = version.parse::<u32>().ok()?;
541    let unit = unit.parse::<usize>().ok()?;
542    let unit_count = match version {
543        CODE_QUERY_INDEX_PLAN_VERSION => CODE_QUERY_INDEX_PLAN_UNIT_COUNT,
544        LEGACY_CODE_QUERY_INDEX_PLAN_V2 => LEGACY_CODE_QUERY_INDEX_PLAN_V2_UNIT_COUNT,
545        LEGACY_CODE_QUERY_INDEX_PLAN_V1 => LEGACY_CODE_QUERY_INDEX_PLAN_V1_UNIT_COUNT,
546        _ => return None,
547    };
548    if unit >= unit_count {
549        return None;
550    }
551    let canonical = format!("{CODE_QUERY_INDEX_SUBPHASE_PREFIX}:v{version}:{unit}");
552    (canonical == state).then_some(CodeQueryIndexSubphase {
553        plan_version: version,
554        completed_unit: unit,
555    })
556}
557
558/// Formats a durable query-index repair token that preserves the exact coarse
559/// phase already completed by the older writer.
560pub(crate) fn code_query_index_repair_state(
561    unit: usize,
562    resume_phase: CodeQueryIndexRepairResumePhase,
563) -> Option<String> {
564    code_query_index_repair_state_for_version(CODE_QUERY_INDEX_PLAN_VERSION, unit, resume_phase)
565}
566
567fn code_query_index_repair_state_for_version(
568    plan_version: u32,
569    unit: usize,
570    resume_phase: CodeQueryIndexRepairResumePhase,
571) -> Option<String> {
572    (matches!(
573        plan_version,
574        LEGACY_CODE_QUERY_INDEX_PLAN_V2 | CODE_QUERY_INDEX_PLAN_VERSION
575    ) && unit < CODE_QUERY_INDEX_PLAN_UNIT_COUNT)
576        .then(|| {
577            format!(
578                "{CODE_QUERY_INDEX_REPAIR_PREFIX}:v{plan_version}:{unit}:resume:{}",
579                resume_phase.code()
580            )
581        })
582}
583
584/// Parses canonical version-2 and current-plan query-index repair tokens.
585pub(crate) fn code_query_index_repair(state: &str) -> Option<CodeQueryIndexRepair> {
586    let suffix = state.strip_prefix(&format!("{CODE_QUERY_INDEX_REPAIR_PREFIX}:v"))?;
587    let (version_and_unit, resume_code) = suffix.split_once(":resume:")?;
588    let (version, unit) = version_and_unit.split_once(':')?;
589    let version = version.parse::<u32>().ok()?;
590    let unit = unit.parse::<usize>().ok()?;
591    let resume_code = resume_code.parse::<u8>().ok()?;
592    if !matches!(
593        version,
594        CODE_QUERY_INDEX_PLAN_VERSION | LEGACY_CODE_QUERY_INDEX_PLAN_V2
595    ) || unit >= CODE_QUERY_INDEX_PLAN_UNIT_COUNT
596    {
597        return None;
598    }
599    let resume_phase = CodeQueryIndexRepairResumePhase::from_code(resume_code)?;
600    let canonical = format!(
601        "{CODE_QUERY_INDEX_REPAIR_PREFIX}:v{version}:{unit}:resume:{}",
602        resume_phase.code()
603    );
604    (canonical == state).then_some(CodeQueryIndexRepair {
605        plan_version: version,
606        completed_unit: unit,
607        resume_phase,
608    })
609}
610
611/// Bounded incremental-work metrics retained across post-delta finalization.
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613pub struct CodeIncrementalSummaryReceipt {
614    pub task_id: String,
615    pub base_resolved_commit_sha: String,
616    pub changed_path_count: usize,
617    pub skipped_unchanged_count: usize,
618    pub deleted_path_count: usize,
619    pub affected_path_count: usize,
620    pub blob_read_count: usize,
621    pub parsed_file_count: usize,
622    pub sqlite_write_count: usize,
623    pub degraded_file_count: usize,
624    pub batch_count: usize,
625}
626
627impl CodeIncrementalSummaryReceipt {
628    pub(crate) fn validate(&self) -> Result<(), DomainError> {
629        let affected_surface = self
630            .parsed_file_count
631            .checked_add(self.deleted_path_count)
632            .ok_or_else(|| {
633                DomainError::invalid(
634                    "incremental_summary",
635                    "affected path surface exceeds platform capacity",
636                )
637            })?;
638        let minimum_sqlite_writes = self
639            .parsed_file_count
640            .checked_add(self.degraded_file_count)
641            .ok_or_else(|| {
642                DomainError::invalid(
643                    "incremental_summary",
644                    "minimum SQLite write count exceeds platform capacity",
645                )
646            })?;
647        if self.task_id.trim().is_empty()
648            || self.task_id.len() > 1_024
649            || self.base_resolved_commit_sha.trim().is_empty()
650            || self.base_resolved_commit_sha.len() > 1_024
651            || self.blob_read_count != self.parsed_file_count
652            || self.parsed_file_count > self.affected_path_count
653            || self.deleted_path_count > self.affected_path_count
654            || self.affected_path_count > affected_surface
655            || self.degraded_file_count > self.parsed_file_count
656            || self.sqlite_write_count < minimum_sqlite_writes
657            || self.batch_count != 1
658        {
659            return Err(DomainError::invalid(
660                "incremental_summary",
661                "durable incremental metrics are inconsistent",
662            ));
663        }
664        Ok(())
665    }
666}
667
668/// Durable progress checkpoint for a repository indexing session.
669#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670pub struct CodeIndexCheckpoint {
671    pub repository_id: String,
672    pub source_scope: String,
673    #[serde(default)]
674    pub resolved_commit_sha: String,
675    #[serde(default)]
676    pub tree_hash: String,
677    #[serde(default)]
678    pub path_filters: Vec<String>,
679    #[serde(default)]
680    pub language_filters: Vec<String>,
681    pub state: String,
682    pub total_path_count: usize,
683    pub parsed_file_count: usize,
684    pub committed_file_count: usize,
685    pub committed_symbol_count: usize,
686    pub committed_reference_count: usize,
687    pub committed_chunk_count: usize,
688    #[serde(default)]
689    pub committed_fact_row_count: usize,
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub incremental_summary: Option<CodeIncrementalSummaryReceipt>,
692    pub batch_count: usize,
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub last_path: Option<String>,
695    pub resource_budget: CodeIndexResourceBudget,
696    pub updated_at_ms: u64,
697}
698
699/// Persistent lifecycle for background code repository index tasks.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702pub enum CodeIndexTaskState {
703    Queued,
704    Running,
705    Succeeded,
706    Retrying,
707    Failed,
708    DeadLetter,
709    Cancelled,
710}
711
712impl CodeIndexTaskState {
713    /// Stable storage and API representation.
714    pub const fn as_str(self) -> &'static str {
715        match self {
716            Self::Queued => "queued",
717            Self::Running => "running",
718            Self::Succeeded => "succeeded",
719            Self::Retrying => "retrying",
720            Self::Failed => "failed",
721            Self::DeadLetter => "dead_letter",
722            Self::Cancelled => "cancelled",
723        }
724    }
725
726    /// Parses the stable storage and API representation.
727    pub fn parse(value: &str) -> Result<Self, DomainError> {
728        match value {
729            "queued" => Ok(Self::Queued),
730            "running" => Ok(Self::Running),
731            "succeeded" => Ok(Self::Succeeded),
732            "retrying" => Ok(Self::Retrying),
733            "failed" => Ok(Self::Failed),
734            "dead_letter" => Ok(Self::DeadLetter),
735            "cancelled" => Ok(Self::Cancelled),
736            _ => Err(DomainError::invalid(
737                "code_index_task_state",
738                "unknown code index task state",
739            )),
740        }
741    }
742
743    /// Returns whether the task can still consume executor capacity.
744    pub const fn is_unfinished(self) -> bool {
745        matches!(self, Self::Queued | Self::Running | Self::Retrying)
746    }
747}
748
749/// Durable background task for one code repository index request.
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct CodeIndexTaskRecord {
752    pub task_id: String,
753    pub repository_id: String,
754    pub alias: String,
755    pub ref_selector: String,
756    pub resolved_commit_sha: String,
757    pub tree_hash: String,
758    pub source_scope: String,
759    pub path_filters: Vec<String>,
760    pub language_filters: Vec<String>,
761    pub mode: CodeIndexMode,
762    pub state: CodeIndexTaskState,
763    #[serde(skip_serializing_if = "Option::is_none")]
764    pub lease_owner: Option<String>,
765    #[serde(skip_serializing_if = "Option::is_none")]
766    pub lease_expires_at_ms: Option<u64>,
767    pub attempt_count: u32,
768    /// Monotonic repository-local generation assigned when this attempt was claimed.
769    #[serde(default)]
770    pub publication_generation: u64,
771    pub next_retry_at_ms: u64,
772    pub input_fingerprint: String,
773    pub resource_budget: CodeIndexResourceBudget,
774    pub payload_json: String,
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub last_error_kind: Option<String>,
777    #[serde(skip_serializing_if = "Option::is_none")]
778    pub last_error_message: Option<String>,
779    pub created_at_ms: u64,
780    pub updated_at_ms: u64,
781}
782
783/// Attempt-scoped token required to publish code-index state.
784#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785pub struct CodeIndexPublicationFence {
786    pub repository_id: String,
787    pub task_id: String,
788    pub lease_owner: String,
789    pub attempt_count: u32,
790    pub generation: u64,
791}
792
793/// Aggregated durable queue state for background code-index tasks.
794#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
795pub struct CodeIndexTaskQueueStatus {
796    pub queued_task_count: usize,
797    pub running_task_count: usize,
798    pub retrying_task_count: usize,
799    pub dead_letter_task_count: usize,
800    pub running_lease_count: usize,
801    #[serde(skip_serializing_if = "Option::is_none")]
802    pub last_error: Option<String>,
803}
804
805/// Scope retention result after pruning old repository snapshots.
806#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
807pub struct CodeScopeRetentionSummary {
808    pub repository_id: String,
809    pub retained_scope_count: usize,
810    pub prunable_scope_count: usize,
811    pub pruned_scope_count: usize,
812    #[serde(default)]
813    pub scope_listing_truncated: bool,
814    #[serde(default)]
815    pub retiring_job_count: usize,
816    #[serde(default)]
817    pub maintenance_pending: bool,
818    pub retained_scopes: Vec<String>,
819    pub prunable_scopes: Vec<String>,
820    pub pruned_scopes: Vec<String>,
821    #[serde(default)]
822    pub retiring_jobs: Vec<CodeScopeRetirementJobStatus>,
823    #[serde(default, skip_serializing_if = "Option::is_none")]
824    pub repository_retention_job: Option<CodeRepositoryRetentionJobStatus>,
825}
826
827/// Observable progress for one durable whole-repository index retention job.
828#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
829pub struct CodeRepositoryRetentionJobStatus {
830    pub repository_id: String,
831    pub initial_scope: String,
832    pub cutoff_ms: u64,
833    #[serde(default)]
834    pub cutoff_publication_generation: u64,
835    pub phase: String,
836    pub created_at_ms: u64,
837    pub updated_at_ms: u64,
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub last_error: Option<String>,
840}
841
842/// Observable progress for one durable, restart-safe scope retirement job.
843#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
844pub struct CodeScopeRetirementJobStatus {
845    pub repository_id: String,
846    pub source_scope: String,
847    pub phase: String,
848    pub deleted_rows: usize,
849    pub updated_at_ms: u64,
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub last_error: Option<String>,
852}
853
854/// Coarse phase timing and counts reported by repository indexing.
855#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
856pub struct CodeIndexProgressSummary {
857    pub git_file_count: usize,
858    pub blob_read_count: usize,
859    pub parsed_file_count: usize,
860    pub sqlite_write_count: usize,
861    pub skipped_file_count: usize,
862    pub degraded_file_count: usize,
863    pub batch_count: usize,
864    pub checkpoint_file_count: usize,
865    pub resource_budget: CodeIndexResourceBudget,
866}
867
868/// Result of applying a code index snapshot.
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870pub struct CodeIndexSummary {
871    pub repository_id: String,
872    pub source_scope: String,
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub base_resolved_commit_sha: Option<String>,
875    pub resolved_commit_sha: String,
876    pub tree_hash: String,
877    pub indexed_file_count: usize,
878    pub changed_path_count: usize,
879    pub skipped_unchanged_count: usize,
880    pub deleted_path_count: usize,
881    pub symbol_count: usize,
882    #[serde(default)]
883    pub handwritten_symbol_count: usize,
884    #[serde(default)]
885    pub generated_symbol_count: usize,
886    pub reference_count: usize,
887    pub chunk_count: usize,
888    pub degraded_file_count: usize,
889    pub progress: CodeIndexProgressSummary,
890}
891
892#[cfg(test)]
893#[path = "mod_tests.rs"]
894mod mod_tests;