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