kimun-notes 0.23.2

A terminal-based notes application
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! Background sync loop (P4). When a server URL is configured, a spawned task
//! keeps the current vault in sync and reports connection status to the UI.
//!
//! The per-tick decisions — probe→capability→auth gating, the sticky
//! auth-failure flag, and the reconcile-vs-drain cadence — live in the pure
//! [`Cadence`] step functions so the whole status policy is testable without a
//! live server. The spawned task is only the shell: timer, client calls, and
//! channel sends.

use std::sync::Arc;
use std::time::Duration;

use crate::server_client::{
    RagClient,
    sync::{RagSync, ServerCapability, ServerProbe},
};
use kimun_core::NoteVault;
use tokio::task::JoinHandle;

use super::RagStatus;
use super::client::server_config;
use crate::components::events::{AppEvent, AppTx};
use crate::settings::SharedSettings;

/// How often the background task flushes pending changes and refreshes status.
const SYNC_INTERVAL: Duration = Duration::from_secs(10);

/// Run a full reconcile (index-wide read + full-collection hash fetch) only
/// every Nth interval — the drain fast path handles the common case, and a
/// reconnect forces a reconcile immediately. At 10s × 30 that's ~5 min.
const RECONCILE_EVERY_N_TICKS: u32 = 30;

/// What a tick decided to do, given the probe. Statuses inside are for the
/// shell to emit verbatim.
#[derive(Debug, PartialEq, Eq)]
enum Plan {
    /// Emit the status and skip this tick's sync entirely.
    Skip(RagStatus),
    /// Optionally flash `Syncing`, then wait: the local index is rebuilding
    /// and syncing from an empty snapshot would wipe the server collection.
    Wait { flash: Option<RagStatus> },
    /// Optionally flash `Syncing`, then sync — a full reconcile tick when
    /// `reconcile`, the drain fast path otherwise.
    Run {
        flash: Option<RagStatus>,
        reconcile: bool,
    },
}

/// The sync call's result, stripped to what the status policy needs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
    /// The pass ran to completion.
    Synced,
    /// Pass skipped: the index flipped to rebuilding between the gate and the
    /// call. Nothing was synced.
    SkippedRebuild,
    /// The server rejected our token (401/403): a credentials problem, not an
    /// unreachable server.
    AuthRejected,
    /// Any other sync failure — treated as unreachable.
    Failed,
}

/// The sync loop's per-tick state machine, kept pure so the policy is
/// table-testable. One `plan` before the sync call, one `settle` after.
struct Cadence {
    /// Force a reconcile on the first successful tick and after any offline
    /// gap; drain-only in between.
    ticks_since_reconcile: u32,
    /// Sticky across ticks: the last sync call was rejected with 401/403.
    /// Suppresses the per-tick "syncing" flash while the token stays wrong.
    auth_failed: bool,
    /// The probe's Ask capability, remembered by `plan` so `settle` reports a
    /// status consistent with the flashes emitted the same tick — the one
    /// derivation lives here, not in the shell.
    llm_available: bool,
}

impl Cadence {
    fn new() -> Self {
        Self {
            ticks_since_reconcile: RECONCILE_EVERY_N_TICKS,
            auth_failed: false,
            llm_available: false,
        }
    }

    /// Re-establish full consistency on the next successful tick.
    fn force_reconcile(&mut self) {
        self.ticks_since_reconcile = RECONCILE_EVERY_N_TICKS;
    }

    /// Decide this tick's action from the probe: offline,
    /// unconfigured (skip sync — the server rejects everything), unauthorized
    /// up front, or semantic-only/full (llm_available gates Ask).
    fn plan(&mut self, probe: Option<&ServerProbe>, has_token: bool, index_ready: bool) -> Plan {
        let probe = match probe {
            Some(p) => p,
            None => {
                self.force_reconcile();
                self.auth_failed = false;
                return Plan::Skip(RagStatus::Offline);
            }
        };
        if probe.capability == ServerCapability::Unconfigured {
            // When an embedder appears, start with a full reconcile.
            self.force_reconcile();
            return Plan::Skip(RagStatus::NotConfigured);
        }
        // The server gates its API behind a token and none is configured:
        // every sync call would 401 (`/health` itself is un-gated, which
        // is why the probe still succeeded). Say so up front instead of
        // rediscovering it as a failure burst every tick.
        if probe.auth_required && !has_token {
            self.force_reconcile();
            return Plan::Skip(RagStatus::Unauthorized);
        }
        let llm_available = probe.capability.llm_available();
        self.llm_available = llm_available;

        // While a wrong token keeps failing, skip the transient "syncing"
        // flash so the footer doesn't flicker syncing ↔ unauthorized.
        let flash = (!self.auth_failed).then_some(RagStatus::Syncing { llm_available });

        // The local index is empty while it (re)builds — a healed schema
        // on first launch, an upgrade, or a manual reindex. Syncing from
        // that snapshot is destructive (a reconcile reads "no notes" and
        // would wipe the server collection), so wait, and run a full
        // reconcile first thing once the index is filled.
        if !index_ready {
            self.force_reconcile();
            return Plan::Wait { flash };
        }

        let reconcile = self.ticks_since_reconcile >= RECONCILE_EVERY_N_TICKS;
        if reconcile {
            self.ticks_since_reconcile = 0;
        } else {
            self.ticks_since_reconcile += 1;
        }
        Plan::Run { flash, reconcile }
    }

    /// Fold the sync call's outcome into the status to report, using the
    /// capability `plan` recorded this tick.
    fn settle(&mut self, outcome: Outcome) -> RagStatus {
        let llm_available = self.llm_available;
        match outcome {
            Outcome::Synced => {
                self.auth_failed = false;
                RagStatus::Online { llm_available }
            }
            // Keep reporting Syncing (not Online) and force a full reconcile
            // once the index is ready again.
            Outcome::SkippedRebuild => {
                self.force_reconcile();
                self.auth_failed = false;
                RagStatus::Syncing { llm_available }
            }
            Outcome::AuthRejected => {
                self.auth_failed = true;
                RagStatus::Unauthorized
            }
            Outcome::Failed => {
                self.auth_failed = false;
                RagStatus::Offline
            }
        }
    }
}

/// Spawns the background sync loop for `vault` if a RAG server is configured.
/// Returns the task handle (abort it when the vault is rebuilt), or `None` when
/// the feature is off. Status is delivered to the UI via [`AppEvent::RagStatus`].
pub fn spawn_rag_sync(
    vault: Arc<NoteVault>,
    settings: &SharedSettings,
    tx: AppTx,
) -> Option<JoinHandle<()>> {
    let (url, token) = server_config(settings)?;

    Some(tokio::spawn(async move {
        let mut interval = tokio::time::interval(SYNC_INTERVAL);
        // Don't stack missed ticks into a back-to-back burst if a slow tick
        // overruns the interval (large vault / slow server).
        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

        // Resolve the vault id (which registers the observer) lazily so a
        // transient failure just retries next tick instead of killing sync for
        // the whole session.
        let mut sync: Option<RagSync> = None;
        let mut cadence = Cadence::new();

        loop {
            interval.tick().await;

            if sync.is_none() {
                match vault.vault_id().await {
                    Ok(id) => {
                        let client = RagClient::new(url.clone(), token.clone(), id.to_string());
                        sync = Some(RagSync::new(vault.clone(), client));
                    }
                    Err(e) => {
                        log::warn!("RAG: cannot read vault id (will retry): {e}");
                        let _ = tx.send(AppEvent::RagStatus(RagStatus::Offline));
                        continue;
                    }
                }
            }
            let sync = sync.as_ref().expect("sync established above");

            // One probe drives reachability, capability, and auth.
            let probe = sync.probe().await;

            let reconcile = match cadence.plan(probe.as_ref(), token.is_some(), sync.index_ready())
            {
                Plan::Skip(status) => {
                    let _ = tx.send(AppEvent::RagStatus(status));
                    continue;
                }
                Plan::Wait { flash } => {
                    if let Some(status) = flash {
                        let _ = tx.send(AppEvent::RagStatus(status));
                    }
                    continue;
                }
                Plan::Run { flash, reconcile } => {
                    if let Some(status) = flash {
                        let _ = tx.send(AppEvent::RagStatus(status));
                    }
                    reconcile
                }
            };

            let result = if reconcile {
                sync.tick().await // drain + reconcile
            } else {
                sync.drain().await // fast path
            };
            let outcome = match &result {
                Ok(true) => Outcome::Synced,
                Ok(false) => Outcome::SkippedRebuild,
                Err(e) if e.is_auth() => {
                    log::warn!("RAG server rejected the configured token: {e}");
                    Outcome::AuthRejected
                }
                Err(e) => {
                    log::debug!("RAG sync failed: {e}");
                    Outcome::Failed
                }
            };
            let status = cadence.settle(outcome);
            let _ = tx.send(AppEvent::RagStatus(status));
        }
    }))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn probe(capability: ServerCapability, auth_required: bool) -> ServerProbe {
        ServerProbe {
            capability,
            auth_required,
        }
    }

    #[test]
    fn offline_probe_reports_offline_and_forces_reconcile() {
        let mut c = Cadence::new();
        // Get past the initial forced reconcile so the reset is observable.
        c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
        assert_eq!(c.plan(None, true, true), Plan::Skip(RagStatus::Offline));
        // The reconnect tick reconciles immediately.
        assert_eq!(
            c.plan(Some(&probe(ServerCapability::Full, false)), true, true),
            Plan::Run {
                flash: Some(RagStatus::Syncing {
                    llm_available: true
                }),
                reconcile: true,
            }
        );
    }

    #[test]
    fn unconfigured_server_skips_sync() {
        let mut c = Cadence::new();
        assert_eq!(
            c.plan(
                Some(&probe(ServerCapability::Unconfigured, false)),
                true,
                true
            ),
            Plan::Skip(RagStatus::NotConfigured)
        );
    }

    #[test]
    fn auth_required_without_token_reports_unauthorized_up_front() {
        let mut c = Cadence::new();
        assert_eq!(
            c.plan(Some(&probe(ServerCapability::Full, true)), false, true),
            Plan::Skip(RagStatus::Unauthorized)
        );
    }

    #[test]
    fn auth_required_with_token_syncs() {
        let mut c = Cadence::new();
        assert!(matches!(
            c.plan(Some(&probe(ServerCapability::Full, true)), true, true),
            Plan::Run { .. }
        ));
    }

    #[test]
    fn index_not_ready_waits_and_reconciles_once_filled() {
        let mut c = Cadence::new();
        // Drain a few ticks first so the pending reconcile is the wait's doing.
        c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
        c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
        assert_eq!(
            c.plan(Some(&probe(ServerCapability::Full, false)), true, false),
            Plan::Wait {
                flash: Some(RagStatus::Syncing {
                    llm_available: true
                })
            }
        );
        assert_eq!(
            c.plan(Some(&probe(ServerCapability::Full, false)), true, true),
            Plan::Run {
                flash: Some(RagStatus::Syncing {
                    llm_available: true
                }),
                reconcile: true,
            }
        );
    }

    #[test]
    fn reconcile_cadence_first_tick_then_drains_then_reconciles_again() {
        let mut c = Cadence::new();
        let p = probe(ServerCapability::Full, false);
        // First successful tick reconciles.
        assert!(matches!(
            c.plan(Some(&p), true, true),
            Plan::Run {
                reconcile: true,
                ..
            }
        ));
        // The next N-1 ticks drain.
        for _ in 0..RECONCILE_EVERY_N_TICKS {
            assert!(matches!(
                c.plan(Some(&p), true, true),
                Plan::Run {
                    reconcile: false,
                    ..
                }
            ));
        }
        // The Nth tick reconciles again.
        assert!(matches!(
            c.plan(Some(&p), true, true),
            Plan::Run {
                reconcile: true,
                ..
            }
        ));
    }

    #[test]
    fn auth_rejection_is_sticky_and_suppresses_the_syncing_flash() {
        let mut c = Cadence::new();
        let p = probe(ServerCapability::Full, true);
        assert!(matches!(c.plan(Some(&p), true, true), Plan::Run { .. }));
        assert_eq!(c.settle(Outcome::AuthRejected), RagStatus::Unauthorized);
        // While the token stays wrong: no syncing flash (no footer flicker).
        assert_eq!(
            c.plan(Some(&p), true, true),
            Plan::Run {
                flash: None,
                reconcile: false,
            }
        );
        // A successful pass clears the stickiness.
        assert_eq!(
            c.settle(Outcome::Synced),
            RagStatus::Online {
                llm_available: true
            }
        );
        assert!(matches!(
            c.plan(Some(&p), true, true),
            Plan::Run { flash: Some(_), .. }
        ));
    }

    #[test]
    fn skipped_pass_reports_syncing_and_forces_reconcile() {
        let mut c = Cadence::new();
        let p = probe(ServerCapability::SemanticOnly, false);
        c.plan(Some(&p), true, true);
        assert_eq!(
            c.settle(Outcome::SkippedRebuild),
            RagStatus::Syncing {
                llm_available: false
            }
        );
        // The next runnable tick is a full reconcile.
        assert!(matches!(
            c.plan(Some(&p), true, true),
            Plan::Run {
                reconcile: true,
                ..
            }
        ));
    }

    #[test]
    fn sync_failure_reports_offline() {
        let mut c = Cadence::new();
        let p = probe(ServerCapability::Full, false);
        c.plan(Some(&p), true, true);
        assert_eq!(c.settle(Outcome::Failed), RagStatus::Offline);
    }
}