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