kimun_notes/server_client/
sync.rs1use std::collections::HashMap;
8use std::sync::Arc;
9
10use kimun_core::{IndexObserver, NoteVault, error::VaultError, nfs::VaultPath};
11
12use crate::server_client::dto::{WireDoc, WireSection};
13use crate::server_client::{
14 DirtyOp, DirtySet, RagClient, RagError, RagObserver, RagTransport, hash_string, reconcile_diff,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ServerCapability {
21 Unconfigured,
24 SemanticOnly,
26 Full,
28}
29
30impl ServerCapability {
31 pub fn from_health(health: &crate::server_client::dto::Health) -> Self {
33 match (health.embedder.is_some(), health.llm_provider.is_some()) {
34 (false, _) => ServerCapability::Unconfigured,
35 (true, false) => ServerCapability::SemanticOnly,
36 (true, true) => ServerCapability::Full,
37 }
38 }
39
40 pub fn llm_available(self) -> bool {
42 matches!(self, ServerCapability::Full)
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct ServerProbe {
53 pub capability: ServerCapability,
54 pub auth_required: bool,
55}
56
57pub struct RagSync {
62 vault: Arc<NoteVault>,
63 dirty: Arc<DirtySet>,
64 observer: Arc<dyn IndexObserver>,
67 client: RagClient,
68}
69
70impl RagSync {
71 pub fn new(vault: Arc<NoteVault>, client: RagClient) -> Self {
77 let dirty = Arc::new(DirtySet::default());
78 let observer: Arc<dyn IndexObserver> = Arc::new(RagObserver::new(dirty.clone()));
79 vault.set_index_observer(observer.clone());
80 Self {
81 vault,
82 dirty,
83 observer,
84 client,
85 }
86 }
87
88 pub async fn probe(&self) -> Option<ServerProbe> {
94 self.client.health().await.ok().map(|h| ServerProbe {
95 capability: ServerCapability::from_health(&h),
96 auth_required: h.auth_required,
97 })
98 }
99
100 pub fn index_ready(&self) -> bool {
104 self.vault.index_ready()
105 }
106
107 pub async fn tick(&self) -> Result<bool, RagError> {
111 let drained = drain(&self.vault, &self.dirty, &self.client).await?;
112 let reconciled = reconcile(&self.vault, &self.client).await?;
113 Ok(drained && reconciled)
114 }
115
116 pub async fn drain(&self) -> Result<bool, RagError> {
121 drain(&self.vault, &self.dirty, &self.client).await
122 }
123
124 pub async fn reconcile(&self) -> Result<bool, RagError> {
128 reconcile(&self.vault, &self.client).await
129 }
130
131 pub fn client(&self) -> &RagClient {
133 &self.client
134 }
135}
136
137impl Drop for RagSync {
138 fn drop(&mut self) {
139 self.vault.clear_index_observer_if(&self.observer);
143 }
144}
145
146pub async fn build_doc(
152 vault: &NoteVault,
153 path: &VaultPath,
154 hash: u64,
155) -> Result<Option<WireDoc>, VaultError> {
156 let chunks = vault.get_note_chunks(path).await?;
157 let sections: Vec<WireSection> = chunks
158 .into_values()
159 .flatten()
160 .map(|c| WireSection {
161 title: c.get_breadcrumb().to_string(),
162 text: c.get_text().to_string(),
163 })
164 .collect();
165 if sections.is_empty() {
166 return Ok(None);
167 }
168 Ok(Some(WireDoc {
169 path: path.to_string(),
170 hash: hash_string(hash),
171 sections,
172 }))
173}
174
175pub async fn drain<T: RagTransport>(
183 vault: &NoteVault,
184 dirty: &DirtySet,
185 transport: &T,
186) -> Result<bool, RagError> {
187 if !vault.index_ready() {
188 return Ok(false);
189 }
190 let ops = dirty.drain();
191 if ops.is_empty() {
192 return Ok(true);
193 }
194
195 let mut upserts: Vec<(VaultPath, u64)> = Vec::new();
196 let mut deletes: Vec<String> = Vec::new();
197 for (path, op) in ops {
198 match op {
199 DirtyOp::Upsert(hash) => upserts.push((path, hash)),
200 DirtyOp::Delete => deletes.push(path.to_string()),
201 }
202 }
203
204 let mut docs = Vec::new();
207 let mut built: Vec<(VaultPath, u64)> = Vec::new();
208 for (path, hash) in upserts {
209 match build_doc(vault, &path, hash).await {
210 Ok(Some(doc)) => {
211 docs.push(doc);
212 built.push((path, hash));
213 }
214 Ok(None) => deletes.push(path.to_string()),
217 Err(_) => dirty.requeue([(path, DirtyOp::Upsert(hash))]),
218 }
219 }
220
221 let mut first_err: Option<RagError> = None;
222 if !docs.is_empty()
223 && let Err(e) = transport.push_docs(docs).await
224 {
225 dirty.requeue(built.into_iter().map(|(p, h)| (p, DirtyOp::Upsert(h))));
226 first_err = Some(e);
227 }
228 if !deletes.is_empty() {
229 let paths_for_requeue: Vec<VaultPath> = deletes.iter().map(VaultPath::new).collect();
230 if let Err(e) = transport.delete_paths(deletes).await {
231 dirty.requeue(paths_for_requeue.into_iter().map(|p| (p, DirtyOp::Delete)));
232 first_err = first_err.or(Some(e));
233 }
234 }
235
236 match first_err {
237 Some(e) => Err(e),
238 None => Ok(true),
239 }
240}
241
242pub async fn reconcile<T: RagTransport>(
250 vault: &NoteVault,
251 transport: &T,
252) -> Result<bool, RagError> {
253 if !vault.index_ready() {
254 return Ok(false);
255 }
256 let notes = vault
257 .get_all_notes()
258 .await
259 .map_err(|e| RagError::Protocol(format!("read vault notes: {e}")))?;
260
261 let local_hashes: HashMap<String, u64> = notes
262 .into_iter()
263 .map(|(entry, content)| (entry.path.to_string(), content.hash))
264 .collect();
265 let local_str: HashMap<String, String> = local_hashes
266 .iter()
267 .map(|(p, h)| (p.clone(), hash_string(*h)))
268 .collect();
269
270 let server = transport.server_hashes().await?;
271 let plan = reconcile_diff(&local_str, &server);
272
273 let mut docs = Vec::new();
274 let mut to_delete = plan.to_delete;
275 for path_str in &plan.to_push {
276 let hash = local_hashes[path_str];
277 match build_doc(vault, &VaultPath::new(path_str), hash)
278 .await
279 .map_err(|e| RagError::Protocol(format!("build doc {path_str}: {e}")))?
280 {
281 Some(doc) => docs.push(doc),
282 None => {
286 if server.contains_key(path_str) {
287 to_delete.push(path_str.clone());
288 }
289 }
290 }
291 }
292 if !docs.is_empty() {
293 transport.push_docs(docs).await?;
294 }
295 if !to_delete.is_empty() {
296 transport.delete_paths(to_delete).await?;
297 }
298 Ok(true)
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use async_trait::async_trait;
305
306 #[test]
307 fn capability_from_health_fields() {
308 use crate::server_client::dto::Health;
309 let h = |embedder: Option<&str>, llm: Option<&str>| Health {
310 status: "ok".into(),
311 reranker: false,
312 embedder: embedder.map(str::to_string),
313 llm_provider: llm.map(str::to_string),
314 auth_required: false,
315 };
316 assert_eq!(
317 ServerCapability::from_health(&h(None, None)),
318 ServerCapability::Unconfigured
319 );
320 assert_eq!(
321 ServerCapability::from_health(&h(None, Some("gemini"))),
323 ServerCapability::Unconfigured
324 );
325 assert_eq!(
326 ServerCapability::from_health(&h(Some("fastembed"), None)),
327 ServerCapability::SemanticOnly
328 );
329 assert_eq!(
330 ServerCapability::from_health(&h(Some("fastembed"), Some("gemini"))),
331 ServerCapability::Full
332 );
333 }
334 use kimun_core::VaultConfig;
335 use std::sync::Mutex;
336 use tempfile::TempDir;
337
338 #[derive(Default)]
339 struct FakeTransport {
340 pushed: Mutex<Vec<WireDoc>>,
341 deleted: Mutex<Vec<String>>,
342 server: Mutex<HashMap<String, String>>,
343 fail_push: Mutex<bool>,
344 }
345
346 #[async_trait]
347 impl RagTransport for FakeTransport {
348 async fn push_docs(&self, docs: Vec<WireDoc>) -> Result<(), RagError> {
349 if *self.fail_push.lock().unwrap() {
350 return Err(RagError::Protocol("boom".into()));
351 }
352 self.pushed.lock().unwrap().extend(docs);
353 Ok(())
354 }
355 async fn delete_paths(&self, paths: Vec<String>) -> Result<(), RagError> {
356 self.deleted.lock().unwrap().extend(paths);
357 Ok(())
358 }
359 async fn server_hashes(&self) -> Result<HashMap<String, String>, RagError> {
360 Ok(self.server.lock().unwrap().clone())
361 }
362 }
363
364 fn register(vault: &NoteVault) -> Arc<DirtySet> {
369 let dirty = Arc::new(DirtySet::default());
370 vault.set_index_observer(Arc::new(RagObserver::new(dirty.clone())));
371 dirty
372 }
373
374 async fn vault(dir: &std::path::Path) -> NoteVault {
375 let vault = NoteVault::new(VaultConfig::new(dir)).await.unwrap();
376 vault.validate_and_init().await.unwrap();
379 vault
380 }
381
382 #[tokio::test]
383 async fn drain_pushes_created_note_and_deletes_removed() {
384 let dir = TempDir::new().unwrap();
385 let vault = vault(dir.path()).await;
386 let dirty = register(&vault);
387 let transport = FakeTransport::default();
388
389 vault
390 .create_note(&VaultPath::new("a.md"), "# Title\n\nbody")
391 .await
392 .unwrap();
393 drain(&vault, &dirty, &transport).await.unwrap();
394
395 {
398 let pushed = transport.pushed.lock().unwrap();
399 assert_eq!(pushed.len(), 1);
400 assert_eq!(pushed[0].path, "/a.md"); assert!(!pushed[0].sections.is_empty());
402 assert!(dirty.is_empty());
403 }
404
405 vault.delete_note(&VaultPath::new("a.md")).await.unwrap();
406 drain(&vault, &dirty, &transport).await.unwrap();
407 assert_eq!(
408 *transport.deleted.lock().unwrap(),
409 vec!["/a.md".to_string()]
410 );
411 }
412
413 #[tokio::test]
414 async fn failed_push_requeues() {
415 let dir = TempDir::new().unwrap();
416 let vault = vault(dir.path()).await;
417 let dirty = register(&vault);
418 let transport = FakeTransport::default();
419 *transport.fail_push.lock().unwrap() = true;
420
421 vault
422 .create_note(&VaultPath::new("a.md"), "body")
423 .await
424 .unwrap();
425 assert!(drain(&vault, &dirty, &transport).await.is_err());
426 assert_eq!(dirty.len(), 1);
428 }
429
430 #[tokio::test]
431 async fn reconcile_pushes_missing_and_deletes_stale() {
432 let dir = TempDir::new().unwrap();
433 let vault = vault(dir.path()).await;
434 let _dirty = register(&vault);
435 let transport = FakeTransport::default();
436
437 vault
438 .create_note(&VaultPath::new("keep.md"), "kept")
439 .await
440 .unwrap();
441 transport
443 .server
444 .lock()
445 .unwrap()
446 .insert("/gone.md".to_string(), "oldhash".to_string());
447
448 assert!(reconcile(&vault, &transport).await.unwrap());
449
450 let pushed = transport.pushed.lock().unwrap();
451 assert!(pushed.iter().any(|d| d.path == "/keep.md"));
452 assert_eq!(
453 *transport.deleted.lock().unwrap(),
454 vec!["/gone.md".to_string()]
455 );
456 }
457
458 #[tokio::test]
459 async fn reconcile_skipped_while_index_not_ready() {
460 let dir = TempDir::new().unwrap();
461 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
465 assert!(!vault.index_ready());
466 let transport = FakeTransport::default();
467 transport
468 .server
469 .lock()
470 .unwrap()
471 .insert("/precious.md".to_string(), "hash".to_string());
472
473 assert!(!reconcile(&vault, &transport).await.unwrap());
474 assert!(transport.deleted.lock().unwrap().is_empty());
475 assert!(transport.pushed.lock().unwrap().is_empty());
476
477 let dirty = register(&vault);
481 dirty.record(&kimun_core::NoteChange::Upsert {
482 path: VaultPath::new("precious.md"),
483 hash: 1,
484 });
485 assert!(!drain(&vault, &dirty, &transport).await.unwrap());
486 assert_eq!(dirty.len(), 1);
487 assert!(transport.deleted.lock().unwrap().is_empty());
488 }
489}