1use 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#[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
58pub 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
65pub 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
96const PASS_ITEMS: usize = 64;
99const PASS_TIME: std::time::Duration = std::time::Duration::from_millis(150);
100
101#[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
130fn 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
147fn 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
162fn 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
177pub(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
204fn 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
247pub(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
281pub 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
326pub 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
357pub fn shared_context_item(id: &str) -> String {
359 format!("{STORE_SHARED_CONTEXT}:{}", &id[..id.len().min(16)])
360}
361
362const DRIFT_SCAN_LIMIT: usize = 2048;
364
365pub(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(¤t), 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
419pub(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(¤t).join("knowledge.json");
445 let had_store = store.exists();
446 let copied = copy_missing(&base.join(old), &base.join(¤t))?;
447 if !had_store && store.exists() {
448 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, ¤t) {
458 record(STORE_KNOWLEDGE, &item, ¤t, true)?;
459 }
460 Ok(copied)
461}
462
463pub 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 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 #[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}