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/// A server update the user should hear about, derived from `/health`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ServerUpdate {
49    /// The server's update check found this newer release.
50    Newer(String),
51    /// The server reports no `version` at all: it predates version reporting,
52    /// so it is behind every release that has it, but which one is unknown.
53    Legacy,
54}
55
56impl ServerUpdate {
57    /// One-line description, shared by the footer hint and the dialog.
58    pub fn summary(&self) -> String {
59        match self {
60            ServerUpdate::Newer(v) => format!("server {v} available"),
61            ServerUpdate::Legacy => "server update available".to_string(),
62        }
63    }
64
65    /// Derives the notice from a health probe: a server without `version` is
66    /// [`Legacy`](ServerUpdate::Legacy); otherwise it is whatever newer release
67    /// the server itself reported, if any.
68    pub fn from_health(health: &crate::server_client::dto::Health) -> Option<Self> {
69        match &health.version {
70            None => Some(ServerUpdate::Legacy),
71            Some(_) => health.latest_version.clone().map(ServerUpdate::Newer),
72        }
73    }
74}
75
76/// One `/health` round-trip's worth of facts: what the server can do, and
77/// whether it gates its API behind a bearer token. `/health` itself is
78/// un-gated, so a client with a missing/wrong token still probes fine —
79/// `auth_required` lets it report "unauthorized" up front instead of
80/// discovering a 401 on the first sync call. `server_update` is the passive
81/// footer hint that the server is out of date.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ServerProbe {
84    pub capability: ServerCapability,
85    pub auth_required: bool,
86    pub server_update: Option<ServerUpdate>,
87}
88
89/// Bundles a vault, its dirty-set, and the server client, and drives sync. The
90/// caller (the TUI) owns the schedule: [`probe`](RagSync::probe) to gate
91/// features, and call [`tick`](RagSync::tick) periodically to keep the server in
92/// step.
93pub struct RagSync {
94    vault: Arc<NoteVault>,
95    dirty: Arc<DirtySet>,
96    /// The exact observer this sync registered, kept so `Drop` can deregister
97    /// *only* ours (by identity) and never a newer one that replaced it.
98    observer: Arc<dyn IndexObserver>,
99    client: RagClient,
100}
101
102impl RagSync {
103    /// Registers the observer on `vault` and returns a handle over it. Construct
104    /// **one** `RagSync` per vault: the observer is zero-or-one, so a second
105    /// `RagSync` for the same vault replaces the first's observer and strands its
106    /// dirty-set (its `tick` still self-heals via reconcile, but its drain fast
107    /// path goes silent).
108    pub fn new(vault: Arc<NoteVault>, client: RagClient) -> Self {
109        let dirty = Arc::new(DirtySet::default());
110        let observer: Arc<dyn IndexObserver> = Arc::new(RagObserver::new(dirty.clone()));
111        vault.set_index_observer(observer.clone());
112        Self {
113            vault,
114            dirty,
115            observer,
116            client,
117        }
118    }
119
120    /// Probe reachability, capability, and auth in one `/health` request:
121    /// `None` = offline; otherwise a [`ServerProbe`] carrying the server's
122    /// [`ServerCapability`] — `Unconfigured` (no embedder: don't sync),
123    /// `SemanticOnly` (search, no Q&A), or `Full` — and whether the API
124    /// requires a bearer token.
125    pub async fn probe(&self) -> Option<ServerProbe> {
126        self.client.health().await.ok().map(|h| ServerProbe {
127            capability: ServerCapability::from_health(&h),
128            auth_required: h.auth_required,
129            server_update: ServerUpdate::from_health(&h),
130        })
131    }
132
133    /// Whether the local index is filled and safe to sync from. `false` while
134    /// a healed/rebuilding index is still empty — syncing then would read "no
135    /// notes" and tear the server collection down (see [`reconcile`]).
136    pub fn index_ready(&self) -> bool {
137        self.vault.index_ready()
138    }
139
140    /// One sync pass: flush pending changes, then reconcile to repair drift.
141    /// Returns `false` when either half was skipped because the local index
142    /// is not ready yet — call again once it is.
143    pub async fn tick(&self) -> Result<bool, RagError> {
144        let drained = drain(&self.vault, &self.dirty, &self.client).await?;
145        let reconciled = reconcile(&self.vault, &self.client).await?;
146        Ok(drained && reconciled)
147    }
148
149    /// Flush pending changes only — the cheap fast path (touches only dirty
150    /// notes). Run this often; run [`tick`](Self::tick)/[`reconcile`](Self::reconcile)
151    /// occasionally as the safety net. Returns `false` when skipped because
152    /// the local index is not ready.
153    pub async fn drain(&self) -> Result<bool, RagError> {
154        drain(&self.vault, &self.dirty, &self.client).await
155    }
156
157    /// Full hash-diff reconciliation only — an index-wide read + a full-collection
158    /// hash fetch. The periodic backbone; not needed on every tick. Returns
159    /// `false` when skipped because the local index is not ready.
160    pub async fn reconcile(&self) -> Result<bool, RagError> {
161        reconcile(&self.vault, &self.client).await
162    }
163
164    /// The underlying client, for queries (search / ask).
165    pub fn client(&self) -> &RagClient {
166        &self.client
167    }
168}
169
170impl Drop for RagSync {
171    fn drop(&mut self) {
172        // Deregister *our* observer so a superseded/aborted sync doesn't leave
173        // the vault feeding a dirty-set nobody drains — but only if it's still
174        // ours, so we never wipe a newer sync that has replaced it.
175        self.vault.clear_index_observer_if(&self.observer);
176    }
177}
178
179/// Builds the wire document for a note: its canonical path, content hash, and
180/// heading sections pulled from the index. Returns `None` when the note has no
181/// indexable sections — an empty note is not RAG content, so it is never pushed
182/// (this keeps both backends from perpetually re-pushing chunkless notes, since
183/// only one of them records a hash for them server-side).
184pub async fn build_doc(
185    vault: &NoteVault,
186    path: &VaultPath,
187    hash: u64,
188) -> Result<Option<WireDoc>, VaultError> {
189    let chunks = vault.get_note_chunks(path).await?;
190    let sections: Vec<WireSection> = chunks
191        .into_values()
192        .flatten()
193        .map(|c| WireSection {
194            title: c.get_breadcrumb().to_string(),
195            text: c.get_text().to_string(),
196        })
197        .collect();
198    if sections.is_empty() {
199        return Ok(None);
200    }
201    Ok(Some(WireDoc {
202        path: path.to_string(),
203        hash: hash_string(hash),
204        sections,
205    }))
206}
207
208/// Flushes the dirty-set to the server. Failed operations are re-queued so the
209/// next drain (or a reconcile) retries them.
210///
211/// Returns `false` (doing nothing) when the local index is not ready: an
212/// unready (healed/rebuilding) index reads as empty, so build_doc would find
213/// no chunks and turn queued upserts into server-side deletes. The dirty-set
214/// stays queued until the index is filled; callers should retry then.
215pub async fn drain<T: RagTransport>(
216    vault: &NoteVault,
217    dirty: &DirtySet,
218    transport: &T,
219) -> Result<bool, RagError> {
220    if !vault.index_ready() {
221        return Ok(false);
222    }
223    let ops = dirty.drain();
224    if ops.is_empty() {
225        return Ok(true);
226    }
227
228    let mut upserts: Vec<(VaultPath, u64)> = Vec::new();
229    let mut deletes: Vec<String> = Vec::new();
230    for (path, op) in ops {
231        match op {
232            DirtyOp::Upsert(hash) => upserts.push((path, hash)),
233            DirtyOp::Delete => deletes.push(path.to_string()),
234        }
235    }
236
237    // Build the docs for upserts; a note that can't be read right now is
238    // re-queued rather than dropped.
239    let mut docs = Vec::new();
240    let mut built: Vec<(VaultPath, u64)> = Vec::new();
241    for (path, hash) in upserts {
242        match build_doc(vault, &path, hash).await {
243            Ok(Some(doc)) => {
244                docs.push(doc);
245                built.push((path, hash));
246            }
247            // An emptied note has no chunks to index — delete it server-side so
248            // its old chunks don't linger (and so /hashes stops reporting it).
249            Ok(None) => deletes.push(path.to_string()),
250            Err(_) => dirty.requeue([(path, DirtyOp::Upsert(hash))]),
251        }
252    }
253
254    let mut first_err: Option<RagError> = None;
255    if !docs.is_empty()
256        && let Err(e) = transport.push_docs(docs).await
257    {
258        dirty.requeue(built.into_iter().map(|(p, h)| (p, DirtyOp::Upsert(h))));
259        first_err = Some(e);
260    }
261    if !deletes.is_empty() {
262        let paths_for_requeue: Vec<VaultPath> = deletes.iter().map(VaultPath::new).collect();
263        if let Err(e) = transport.delete_paths(deletes).await {
264            dirty.requeue(paths_for_requeue.into_iter().map(|p| (p, DirtyOp::Delete)));
265            first_err = first_err.or(Some(e));
266        }
267    }
268
269    match first_err {
270        Some(e) => Err(e),
271        None => Ok(true),
272    }
273}
274
275/// Reconciles the server with the vault: diff hash sets, then push/delete only
276/// the differences. Self-healing — repairs anything the drain path missed.
277///
278/// Returns `false` (doing nothing) when the local index is not ready: a
279/// healed/rebuilding index reads as an empty vault, and diffing against that
280/// snapshot would put every server doc in `to_delete` — wiping the collection.
281/// Callers should retry once the index is filled.
282pub async fn reconcile<T: RagTransport>(
283    vault: &NoteVault,
284    transport: &T,
285) -> Result<bool, RagError> {
286    if !vault.index_ready() {
287        return Ok(false);
288    }
289    let notes = vault
290        .get_all_notes()
291        .await
292        .map_err(|e| RagError::Protocol(format!("read vault notes: {e}")))?;
293
294    let local_hashes: HashMap<String, u64> = notes
295        .into_iter()
296        .map(|(entry, content)| (entry.path.to_string(), content.hash))
297        .collect();
298    let local_str: HashMap<String, String> = local_hashes
299        .iter()
300        .map(|(p, h)| (p.clone(), hash_string(*h)))
301        .collect();
302
303    let server = transport.server_hashes().await?;
304    let plan = reconcile_diff(&local_str, &server);
305
306    let mut docs = Vec::new();
307    let mut to_delete = plan.to_delete;
308    for path_str in &plan.to_push {
309        let hash = local_hashes[path_str];
310        match build_doc(vault, &VaultPath::new(path_str), hash)
311            .await
312            .map_err(|e| RagError::Protocol(format!("build doc {path_str}: {e}")))?
313        {
314            Some(doc) => docs.push(doc),
315            // Empty note: it carries no chunks. If the server still has it (it
316            // was emptied), delete it; if not, it's already converged (nothing
317            // to push). Either way it never becomes a stale server entry.
318            None => {
319                if server.contains_key(path_str) {
320                    to_delete.push(path_str.clone());
321                }
322            }
323        }
324    }
325    if !docs.is_empty() {
326        transport.push_docs(docs).await?;
327    }
328    if !to_delete.is_empty() {
329        transport.delete_paths(to_delete).await?;
330    }
331    Ok(true)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use async_trait::async_trait;
338
339    #[test]
340    fn server_update_from_health_fields() {
341        use crate::server_client::dto::Health;
342        let h = |version: Option<&str>, latest: Option<&str>| Health {
343            status: "ok".into(),
344            reranker: false,
345            embedder: None,
346            llm_provider: None,
347            auth_required: false,
348            version: version.map(str::to_string),
349            latest_version: latest.map(str::to_string),
350        };
351        assert_eq!(ServerUpdate::from_health(&h(Some("0.4.4"), None)), None);
352        assert_eq!(
353            ServerUpdate::from_health(&h(Some("0.4.4"), Some("0.4.5"))),
354            Some(ServerUpdate::Newer("0.4.5".into()))
355        );
356        // A 0.4.3-or-earlier server's /health has no version at all.
357        let legacy: Health = serde_json::from_str(
358            r#"{"status":"ok","reranker":false,"embedder":"fastembed","llm_provider":null,"auth_required":false,"degraded":null}"#,
359        )
360        .unwrap();
361        assert_eq!(
362            ServerUpdate::from_health(&legacy),
363            Some(ServerUpdate::Legacy)
364        );
365    }
366
367    #[test]
368    fn capability_from_health_fields() {
369        use crate::server_client::dto::Health;
370        let h = |embedder: Option<&str>, llm: Option<&str>| Health {
371            status: "ok".into(),
372            reranker: false,
373            embedder: embedder.map(str::to_string),
374            llm_provider: llm.map(str::to_string),
375            auth_required: false,
376            version: Some("0.4.4".into()),
377            latest_version: None,
378        };
379        assert_eq!(
380            ServerCapability::from_health(&h(None, None)),
381            ServerCapability::Unconfigured
382        );
383        assert_eq!(
384            // An LLM without an embedder still can't answer — retrieval is dead.
385            ServerCapability::from_health(&h(None, Some("gemini"))),
386            ServerCapability::Unconfigured
387        );
388        assert_eq!(
389            ServerCapability::from_health(&h(Some("fastembed"), None)),
390            ServerCapability::SemanticOnly
391        );
392        assert_eq!(
393            ServerCapability::from_health(&h(Some("fastembed"), Some("gemini"))),
394            ServerCapability::Full
395        );
396    }
397    use kimun_core::VaultConfig;
398    use std::sync::Mutex;
399    use tempfile::TempDir;
400
401    #[derive(Default)]
402    struct FakeTransport {
403        pushed: Mutex<Vec<WireDoc>>,
404        deleted: Mutex<Vec<String>>,
405        server: Mutex<HashMap<String, String>>,
406        fail_push: Mutex<bool>,
407    }
408
409    #[async_trait]
410    impl RagTransport for FakeTransport {
411        async fn push_docs(&self, docs: Vec<WireDoc>) -> Result<(), RagError> {
412            if *self.fail_push.lock().unwrap() {
413                return Err(RagError::Protocol("boom".into()));
414            }
415            self.pushed.lock().unwrap().extend(docs);
416            Ok(())
417        }
418        async fn delete_paths(&self, paths: Vec<String>) -> Result<(), RagError> {
419            self.deleted.lock().unwrap().extend(paths);
420            Ok(())
421        }
422        async fn server_hashes(&self) -> Result<HashMap<String, String>, RagError> {
423            Ok(self.server.lock().unwrap().clone())
424        }
425    }
426
427    /// Test-only observer wiring feeding a bare dirty-set, for exercising the
428    /// free `drain`/`reconcile` fns directly. Production always goes through
429    /// [`RagSync::new`], which additionally keeps the observer handle so its
430    /// `Drop` can deregister by identity.
431    fn register(vault: &NoteVault) -> Arc<DirtySet> {
432        let dirty = Arc::new(DirtySet::default());
433        vault.set_index_observer(Arc::new(RagObserver::new(dirty.clone())));
434        dirty
435    }
436
437    /// A vault root as a `SystemPath`. Built from `kimun_core` rather than the
438    /// crate's own test helper: this module must not name anything outside
439    /// itself, so it stays extractable as a crate (adr/0042).
440    fn sys(path: impl AsRef<std::path::Path>) -> kimun_core::SystemPath {
441        kimun_core::SystemPath::try_absolute(path).expect("test path must be absolute")
442    }
443
444    async fn vault(dir: &std::path::Path) -> NoteVault {
445        let vault = NoteVault::new(VaultConfig::new(sys(dir))).await.unwrap();
446        // Fill the freshly-healed index so index_ready() holds — the state the
447        // drain/reconcile gates require (mirrors the app's validate_and_init).
448        vault.validate_and_init().await.unwrap();
449        vault
450    }
451
452    #[tokio::test]
453    async fn drain_pushes_created_note_and_deletes_removed() {
454        let dir = TempDir::new().unwrap();
455        let vault = vault(dir.path()).await;
456        let dirty = register(&vault);
457        let transport = FakeTransport::default();
458
459        vault
460            .create_note(&VaultPath::new("a.md"), "# Title\n\nbody")
461            .await
462            .unwrap();
463        drain(&vault, &dirty, &transport).await.unwrap();
464
465        // Block-scoped: clippy's await_holding_lock tracks lexical scope, so an
466        // explicit drop() before the awaits below wouldn't silence it.
467        {
468            let pushed = transport.pushed.lock().unwrap();
469            assert_eq!(pushed.len(), 1);
470            assert_eq!(pushed[0].path, "/a.md"); // canonical
471            assert!(!pushed[0].sections.is_empty());
472            assert!(dirty.is_empty());
473        }
474
475        vault.delete_note(&VaultPath::new("a.md")).await.unwrap();
476        drain(&vault, &dirty, &transport).await.unwrap();
477        assert_eq!(
478            *transport.deleted.lock().unwrap(),
479            vec!["/a.md".to_string()]
480        );
481    }
482
483    #[tokio::test]
484    async fn failed_push_requeues() {
485        let dir = TempDir::new().unwrap();
486        let vault = vault(dir.path()).await;
487        let dirty = register(&vault);
488        let transport = FakeTransport::default();
489        *transport.fail_push.lock().unwrap() = true;
490
491        vault
492            .create_note(&VaultPath::new("a.md"), "body")
493            .await
494            .unwrap();
495        assert!(drain(&vault, &dirty, &transport).await.is_err());
496        // The op survived for a later retry.
497        assert_eq!(dirty.len(), 1);
498    }
499
500    #[tokio::test]
501    async fn reconcile_pushes_missing_and_deletes_stale() {
502        let dir = TempDir::new().unwrap();
503        let vault = vault(dir.path()).await;
504        let _dirty = register(&vault);
505        let transport = FakeTransport::default();
506
507        vault
508            .create_note(&VaultPath::new("keep.md"), "kept")
509            .await
510            .unwrap();
511        // Server already has a stale note the vault no longer contains.
512        transport
513            .server
514            .lock()
515            .unwrap()
516            .insert("/gone.md".to_string(), "oldhash".to_string());
517
518        assert!(reconcile(&vault, &transport).await.unwrap());
519
520        let pushed = transport.pushed.lock().unwrap();
521        assert!(pushed.iter().any(|d| d.path == "/keep.md"));
522        assert_eq!(
523            *transport.deleted.lock().unwrap(),
524            vec!["/gone.md".to_string()]
525        );
526    }
527
528    #[tokio::test]
529    async fn reconcile_skipped_while_index_not_ready() {
530        let dir = TempDir::new().unwrap();
531        // No validate_and_init: the fresh index is healed-but-empty, exactly
532        // the state where a reconcile would read "no local notes" and delete
533        // the whole server collection.
534        let vault = NoteVault::new(VaultConfig::new(sys(dir.path())))
535            .await
536            .unwrap();
537        assert!(!vault.index_ready());
538        let transport = FakeTransport::default();
539        transport
540            .server
541            .lock()
542            .unwrap()
543            .insert("/precious.md".to_string(), "hash".to_string());
544
545        assert!(!reconcile(&vault, &transport).await.unwrap());
546        assert!(transport.deleted.lock().unwrap().is_empty());
547        assert!(transport.pushed.lock().unwrap().is_empty());
548
549        // Drain likewise holds queued ops instead of misreading the empty
550        // index (an upsert would otherwise become a server-side delete),
551        // and reports the skip so callers don't claim the vault is synced.
552        let dirty = register(&vault);
553        dirty.record(&kimun_core::NoteChange::Upsert {
554            path: VaultPath::new("precious.md"),
555            hash: 1,
556        });
557        assert!(!drain(&vault, &dirty, &transport).await.unwrap());
558        assert_eq!(dirty.len(), 1);
559        assert!(transport.deleted.lock().unwrap().is_empty());
560    }
561}