1use 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
25const SYNC_INTERVAL: Duration = Duration::from_secs(10);
27
28const RECONCILE_EVERY_N_TICKS: u32 = 30;
32
33#[derive(Debug, PartialEq, Eq)]
36enum Plan {
37 Skip(RagStatus),
39 Wait { flash: Option<RagStatus> },
42 Run {
45 flash: Option<RagStatus>,
46 reconcile: bool,
47 },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52enum Outcome {
53 Synced,
55 SkippedRebuild,
58 AuthRejected,
61 Failed,
63}
64
65struct Cadence {
68 ticks_since_reconcile: u32,
71 auth_failed: bool,
74 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 fn force_reconcile(&mut self) {
91 self.ticks_since_reconcile = RECONCILE_EVERY_N_TICKS;
92 }
93
94 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 self.force_reconcile();
109 return Plan::Skip(RagStatus::NotConfigured);
110 }
111 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 let flash = (!self.auth_failed).then_some(RagStatus::Syncing { llm_available });
125
126 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 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 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
173pub 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 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
188
189 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 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 } else {
239 sync.drain().await };
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 c.plan(Some(&probe(ServerCapability::Full, false)), true, true);
275 assert_eq!(c.plan(None, true, true), Plan::Skip(RagStatus::Offline));
276 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 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 assert!(matches!(
350 c.plan(Some(&p), true, true),
351 Plan::Run {
352 reconcile: true,
353 ..
354 }
355 ));
356 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 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 assert_eq!(
384 c.plan(Some(&p), true, true),
385 Plan::Run {
386 flash: None,
387 reconcile: false,
388 }
389 );
390 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 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}