Skip to main content

kimun_notes/rag/
sync.rs

1//! Background sync loop (P4). When a server URL is configured, a spawned task
2//! keeps the current vault in sync and reports connection status to the UI.
3//!
4//! The per-tick decisions — probe→capability→auth gating, the sticky
5//! auth-failure flag, and the reconcile-vs-drain cadence — live in the pure
6//! [`Cadence`] step functions so the whole status policy is testable without a
7//! live server. The spawned task is only the shell: timer, client calls, and
8//! channel sends.
9
10use std::sync::Arc;
11use std::time::Duration;
12
13use crate::server_client::{
14    RagClient,
15    sync::{RagSync, ServerCapability, ServerProbe},
16};
17use kimun_core::NoteVault;
18use tokio::task::JoinHandle;
19
20use super::RagStatus;
21use super::client::server_config;
22use crate::components::events::{AppEvent, AppTx};
23use crate::settings::SharedSettings;
24
25/// How often the background task flushes pending changes and refreshes status.
26const SYNC_INTERVAL: Duration = Duration::from_secs(10);
27
28/// Run a full reconcile (index-wide read + full-collection hash fetch) only
29/// every Nth interval — the drain fast path handles the common case, and a
30/// reconnect forces a reconcile immediately. At 10s × 30 that's ~5 min.
31const RECONCILE_EVERY_N_TICKS: u32 = 30;
32
33/// What a tick decided to do, given the probe. Statuses inside are for the
34/// shell to emit verbatim.
35#[derive(Debug, PartialEq, Eq)]
36enum Plan {
37    /// Emit the status and skip this tick's sync entirely.
38    Skip(RagStatus),
39    /// Optionally flash `Syncing`, then wait: the local index is rebuilding
40    /// and syncing from an empty snapshot would wipe the server collection.
41    Wait { flash: Option<RagStatus> },
42    /// Optionally flash `Syncing`, then sync — a full reconcile tick when
43    /// `reconcile`, the drain fast path otherwise.
44    Run {
45        flash: Option<RagStatus>,
46        reconcile: bool,
47    },
48}
49
50/// The sync call's result, stripped to what the status policy needs.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52enum Outcome {
53    /// The pass ran to completion.
54    Synced,
55    /// Pass skipped: the index flipped to rebuilding between the gate and the
56    /// call. Nothing was synced.
57    SkippedRebuild,
58    /// The server rejected our token (401/403): a credentials problem, not an
59    /// unreachable server.
60    AuthRejected,
61    /// Any other sync failure — treated as unreachable.
62    Failed,
63}
64
65/// The sync loop's per-tick state machine, kept pure so the policy is
66/// table-testable. One `plan` before the sync call, one `settle` after.
67struct Cadence {
68    /// Force a reconcile on the first successful tick and after any offline
69    /// gap; drain-only in between.
70    ticks_since_reconcile: u32,
71    /// Sticky across ticks: the last sync call was rejected with 401/403.
72    /// Suppresses the per-tick "syncing" flash while the token stays wrong.
73    auth_failed: bool,
74    /// The probe's Ask capability, remembered by `plan` so `settle` reports a
75    /// status consistent with the flashes emitted the same tick — the one
76    /// derivation lives here, not in the shell.
77    llm_available: bool,
78}
79
80impl Cadence {
81    fn new() -> Self {
82        Self {
83            ticks_since_reconcile: RECONCILE_EVERY_N_TICKS,
84            auth_failed: false,
85            llm_available: false,
86        }
87    }
88
89    /// Re-establish full consistency on the next successful tick.
90    fn force_reconcile(&mut self) {
91        self.ticks_since_reconcile = RECONCILE_EVERY_N_TICKS;
92    }
93
94    /// Decide this tick's action from the probe: offline,
95    /// unconfigured (skip sync — the server rejects everything), unauthorized
96    /// up front, or semantic-only/full (llm_available gates Ask).
97    fn plan(&mut self, probe: Option<&ServerProbe>, has_token: bool, index_ready: bool) -> Plan {
98        let probe = match probe {
99            Some(p) => p,
100            None => {
101                self.force_reconcile();
102                self.auth_failed = false;
103                return Plan::Skip(RagStatus::Offline);
104            }
105        };
106        if probe.capability == ServerCapability::Unconfigured {
107            // When an embedder appears, start with a full reconcile.
108            self.force_reconcile();
109            return Plan::Skip(RagStatus::NotConfigured);
110        }
111        // The server gates its API behind a token and none is configured:
112        // every sync call would 401 (`/health` itself is un-gated, which
113        // is why the probe still succeeded). Say so up front instead of
114        // rediscovering it as a failure burst every tick.
115        if probe.auth_required && !has_token {
116            self.force_reconcile();
117            return Plan::Skip(RagStatus::Unauthorized);
118        }
119        let llm_available = probe.capability.llm_available();
120        self.llm_available = llm_available;
121
122        // While a wrong token keeps failing, skip the transient "syncing"
123        // flash so the footer doesn't flicker syncing ↔ unauthorized.
124        let flash = (!self.auth_failed).then_some(RagStatus::Syncing { llm_available });
125
126        // The local index is empty while it (re)builds — a healed schema
127        // on first launch, an upgrade, or a manual reindex. Syncing from
128        // that snapshot is destructive (a reconcile reads "no notes" and
129        // would wipe the server collection), so wait, and run a full
130        // reconcile first thing once the index is filled.
131        if !index_ready {
132            self.force_reconcile();
133            return Plan::Wait { flash };
134        }
135
136        let reconcile = self.ticks_since_reconcile >= RECONCILE_EVERY_N_TICKS;
137        if reconcile {
138            self.ticks_since_reconcile = 0;
139        } else {
140            self.ticks_since_reconcile += 1;
141        }
142        Plan::Run { flash, reconcile }
143    }
144
145    /// Fold the sync call's outcome into the status to report, using the
146    /// capability `plan` recorded this tick.
147    fn settle(&mut self, outcome: Outcome) -> RagStatus {
148        let llm_available = self.llm_available;
149        match outcome {
150            Outcome::Synced => {
151                self.auth_failed = false;
152                RagStatus::Online { llm_available }
153            }
154            // Keep reporting Syncing (not Online) and force a full reconcile
155            // once the index is ready again.
156            Outcome::SkippedRebuild => {
157                self.force_reconcile();
158                self.auth_failed = false;
159                RagStatus::Syncing { llm_available }
160            }
161            Outcome::AuthRejected => {
162                self.auth_failed = true;
163                RagStatus::Unauthorized
164            }
165            Outcome::Failed => {
166                self.auth_failed = false;
167                RagStatus::Offline
168            }
169        }
170    }
171}
172
173/// Spawns the background sync loop for `vault` if a RAG server is configured.
174/// Returns the task handle (abort it when the vault is rebuilt), or `None` when
175/// the feature is off. Status is delivered to the UI via [`AppEvent::RagStatus`].
176pub fn spawn_rag_sync(
177    vault: Arc<NoteVault>,
178    settings: &SharedSettings,
179    tx: AppTx,
180) -> Option<JoinHandle<()>> {
181    let (url, token) = server_config(settings)?;
182
183    Some(tokio::spawn(async move {
184        let mut interval = tokio::time::interval(SYNC_INTERVAL);
185        // Don't stack missed ticks into a back-to-back burst if a slow tick
186        // overruns the interval (large vault / slow server).
187        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
188
189        // Resolve the vault id (which registers the observer) lazily so a
190        // transient failure just retries next tick instead of killing sync for
191        // the whole session.
192        let mut sync: Option<RagSync> = None;
193        let mut cadence = Cadence::new();
194
195        loop {
196            interval.tick().await;
197
198            if sync.is_none() {
199                match vault.vault_id().await {
200                    Ok(id) => {
201                        let client = RagClient::new(url.clone(), token.clone(), id.to_string());
202                        sync = Some(RagSync::new(vault.clone(), client));
203                    }
204                    Err(e) => {
205                        log::warn!("RAG: cannot read vault id (will retry): {e}");
206                        let _ = tx.send(AppEvent::RagStatus(RagStatus::Offline));
207                        continue;
208                    }
209                }
210            }
211            let sync = sync.as_ref().expect("sync established above");
212
213            // One probe drives reachability, capability, and auth.
214            let probe = sync.probe().await;
215
216            let reconcile = match cadence.plan(probe.as_ref(), token.is_some(), sync.index_ready())
217            {
218                Plan::Skip(status) => {
219                    let _ = tx.send(AppEvent::RagStatus(status));
220                    continue;
221                }
222                Plan::Wait { flash } => {
223                    if let Some(status) = flash {
224                        let _ = tx.send(AppEvent::RagStatus(status));
225                    }
226                    continue;
227                }
228                Plan::Run { flash, reconcile } => {
229                    if let Some(status) = flash {
230                        let _ = tx.send(AppEvent::RagStatus(status));
231                    }
232                    reconcile
233                }
234            };
235
236            let result = if reconcile {
237                sync.tick().await // drain + reconcile
238            } else {
239                sync.drain().await // fast path
240            };
241            let outcome = match &result {
242                Ok(true) => Outcome::Synced,
243                Ok(false) => Outcome::SkippedRebuild,
244                Err(e) if e.is_auth() => {
245                    log::warn!("RAG server rejected the configured token: {e}");
246                    Outcome::AuthRejected
247                }
248                Err(e) => {
249                    log::debug!("RAG sync failed: {e}");
250                    Outcome::Failed
251                }
252            };
253            let status = cadence.settle(outcome);
254            let _ = tx.send(AppEvent::RagStatus(status));
255        }
256    }))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn probe(capability: ServerCapability, auth_required: bool) -> ServerProbe {
264        ServerProbe {
265            capability,
266            auth_required,
267        }
268    }
269
270    #[test]
271    fn offline_probe_reports_offline_and_forces_reconcile() {
272        let mut c = Cadence::new();
273        // Get past the initial forced reconcile so the reset is observable.
274        c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
275        assert_eq!(c.plan(None, true, true), Plan::Skip(RagStatus::Offline));
276        // The reconnect tick reconciles immediately.
277        assert_eq!(
278            c.plan(Some(&probe(ServerCapability::Full, false)), true, true),
279            Plan::Run {
280                flash: Some(RagStatus::Syncing {
281                    llm_available: true
282                }),
283                reconcile: true,
284            }
285        );
286    }
287
288    #[test]
289    fn unconfigured_server_skips_sync() {
290        let mut c = Cadence::new();
291        assert_eq!(
292            c.plan(
293                Some(&probe(ServerCapability::Unconfigured, false)),
294                true,
295                true
296            ),
297            Plan::Skip(RagStatus::NotConfigured)
298        );
299    }
300
301    #[test]
302    fn auth_required_without_token_reports_unauthorized_up_front() {
303        let mut c = Cadence::new();
304        assert_eq!(
305            c.plan(Some(&probe(ServerCapability::Full, true)), false, true),
306            Plan::Skip(RagStatus::Unauthorized)
307        );
308    }
309
310    #[test]
311    fn auth_required_with_token_syncs() {
312        let mut c = Cadence::new();
313        assert!(matches!(
314            c.plan(Some(&probe(ServerCapability::Full, true)), true, true),
315            Plan::Run { .. }
316        ));
317    }
318
319    #[test]
320    fn index_not_ready_waits_and_reconciles_once_filled() {
321        let mut c = Cadence::new();
322        // Drain a few ticks first so the pending reconcile is the wait's doing.
323        c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
324        c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
325        assert_eq!(
326            c.plan(Some(&probe(ServerCapability::Full, false)), true, false),
327            Plan::Wait {
328                flash: Some(RagStatus::Syncing {
329                    llm_available: true
330                })
331            }
332        );
333        assert_eq!(
334            c.plan(Some(&probe(ServerCapability::Full, false)), true, true),
335            Plan::Run {
336                flash: Some(RagStatus::Syncing {
337                    llm_available: true
338                }),
339                reconcile: true,
340            }
341        );
342    }
343
344    #[test]
345    fn reconcile_cadence_first_tick_then_drains_then_reconciles_again() {
346        let mut c = Cadence::new();
347        let p = probe(ServerCapability::Full, false);
348        // First successful tick reconciles.
349        assert!(matches!(
350            c.plan(Some(&p), true, true),
351            Plan::Run {
352                reconcile: true,
353                ..
354            }
355        ));
356        // The next N-1 ticks drain.
357        for _ in 0..RECONCILE_EVERY_N_TICKS {
358            assert!(matches!(
359                c.plan(Some(&p), true, true),
360                Plan::Run {
361                    reconcile: false,
362                    ..
363                }
364            ));
365        }
366        // The Nth tick reconciles again.
367        assert!(matches!(
368            c.plan(Some(&p), true, true),
369            Plan::Run {
370                reconcile: true,
371                ..
372            }
373        ));
374    }
375
376    #[test]
377    fn auth_rejection_is_sticky_and_suppresses_the_syncing_flash() {
378        let mut c = Cadence::new();
379        let p = probe(ServerCapability::Full, true);
380        assert!(matches!(c.plan(Some(&p), true, true), Plan::Run { .. }));
381        assert_eq!(c.settle(Outcome::AuthRejected), RagStatus::Unauthorized);
382        // While the token stays wrong: no syncing flash (no footer flicker).
383        assert_eq!(
384            c.plan(Some(&p), true, true),
385            Plan::Run {
386                flash: None,
387                reconcile: false,
388            }
389        );
390        // A successful pass clears the stickiness.
391        assert_eq!(
392            c.settle(Outcome::Synced),
393            RagStatus::Online {
394                llm_available: true
395            }
396        );
397        assert!(matches!(
398            c.plan(Some(&p), true, true),
399            Plan::Run { flash: Some(_), .. }
400        ));
401    }
402
403    #[test]
404    fn skipped_pass_reports_syncing_and_forces_reconcile() {
405        let mut c = Cadence::new();
406        let p = probe(ServerCapability::SemanticOnly, false);
407        c.plan(Some(&p), true, true);
408        assert_eq!(
409            c.settle(Outcome::SkippedRebuild),
410            RagStatus::Syncing {
411                llm_available: false
412            }
413        );
414        // The next runnable tick is a full reconcile.
415        assert!(matches!(
416            c.plan(Some(&p), true, true),
417            Plan::Run {
418                reconcile: true,
419                ..
420            }
421        ));
422    }
423
424    #[test]
425    fn sync_failure_reports_offline() {
426        let mut c = Cadence::new();
427        let p = probe(ServerCapability::Full, false);
428        c.plan(Some(&p), true, true);
429        assert_eq!(c.settle(Outcome::Failed), RagStatus::Offline);
430    }
431}