1use std::collections::{BTreeMap, HashMap};
29use std::path::{Path, PathBuf};
30use std::sync::Mutex;
31use std::time::{Duration, Instant};
32
33use serde::Serialize;
34
35use crate::backend::memory_dir_claims as claims;
36use crate::backend::memory_record::{self as record, AppendOutcome, ImportedVersion, NewVersion};
37use crate::backend::storage::error::StoreError;
38use crate::backend::storage::filestore::FileStore;
39use crate::backend::storage::store::Store;
40
41const INDEX_FILE: &str = "MEMORY.md";
42const LIST_TTL: Duration = Duration::from_secs(30 * 60);
44
45#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
46#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptionFile")]
47pub(crate) struct CandidateFile {
48 pub name: String,
49 #[ts(type = "number")]
50 pub size: u64,
51 #[ts(type = "number")]
52 pub modified_ms: i64,
53 pub sha256: String,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
59#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptionCandidate")]
60pub(crate) struct Candidate {
61 pub index: usize,
62 pub account: String,
67 pub dir_hash: String,
69 pub files: Vec<CandidateFile>,
70}
71
72#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
73#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptionList")]
74pub(crate) struct AdoptionList {
75 pub list_id: String,
76 pub agent_uid: String,
77 pub candidates: Vec<Candidate>,
78}
79
80#[derive(Debug, Default, PartialEq, Serialize, ts_rs::TS)]
81#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "NativeMemoryAdoptReport")]
82pub(crate) struct AdoptReport {
83 pub files_added: usize,
85 pub versions_kept: usize,
87 pub index_lines_added: usize,
89}
90
91#[derive(Debug, PartialEq)]
92pub(crate) enum AdoptError {
93 UnknownList,
95 BadChoice,
96 Changed { index: usize },
99 Busy,
101 Store(String),
102}
103
104impl From<StoreError> for AdoptError {
105 fn from(e: StoreError) -> Self {
106 AdoptError::Store(e.to_string())
107 }
108}
109
110struct IssuedDir {
112 path: PathBuf,
113 dir_hash: String,
114 account: String,
115 only: Option<Vec<String>>,
117}
118
119struct Issued {
120 agent_uid: String,
121 dirs: Vec<IssuedDir>,
122 at: Instant,
123}
124
125fn issued() -> &'static Mutex<HashMap<String, Issued>> {
126 static ISSUED: std::sync::OnceLock<Mutex<HashMap<String, Issued>>> = std::sync::OnceLock::new();
127 ISSUED.get_or_init(|| Mutex::new(HashMap::new()))
128}
129
130fn account_project_roots(shared: &Path) -> Vec<(String, PathBuf)> {
133 let mut roots: Vec<(String, PathBuf)> = std::fs::read_dir(shared.join("identities"))
134 .map(|d| {
135 d.flatten()
136 .map(|e| (e.file_name().to_string_lossy().into_owned(), e.path().join("claude").join("projects")))
137 .collect()
138 })
139 .unwrap_or_default();
140 roots.sort();
141 roots.push(("default".into(), shared.join("providers").join("claude").join("projects")));
142 roots
143}
144
145fn folder_files(dir: &Path, only: Option<&[String]>) -> Vec<(CandidateFile, Vec<u8>)> {
148 let mut out: Vec<(CandidateFile, Vec<u8>)> = std::fs::read_dir(dir)
149 .map(|d| {
150 d.flatten()
151 .filter_map(|e| {
152 let name = e.file_name().to_string_lossy().into_owned();
153 crate::server::native_memory_handlers::validate_filename(&name).ok()?;
154 if only.is_some_and(|o| !o.contains(&name)) {
155 return None;
156 }
157 let meta = std::fs::metadata(e.path()).ok().filter(|m| m.is_file())?;
158 if meta.len() as usize > record::MAX_BODY_BYTES {
159 return None;
160 }
161 let body = std::fs::read(e.path()).ok()?;
162 let modified_ms = meta
163 .modified()
164 .ok()
165 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
166 .map_or(0, |d| d.as_millis() as i64);
167 let file = CandidateFile { name, size: meta.len(), modified_ms, sha256: record::sha256_hex(&body) };
168 Some((file, body))
169 })
170 .collect()
171 })
172 .unwrap_or_default();
173 out.sort_by(|a, b| a.0.name.cmp(&b.0.name));
174 out
175}
176
177fn dir_hash(files: &[(CandidateFile, Vec<u8>)]) -> String {
178 let listing: String = files.iter().map(|(f, _)| format!("{}\t{}\n", f.name, f.sha256)).collect();
179 record::sha256_hex(listing.as_bytes())
180}
181
182pub(crate) fn list_in(fs: &FileStore, mstore: &Store, agent_uid: &str, own_dir: &Path, shared: &Path) -> Result<AdoptionList, StoreError> {
185 let name = own_dir.parent().and_then(Path::file_name).map(|n| n.to_os_string());
187 let own_id = claims::dir_id(own_dir);
188 let mut dirs = Vec::new();
189 let mut candidates = Vec::new();
190 if let Some(name) = name {
191 for (account, root) in account_project_roots(shared) {
192 let dir = root.join(&name).join("memory");
193 if !dir.is_dir() || claims::dir_id(&dir) == own_id || claims::used_by_another(fs, mstore, agent_uid, &dir)? {
194 continue;
195 }
196 let files = folder_files(&dir, None);
197 if files.is_empty() {
198 continue;
199 }
200 let hash = dir_hash(&files);
201 candidates.push(Candidate {
202 index: candidates.len(),
203 account: account.clone(),
204 dir_hash: hash.clone(),
205 files: files.into_iter().map(|(f, _)| f).collect(),
206 });
207 dirs.push(IssuedDir { path: dir, dir_hash: hash, account, only: None });
208 }
209 }
210 let heads = record::heads(fs, agent_uid)?;
213 if let Some(held) = heads.held.get(&own_id) {
214 let names: Vec<String> = held.keys().cloned().collect();
215 let files: Vec<_> = folder_files(own_dir, Some(&names))
216 .into_iter()
217 .filter(|(f, _)| held.get(&f.name) == Some(&f.sha256))
218 .collect();
219 if !files.is_empty() {
220 let hash = dir_hash(&files);
221 let only = files.iter().map(|(f, _)| f.name.clone()).collect();
222 candidates.push(Candidate {
223 index: candidates.len(),
224 account: "held".into(),
225 dir_hash: hash.clone(),
226 files: files.into_iter().map(|(f, _)| f).collect(),
227 });
228 dirs.push(IssuedDir { path: own_dir.to_path_buf(), dir_hash: hash, account: "held".into(), only: Some(only) });
229 }
230 }
231 let list_id = uuid::Uuid::new_v4().simple().to_string();
232 let mut map = issued().lock().unwrap_or_else(|e| e.into_inner());
233 map.retain(|_, i| i.at.elapsed() < LIST_TTL);
234 map.insert(list_id.clone(), Issued { agent_uid: agent_uid.to_string(), dirs, at: Instant::now() });
235 Ok(AdoptionList { list_id, agent_uid: agent_uid.to_string(), candidates })
236}
237
238pub(crate) fn list(fs: &FileStore, mstore: &Store, agent: &crate::backend::storage::AgentDefinition) -> Result<Option<AdoptionList>, StoreError> {
241 use crate::server::native_memory_handlers::{resolve_memory_dir_by_id, MemoryDirProvenance};
242 let Some(own) = resolve_memory_dir_by_id(mstore, agent).filter(|r| r.provenance != MemoryDirProvenance::Unverified) else {
243 return Ok(None);
244 };
245 let Some(shared) = crate::registry::resolve_global_shared_root() else { return Ok(None) };
246 list_in(fs, mstore, &agent.id, &own.path, &shared).map(Some)
247}
248
249fn link_target(line: &str) -> Option<&str> {
251 let start = line.find("](")? + 2;
252 let end = line[start..].find(')')? + start;
253 Some(line[start..end].trim())
254}
255
256fn union_index(base: &str, others: &[&str]) -> (String, usize) {
260 let mut out = base.to_string();
261 let mut targets: std::collections::HashSet<String> = base.lines().filter_map(link_target).map(str::to_string).collect();
262 let mut plain: std::collections::HashSet<String> = base.lines().map(|l| l.trim().to_string()).collect();
263 let mut added = 0;
264 for other in others {
265 for line in other.lines() {
266 let new = match link_target(line) {
267 Some(t) => targets.insert(t.to_string()),
268 None => !line.trim().is_empty() && plain.insert(line.trim().to_string()),
269 };
270 if new {
271 if !out.is_empty() && !out.ends_with('\n') {
272 out.push('\n');
273 }
274 out.push_str(line);
275 out.push('\n');
276 plain.insert(line.trim().to_string());
277 added += 1;
278 }
279 }
280 }
281 (out, added)
282}
283
284pub(crate) fn adopt(
288 fs: &FileStore,
289 mstore: &Store,
290 agent_uid: &str,
291 list_id: &str,
292 choices: &[(usize, String)],
293) -> Result<AdoptReport, AdoptError> {
294 let chosen: Vec<(PathBuf, String, Option<Vec<String>>)> = {
295 let map = issued().lock().unwrap_or_else(|e| e.into_inner());
296 let list = map
297 .get(list_id)
298 .filter(|l| l.agent_uid == agent_uid && l.at.elapsed() < LIST_TTL)
299 .ok_or(AdoptError::UnknownList)?;
300 let mut out = Vec::new();
301 for (index, hash) in choices {
302 let d = list.dirs.get(*index).ok_or(AdoptError::BadChoice)?;
303 if &d.dir_hash != hash {
304 return Err(AdoptError::BadChoice);
305 }
306 out.push((d.path.clone(), d.account.clone(), d.only.clone()));
307 }
308 out
309 };
310 if chosen.is_empty() {
311 return Err(AdoptError::BadChoice);
312 }
313 let lease = crate::backend::memory_reconcile::take_pass_lease(fs, agent_uid, Instant::now() + Duration::from_secs(5))?
314 .ok_or(AdoptError::Busy)?;
315 let result = adopt_under_lease(fs, mstore, agent_uid, &chosen, choices);
316 crate::backend::memory_reconcile::release_pass_lease(fs, agent_uid, &lease);
317 if result.is_ok() {
318 issued().lock().unwrap_or_else(|e| e.into_inner()).remove(list_id);
319 }
320 result
321}
322
323fn adopt_under_lease(
324 fs: &FileStore,
325 mstore: &Store,
326 agent_uid: &str,
327 chosen: &[(PathBuf, String, Option<Vec<String>>)],
328 choices: &[(usize, String)],
329) -> Result<AdoptReport, AdoptError> {
330 let mut by_file: BTreeMap<String, Vec<(Vec<u8>, i64, String)>> = BTreeMap::new();
334 let mut released: Vec<(PathBuf, Vec<String>)> = Vec::new();
335 for ((dir, account, only), (index, hash)) in chosen.iter().zip(choices) {
336 let files = folder_files(dir, only.as_deref());
337 let others = only.is_none() && claims::used_by_another(fs, mstore, agent_uid, dir)?;
338 if &dir_hash(&files) != hash || others {
339 return Err(AdoptError::Changed { index: *index });
340 }
341 if let Some(names) = only {
342 released.push((dir.clone(), names.clone()));
343 }
344 for (f, body) in files {
345 by_file.entry(f.name).or_default().push((body, f.modified_ms, account.clone()));
346 }
347 }
348 let heads = record::heads(fs, agent_uid)?;
349 let mut report = AdoptReport::default();
350 for (name, mut bodies) in by_file {
351 bodies.sort_by_key(|(_, mtime, _)| *mtime);
352 let history: Vec<ImportedVersion> = bodies
353 .iter()
354 .map(|(body, mtime, account)| ImportedVersion {
355 file: name.clone(),
356 body: body.clone(),
357 source: "adopted".into(),
358 source_detail: format!("earlier account {account}"),
359 created_at_ms: *mtime,
360 })
361 .collect();
362 let head = heads.files.get(&name);
363 let live_head = head.filter(|h| h.sha256.is_some());
364 if name == INDEX_FILE {
365 report.versions_kept += record::add_history(fs, agent_uid, &history)?;
368 let current = match live_head.and_then(|h| h.sha256.as_deref()) {
369 Some(sha) => record::body(fs, agent_uid, sha)?.map(|b| String::from_utf8_lossy(&b).into_owned()),
370 None => None,
371 };
372 let texts: Vec<String> = bodies.iter().rev().map(|(b, _, _)| String::from_utf8_lossy(b).into_owned()).collect();
373 let (base, rest) = match ¤t {
374 Some(c) => (c.as_str(), &texts[..]),
375 None => (texts[0].as_str(), &texts[1..]),
376 };
377 let (union, added) = union_index(base, &rest.iter().map(String::as_str).collect::<Vec<_>>());
378 if current.as_deref() != Some(union.as_str()) {
379 let outcome = record::append_version(
380 fs,
381 agent_uid,
382 NewVersion {
383 file: INDEX_FILE,
384 body: Some(union.as_bytes()),
385 expected_parent: head.map(|h| h.version.as_str()),
386 merged_parent: None,
387 conflicts_with: None,
388 source: "adopted",
389 source_detail: "index union with earlier accounts",
390 project_to: None,
391 },
392 )?;
393 if !matches!(outcome, AppendOutcome::Appended(_) | AppendOutcome::Unchanged(_)) {
394 return Err(AdoptError::Busy);
395 }
396 report.index_lines_added += added;
397 if current.is_none() {
398 report.files_added += 1;
399 }
400 }
401 continue;
402 }
403 if live_head.is_some() {
404 report.versions_kept += record::add_history(fs, agent_uid, &history)?;
406 continue;
407 }
408 let (newest, older) = history.split_last().expect("at least one body per file");
411 report.versions_kept += record::add_history(fs, agent_uid, older)?;
412 let outcome = record::append_version(
413 fs,
414 agent_uid,
415 NewVersion {
416 file: &name,
417 body: Some(&newest.body),
418 expected_parent: head.map(|h| h.version.as_str()),
419 merged_parent: None,
420 conflicts_with: None,
421 source: "adopted",
422 source_detail: &newest.source_detail,
423 project_to: None,
424 },
425 )?;
426 match outcome {
427 AppendOutcome::Appended(_) => report.files_added += 1,
428 AppendOutcome::Unchanged(_) => {}
429 _ => return Err(AdoptError::Busy),
430 }
431 }
432 for (dir, names) in released {
433 record::release_held(fs, agent_uid, &claims::dir_id(&dir), &names)?;
434 }
435 Ok(report)
436}
437
438#[cfg(test)]
439mod tests;