Skip to main content

kimun_notes/server_client/
sync.rs

1//! Orchestration: turn observed changes and the vault's authoritative state into
2//! server pushes/deletes. [`RagSync`] wires the observer; `drain` flushes the
3//! dirty-set (the fast path); `reconcile` is the correctness backbone.
4//! All server I/O goes through [`RagTransport`], so this logic is
5//! tested with a fake against a real vault.
6
7use 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/// What a reachable server can do, derived from `/health`: search
18/// needs an embedder, question-answering needs an embedder AND an LLM.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ServerCapability {
21    /// No embedder configured — nothing works server-side; the client must not
22    /// push or reconcile (every call would 503).
23    Unconfigured,
24    /// Embedder, no LLM: search and sync work, question-answering does not.
25    SemanticOnly,
26    /// Embedder and LLM: everything works.
27    Full,
28}
29
30impl ServerCapability {
31    /// Derives the capability from a health probe's fields.
32    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    /// Whether question-answering is usable.
41    pub fn llm_available(self) -> bool {
42        matches!(self, ServerCapability::Full)
43    }
44}
45
46/// One `/health` round-trip's worth of facts: what the server can do, and
47/// whether it gates its API behind a bearer token. `/health` itself is
48/// un-gated, so a client with a missing/wrong token still probes fine —
49/// `auth_required` lets it report "unauthorized" up front instead of
50/// discovering a 401 on the first sync call.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct ServerProbe {
53    pub capability: ServerCapability,
54    pub auth_required: bool,
55}
56
57/// Bundles a vault, its dirty-set, and the server client, and drives sync. The
58/// caller (the TUI) owns the schedule: [`probe`](RagSync::probe) to gate
59/// features, and call [`tick`](RagSync::tick) periodically to keep the server in
60/// step.
61pub struct RagSync {
62    vault: Arc<NoteVault>,
63    dirty: Arc<DirtySet>,
64    /// The exact observer this sync registered, kept so `Drop` can deregister
65    /// *only* ours (by identity) and never a newer one that replaced it.
66    observer: Arc<dyn IndexObserver>,
67    client: RagClient,
68}
69
70impl RagSync {
71    /// Registers the observer on `vault` and returns a handle over it. Construct
72    /// **one** `RagSync` per vault: the observer is zero-or-one, so a second
73    /// `RagSync` for the same vault replaces the first's observer and strands its
74    /// dirty-set (its `tick` still self-heals via reconcile, but its drain fast
75    /// path goes silent).
76    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    /// Probe reachability, capability, and auth in one `/health` request:
89    /// `None` = offline; otherwise a [`ServerProbe`] carrying the server's
90    /// [`ServerCapability`] — `Unconfigured` (no embedder: don't sync),
91    /// `SemanticOnly` (search, no Q&A), or `Full` — and whether the API
92    /// requires a bearer token.
93    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    /// Whether the local index is filled and safe to sync from. `false` while
101    /// a healed/rebuilding index is still empty — syncing then would read "no
102    /// notes" and tear the server collection down (see [`reconcile`]).
103    pub fn index_ready(&self) -> bool {
104        self.vault.index_ready()
105    }
106
107    /// One sync pass: flush pending changes, then reconcile to repair drift.
108    /// Returns `false` when either half was skipped because the local index
109    /// is not ready yet — call again once it is.
110    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    /// Flush pending changes only — the cheap fast path (touches only dirty
117    /// notes). Run this often; run [`tick`](Self::tick)/[`reconcile`](Self::reconcile)
118    /// occasionally as the safety net. Returns `false` when skipped because
119    /// the local index is not ready.
120    pub async fn drain(&self) -> Result<bool, RagError> {
121        drain(&self.vault, &self.dirty, &self.client).await
122    }
123
124    /// Full hash-diff reconciliation only — an index-wide read + a full-collection
125    /// hash fetch. The periodic backbone; not needed on every tick. Returns
126    /// `false` when skipped because the local index is not ready.
127    pub async fn reconcile(&self) -> Result<bool, RagError> {
128        reconcile(&self.vault, &self.client).await
129    }
130
131    /// The underlying client, for queries (search / ask).
132    pub fn client(&self) -> &RagClient {
133        &self.client
134    }
135}
136
137impl Drop for RagSync {
138    fn drop(&mut self) {
139        // Deregister *our* observer so a superseded/aborted sync doesn't leave
140        // the vault feeding a dirty-set nobody drains — but only if it's still
141        // ours, so we never wipe a newer sync that has replaced it.
142        self.vault.clear_index_observer_if(&self.observer);
143    }
144}
145
146/// Builds the wire document for a note: its canonical path, content hash, and
147/// heading sections pulled from the index. Returns `None` when the note has no
148/// indexable sections — an empty note is not RAG content, so it is never pushed
149/// (this keeps both backends from perpetually re-pushing chunkless notes, since
150/// only one of them records a hash for them server-side).
151pub 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
175/// Flushes the dirty-set to the server. Failed operations are re-queued so the
176/// next drain (or a reconcile) retries them.
177///
178/// Returns `false` (doing nothing) when the local index is not ready: an
179/// unready (healed/rebuilding) index reads as empty, so build_doc would find
180/// no chunks and turn queued upserts into server-side deletes. The dirty-set
181/// stays queued until the index is filled; callers should retry then.
182pub 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    // Build the docs for upserts; a note that can't be read right now is
205    // re-queued rather than dropped.
206    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            // An emptied note has no chunks to index — delete it server-side so
215            // its old chunks don't linger (and so /hashes stops reporting it).
216            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
242/// Reconciles the server with the vault: diff hash sets, then push/delete only
243/// the differences. Self-healing — repairs anything the drain path missed.
244///
245/// Returns `false` (doing nothing) when the local index is not ready: a
246/// healed/rebuilding index reads as an empty vault, and diffing against that
247/// snapshot would put every server doc in `to_delete` — wiping the collection.
248/// Callers should retry once the index is filled.
249pub 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            // Empty note: it carries no chunks. If the server still has it (it
283            // was emptied), delete it; if not, it's already converged (nothing
284            // to push). Either way it never becomes a stale server entry.
285            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            // An LLM without an embedder still can't answer — retrieval is dead.
322            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    /// Test-only observer wiring feeding a bare dirty-set, for exercising the
365    /// free `drain`/`reconcile` fns directly. Production always goes through
366    /// [`RagSync::new`], which additionally keeps the observer handle so its
367    /// `Drop` can deregister by identity.
368    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    /// A vault root as a `SystemPath`. Built from `kimun_core` rather than the
375    /// crate's own test helper: this module must not name anything outside
376    /// itself, so it stays extractable as a crate (adr/0042).
377    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        // Fill the freshly-healed index so index_ready() holds — the state the
384        // drain/reconcile gates require (mirrors the app's validate_and_init).
385        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        // Block-scoped: clippy's await_holding_lock tracks lexical scope, so an
403        // explicit drop() before the awaits below wouldn't silence it.
404        {
405            let pushed = transport.pushed.lock().unwrap();
406            assert_eq!(pushed.len(), 1);
407            assert_eq!(pushed[0].path, "/a.md"); // canonical
408            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        // The op survived for a later retry.
434        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        // Server already has a stale note the vault no longer contains.
449        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        // No validate_and_init: the fresh index is healed-but-empty, exactly
469        // the state where a reconcile would read "no local notes" and delete
470        // the whole server collection.
471        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        // Drain likewise holds queued ops instead of misreading the empty
487        // index (an upsert would otherwise become a server-side delete),
488        // and reports the skip so callers don't claim the vault is synced.
489        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}