Skip to main content

lean_ctx/
cloud_sync.rs

1use crate::core::config::Config;
2
3/// Outcome of one background Personal-Cloud auto-push (GL #384).
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum AutoSyncOutcome {
6    /// At least one surface pushed (or there was nothing to push).
7    Synced,
8    /// The server gated sync behind Pro (HTTP 402) — stop for today.
9    Gated,
10    /// Every push failed without a 402 (offline / server down) — try again
11    /// at the next opportunity, do not consume today's slot.
12    NetworkFailure,
13}
14
15/// Whether the auto-sync should run now: opt-in flag, logged in, and not
16/// already synced today (the debounce). Pure for unit testing.
17#[must_use]
18pub fn should_auto_sync(
19    auto_sync: bool,
20    logged_in: bool,
21    last_auto_sync: Option<&str>,
22    today: &str,
23) -> bool {
24    auto_sync && logged_in && last_auto_sync != Some(today)
25}
26
27/// Whether the background index push should run for this project (GL #392):
28/// separate opt-in, logged in, a local index actually exists, and this
29/// project hasn't pushed today. Pure for unit testing.
30#[must_use]
31pub fn should_auto_push_index(
32    auto_index: bool,
33    logged_in: bool,
34    local_index_exists: bool,
35    last_push_for_project: Option<&str>,
36    today: &str,
37) -> bool {
38    auto_index && logged_in && local_index_exists && last_push_for_project != Some(today)
39}
40
41/// Classify per-surface push results into one [`AutoSyncOutcome`]. A 402
42/// anywhere wins (the account is gated); otherwise total failure means the
43/// network is down; anything else counts as synced.
44#[must_use]
45pub fn classify_outcomes(results: &[Result<(), String>]) -> AutoSyncOutcome {
46    if results
47        .iter()
48        .any(|r| r.as_ref().is_err_and(|e| e.contains("402")))
49    {
50        return AutoSyncOutcome::Gated;
51    }
52    if !results.is_empty() && results.iter().all(Result::is_err) {
53        return AutoSyncOutcome::NetworkFailure;
54    }
55    AutoSyncOutcome::Synced
56}
57
58pub fn cloud_background_tasks() {
59    // Persist path: read global-only so the daily background save never leaks a
60    // project-local override into the global config (#443).
61    let mut config = Config::load_global();
62    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
63
64    let already_contributed = config
65        .cloud
66        .last_contribute
67        .as_deref()
68        .is_some_and(|d| d == today);
69    let already_synced = config
70        .cloud
71        .last_sync
72        .as_deref()
73        .is_some_and(|d| d == today);
74    let already_gain_synced = config
75        .cloud
76        .last_gain_sync
77        .as_deref()
78        .is_some_and(|d| d == today);
79    let already_pulled = config
80        .cloud
81        .last_model_pull
82        .as_deref()
83        .is_some_and(|d| d == today);
84
85    if config.cloud.contribute_enabled && !already_contributed {
86        let entries = collect_contribute_entries();
87        if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
88            config.cloud.last_contribute = Some(today.clone());
89        }
90    }
91
92    if crate::cloud_client::is_logged_in() {
93        if !already_synced {
94            let store = crate::core::stats::load();
95            let entries = build_sync_entries(&store);
96            if !entries.is_empty() && crate::cloud_client::sync_stats(&entries).is_ok() {
97                config.cloud.last_sync = Some(today.clone());
98            }
99        }
100
101        if !already_gain_synced {
102            let engine = crate::core::gain::GainEngine::load();
103            let summary = engine.summary(None);
104            let trend = match summary.score.trend {
105                crate::core::gain::gain_score::Trend::Rising => "rising",
106                crate::core::gain::gain_score::Trend::Stable => "stable",
107                crate::core::gain::gain_score::Trend::Declining => "declining",
108            };
109            let entry = serde_json::json!({
110                "recorded_at": format!("{today}T00:00:00Z"),
111                "total": summary.score.total as f64,
112                "compression": summary.score.compression as f64,
113                "cost_efficiency": summary.score.cost_efficiency as f64,
114                "quality": summary.score.quality as f64,
115                "consistency": summary.score.consistency as f64,
116                "navigability": summary.score.navigability as f64,
117                "trend": trend,
118                "avoided_usd": summary.avoided_usd,
119                "tool_spend_usd": summary.tool_spend_usd,
120                "model_key": summary.model.model_key,
121            });
122            if crate::cloud_client::push_gain(&[entry]).is_ok() {
123                config.cloud.last_gain_sync = Some(today.clone());
124            }
125        }
126
127        if !already_pulled && let Ok(data) = crate::cloud_client::pull_cloud_models() {
128            let _ = crate::cloud_client::save_cloud_models(&data);
129            config.cloud.last_model_pull = Some(today.clone());
130        }
131
132        // Opt-in Personal-Cloud auto-push (GL #384): silent, once per day,
133        // offline-tolerant. A network failure leaves the slot open so the
134        // next background cycle retries; a Pro gate consumes it (one quiet
135        // attempt per day on a Free account, never error spam).
136        if should_auto_sync(
137            config.cloud.auto_sync,
138            true,
139            config.cloud.last_auto_sync.as_deref(),
140            &today,
141        ) && auto_sync_personal_cloud() != AutoSyncOutcome::NetworkFailure
142        {
143            config.cloud.last_auto_sync = Some(today.clone());
144        }
145
146        // Opt-in hosted-index auto-push (GL #392): once per project per day,
147        // only when a local index exists. Quota/Pro rejections consume the
148        // slot (one quiet attempt per day); network failures leave it open.
149        if let Ok(root) = std::env::current_dir() {
150            let project_hash = crate::core::index_namespace::namespace_hash(&root);
151            if should_auto_push_index(
152                config.cloud.auto_index,
153                true,
154                crate::core::index_bundle::local_index_present(&root),
155                config
156                    .cloud
157                    .last_index_push
158                    .get(&project_hash)
159                    .map(String::as_str),
160                &today,
161            ) {
162                match crate::cloud_client::push_index_bundle(&root) {
163                    Ok((hash, bytes)) => {
164                        tracing::debug!(project = %hash, bytes, "auto-index: pushed");
165                        config
166                            .cloud
167                            .last_index_push
168                            .insert(project_hash, today.clone());
169                    }
170                    Err(e) if e.contains("Pro") || e.contains("Quota") => {
171                        tracing::debug!(error = %e, "auto-index: gated, retry tomorrow");
172                        config
173                            .cloud
174                            .last_index_push
175                            .insert(project_hash, today.clone());
176                    }
177                    Err(e) => {
178                        tracing::debug!(error = %e, "auto-index: push failed, slot stays open");
179                    }
180                }
181            }
182        }
183    }
184
185    if let Err(e) = config.save() {
186        tracing::warn!("could not persist cloud background state: {e}");
187    }
188}
189
190/// Push every Personal-Cloud surface silently (background variant of
191/// `lean-ctx sync`'s interactive flow — tracing instead of stdout).
192fn auto_sync_personal_cloud() -> AutoSyncOutcome {
193    let store = crate::core::stats::load();
194    let mut results: Vec<Result<(), String>> = Vec::new();
195
196    let mut push = |label: &str, result: Result<String, String>| match result {
197        Ok(_) => {
198            tracing::debug!(surface = label, "auto-sync: pushed");
199            results.push(Ok(()));
200        }
201        Err(e) => {
202            tracing::debug!(surface = label, error = %e, "auto-sync: push failed");
203            results.push(Err(e));
204        }
205    };
206
207    let commands = collect_command_entries(&store);
208    if !commands.is_empty() {
209        push("commands", crate::cloud_client::push_commands(&commands));
210    }
211    let cep = collect_cep_entries(&store);
212    if !cep.is_empty() {
213        push("cep", crate::cloud_client::push_cep(&cep));
214    }
215    let knowledge = collect_knowledge_entries();
216    if !knowledge.is_empty() {
217        push("knowledge", crate::cloud_client::push_knowledge(&knowledge));
218    }
219    let gotchas = collect_gotcha_entries();
220    if !gotchas.is_empty() {
221        push("gotchas", crate::cloud_client::push_gotchas(&gotchas));
222    }
223    let buddy = crate::core::buddy::BuddyState::compute();
224    if let Ok(buddy_data) = serde_json::to_value(&buddy) {
225        push("buddy", crate::cloud_client::push_buddy(&buddy_data));
226    }
227    let feedback = collect_feedback_entries();
228    if !feedback.is_empty() {
229        push("feedback", crate::cloud_client::push_feedback(&feedback));
230    }
231
232    let outcome = classify_outcomes(&results);
233    tracing::info!(
234        ?outcome,
235        surfaces = results.len(),
236        "personal-cloud auto-sync done"
237    );
238    outcome
239}
240
241pub fn build_sync_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
242    let mut entries = Vec::new();
243    let cep = &store.cep;
244    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
245
246    let mut cep_cache_by_day: std::collections::HashMap<String, (u64, u64)> =
247        std::collections::HashMap::new();
248    for s in &cep.scores {
249        if let Some(date) = s.timestamp.get(..10) {
250            let entry = cep_cache_by_day.entry(date.to_string()).or_default();
251            let calls = s.tool_calls.max(1);
252            let hits = (calls as f64 * s.cache_hit_rate as f64 / 100.0).round() as u64;
253            entry.0 += calls;
254            entry.1 += hits;
255        }
256    }
257
258    let mut mcp_saved_total = 0u64;
259    for (cmd, s) in &store.commands {
260        if cmd.starts_with("ctx_") {
261            mcp_saved_total += s.input_tokens.saturating_sub(s.output_tokens);
262        }
263    }
264    let global_saved = store
265        .total_input_tokens
266        .saturating_sub(store.total_output_tokens)
267        .max(1);
268    let mcp_ratio = mcp_saved_total as f64 / global_saved as f64;
269
270    for day in &store.daily {
271        let tokens_original = day.input_tokens;
272        let tokens_compressed = day.output_tokens;
273        let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
274        let (day_calls, day_hits) = cep_cache_by_day.get(&day.date).copied().unwrap_or((0, 0));
275        let day_mcp_saved = (tokens_saved as f64 * mcp_ratio).round() as u64;
276        let day_hook_saved = tokens_saved.saturating_sub(day_mcp_saved);
277        entries.push(serde_json::json!({
278            "date": day.date,
279            "tokens_original": tokens_original,
280            "tokens_compressed": tokens_compressed,
281            "tokens_saved": tokens_saved,
282            "mcp_tokens_saved": day_mcp_saved,
283            "hook_tokens_saved": day_hook_saved,
284            "tool_calls": day.commands,
285            "cache_hits": day_hits,
286            "cache_misses": day_calls.saturating_sub(day_hits),
287        }));
288    }
289
290    let has_today = entries.iter().any(|e| e["date"].as_str() == Some(&today));
291    if !has_today && (cep.total_tokens_original > 0 || store.total_commands > 0) {
292        let today_saved = cep
293            .total_tokens_original
294            .saturating_sub(cep.total_tokens_compressed);
295        let today_mcp = (today_saved as f64 * mcp_ratio).round() as u64;
296        entries.push(serde_json::json!({
297            "date": today,
298            "tokens_original": cep.total_tokens_original,
299            "tokens_compressed": cep.total_tokens_compressed,
300            "tokens_saved": today_saved,
301            "mcp_tokens_saved": today_mcp,
302            "hook_tokens_saved": today_saved.saturating_sub(today_mcp),
303            "tool_calls": store.total_commands,
304            "cache_hits": cep.total_cache_hits,
305            "cache_misses": cep.total_cache_reads.saturating_sub(cep.total_cache_hits),
306        }));
307    }
308
309    entries
310}
311
312// ── Personal-Cloud surface collectors ────────────────────────────────────────
313// Shared by the interactive `lean-ctx sync` flow and the background auto-sync
314// (GL #384): pure local reads, no network, no stdout.
315
316pub fn collect_knowledge_entries() -> Vec<serde_json::Value> {
317    let Ok(data_dir) = crate::core::paths::data_dir() else {
318        return Vec::new();
319    };
320    let knowledge_dir = data_dir.join("knowledge");
321    if !knowledge_dir.is_dir() {
322        return Vec::new();
323    }
324
325    let mut entries = Vec::new();
326
327    for project_entry in std::fs::read_dir(&knowledge_dir).into_iter().flatten() {
328        let Ok(project_entry) = project_entry else {
329            continue;
330        };
331        let project_path = project_entry.path();
332        if !project_path.is_dir() {
333            continue;
334        }
335
336        for file_entry in std::fs::read_dir(&project_path).into_iter().flatten() {
337            let Ok(file_entry) = file_entry else { continue };
338            let file_path = file_entry.path();
339            if file_path.extension().and_then(|e| e.to_str()) != Some("json") {
340                continue;
341            }
342            let Ok(data) = std::fs::read_to_string(&file_path) else {
343                continue;
344            };
345            let parsed: serde_json::Value = match serde_json::from_str(&data) {
346                Ok(v) => v,
347                Err(_) => continue,
348            };
349
350            if let Some(facts) = parsed["facts"].as_array() {
351                for fact in facts {
352                    let cat = fact["category"].as_str().unwrap_or("general");
353                    let key = fact["key"].as_str().unwrap_or("");
354                    let val = fact["value"]
355                        .as_str()
356                        .or_else(|| fact["description"].as_str())
357                        .unwrap_or("");
358                    if !key.is_empty() {
359                        entries.push(serde_json::json!({
360                            "category": cat,
361                            "key": key,
362                            "value": val,
363                        }));
364                    }
365                }
366            }
367
368            if let Some(gotchas) = parsed["gotchas"].as_array() {
369                for g in gotchas {
370                    let pattern = g["pattern"].as_str().unwrap_or("");
371                    let fix = g["fix"].as_str().unwrap_or("");
372                    if !pattern.is_empty() {
373                        entries.push(serde_json::json!({
374                            "category": "gotcha",
375                            "key": pattern,
376                            "value": fix,
377                        }));
378                    }
379                }
380            }
381        }
382    }
383
384    entries
385}
386
387pub fn collect_command_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
388    store
389        .commands
390        .iter()
391        .map(|(name, stats)| {
392            let tokens_saved = stats.input_tokens.saturating_sub(stats.output_tokens);
393            serde_json::json!({
394                "command": name,
395                "source": if name.starts_with("ctx_") { "mcp" } else { "hook" },
396                "count": stats.count,
397                "input_tokens": stats.input_tokens,
398                "output_tokens": stats.output_tokens,
399                "tokens_saved": tokens_saved,
400            })
401        })
402        .collect()
403}
404
405fn complexity_to_float(s: &str) -> f64 {
406    match s.to_lowercase().as_str() {
407        "trivial" => 0.1,
408        "simple" => 0.3,
409        "moderate" => 0.5,
410        "complex" => 0.7,
411        "architectural" => 0.9,
412        other => other.parse::<f64>().unwrap_or(0.5),
413    }
414}
415
416pub fn collect_cep_entries(store: &crate::core::stats::StatsStore) -> Vec<serde_json::Value> {
417    store
418        .cep
419        .scores
420        .iter()
421        .map(|s| {
422            serde_json::json!({
423                "recorded_at": s.timestamp,
424                "score": s.score as f64 / 100.0,
425                "cache_hit_rate": s.cache_hit_rate as f64 / 100.0,
426                "mode_diversity": s.mode_diversity as f64 / 100.0,
427                "compression_rate": s.compression_rate as f64 / 100.0,
428                "tool_calls": s.tool_calls,
429                "tokens_saved": s.tokens_saved,
430                "complexity": complexity_to_float(&s.complexity),
431            })
432        })
433        .collect()
434}
435
436pub fn collect_gotcha_entries() -> Vec<serde_json::Value> {
437    let mut all_gotchas = crate::core::gotcha_tracker::load_universal_gotchas();
438
439    if let Ok(knowledge_dir) = crate::core::paths::data_dir().map(|d| d.join("knowledge"))
440        && let Ok(entries) = std::fs::read_dir(&knowledge_dir)
441    {
442        for entry in entries.flatten() {
443            let gotcha_path = entry.path().join("gotchas.json");
444            if gotcha_path.exists()
445                && let Ok(content) = std::fs::read_to_string(&gotcha_path)
446                && let Ok(store) =
447                    serde_json::from_str::<crate::core::gotcha_tracker::GotchaStore>(&content)
448            {
449                for g in store.gotchas {
450                    if !all_gotchas
451                        .iter()
452                        .any(|existing| existing.trigger == g.trigger)
453                    {
454                        all_gotchas.push(g);
455                    }
456                }
457            }
458        }
459    }
460
461    all_gotchas
462        .iter()
463        .map(|g| {
464            serde_json::json!({
465                "pattern": g.trigger,
466                "fix": g.resolution,
467                "severity": format!("{:?}", g.severity).to_lowercase(),
468                "category": format!("{:?}", g.category).to_lowercase(),
469                "occurrences": g.occurrences,
470                "prevented_count": g.prevented_count,
471                "confidence": g.confidence,
472            })
473        })
474        .collect()
475}
476
477pub fn collect_feedback_entries() -> Vec<serde_json::Value> {
478    let store = crate::core::feedback::FeedbackStore::load();
479    store
480        .learned_thresholds
481        .iter()
482        .map(|(lang, thresholds)| {
483            serde_json::json!({
484                "language": lang,
485                "entropy": thresholds.entropy,
486                "jaccard": thresholds.jaccard,
487                "sample_count": thresholds.sample_count,
488                "avg_efficiency": thresholds.avg_efficiency,
489            })
490        })
491        .collect()
492}
493
494pub fn collect_contribute_entries() -> Vec<serde_json::Value> {
495    let mut entries = Vec::new();
496
497    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
498        let mode_stats_path = data_dir.join("mode_stats.json");
499        if let Ok(data) = std::fs::read_to_string(&mode_stats_path)
500            && let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data)
501            && let Some(history) = predictor["history"].as_object()
502        {
503            for (_key, outcomes) in history {
504                if let Some(arr) = outcomes.as_array() {
505                    for outcome in arr.iter().rev().take(3) {
506                        let ext = outcome["ext"].as_str().unwrap_or("unknown");
507                        let mode = outcome["mode"].as_str().unwrap_or("full");
508                        let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
509                        let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
510                        let ratio = if t_in > 0 {
511                            1.0 - t_out as f64 / t_in as f64
512                        } else {
513                            0.0
514                        };
515                        let bucket = match t_in {
516                            0..=500 => "0-500",
517                            501..=2000 => "500-2k",
518                            2001..=10000 => "2k-10k",
519                            _ => "10k+",
520                        };
521                        entries.push(serde_json::json!({
522                            "file_ext": format!(".{ext}"),
523                            "size_bucket": bucket,
524                            "best_mode": mode,
525                            "compression_ratio": (ratio * 100.0).round() / 100.0,
526                        }));
527                        if entries.len() >= 200 {
528                            return entries;
529                        }
530                    }
531                }
532            }
533        }
534    }
535
536    if entries.is_empty() {
537        let stats_data = crate::core::stats::format_gain_json();
538        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
539            let original = parsed["cep"]["total_tokens_original"].as_u64().unwrap_or(0);
540            let compressed = parsed["cep"]["total_tokens_compressed"]
541                .as_u64()
542                .unwrap_or(0);
543            let ratio = if original > 0 {
544                1.0 - compressed as f64 / original as f64
545            } else {
546                0.0
547            };
548            if let Some(modes) = parsed["cep"]["modes"].as_object() {
549                let read_modes = [
550                    "full",
551                    "map",
552                    "signatures",
553                    "auto",
554                    "aggressive",
555                    "entropy",
556                    "diff",
557                    "lines",
558                    "task",
559                    "reference",
560                ];
561                for (mode, count) in modes {
562                    if !read_modes.contains(&mode.as_str()) || count.as_u64().unwrap_or(0) == 0 {
563                        continue;
564                    }
565                    entries.push(serde_json::json!({
566                        "file_ext": "mixed",
567                        "size_bucket": "mixed",
568                        "best_mode": mode,
569                        "compression_ratio": (ratio * 100.0).round() / 100.0,
570                    }));
571                }
572            }
573        }
574    }
575
576    entries
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn auto_sync_requires_flag_login_and_unused_slot() {
585        // Disabled flag blocks everything else.
586        assert!(!should_auto_sync(false, true, None, "2026-06-10"));
587        // Logged out never syncs.
588        assert!(!should_auto_sync(true, false, None, "2026-06-10"));
589        // Fresh slot + flag + login → go.
590        assert!(should_auto_sync(true, true, None, "2026-06-10"));
591        // Already synced today → debounced.
592        assert!(!should_auto_sync(
593            true,
594            true,
595            Some("2026-06-10"),
596            "2026-06-10"
597        ));
598        // Synced yesterday → today's slot is free.
599        assert!(should_auto_sync(
600            true,
601            true,
602            Some("2026-06-09"),
603            "2026-06-10"
604        ));
605    }
606
607    #[test]
608    fn auto_index_push_needs_flag_login_index_and_fresh_slot() {
609        let t = "2026-06-10";
610        // All preconditions met → push.
611        assert!(should_auto_push_index(true, true, true, None, t));
612        // Separate opt-in: auto_sync users are NOT auto-enrolled.
613        assert!(!should_auto_push_index(false, true, true, None, t));
614        // Logged out / no local index → silently skip, no error path.
615        assert!(!should_auto_push_index(true, false, true, None, t));
616        assert!(!should_auto_push_index(true, true, false, None, t));
617        // Per-project debounce: today consumed, yesterday frees the slot.
618        assert!(!should_auto_push_index(true, true, true, Some(t), t));
619        assert!(should_auto_push_index(
620            true,
621            true,
622            true,
623            Some("2026-06-09"),
624            t
625        ));
626    }
627
628    #[test]
629    fn outcome_classification_is_gate_then_network_then_synced() {
630        // Nothing to push counts as synced (slot consumed, no retry storm).
631        assert_eq!(classify_outcomes(&[]), AutoSyncOutcome::Synced);
632        // Any 402 means the account is gated, even with other failures.
633        assert_eq!(
634            classify_outcomes(&[
635                Err("HTTP 402: upgrade required".into()),
636                Err("connection refused".into()),
637            ]),
638            AutoSyncOutcome::Gated
639        );
640        // All failed without a 402 → offline, keep the slot open.
641        assert_eq!(
642            classify_outcomes(&[Err("connection refused".into()), Err("timeout".into()),]),
643            AutoSyncOutcome::NetworkFailure
644        );
645        // Partial success is success.
646        assert_eq!(
647            classify_outcomes(&[Ok(()), Err("timeout".into())]),
648            AutoSyncOutcome::Synced
649        );
650    }
651}