Skip to main content

lean_ctx_server/tools/ctx_knowledge/
restore.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Explicit cross-store restore from the lossless memory archive (#995 Phase 6).
3//!
4//! Every capacity reclaim (facts, history, patterns) archives the
5//! evicted tail under `memory/archive/<store>/` before dropping it. This module
6//! reads those archives back and merges the matching items into the live stores —
7//! the user-facing "undo" for an over-eager reclaim, surfaced as
8//! `lean-ctx knowledge restore` and `ctx_knowledge action=restore`.
9//!
10//! Idempotent: an item already present (by its store-specific identity) is never
11//! duplicated, and a live fact never has its key clobbered by an older archived
12//! value.
13
14use lean_ctx::core::context_admission::stores::StoreAdmission;
15use lean_ctx::core::knowledge::ProjectKnowledge;
16use lean_ctx::core::memory_archive::{ArchiveConfig, MemoryStore, reachable_archives};
17
18/// Default cap on how many items a single restore call rehydrates, across all
19/// requested stores. Generous enough to undo a normal reclaim, bounded so a
20/// `restore` with no query cannot resurrect an unbounded archive in one shot.
21pub const DEFAULT_RESTORE_LIMIT: usize = 50;
22
23/// Parsed `restore` request.
24pub struct RestoreOptions {
25    /// Stores to scan. Empty selection is treated as "all stores".
26    pub stores: Vec<MemoryStore>,
27    /// Optional case-insensitive substring filter on each item's text.
28    pub query: Option<String>,
29    /// Maximum items restored across all stores.
30    pub limit: usize,
31}
32
33impl RestoreOptions {
34    /// Restore from `store` (or all stores when `None`), filtered by `query`.
35    pub fn new(store: Option<MemoryStore>, query: Option<String>, limit: usize) -> Self {
36        Self {
37            stores: store.map_or_else(|| MemoryStore::all().to_vec(), |s| vec![s]),
38            query: query.filter(|q| !q.trim().is_empty()),
39            limit: limit.max(1),
40        }
41    }
42}
43
44/// Per-store restore outcome.
45pub struct StoreRestore {
46    pub store: MemoryStore,
47    /// Archived items that matched the query (before the dedup/limit cut).
48    pub matched: usize,
49    /// Items actually merged back into the live store.
50    pub restored: usize,
51}
52
53/// Aggregate restore report.
54pub struct RestoreReport {
55    pub per_store: Vec<StoreRestore>,
56    pub query: Option<String>,
57}
58
59impl RestoreReport {
60    pub(crate) fn total_restored(&self) -> usize {
61        self.per_store.iter().map(|s| s.restored).sum()
62    }
63}
64
65/// Read + query-filter every reachable archive for `store`/`scope`, newest
66/// archive first. `searchable` projects an item to the text the query matches on.
67fn collect<T, S>(
68    store: MemoryStore,
69    scope: Option<&str>,
70    cfg: &ArchiveConfig,
71    query: Option<&str>,
72    admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
73    searchable: S,
74) -> Vec<T>
75where
76    T: serde::de::DeserializeOwned,
77    S: Fn(&T) -> String,
78{
79    let needle = query.map(str::to_lowercase);
80    let mut out = Vec::new();
81    // Newest archives first: the most recent eviction is the likeliest undo target.
82    for path in reachable_archives(store, scope, cfg).into_iter().rev() {
83        let Ok(items) =
84            lean_ctx::core::memory_archive::restore_knowledge_items::<T>(&path, admission)
85        else {
86            continue;
87        };
88        for it in items {
89            if needle
90                .as_deref()
91                .is_none_or(|n| searchable(&it).to_lowercase().contains(n))
92            {
93                out.push(it);
94            }
95        }
96    }
97    out
98}
99
100/// Restore archived facts. Skips facts already present (same category/key/value)
101/// and never resurrects an older value for a key a live fact still owns.
102fn restore_facts(
103    knowledge: &mut ProjectKnowledge,
104    cfg: &ArchiveConfig,
105    query: Option<&str>,
106    remaining: &mut usize,
107    admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
108) -> StoreRestore {
109    let archived = collect::<lean_ctx::core::knowledge::KnowledgeFact, _>(
110        MemoryStore::Facts,
111        None,
112        cfg,
113        None,
114        admission,
115        |f| format!("{} {} {}", f.category, f.key, f.value),
116    );
117    // Authorization precedes query matching and even the reported match count.
118    // The archive remains unchanged when a source is currently unavailable.
119    let mut candidate = ProjectKnowledge::new(&knowledge.project_root);
120    candidate.facts = archived;
121    let needle = query.map(str::to_lowercase);
122    let cands: Vec<_> = candidate
123        .admit_sources()
124        .facts
125        .into_iter()
126        .filter(|f| {
127            needle.as_ref().is_none_or(|needle| {
128                format!("{} {} {}", f.category, f.key, f.value)
129                    .to_lowercase()
130                    .contains(needle)
131            })
132        })
133        .collect();
134    let matched = cands.len();
135    let mut restored = 0;
136    for f in cands {
137        if *remaining == 0 {
138            break;
139        }
140        let already = knowledge
141            .facts
142            .iter()
143            .chain(knowledge.withheld.iter().filter_map(|item| match item {
144                lean_ctx::core::knowledge::WithheldKnowledge::Fact(fact) => Some(fact),
145                _ => None,
146            }))
147            .any(|e| lean_ctx::core::knowledge::source_view::same_record(e, &f));
148        if already {
149            continue;
150        }
151        let key_owned = knowledge
152            .facts
153            .iter()
154            .chain(knowledge.withheld.iter().filter_map(|item| match item {
155                lean_ctx::core::knowledge::WithheldKnowledge::Fact(fact) => Some(fact),
156                _ => None,
157            }))
158            .any(|e| e.category == f.category && e.key == f.key && e.is_current());
159        if key_owned {
160            continue;
161        }
162        knowledge.facts.push(f);
163        restored += 1;
164        *remaining -= 1;
165    }
166    StoreRestore {
167        store: MemoryStore::Facts,
168        matched,
169        restored,
170    }
171}
172
173/// Restore archived consolidated-history insights (dedup by summary+sessions+ts).
174fn restore_history(
175    knowledge: &mut ProjectKnowledge,
176    hash: &str,
177    cfg: &ArchiveConfig,
178    query: Option<&str>,
179    remaining: &mut usize,
180    admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
181) -> StoreRestore {
182    let cands = collect::<lean_ctx::core::knowledge::ConsolidatedInsight, _>(
183        MemoryStore::History,
184        Some(hash),
185        cfg,
186        query,
187        admission,
188        |h| h.summary.clone(),
189    );
190    let matched = cands.len();
191    let mut restored = 0;
192    for h in cands {
193        if *remaining == 0 {
194            break;
195        }
196        let already = knowledge.history.iter().any(|e| {
197            e.summary == h.summary
198                && e.from_sessions == h.from_sessions
199                && e.timestamp == h.timestamp
200        });
201        if already {
202            continue;
203        }
204        knowledge.history.push(h);
205        restored += 1;
206        *remaining -= 1;
207    }
208    StoreRestore {
209        store: MemoryStore::History,
210        matched,
211        restored,
212    }
213}
214
215/// Restore archived project patterns (dedup by type+description+source+created).
216fn restore_patterns(
217    knowledge: &mut ProjectKnowledge,
218    hash: &str,
219    cfg: &ArchiveConfig,
220    query: Option<&str>,
221    remaining: &mut usize,
222    admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
223) -> StoreRestore {
224    let cands = collect::<lean_ctx::core::knowledge::ProjectPattern, _>(
225        MemoryStore::Patterns,
226        Some(hash),
227        cfg,
228        query,
229        admission,
230        |p| format!("{} {}", p.pattern_type, p.description),
231    );
232    let matched = cands.len();
233    let mut restored = 0;
234    for p in cands {
235        if *remaining == 0 {
236            break;
237        }
238        let already = knowledge.patterns.iter().any(|e| {
239            e.pattern_type == p.pattern_type
240                && e.description == p.description
241                && e.source_session == p.source_session
242                && e.created_at == p.created_at
243        });
244        if already {
245            continue;
246        }
247        knowledge.patterns.push(p);
248        restored += 1;
249        *remaining -= 1;
250    }
251    StoreRestore {
252        store: MemoryStore::Patterns,
253        matched,
254        restored,
255    }
256}
257
258/// Restore archived items into the live stores. Facts/history/patterns share one
259/// locked knowledge write. Archives of the retired procedure store stay on disk
260/// untouched and are never restored.
261pub fn run_restore(project_root: &str, opts: &RestoreOptions) -> Result<RestoreReport, String> {
262    lean_ctx::core::policy::runtime::with_explicit_project_source_view(
263        std::path::Path::new(project_root),
264        |_| run_restore_in_view(project_root, opts),
265    )?
266}
267
268fn run_restore_in_view(project_root: &str, opts: &RestoreOptions) -> Result<RestoreReport, String> {
269    let admission = StoreAdmission::current();
270    let knowledge_admission =
271        lean_ctx::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
272    let cfg = ArchiveConfig::from_env();
273    let query = opts.query.clone();
274    let want = |s: MemoryStore| opts.stores.contains(&s);
275
276    let mut per_store: Vec<StoreRestore> = Vec::new();
277    let remaining = opts.limit;
278
279    let knowledge_wanted =
280        want(MemoryStore::Facts) || want(MemoryStore::History) || want(MemoryStore::Patterns);
281
282    if want(MemoryStore::Facts) {
283        // Legacy facts whose session proves this project join its partition.
284        lean_ctx::core::legacy_adoption::auto_adopt_facts(project_root);
285    }
286    if knowledge_wanted {
287        let (_k, results) = ProjectKnowledge::mutate_locked_with_admission(
288            project_root,
289            &knowledge_admission,
290            |knowledge| {
291                let hash = knowledge.project_hash.clone();
292                let mut local = Vec::new();
293                let mut rem = remaining;
294                if want(MemoryStore::Facts) {
295                    local.push(restore_facts(
296                        knowledge,
297                        &cfg,
298                        query.as_deref(),
299                        &mut rem,
300                        &knowledge_admission,
301                    ));
302                }
303                if want(MemoryStore::History) {
304                    local.push(restore_history(
305                        knowledge,
306                        &hash,
307                        &cfg,
308                        query.as_deref(),
309                        &mut rem,
310                        &knowledge_admission,
311                    ));
312                }
313                if want(MemoryStore::Patterns) {
314                    local.push(restore_patterns(
315                        knowledge,
316                        &hash,
317                        &cfg,
318                        query.as_deref(),
319                        &mut rem,
320                        &knowledge_admission,
321                    ));
322                }
323                local
324            },
325        )?;
326        per_store.extend(results);
327    }
328
329    if !admission.is_current() {
330        return Err("session admission policy changed; retry".into());
331    }
332    knowledge_admission.authorize_gateway()?;
333    Ok(RestoreReport {
334        per_store,
335        query: opts.query.clone(),
336    })
337}
338
339/// Deterministic, human-readable restore summary (no timestamps — #498).
340pub fn format_restore_report(report: &RestoreReport) -> String {
341    let total = report.total_restored();
342    let scope = match &report.query {
343        Some(q) => format!(" matching \"{q}\""),
344        None => String::new(),
345    };
346
347    if total == 0 {
348        let matched: usize = report.per_store.iter().map(|s| s.matched).sum();
349        if matched == 0 {
350            return format!("No archived items{scope} to restore.");
351        }
352        return format!(
353            "Nothing restored{scope}: all {matched} matching archived item(s) are already live."
354        );
355    }
356
357    let mut out = format!("Restored {total} item(s){scope} from archive:");
358    for s in &report.per_store {
359        if s.restored > 0 {
360            out.push_str(&format!(
361                "\n  {}: {} restored ({} matched)",
362                s.store.as_str(),
363                s.restored,
364                s.matched
365            ));
366        }
367    }
368    out
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use lean_ctx::core::knowledge::ProjectKnowledge;
375    use lean_ctx::core::memory_archive::archive_items;
376    use lean_ctx::core::memory_policy::MemoryPolicy;
377
378    /// Sandbox the data dir and hand the closure a fresh project root inside it.
379    fn with_sandbox<T>(f: impl FnOnce(String) -> T) -> T {
380        let isolated = lean_ctx::core::data_dir::isolated_data_dir();
381        // A raw nanosecond timestamp can accidentally pass a payment-card
382        // checksum. Keep this fixture's project identity free of such content
383        // while exercising the normal Gateway PII policy without exemptions.
384        let root = isolated.path().join("proj").to_string_lossy().to_string();
385        std::fs::create_dir_all(&root).unwrap();
386        f(root)
387    }
388
389    /// Archive `facts` into `root`'s partition, as the request that restores
390    /// them resolves it.
391    fn archive_facts_of(root: &str, facts: &[lean_ctx::core::knowledge::KnowledgeFact]) {
392        let project = std::path::Path::new(root).canonicalize().unwrap();
393        lean_ctx::core::policy::runtime::REQUEST_PROJECT
394            .sync_scope(std::cell::RefCell::new(Some(project)), || {
395                archive_items(MemoryStore::Facts, None, facts, &ArchiveConfig::default())
396            })
397            .unwrap();
398    }
399
400    /// Archive every current fact, then drop them from the live store — the exact
401    /// state a capacity reclaim leaves behind.
402    fn archive_then_drop_facts(root: &str) {
403        let mut k = ProjectKnowledge::load(root).expect("knowledge");
404        archive_facts_of(root, &k.facts);
405        k.facts.clear();
406        k.save().unwrap();
407    }
408
409    #[test]
410    fn restore_facts_round_trip_is_idempotent() {
411        with_sandbox(|root| {
412            let policy = MemoryPolicy::default();
413            let mut k = ProjectKnowledge::new(&root);
414            k.remember("auth", "token", "JWT RS256", "s1", 0.9, &policy);
415            k.remember("db", "engine", "Postgres", "s1", 0.8, &policy);
416            k.save().unwrap();
417            archive_then_drop_facts(&root);
418
419            let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 50);
420            let report = run_restore(&root, &opts).unwrap();
421            assert_eq!(report.total_restored(), 2);
422
423            let reloaded = ProjectKnowledge::load(&root).unwrap();
424            assert_eq!(reloaded.facts.iter().filter(|f| f.is_current()).count(), 2);
425
426            // Already live → second restore recovers nothing.
427            let again = run_restore(&root, &opts).unwrap();
428            assert_eq!(again.total_restored(), 0);
429        });
430    }
431
432    #[test]
433    fn restore_facts_honors_query_filter() {
434        with_sandbox(|root| {
435            let policy = MemoryPolicy::default();
436            let mut k = ProjectKnowledge::new(&root);
437            k.remember("auth", "token", "JWT RS256", "s1", 0.9, &policy);
438            k.remember("db", "engine", "Postgres", "s1", 0.8, &policy);
439            k.save().unwrap();
440            archive_then_drop_facts(&root);
441
442            let opts = RestoreOptions::new(Some(MemoryStore::Facts), Some("postgres".into()), 50);
443            let report = run_restore(&root, &opts).unwrap();
444            assert_eq!(report.total_restored(), 1);
445
446            let reloaded = ProjectKnowledge::load(&root).unwrap();
447            assert!(
448                reloaded
449                    .facts
450                    .iter()
451                    .any(|f| f.key == "engine" && f.is_current())
452            );
453            assert!(
454                !reloaded
455                    .facts
456                    .iter()
457                    .any(|f| f.key == "token" && f.is_current())
458            );
459        });
460    }
461
462    #[test]
463    fn restore_never_resurrects_a_superseded_live_key() {
464        with_sandbox(|root| {
465            let policy = MemoryPolicy::default();
466            // An older value for auth/token sits in the archive…
467            let mut k = ProjectKnowledge::new(&root);
468            k.remember("auth", "token", "opaque session token", "s0", 0.7, &policy);
469            archive_facts_of(&root, &k.facts);
470            // …while a different value currently owns that key.
471            k.facts.clear();
472            k.remember("auth", "token", "JWT RS256", "s1", 0.9, &policy);
473            k.save().unwrap();
474
475            let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 50);
476            let report = run_restore(&root, &opts).unwrap();
477            assert_eq!(
478                report.total_restored(),
479                0,
480                "a live key must not be shadowed by an archived older value"
481            );
482        });
483    }
484
485    #[test]
486    fn restore_patterns_round_trip_is_scoped() {
487        with_sandbox(|root| {
488            let policy = MemoryPolicy::default();
489            let mut k = ProjectKnowledge::new(&root);
490            k.add_pattern(
491                "error-handling",
492                "wrap IO in Result",
493                Vec::new(),
494                "s1",
495                &policy,
496            );
497            k.add_pattern("naming", "snake_case modules", Vec::new(), "s1", &policy);
498            archive_items(
499                MemoryStore::Patterns,
500                Some(&k.project_hash),
501                &k.patterns,
502                &ArchiveConfig::default(),
503            )
504            .unwrap();
505            k.patterns.clear();
506            k.save().unwrap();
507
508            let opts = RestoreOptions::new(Some(MemoryStore::Patterns), None, 50);
509            let report = run_restore(&root, &opts).unwrap();
510            assert_eq!(report.total_restored(), 2);
511
512            let reloaded = ProjectKnowledge::load(&root).unwrap();
513            assert_eq!(reloaded.patterns.len(), 2);
514        });
515    }
516
517    #[test]
518    fn restore_report_is_deterministic() {
519        // #498: report bodies must be byte-stable for prompt caching — no
520        // timestamps, counters or run-dependent ordering in the rendered text.
521        with_sandbox(|root| {
522            let policy = MemoryPolicy::default();
523            let mut k = ProjectKnowledge::new(&root);
524            k.remember("auth", "token", "JWT", "s1", 0.9, &policy);
525            k.save().unwrap();
526            archive_then_drop_facts(&root);
527
528            let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 50);
529            let report = run_restore(&root, &opts).unwrap();
530            assert_eq!(
531                format_restore_report(&report),
532                format_restore_report(&report)
533            );
534        });
535    }
536
537    #[test]
538    fn restore_limit_caps_total_recovered() {
539        with_sandbox(|root| {
540            let policy = MemoryPolicy::default();
541            let mut k = ProjectKnowledge::new(&root);
542            for i in 0..5 {
543                k.remember(
544                    "finding",
545                    &format!("k{i}"),
546                    &format!("value {i}"),
547                    "s1",
548                    0.8,
549                    &policy,
550                );
551            }
552            k.save().unwrap();
553            archive_then_drop_facts(&root);
554
555            let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 2);
556            let report = run_restore(&root, &opts).unwrap();
557            assert_eq!(report.total_restored(), 2, "limit bounds the recovery");
558        });
559    }
560
561    #[test]
562    fn restore_reports_nothing_when_archive_empty() {
563        with_sandbox(|root| {
564            let opts = RestoreOptions::new(None, None, 50);
565            let report = run_restore(&root, &opts).unwrap();
566            assert_eq!(report.total_restored(), 0);
567            assert!(format_restore_report(&report).contains("No archived items"));
568        });
569    }
570}