Skip to main content

lean_ctx/core/
legacy_adoption.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Adoption of context that releases before 3.11.5 stored without a
3//! provable project (AP05).
4//!
5//! Such context is served to no project and is never assigned to the active
6//! one by guesswork. Two ways bring it back, both copying (never moving)
7//! through the admission of a normal write to the target project:
8//!
9//! - **Automatic, only with proof:** a legacy `ctx_expand` archive whose
10//!   recorded session, or a legacy archived fact whose `source_session`,
11//!   resolves to a session record whose project root is exactly the request
12//!   project's. Nothing else is adopted automatically.
13//! - **Explicit:** the owner of this machine chooses items and a project with
14//!   `lean-ctx migrate legacy-context` (`cli::migrate_legacy`).
15//!
16//! Both append to one journal, `migrations/legacy-context.jsonl` in the data
17//! directory: timestamp, store, item id, target project hash, outcome — never
18//! content. An item adopted into a project is never adopted there again.
19
20use std::collections::{HashMap, HashSet};
21use std::io::Write as _;
22use std::path::{Path, PathBuf};
23use std::sync::{Mutex, OnceLock};
24
25pub(crate) const STORE_FACTS_ARCHIVE: &str = "facts-archive";
26pub(crate) const STORE_EXPAND_ARCHIVE: &str = "expand-archive";
27pub(crate) const STORE_SHARED_CONTEXT: &str = "shared-context";
28pub(crate) const STORE_KNOWLEDGE: &str = "knowledge";
29
30/// One journal line.
31#[derive(Debug, serde::Serialize, serde::Deserialize)]
32pub struct JournalLine {
33    pub timestamp: String,
34    pub store: String,
35    pub item: String,
36    pub project: String,
37    pub outcome: String,
38}
39
40fn journal_path() -> Result<PathBuf, String> {
41    Ok(crate::core::data_dir::lean_ctx_data_dir()?
42        .join("migrations")
43        .join("legacy-context.jsonl"))
44}
45
46pub fn read_journal() -> Vec<JournalLine> {
47    journal_path()
48        .ok()
49        .and_then(|path| std::fs::read_to_string(path).ok())
50        .map(|text| {
51            text.lines()
52                .filter_map(|line| serde_json::from_str(line).ok())
53                .collect()
54        })
55        .unwrap_or_default()
56}
57
58/// Whether `item` was adopted into the project `partition` before.
59pub fn adopted(journal: &[JournalLine], item: &str, partition: &str) -> bool {
60    journal
61        .iter()
62        .any(|line| line.item == item && line.project == partition && line.outcome == "adopted")
63}
64
65/// Append one outcome (append-only, owner-only permissions).
66pub fn record(store: &str, item: &str, partition: &str, adopted: bool) -> Result<(), String> {
67    let path = journal_path()?;
68    if let Some(parent) = path.parent() {
69        std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
70    }
71    let mut file = std::fs::OpenOptions::new()
72        .create(true)
73        .append(true)
74        .open(&path)
75        .map_err(|e| format!("open {}: {e}", path.display()))?;
76    #[cfg(unix)]
77    {
78        use std::os::unix::fs::PermissionsExt;
79        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
80    }
81    let line = JournalLine {
82        timestamp: chrono::Utc::now().to_rfc3339(),
83        store: store.to_string(),
84        item: item.to_string(),
85        project: partition.to_string(),
86        outcome: if adopted { "adopted" } else { "refused" }.to_string(),
87    };
88    let json = serde_json::to_string(&line).map_err(|e| e.to_string())?;
89    writeln!(file, "{json}").map_err(|e| format!("write {}: {e}", path.display()))
90}
91
92fn canonical(root: &str) -> PathBuf {
93    crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000)
94}
95
96/// Bounded work per automatic pass: a request never waits long for it; what
97/// is left is picked up by the next lookup.
98const PASS_ITEMS: usize = 64;
99const PASS_TIME: std::time::Duration = std::time::Duration::from_millis(150);
100
101/// Process-wide state of the automatic archive pass, per data directory and
102/// project (`pass_key`): the legacy and drifted candidates (one metadata scan
103/// each), the project each recorded session belongs to (one session read per
104/// session), and the candidates already decided.
105#[derive(Default)]
106struct ArchivePass {
107    candidates: HashMap<String, Vec<Candidate>>,
108    session_roots: HashMap<String, Option<PathBuf>>,
109    decided: HashSet<(String, String)>,
110    complete: HashSet<String>,
111}
112
113fn pass_key(partition: &str) -> String {
114    let data = crate::core::data_dir::lean_ctx_data_dir().unwrap_or_default();
115    format!("{}|{partition}", data.display())
116}
117
118#[derive(Clone)]
119struct Candidate {
120    id: String,
121    session: Option<String>,
122    recorded_root: Option<String>,
123}
124
125fn archive_pass() -> &'static Mutex<ArchivePass> {
126    static ARCHIVE_PASS: OnceLock<Mutex<ArchivePass>> = OnceLock::new();
127    ARCHIVE_PASS.get_or_init(|| Mutex::new(ArchivePass::default()))
128}
129
130/// The canonical project root of stored session `session_id`, cached.
131fn session_root(session_id: &str) -> Option<PathBuf> {
132    if let Ok(pass) = archive_pass().lock()
133        && let Some(root) = pass.session_roots.get(session_id)
134    {
135        return root.clone();
136    }
137    let root = crate::core::session::SessionState::load_by_id(session_id)
138        .and_then(|session| session.project_root)
139        .map(|root| canonical(&root));
140    if let Ok(mut pass) = archive_pass().lock() {
141        pass.session_roots
142            .insert(session_id.to_string(), root.clone());
143    }
144    root
145}
146
147/// Whether a candidate provably belongs to the project at `wanted`: its
148/// recorded root is that path (an identity change moved only the hash), or
149/// its recorded session is a session of exactly that project.
150fn proven(candidate: &Candidate, wanted: &Path) -> bool {
151    candidate
152        .recorded_root
153        .as_deref()
154        .is_some_and(|root| canonical(root) == wanted)
155        || candidate
156            .session
157            .as_deref()
158            .and_then(session_root)
159            .is_some_and(|root| root == wanted)
160}
161
162/// One automatic pass per project and process for stores without a
163/// per-item budget.
164fn first_pass(kind: &str, partition: &str) -> bool {
165    static ADOPTION_PASSES: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
166    ADOPTION_PASSES
167        .get_or_init(|| Mutex::new(HashSet::new()))
168        .lock()
169        .is_ok_and(|mut done| done.insert(format!("{kind}:{}", pass_key(partition))))
170}
171
172fn request_root() -> Option<String> {
173    crate::core::policy::diagnostics::request_project()
174        .map(|root| root.to_string_lossy().into_owned())
175}
176
177/// Adopt the legacy or drifted archive `id` into the request project if it
178/// provably belongs there. `Some` is the adopted copy's id.
179pub(crate) fn adopt_provable_archive(id: &str) -> Option<String> {
180    let root = request_root()?;
181    let entry = crate::core::archive::unbound_entry(id)
182        .filter(|entry| entry.projects.is_empty() || crate::core::archive::is_drifted(entry))?;
183    let candidate = Candidate {
184        id: entry.id,
185        session: entry.session_id,
186        recorded_root: entry.project_root,
187    };
188    if !proven(&candidate, &canonical(&root)) {
189        return None;
190    }
191    adopt_archive(&candidate.id, &root).ok()
192}
193
194fn adopt_archive(id: &str, root: &str) -> Result<String, String> {
195    let partition = crate::core::project_hash::hash_project_root(root);
196    let item = format!("{STORE_EXPAND_ARCHIVE}:{id}");
197    let result = crate::core::archive::adopt_legacy_entry(id, Path::new(root));
198    if !adopted(&read_journal(), &item, &partition) {
199        record(STORE_EXPAND_ARCHIVE, &item, &partition, result.is_ok())?;
200    }
201    result
202}
203
204/// The candidates of `partition` not decided yet, at most `PASS_ITEMS`; the
205/// metadata scan runs once per process, outside the lock.
206fn pending_candidates(key: &str) -> Option<Vec<Candidate>> {
207    let scanned = {
208        let pass = archive_pass().lock().ok()?;
209        if pass.complete.contains(key) {
210            return None;
211        }
212        pass.candidates.contains_key(key)
213    };
214    if !scanned {
215        let candidates = crate::core::archive::legacy_unscoped_entries()
216            .into_iter()
217            .map(|entry| Candidate {
218                id: entry.id,
219                session: entry.session_id,
220                recorded_root: entry.project_root,
221            })
222            .collect();
223        archive_pass()
224            .lock()
225            .ok()?
226            .candidates
227            .entry(key.to_string())
228            .or_insert(candidates);
229    }
230    let pass = archive_pass().lock().ok()?;
231    Some(
232        pass.candidates
233            .get(key)
234            .into_iter()
235            .flatten()
236            .filter(|candidate| {
237                !pass
238                    .decided
239                    .contains(&(key.to_string(), candidate.id.clone()))
240            })
241            .take(PASS_ITEMS)
242            .cloned()
243            .collect(),
244    )
245}
246
247/// Adopt the legacy and drifted archives that provably belong to the request
248/// project. Bounded per call (items and time); the rest follows on the next
249/// lookup, so no request waits long.
250pub(crate) fn auto_adopt_archives() {
251    let Some(root) = request_root() else {
252        return;
253    };
254    let partition = crate::core::project_hash::hash_project_root(&root);
255    let key = pass_key(&partition);
256    let Some(pending) = pending_candidates(&key) else {
257        return;
258    };
259    let wanted = canonical(&root);
260    let journal = read_journal();
261    let deadline = std::time::Instant::now() + PASS_TIME;
262    for candidate in &pending {
263        if std::time::Instant::now() >= deadline {
264            return;
265        }
266        let item = format!("{STORE_EXPAND_ARCHIVE}:{}", candidate.id);
267        if !adopted(&journal, &item, &partition) && proven(candidate, &wanted) {
268            let _ = adopt_archive(&candidate.id, &root);
269        }
270        if let Ok(mut pass) = archive_pass().lock() {
271            pass.decided.insert((key.clone(), candidate.id.clone()));
272        }
273    }
274    if pending.len() < PASS_ITEMS
275        && let Ok(mut pass) = archive_pass().lock()
276    {
277        pass.complete.insert(key);
278    }
279}
280
281/// Adopt the archived facts that provably belong to `project_root` into its
282/// facts partition (once per process and project): legacy facts whose
283/// `source_session` is a session of this project, and facts archived for
284/// this very path under an earlier identity of the project.
285pub fn auto_adopt_facts(project_root: &str) {
286    let partition = crate::core::project_hash::hash_project_root(project_root);
287    if !first_pass(STORE_FACTS_ARCHIVE, &partition) {
288        return;
289    }
290    let wanted = canonical(project_root);
291    let journal = read_journal();
292    let mut items = Vec::new();
293    let mut facts = Vec::new();
294    for (path, envelope) in crate::core::memory_archive::legacy_facts_archives() {
295        for (item, fact) in legacy_facts_of(&path, &envelope) {
296            let proven = fact["source_session"]
297                .as_str()
298                .and_then(session_root)
299                .is_some_and(|root| root == wanted);
300            if proven && !adopted(&journal, &item, &partition) {
301                items.push(item);
302                facts.push(fact);
303            }
304        }
305    }
306    for (path, envelope) in crate::core::memory_archive::drifted_facts_archives(&wanted) {
307        for (item, fact) in legacy_facts_of(&path, &envelope) {
308            if !adopted(&journal, &item, &partition) {
309                items.push(item);
310                facts.push(fact);
311            }
312        }
313    }
314    if facts.is_empty() {
315        return;
316    }
317    let outcome = crate::core::memory_archive::adopt_legacy_facts(Path::new(project_root), &facts);
318    for (index, item) in items.iter().enumerate() {
319        let ok = outcome
320            .as_ref()
321            .is_ok_and(|flags| flags.get(index) == Some(&true));
322        let _ = record(STORE_FACTS_ARCHIVE, item, &partition, ok);
323    }
324}
325
326/// Copy `ctx_memory` facts stored for this very path under an earlier
327/// identity of the project into its current partition (once per process and
328/// project), through the admission of a normal write.
329pub fn auto_adopt_shared_context(project_root: &str) {
330    let partition = crate::core::project_hash::hash_project_root(project_root);
331    if !first_pass(STORE_SHARED_CONTEXT, &partition) {
332        return;
333    }
334    let drifted = crate::core::shared_context::SharedContext::drifted_entries(project_root);
335    if drifted.is_empty() {
336        return;
337    }
338    let Ok(store) = crate::core::shared_context::SharedContext::for_project(project_root) else {
339        return;
340    };
341    let journal = read_journal();
342    for (_, entry) in drifted {
343        let item = shared_context_item(&entry.id);
344        if adopted(&journal, &item, &partition) {
345            continue;
346        }
347        let ok = crate::core::context_admission::carrier::admit_for_store(&entry.content)
348            .is_ok_and(|admitted| {
349                store
350                    .put(admitted, entry.agent.clone(), entry.category.clone())
351                    .is_ok()
352            });
353        let _ = record(STORE_SHARED_CONTEXT, &item, &partition, ok);
354    }
355}
356
357/// The journal and listing id of a shared-context fact.
358pub fn shared_context_item(id: &str) -> String {
359    format!("{STORE_SHARED_CONTEXT}:{}", &id[..id.len().min(16)])
360}
361
362/// Upper bound of partitions one drift lookup inspects.
363const DRIFT_SCAN_LIMIT: usize = 2048;
364
365/// The knowledge partition (`knowledge/<hash>`) that recorded `project_root`
366/// (canonically) under an earlier identity of the project, if any: proof by
367/// the stored root. One bounded scan per data directory and root per process.
368pub(crate) fn drifted_knowledge_partition(project_root: &str) -> Option<String> {
369    static KNOWLEDGE_DRIFT: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
370    let current = crate::core::project_hash::hash_project_root(project_root);
371    let wanted = canonical(project_root);
372    let key = format!("{}|{}", pass_key(&current), wanted.display());
373    let cache = KNOWLEDGE_DRIFT.get_or_init(|| Mutex::new(HashMap::new()));
374    if let Some(found) = cache.lock().ok()?.get(&key) {
375        return found.clone();
376    }
377    #[derive(serde::Deserialize)]
378    struct Recorded {
379        #[serde(default)]
380        project_root: String,
381    }
382    let base = crate::core::data_dir::lean_ctx_data_dir()
383        .ok()?
384        .join("knowledge");
385    let mut found: Option<(std::time::SystemTime, String)> = None;
386    if let Ok(entries) = std::fs::read_dir(&base) {
387        for entry in entries.flatten().take(DRIFT_SCAN_LIMIT) {
388            let name = entry.file_name().to_string_lossy().into_owned();
389            if name == current {
390                continue;
391            }
392            let file = entry.path().join("knowledge.json");
393            let Ok(reader) = std::fs::File::open(&file) else {
394                continue;
395            };
396            let Ok(recorded) =
397                serde_json::from_reader::<_, Recorded>(std::io::BufReader::new(reader))
398            else {
399                continue;
400            };
401            if recorded.project_root.is_empty() || canonical(&recorded.project_root) != wanted {
402                continue;
403            }
404            let modified = std::fs::metadata(&file)
405                .and_then(|meta| meta.modified())
406                .unwrap_or(std::time::UNIX_EPOCH);
407            if found.as_ref().is_none_or(|(newest, _)| modified > *newest) {
408                found = Some((modified, name));
409            }
410        }
411    }
412    let found = found.map(|(_, name)| name);
413    if let Ok(mut cache) = cache.lock() {
414        cache.insert(key, found.clone());
415    }
416    found
417}
418
419/// Copy the files of the knowledge partition `old` that the current
420/// partition of `project_root` does not have yet (knowledge, gotchas, …).
421/// Never overwrites and never removes; journaled once per partition pair.
422pub(crate) fn copy_knowledge_partition(old: &str, project_root: &str) -> Result<bool, String> {
423    fn copy_missing(from: &Path, to: &Path) -> Result<bool, String> {
424        std::fs::create_dir_all(to).map_err(|e| format!("create {}: {e}", to.display()))?;
425        let mut copied = false;
426        for entry in std::fs::read_dir(from)
427            .map_err(|e| format!("read {}: {e}", from.display()))?
428            .flatten()
429        {
430            let source = entry.path();
431            let target = to.join(entry.file_name());
432            if source.is_dir() {
433                copied |= copy_missing(&source, &target)?;
434            } else if !target.exists() {
435                std::fs::copy(&source, &target)
436                    .map_err(|e| format!("copy {}: {e}", source.display()))?;
437                copied = true;
438            }
439        }
440        Ok(copied)
441    }
442    let current = crate::core::project_hash::hash_project_root(project_root);
443    let base = crate::core::data_dir::lean_ctx_data_dir()?.join("knowledge");
444    let store = base.join(&current).join("knowledge.json");
445    let had_store = store.exists();
446    let copied = copy_missing(&base.join(old), &base.join(&current))?;
447    if !had_store && store.exists() {
448        // The copy names its new partition; the recorded root stays.
449        let text = std::fs::read_to_string(&store).map_err(|e| e.to_string())?;
450        let mut value: serde_json::Value =
451            serde_json::from_str(&text).map_err(|e| e.to_string())?;
452        value["project_hash"] = serde_json::Value::String(current.clone());
453        let json = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?;
454        crate::config_io::write_atomic(&store, &json)?;
455    }
456    let item = format!("{STORE_KNOWLEDGE}:{old}");
457    if copied && !adopted(&read_journal(), &item, &current) {
458        record(STORE_KNOWLEDGE, &item, &current, true)?;
459    }
460    Ok(copied)
461}
462
463/// The facts of one legacy facts archive with their stable item ids.
464pub fn legacy_facts_of(
465    path: &Path,
466    envelope: &serde_json::Value,
467) -> Vec<(String, serde_json::Value)> {
468    let stem = path
469        .file_stem()
470        .map(|s| s.to_string_lossy().into_owned())
471        .unwrap_or_default();
472    // A partition's archives are named with the partition: stems can repeat.
473    let stem = match envelope["scope"].as_str() {
474        Some(scope) => format!("{scope}/{stem}"),
475        None => stem,
476    };
477    envelope
478        .get("items")
479        .or_else(|| envelope.get("facts"))
480        .and_then(serde_json::Value::as_array)
481        .into_iter()
482        .flatten()
483        .enumerate()
484        .map(|(index, fact)| {
485            (
486                format!("{STORE_FACTS_ARCHIVE}:{stem}#{index}"),
487                fact.clone(),
488            )
489        })
490        .collect()
491}
492
493#[cfg(test)]
494mod tests {
495    /// AP05 review I2: a partition hash covers the project's identity, so
496    /// adding a git remote moved every partition and the project's earlier
497    /// archives, `ctx_memory` facts, knowledge and shared agent knowledge
498    /// were no longer visible to it.
499    #[test]
500    fn an_identity_change_keeps_the_projects_archives_and_memory() {
501        let _data = crate::core::data_dir::isolated_data_dir();
502        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
503        crate::test_env::set_var("LEAN_CTX_ARCHIVE", "1");
504        let project = tempfile::tempdir().unwrap();
505        let root = project.path().canonicalize().unwrap();
506        let root_str = root.to_str().unwrap();
507        let in_project = |f: &dyn Fn()| {
508            crate::core::policy::runtime::REQUEST_PROJECT
509                .sync_scope(std::cell::RefCell::new(Some(root.clone())), f);
510        };
511        let output = "identity drift marker output\n".repeat(64);
512        in_project(&|| {
513            crate::core::archive::store("ctx_shell", "make drift", &output, None)
514                .expect("archived");
515        });
516        crate::core::shared_context::SharedContext::for_project(root_str)
517            .unwrap()
518            .put("drift-memory-marker", "codex", "fact")
519            .unwrap();
520        let mut knowledge = crate::core::knowledge::ProjectKnowledge::new(root_str);
521        knowledge.remember(
522            "decision",
523            "drift-key",
524            "drift knowledge survives",
525            "s1",
526            0.9,
527            &crate::core::memory_policy::MemoryPolicy::default(),
528        );
529        knowledge.save().unwrap();
530        crate::core::agents::AgentRegistry::new()
531            .share_knowledge(
532                root_str,
533                "a1",
534                "decision",
535                &[("db".into(), "drift-shared".into())],
536            )
537            .unwrap();
538        let before = crate::core::project_hash::hash_project_root(root_str);
539
540        std::fs::create_dir_all(root.join(".git")).unwrap();
541        std::fs::write(
542            root.join(".git/config"),
543            "[remote \"origin\"]\n\turl = git@github.com:owner/drift.git\n",
544        )
545        .unwrap();
546        assert_ne!(
547            crate::core::project_hash::hash_project_root(root_str),
548            before
549        );
550
551        in_project(&|| {
552            let listed = crate::core::archive::list_entries(None);
553            assert!(
554                listed.iter().any(|entry| entry.command == "make drift"),
555                "{listed:?}"
556            );
557        });
558        let hint = crate::core::shared_context::session_start_hint(root_str).unwrap_or_default();
559        assert!(hint.contains("drift-memory-marker"), "{hint}");
560        let recalled = crate::core::knowledge::ProjectKnowledge::load(root_str).expect("knowledge");
561        assert!(
562            recalled
563                .facts
564                .iter()
565                .any(|fact| fact.key == "drift-key" && fact.is_current())
566        );
567        let shared = crate::core::agents::AgentRegistry::new()
568            .receive_shared_knowledge(root_str, "a2")
569            .unwrap();
570        assert!(shared.iter().any(|fact| fact.text == "db=drift-shared"));
571        crate::test_env::remove_var("LEAN_CTX_ARCHIVE");
572    }
573}