1mod canvas;
8mod code;
9mod file_index;
10mod sqlite;
11
12use std::{error::Error, fmt, future::Future, pin::Pin};
13
14use serde::{Deserialize, Serialize};
15
16use crate::domain::{
17 AuditEventRecord, AuditStatus, CodeChunkRecord, CodeGraphBatch, CodeGraphCommitReceipt,
18 CodeParseStatusCounts, CodeReferenceRecord, CodeSymbolRecord, CommitReceipt,
19 GraphMutationBatch, GraphVersion, IndexKind, IndexModality, IndexStatus,
20 ProposalConflictRecord, ProposalConflictSeverity, ProposalKind, ProposalProvenance,
21 ProposalRecord, ProposalState, RetrievalHit, RetrieverSource, ServiceOperatorState,
22 ServiceOperatorStatus, WorkerKind, WorkerStatus, WorkerTaskRecord,
23};
24
25pub use canvas::{
26 GraphCanvasSelection, GraphCanvasStorageEdge, GraphCanvasStorageNode,
27 GraphCanvasStorageRequest, GraphCanvasStorageSnapshot,
28};
29pub use code::{
30 CODE_INDEX_TASK_LEASE_RECOVERY_UNAVAILABLE, CODE_INDEX_TASK_LEASE_RENEWAL_UNAVAILABLE,
31 CodeImpactChanges, CodeIndexTaskClaimRequest, CodeIndexTaskCompletion, CodeIndexTaskFailure,
32 CodeIndexTaskLeaseRenewal, CodeIndexTaskSeed, CodeRepositorySetMemberSeed,
33 CodeRepositorySetRefreshTaskClaimRequest, CodeRepositorySetRefreshTaskCompletion,
34 CodeRepositorySetRefreshTaskFailure, CodeRepositorySetRefreshTaskSeed, CodeRepositorySetSeed,
35 CodeRepositoryStore, CodeScopeRetentionRequest,
36};
37pub use file_index::{
38 FileIndexDiagnostics, FileIndexEntry, FileIndexRoot, FileIndexRootStatus, FileIndexRootUpdate,
39 FileIndexScanSummary, FileSearchHit, FileSearchRequest,
40};
41pub use sqlite::SqliteGraphStore;
42
43pub type StorageFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, StorageError>> + Send + 'a>>;
44
45pub const DEFAULT_INDEX_SOURCE_SCOPE: &str = "graph";
47
48pub trait GraphStore: Send + Sync {
50 fn commit_mutation_batch(&self, batch: GraphMutationBatch) -> StorageFuture<'_, CommitReceipt>;
51
52 fn inspect_graph(&self) -> StorageFuture<'_, GraphInspection>;
53
54 fn graph_canvas(
55 &self,
56 _request: GraphCanvasStorageRequest,
57 ) -> StorageFuture<'_, GraphCanvasStorageSnapshot> {
58 Box::pin(async {
59 Err(StorageError::InvalidInput(
60 "graph canvas storage is unavailable".to_owned(),
61 ))
62 })
63 }
64
65 fn search(&self, request: GraphSearchRequest) -> StorageFuture<'_, Vec<RetrievalHit>>;
66
67 fn current_graph_version(&self) -> StorageFuture<'_, GraphVersion>;
68}
69
70pub trait MutationLogStore: Send + Sync {
72 fn read_after(
73 &self,
74 graph_version: GraphVersion,
75 limit: usize,
76 ) -> StorageFuture<'_, Vec<MutationLogEntry>>;
77}
78
79pub trait IndexStore: Send + Sync {
81 fn index_statuses(&self) -> StorageFuture<'_, Vec<IndexStatus>>;
82
83 fn mark_refresh_complete(
84 &self,
85 kind: IndexKind,
86 graph_version: GraphVersion,
87 ) -> StorageFuture<'_, IndexStatus>;
88
89 fn index_cursors(&self) -> StorageFuture<'_, Vec<IndexCursor>> {
90 Box::pin(async {
91 Err(StorageError::InvalidInput(
92 "index cursor storage is unavailable".to_owned(),
93 ))
94 })
95 }
96
97 fn queue_index_refreshes(
98 &self,
99 _request: IndexRefreshQueueRequest,
100 ) -> StorageFuture<'_, IndexRefreshDiagnostics> {
101 Box::pin(async {
102 Err(StorageError::InvalidInput(
103 "index refresh task storage is unavailable".to_owned(),
104 ))
105 })
106 }
107
108 fn claim_index_refresh_task(
109 &self,
110 _request: IndexRefreshClaimRequest,
111 ) -> StorageFuture<'_, Option<IndexRefreshTask>> {
112 Box::pin(async {
113 Err(StorageError::InvalidInput(
114 "index refresh task storage is unavailable".to_owned(),
115 ))
116 })
117 }
118
119 fn complete_index_refresh_task(
120 &self,
121 _request: IndexRefreshCompletion,
122 ) -> StorageFuture<'_, IndexRefreshTask> {
123 Box::pin(async {
124 Err(StorageError::InvalidInput(
125 "index refresh task storage is unavailable".to_owned(),
126 ))
127 })
128 }
129
130 fn fail_index_refresh_task(
131 &self,
132 _request: IndexRefreshFailure,
133 ) -> StorageFuture<'_, IndexRefreshTask> {
134 Box::pin(async {
135 Err(StorageError::InvalidInput(
136 "index refresh task storage is unavailable".to_owned(),
137 ))
138 })
139 }
140
141 fn index_refresh_diagnostics(
142 &self,
143 _now_ms: u64,
144 ) -> StorageFuture<'_, IndexRefreshDiagnostics> {
145 Box::pin(async {
146 Err(StorageError::InvalidInput(
147 "index refresh diagnostics are unavailable".to_owned(),
148 ))
149 })
150 }
151
152 fn queue_worker_tasks(
153 &self,
154 _tasks: Vec<WorkerTaskSeed>,
155 ) -> StorageFuture<'_, Vec<WorkerTaskRecord>> {
156 Box::pin(async { Ok(Vec::new()) })
157 }
158
159 fn worker_statuses(&self) -> StorageFuture<'_, Vec<WorkerStatus>> {
160 Box::pin(async { Ok(Vec::new()) })
161 }
162
163 fn claim_worker_task(
164 &self,
165 _request: WorkerTaskClaimRequest,
166 ) -> StorageFuture<'_, Option<WorkerTaskRecord>> {
167 Box::pin(async { Ok(None) })
168 }
169
170 fn complete_worker_task(
171 &self,
172 _request: WorkerTaskCompletion,
173 ) -> StorageFuture<'_, WorkerTaskRecord> {
174 Box::pin(async {
175 Err(StorageError::InvalidInput(
176 "worker task storage is unavailable".to_owned(),
177 ))
178 })
179 }
180
181 fn fail_worker_task(&self, _request: WorkerTaskFailure) -> StorageFuture<'_, WorkerTaskRecord> {
182 Box::pin(async {
183 Err(StorageError::InvalidInput(
184 "worker task storage is unavailable".to_owned(),
185 ))
186 })
187 }
188
189 fn insert_proposal(&self, _proposal: NewProposal) -> StorageFuture<'_, ProposalRecord> {
190 Box::pin(async {
191 Err(StorageError::InvalidInput(
192 "proposal storage is unavailable".to_owned(),
193 ))
194 })
195 }
196
197 fn list_proposals(
198 &self,
199 _request: ProposalListRequest,
200 ) -> StorageFuture<'_, Vec<ProposalRecord>> {
201 Box::pin(async { Ok(Vec::new()) })
202 }
203
204 fn proposal_by_id(&self, _proposal_id: String) -> StorageFuture<'_, Option<ProposalRecord>> {
205 Box::pin(async { Ok(None) })
206 }
207
208 fn proposal_conflicts(
209 &self,
210 _proposal_id: String,
211 ) -> StorageFuture<'_, Vec<ProposalConflictRecord>> {
212 Box::pin(async { Ok(Vec::new()) })
213 }
214
215 fn decide_proposal(&self, _request: ProposalDecision) -> StorageFuture<'_, ProposalRecord> {
216 Box::pin(async {
217 Err(StorageError::InvalidInput(
218 "proposal storage is unavailable".to_owned(),
219 ))
220 })
221 }
222
223 fn insert_audit_event(&self, _event: NewAuditEvent) -> StorageFuture<'_, AuditEventRecord> {
224 Box::pin(async {
225 Err(StorageError::InvalidInput(
226 "audit storage is unavailable".to_owned(),
227 ))
228 })
229 }
230
231 fn query_audit_events(
232 &self,
233 _request: AuditQueryRequest,
234 ) -> StorageFuture<'_, Vec<AuditEventRecord>> {
235 Box::pin(async { Ok(Vec::new()) })
236 }
237
238 fn audit_event_count(&self) -> StorageFuture<'_, usize> {
239 Box::pin(async { Ok(0) })
240 }
241
242 fn service_operator_status(&self) -> StorageFuture<'_, ServiceOperatorStatus> {
243 Box::pin(async {
244 Ok(ServiceOperatorStatus {
245 state: ServiceOperatorState::Disabled,
246 silent_updates_enabled: false,
247 allowed_scopes: Vec::new(),
248 last_run_at_ms: None,
249 next_retry_at_ms: None,
250 last_error: None,
251 updated_at_ms: 0,
252 })
253 })
254 }
255
256 fn update_service_operator(
257 &self,
258 _request: ServiceOperatorUpdate,
259 ) -> StorageFuture<'_, ServiceOperatorStatus> {
260 Box::pin(async {
261 Err(StorageError::InvalidInput(
262 "service operator storage is unavailable".to_owned(),
263 ))
264 })
265 }
266
267 fn replace_file_index_root(
268 &self,
269 _update: FileIndexRootUpdate,
270 ) -> StorageFuture<'_, FileIndexRootStatus> {
271 unavailable_file_index_storage()
272 }
273
274 fn mark_file_index_roots_unconfigured(
275 &self,
276 _active_roots: Vec<FileIndexRoot>,
277 _now_ms: u64,
278 ) -> StorageFuture<'_, FileIndexDiagnostics> {
279 unavailable_file_index_storage()
280 }
281
282 fn search_files(&self, _request: FileSearchRequest) -> StorageFuture<'_, Vec<FileSearchHit>> {
283 unavailable_file_index_storage()
284 }
285
286 fn file_index_diagnostics(&self) -> StorageFuture<'_, FileIndexDiagnostics> {
287 unavailable_file_index_storage()
288 }
289}
290
291fn unavailable_file_index_storage<T>() -> StorageFuture<'static, T> {
292 Box::pin(async {
293 Err(StorageError::InvalidInput(
294 "file index storage is unavailable".to_owned(),
295 ))
296 })
297}
298
299pub trait CodeGraphStore: Send + Sync {
301 fn commit_code_graph_batch(
302 &self,
303 batch: CodeGraphBatch,
304 ) -> StorageFuture<'_, CodeGraphCommitReceipt>;
305
306 fn search_code_symbols(
307 &self,
308 request: CodeSymbolSearchRequest,
309 ) -> StorageFuture<'_, Vec<CodeSymbolRecord>>;
310
311 fn search_code_references(
312 &self,
313 request: CodeReferenceSearchRequest,
314 ) -> StorageFuture<'_, Vec<CodeReferenceRecord>>;
315
316 fn search_code_chunks(
317 &self,
318 request: CodeChunkSearchRequest,
319 ) -> StorageFuture<'_, Vec<CodeChunkRecord>>;
320}
321
322pub trait KnowledgeStore:
324 GraphStore + MutationLogStore + IndexStore + CodeGraphStore + CodeRepositoryStore
325{
326}
327
328impl<T> KnowledgeStore for T where
329 T: GraphStore + MutationLogStore + IndexStore + CodeGraphStore + CodeRepositoryStore
330{
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct GraphSearchRequest {
336 pub query: String,
337 pub source_scope: Option<String>,
338 pub graph_version: GraphVersion,
339 pub limit: usize,
340 pub disabled_retriever_sources: Vec<RetrieverSource>,
341}
342
343impl GraphSearchRequest {
344 pub fn allows_retriever_source(&self, source: RetrieverSource) -> bool {
346 !self.disabled_retriever_sources.contains(&source)
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
352pub struct CodeSymbolSearchRequest {
353 pub source_scope: Option<String>,
354 pub path: Option<String>,
355 pub name: Option<String>,
356 pub graph_version: GraphVersion,
357 pub limit: usize,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct CodeReferenceSearchRequest {
363 pub source_scope: Option<String>,
364 pub path: Option<String>,
365 pub symbol_text: Option<String>,
366 pub target_symbol_id: Option<String>,
367 pub graph_version: GraphVersion,
368 pub limit: usize,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct CodeChunkSearchRequest {
374 pub source_scope: Option<String>,
375 pub path: Option<String>,
376 pub query: Option<String>,
377 pub graph_version: GraphVersion,
378 pub limit: usize,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct WorkerTaskSeed {
384 pub kind: WorkerKind,
385 pub source_scope: String,
386 pub evidence_id: Option<String>,
387 pub target_graph_version: GraphVersion,
388 pub input_fingerprint: String,
389 pub payload_json: String,
390 pub now_ms: u64,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct WorkerTaskClaimRequest {
396 pub kind: Option<WorkerKind>,
397 pub lease_owner: String,
398 pub lease_duration_ms: u64,
399 pub max_attempts: u32,
400 pub now_ms: u64,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct WorkerTaskCompletion {
406 pub task_id: String,
407 pub lease_owner: String,
408 pub attempt_count: u32,
409 pub now_ms: u64,
410}
411
412#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct WorkerTaskFailure {
415 pub task_id: String,
416 pub lease_owner: String,
417 pub attempt_count: u32,
418 pub error_kind: String,
419 pub error_message: String,
420 pub retry_backoff_ms: u64,
421 pub max_attempts: u32,
422 pub now_ms: u64,
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct NewProposal {
428 pub proposal_id: String,
429 pub source_scope: String,
430 pub kind: ProposalKind,
431 pub title: String,
432 pub summary: String,
433 pub payload_json: String,
434 pub origin: String,
435 pub provenance: ProposalProvenance,
436 pub confidence_basis_points: u16,
437 pub conflicts: Vec<NewProposalConflict>,
438 pub now_ms: u64,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct NewProposalConflict {
444 pub conflict_id: String,
445 pub existing_fact_kind: String,
446 pub existing_fact_id: String,
447 pub severity: ProposalConflictSeverity,
448 pub reason: String,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub struct ProposalListRequest {
454 pub state: Option<ProposalState>,
455 pub limit: usize,
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct ProposalDecision {
461 pub proposal_id: String,
462 pub next_state: ProposalState,
463 pub actor: String,
464 pub reason: Option<String>,
465 pub now_ms: u64,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq)]
470pub struct NewAuditEvent {
471 pub operation: String,
472 pub interface: String,
473 pub request_id: String,
474 pub trace_id: String,
475 pub status: AuditStatus,
476 pub actor: Option<String>,
477 pub source_scope: Option<String>,
478 pub graph_version: u64,
479 pub detail_json: String,
480 pub message: Option<String>,
481 pub now_ms: u64,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct AuditQueryRequest {
487 pub operation: Option<String>,
488 pub limit: usize,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct ServiceOperatorUpdate {
494 pub state: ServiceOperatorState,
495 pub silent_updates_enabled: bool,
496 pub allowed_scopes: Vec<String>,
497 pub last_error: Option<String>,
498 pub now_ms: u64,
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct GraphInspection {
504 pub graph_version: GraphVersion,
505 pub entity_count: usize,
506 pub evidence_count: usize,
507 pub relation_count: usize,
508 pub claim_count: usize,
509 pub event_count: usize,
510 pub mutation_count: usize,
511 pub code_file_count: usize,
512 pub code_symbol_count: usize,
513 pub code_reference_count: usize,
514 pub code_chunk_count: usize,
515 pub code_parse_status_counts: CodeParseStatusCounts,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct MutationLogEntry {
521 pub graph_version: GraphVersion,
522 pub evidence_count: usize,
523 pub entity_count: usize,
524 pub relation_count: usize,
525 pub claim_count: usize,
526 pub event_count: usize,
527 pub affected_scopes: Vec<String>,
528 pub affected_entity_ids: Vec<String>,
529 pub evidence_ids: Vec<String>,
530 pub source_hashes: Vec<String>,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
535pub struct IndexCursor {
536 pub kind: IndexKind,
537 pub source_scope: String,
538 pub modality: IndexModality,
539 pub index_version: u64,
540 pub indexed_graph_version: GraphVersion,
541 pub state: crate::domain::IndexState,
542 pub last_error: Option<String>,
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub source_hash: Option<String>,
545 #[serde(skip_serializing_if = "Option::is_none")]
546 pub backend_cursor: Option<String>,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub model_name: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
550 pub model_dimension: Option<u32>,
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(rename_all = "snake_case")]
556pub enum IndexRefreshTaskState {
557 Queued,
558 Running,
559 Succeeded,
560 Retrying,
561 Failed,
562 DeadLetter,
563}
564
565impl IndexRefreshTaskState {
566 pub const fn as_str(self) -> &'static str {
568 match self {
569 Self::Queued => "queued",
570 Self::Running => "running",
571 Self::Succeeded => "succeeded",
572 Self::Retrying => "retrying",
573 Self::Failed => "failed",
574 Self::DeadLetter => "dead_letter",
575 }
576 }
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct IndexRefreshTask {
582 pub task_id: String,
583 pub kind: IndexKind,
584 pub source_scope: String,
585 pub modality: IndexModality,
586 pub target_graph_version: GraphVersion,
587 pub state: IndexRefreshTaskState,
588 pub lease_owner: Option<String>,
589 pub lease_expires_at_ms: Option<u64>,
590 pub attempt_count: u32,
591 pub next_retry_at_ms: u64,
592 pub input_fingerprint: String,
593 pub cursor_before: GraphVersion,
594 pub cursor_after: Option<GraphVersion>,
595 pub last_error_kind: Option<String>,
596 pub last_error_message: Option<String>,
597 pub created_at_ms: u64,
598 pub updated_at_ms: u64,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct IndexRefreshQueueRequest {
604 pub kinds: Vec<IndexKind>,
605 pub target_graph_version: GraphVersion,
606 pub max_queue_depth: usize,
607 pub reset_dead_letter_tasks: bool,
608 pub now_ms: u64,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct IndexRefreshClaimRequest {
614 pub lease_owner: String,
615 pub lease_duration_ms: u64,
616 pub max_attempts: u32,
617 pub now_ms: u64,
618}
619
620#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct IndexRefreshCompletion {
623 pub task_id: String,
624 pub lease_owner: String,
625 pub attempt_count: u32,
626 pub indexed_graph_version: GraphVersion,
627 pub model_name: Option<String>,
628 pub model_dimension: Option<u32>,
629 pub now_ms: u64,
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
634pub struct IndexRefreshFailure {
635 pub task_id: String,
636 pub lease_owner: String,
637 pub attempt_count: u32,
638 pub error_kind: String,
639 pub error_message: String,
640 pub retry_backoff_ms: u64,
641 pub max_attempts: u32,
642 pub now_ms: u64,
643}
644
645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647pub struct IndexLag {
648 pub kind: IndexKind,
649 pub lag_versions: u64,
650}
651
652#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654pub struct IndexStalenessReason {
655 pub kind: IndexKind,
656 #[serde(skip_serializing_if = "Option::is_none")]
657 pub source_scope: Option<String>,
658 #[serde(skip_serializing_if = "Option::is_none")]
659 pub modality: Option<IndexModality>,
660 pub reason: String,
661 pub lag_versions: u64,
662 #[serde(skip_serializing_if = "Option::is_none")]
663 pub last_error: Option<String>,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
668pub struct IndexRefreshDiagnostics {
669 pub queue_depth: usize,
670 pub running_count: usize,
671 pub retrying_count: usize,
672 pub dead_letter_count: usize,
673 pub oldest_unfinished_age_ms: Option<u64>,
674 pub index_lag_by_kind: Vec<IndexLag>,
675 pub max_index_lag_versions: u64,
676 pub stale_index_count: usize,
677 pub stale_reasons: Vec<IndexStalenessReason>,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682pub struct StorageHealth {
683 pub graph_version: GraphVersion,
684 pub healthy: bool,
685 pub detail: String,
686}
687
688#[derive(Debug)]
690pub enum StorageError {
691 Io(std::io::Error),
692 Sqlite(rusqlite::Error),
693 Join(tokio::task::JoinError),
694 LockPoisoned,
695 InvalidInput(String),
696}
697
698impl fmt::Display for StorageError {
699 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
700 match self {
701 Self::Io(error) => write!(formatter, "storage I/O failed: {error}"),
702 Self::Sqlite(error) => write!(formatter, "sqlite operation failed: {error}"),
703 Self::Join(error) => write!(formatter, "storage worker failed: {error}"),
704 Self::LockPoisoned => write!(formatter, "sqlite connection lock was poisoned"),
705 Self::InvalidInput(message) => write!(formatter, "invalid storage input: {message}"),
706 }
707 }
708}
709
710impl Error for StorageError {}
711
712impl From<std::io::Error> for StorageError {
713 fn from(error: std::io::Error) -> Self {
714 Self::Io(error)
715 }
716}
717
718impl From<rusqlite::Error> for StorageError {
719 fn from(error: rusqlite::Error) -> Self {
720 Self::Sqlite(error)
721 }
722}
723
724impl From<tokio::task::JoinError> for StorageError {
725 fn from(error: tokio::task::JoinError) -> Self {
726 Self::Join(error)
727 }
728}
729
730#[cfg(test)]
731#[path = "mod_tests.rs"]
732mod tests;