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