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