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 fn sys(path: impl AsRef<std::path::Path>) -> kimun_core::SystemPath {
378 kimun_core::SystemPath::try_absolute(path).expect("test path must be absolute")
379 }
380
381 async fn vault(dir: &std::path::Path) -> NoteVault {
382 let vault = NoteVault::new(VaultConfig::new(sys(dir))).await.unwrap();
383 vault.validate_and_init().await.unwrap();
386 vault
387 }
388
389 #[tokio::test]
390 async fn drain_pushes_created_note_and_deletes_removed() {
391 let dir = TempDir::new().unwrap();
392 let vault = vault(dir.path()).await;
393 let dirty = register(&vault);
394 let transport = FakeTransport::default();
395
396 vault
397 .create_note(&VaultPath::new("a.md"), "# Title\n\nbody")
398 .await
399 .unwrap();
400 drain(&vault, &dirty, &transport).await.unwrap();
401
402 {
405 let pushed = transport.pushed.lock().unwrap();
406 assert_eq!(pushed.len(), 1);
407 assert_eq!(pushed[0].path, "/a.md"); assert!(!pushed[0].sections.is_empty());
409 assert!(dirty.is_empty());
410 }
411
412 vault.delete_note(&VaultPath::new("a.md")).await.unwrap();
413 drain(&vault, &dirty, &transport).await.unwrap();
414 assert_eq!(
415 *transport.deleted.lock().unwrap(),
416 vec!["/a.md".to_string()]
417 );
418 }
419
420 #[tokio::test]
421 async fn failed_push_requeues() {
422 let dir = TempDir::new().unwrap();
423 let vault = vault(dir.path()).await;
424 let dirty = register(&vault);
425 let transport = FakeTransport::default();
426 *transport.fail_push.lock().unwrap() = true;
427
428 vault
429 .create_note(&VaultPath::new("a.md"), "body")
430 .await
431 .unwrap();
432 assert!(drain(&vault, &dirty, &transport).await.is_err());
433 assert_eq!(dirty.len(), 1);
435 }
436
437 #[tokio::test]
438 async fn reconcile_pushes_missing_and_deletes_stale() {
439 let dir = TempDir::new().unwrap();
440 let vault = vault(dir.path()).await;
441 let _dirty = register(&vault);
442 let transport = FakeTransport::default();
443
444 vault
445 .create_note(&VaultPath::new("keep.md"), "kept")
446 .await
447 .unwrap();
448 transport
450 .server
451 .lock()
452 .unwrap()
453 .insert("/gone.md".to_string(), "oldhash".to_string());
454
455 assert!(reconcile(&vault, &transport).await.unwrap());
456
457 let pushed = transport.pushed.lock().unwrap();
458 assert!(pushed.iter().any(|d| d.path == "/keep.md"));
459 assert_eq!(
460 *transport.deleted.lock().unwrap(),
461 vec!["/gone.md".to_string()]
462 );
463 }
464
465 #[tokio::test]
466 async fn reconcile_skipped_while_index_not_ready() {
467 let dir = TempDir::new().unwrap();
468 let vault = NoteVault::new(VaultConfig::new(sys(dir.path())))
472 .await
473 .unwrap();
474 assert!(!vault.index_ready());
475 let transport = FakeTransport::default();
476 transport
477 .server
478 .lock()
479 .unwrap()
480 .insert("/precious.md".to_string(), "hash".to_string());
481
482 assert!(!reconcile(&vault, &transport).await.unwrap());
483 assert!(transport.deleted.lock().unwrap().is_empty());
484 assert!(transport.pushed.lock().unwrap().is_empty());
485
486 let dirty = register(&vault);
490 dirty.record(&kimun_core::NoteChange::Upsert {
491 path: VaultPath::new("precious.md"),
492 hash: 1,
493 });
494 assert!(!drain(&vault, &dirty, &transport).await.unwrap());
495 assert_eq!(dirty.len(), 1);
496 assert!(transport.deleted.lock().unwrap().is_empty());
497 }
498}