1use super::{
11 migration::{self, MigrationManifest},
12 MemoryBinding,
13};
14use agent_core::memory::store::ProjectScope;
15use anyhow::{bail, Context, Result};
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use sha2::{Digest, Sha256};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22const IDENTITY_FORMAT: &str = "synaps-repository-memory-scope/1";
23const BATCH_FORMAT: &str = "synaps-repository-memory-migration/1";
24
25#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27pub struct ScopeMember {
28 pub project: String,
29 pub root: PathBuf,
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34pub struct IdentityReport {
35 pub format: &'static str,
36 pub canonical_project: String,
37 pub canonical_root: PathBuf,
39 pub legacy_project: String,
40 pub members: Vec<ScopeMember>,
41 pub mapping_digest: String,
42}
43
44pub fn identity_report(
49 canonical: &ProjectScope,
50 legacy: &ProjectScope,
51 verified: &[ProjectScope],
52) -> Result<IdentityReport> {
53 let mut members = BTreeMap::new();
54 for scope in verified {
55 if let Some(prior) = members.insert(scope.key().to_owned(), scope.root().to_path_buf()) {
56 if prior != scope.root() {
57 bail!("conflicting verified source roots for one memory scope");
58 }
59 }
60 }
61 members
65 .entry(canonical.key().to_owned())
66 .or_insert_with(|| canonical.root().to_path_buf());
67 if !members.contains_key(legacy.key()) {
68 bail!("current legacy scope is not in the verified repository mapping");
69 }
70 let members: Vec<_> = members
71 .into_iter()
72 .map(|(project, root)| ScopeMember { project, root })
73 .collect();
74 let mut hash = Sha256::new();
78 hash_part(&mut hash, IDENTITY_FORMAT.as_bytes());
79 hash_part(&mut hash, canonical.key().as_bytes());
80 hash_part(&mut hash, canonical.root().as_os_str().as_encoded_bytes());
81 for member in &members {
82 hash_part(&mut hash, member.project.as_bytes());
83 hash_part(&mut hash, member.root.as_os_str().as_encoded_bytes());
84 }
85 Ok(IdentityReport {
86 format: IDENTITY_FORMAT,
87 canonical_project: canonical.key().into(),
88 canonical_root: canonical.root().to_path_buf(),
89 legacy_project: legacy.key().into(),
90 members,
91 mapping_digest: format!("{:x}", hash.finalize()),
92 })
93}
94
95#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
99pub struct RepositoryManifest {
100 pub format: &'static str,
101 pub identity: IdentityReport,
102 pub sources: Vec<MigrationManifest>,
103 pub manifest_digest: String,
104 pub atomicity: &'static str,
105}
106
107pub fn preview_builtin_in(
108 base: &Path,
109 canonical: &ProjectScope,
110 legacy: &ProjectScope,
111 verified: &[ProjectScope],
112) -> Result<RepositoryManifest> {
113 let identity = identity_report(canonical, legacy, verified)?;
114 let scopes: BTreeMap<_, _> = std::iter::once(canonical)
115 .chain(verified)
116 .map(|s| (s.key(), s))
117 .collect();
118 let mut sources = Vec::new();
119 for scope in scopes.values() {
120 sources.push(migration::preview_in(base, scope)?);
121 }
122 let mut hash = Sha256::new();
123 hash_part(&mut hash, BATCH_FORMAT.as_bytes());
124 hash_part(&mut hash, identity.mapping_digest.as_bytes());
125 for source in &sources {
126 hash_part(&mut hash, source.project.as_bytes());
127 hash_part(&mut hash, source.manifest_digest.as_bytes());
128 }
129 Ok(RepositoryManifest {
130 format: BATCH_FORMAT,
131 identity,
132 sources,
133 manifest_digest: format!("{:x}", hash.finalize()),
134 atomicity: "one bounded atomic import per source, then separate alias link; partial progress possible",
135 })
136}
137
138pub fn report(binding: &MemoryBinding) -> Result<IdentityReport> {
141 let identity = binding.repository_identity()?;
142 identity_report(&identity.scope, &identity.legacy_scope, &identity.aliases)
143}
144
145pub fn preview_builtin(binding: &MemoryBinding) -> Result<RepositoryManifest> {
146 let identity = binding.repository_identity()?;
147 preview_builtin_in(
148 binding.base(),
149 &identity.scope,
150 &identity.legacy_scope,
151 &identity.aliases,
152 )
153}
154
155fn hash_part(hash: &mut Sha256, part: &[u8]) {
156 hash.update((part.len() as u64).to_le_bytes());
157 hash.update(part);
158}
159
160#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
163pub struct LinkManifest {
164 pub format: &'static str,
165 pub identity: IdentityReport,
166 pub destination: PathBuf,
167 pub link_projects: Vec<String>,
168 pub manifest_digest: String,
169 pub effect: &'static str,
170}
171
172pub fn preview_link(binding: &MemoryBinding, projects: &[String]) -> Result<LinkManifest> {
173 let identity = report(binding)?;
174 let destination = binding
175 .brain_path()
176 .context("shared Axel destination unavailable")?
177 .to_path_buf();
178 let mut projects = projects.to_vec();
179 projects.sort();
180 projects.dedup();
181 if projects
182 .iter()
183 .any(|p| !identity.members.iter().any(|m| &m.project == p))
184 {
185 bail!("alias linking requires exact host-verified source scope keys");
186 }
187 projects.retain(|p| p != &identity.canonical_project);
188 let mut hash = Sha256::new();
189 hash_part(&mut hash, b"synaps-repository-memory-link/1");
190 hash_part(&mut hash, identity.mapping_digest.as_bytes());
191 hash_part(&mut hash, destination.as_os_str().as_encoded_bytes());
192 for project in &projects {
193 hash_part(&mut hash, project.as_bytes());
194 }
195 Ok(LinkManifest {
196 format: "synaps-repository-memory-link/1", identity, destination,
197 link_projects: projects, manifest_digest: format!("{:x}", hash.finalize()),
198 effect: "explicitly share existing selected scope memory/history and deletion evidence; no payload identity rewrite; separate atomic link per scope",
199 })
200}
201
202pub fn verified_projects(binding: &MemoryBinding) -> Result<Vec<String>> {
203 Ok(report(binding)?
204 .members
205 .into_iter()
206 .map(|m| m.project)
207 .collect())
208}
209
210pub fn source_binding(binding: &MemoryBinding, project: &str) -> Result<MemoryBinding> {
213 let identity = binding.repository_identity()?;
214 let source = identity
215 .aliases
216 .iter()
217 .find(|s| s.key() == project)
218 .or_else(|| (identity.scope.key() == project).then_some(&identity.scope))
219 .context("source target must exactly match a verified repository scope key")?;
220 Ok(binding.with_scope(source.clone()))
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct ScopeInfo {
226 pub canonical_project: String,
227 pub members: Vec<String>,
228 pub mode: String,
229 pub user_scope: bool,
230}
231
232pub async fn scope_info(binding: &MemoryBinding) -> Result<ScopeInfo> {
234 parse_scope_info(
235 binding.rpc("scope_info", json!({})).await?,
236 binding.scope()?.key(),
237 )
238}
239fn parse_scope_info(value: Value, requested: &str) -> Result<ScopeInfo> {
240 let info: ScopeInfo = serde_json::from_value(value)
241 .map_err(|_| anyhow::anyhow!("invalid service scope metadata"))?;
242 if !matches!(info.mode.as_str(), "multi_project" | "pinned")
243 || info.user_scope
244 || info.members.len() > 4096
245 || !info
246 .members
247 .iter()
248 .all(|p| valid_project(p) && p != "p0000000000000000")
249 || info
250 .members
251 .iter()
252 .collect::<std::collections::BTreeSet<_>>()
253 .len()
254 != info.members.len()
255 || !info.members.iter().any(|p| p == requested)
256 || !info.members.contains(&info.canonical_project)
257 {
258 bail!("invalid service scope membership metadata");
259 }
260 Ok(info)
261}
262fn valid_project(s: &str) -> bool {
263 s.len() == 17
264 && s.starts_with('p')
265 && s[1..]
266 .bytes()
267 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
268}
269fn require_digest(expected: &str, actual: &str) -> Result<()> {
270 if !migration::is_digest(expected) || expected != actual {
271 bail!("source, destination or repository mapping changed / preview digest mismatched; preview again (nothing dispatched)");
272 }
273 Ok(())
274}
275
276#[derive(Debug, Clone, Serialize)]
279pub struct ApplyReport {
280 pub complete: bool,
281 pub imported_projects: Vec<String>,
282 pub linked_projects: Vec<String>,
283 pub failed_project: Option<String>,
284 pub failed_operation: Option<&'static str>,
285 pub outcome_unconfirmed: bool,
286 pub source_unchanged: bool,
287 pub config_unchanged: bool,
288 pub atomicity: &'static str,
289 pub recovery: &'static str,
290}
291impl ApplyReport {
292 fn new() -> Self {
293 Self { complete: false, imported_projects: vec![], linked_projects: vec![],
294 failed_project: None, failed_operation: None, outcome_unconfirmed: false,
295 source_unchanged: true, config_unchanged: true,
296 atomicity: "separate <=24 MiB atomic imports and separate alias links; partial progress possible",
297 recovery: "stop writers; reconcile or retry the identical preview/options; never replace IDs; no config cutover performed" }
298 }
299 fn failed(&mut self, project: &str, operation: &'static str) {
300 self.failed_project = Some(project.into());
301 self.failed_operation = Some(operation);
302 self.outcome_unconfirmed = true;
303 }
304}
305
306pub async fn apply_link(
307 binding: &MemoryBinding,
308 projects: &[String],
309 digest: &str,
310) -> Result<ApplyReport> {
311 let preview = preview_link(binding, projects)?;
312 require_digest(digest, &preview.manifest_digest)?;
313 let mut result = ApplyReport::new();
314 link_members(binding, &preview, &mut result).await;
315 Ok(result)
316}
317async fn link_members(binding: &MemoryBinding, preview: &LinkManifest, result: &mut ApplyReport) {
318 for project in &preview.link_projects {
319 let member = preview
320 .identity
321 .members
322 .iter()
323 .find(|m| &m.project == project)
324 .expect("validated mapping");
325 let operation = async {
326 let source = source_binding(binding, project)?;
329 let info = scope_info(&source).await?;
330 if info.mode != "multi_project" {
331 bail!("pinned destination requires explicit upgrade-memory first");
332 }
333 let value = binding
334 .rpc(
335 "scope_alias",
336 json!({
337 "alias_project": project, "alias_root": member.root,
338 "canonical_root": preview.identity.canonical_root,
339 }),
340 )
341 .await?;
342 let info = parse_scope_info(value, project)?;
343 if info.canonical_project != preview.identity.canonical_project {
344 bail!("alias acknowledgement does not match canonical repository");
345 }
346 Ok::<_, anyhow::Error>(())
347 }
348 .await;
349 if operation.is_err() {
350 result.failed(project, "scope_alias");
351 return;
352 }
353 result.linked_projects.push(project.clone());
354 }
355 result.complete = true;
356}
357
358#[derive(Debug, Clone, Serialize)]
360pub struct MigrationPlan {
361 pub format: &'static str,
362 pub sources: Vec<MigrationManifest>,
363 pub linking: LinkManifest,
364 pub manifest_digest: String,
365 pub atomicity: &'static str,
366}
367fn plan(sources: Vec<MigrationManifest>, linking: LinkManifest) -> MigrationPlan {
368 let mut hash = Sha256::new();
369 hash_part(&mut hash, BATCH_FORMAT.as_bytes());
370 hash_part(&mut hash, linking.manifest_digest.as_bytes());
371 for source in &sources {
372 hash_part(&mut hash, source.project.as_bytes());
373 hash_part(&mut hash, source.manifest_digest.as_bytes());
374 }
375 MigrationPlan { format: BATCH_FORMAT, sources, linking, manifest_digest: format!("{:x}", hash.finalize()),
376 atomicity: "one <=24 MiB atomic import per original scope, then separate explicit alias links; partial progress possible" }
377}
378pub fn preview_migration(binding: &MemoryBinding) -> Result<MigrationPlan> {
379 let sources = preview_builtin(binding)?.sources;
380 Ok(plan(
381 sources,
382 preview_link(binding, &verified_projects(binding)?)?,
383 ))
384}
385pub async fn apply_migration(binding: &MemoryBinding, digest: &str) -> Result<ApplyReport> {
386 let preview = preview_migration(binding)?;
389 require_digest(digest, &preview.manifest_digest)?;
390 let mut result = ApplyReport::new();
391 for source in &preview.sources {
392 let target = source_binding(binding, &source.project)?;
393 if migration::apply(&target, &source.manifest_digest)
394 .await
395 .is_err()
396 {
397 result.failed(&source.project, "migration_apply");
398 return Ok(result);
399 }
400 result.imported_projects.push(source.project.clone());
401 }
402 link_members(binding, &preview.linking, &mut result).await;
403 Ok(result)
404}
405
406pub async fn preview_external(
409 binding: &MemoryBinding,
410 source: &migration::MigrationSource,
411 target: &str,
412) -> Result<MigrationPlan> {
413 let scoped = source_binding(binding, target)?;
414 let manifest = migration::preview_from(&scoped, source).await?;
415 Ok(plan(
416 vec![manifest],
417 preview_link(binding, &[target.into()])?,
418 ))
419}
420pub async fn apply_external(
421 binding: &MemoryBinding,
422 source: &migration::MigrationSource,
423 target: &str,
424 digest: &str,
425) -> Result<ApplyReport> {
426 let preview = preview_external(binding, source, target).await?;
427 require_digest(digest, &preview.manifest_digest)?;
428 let mut result = ApplyReport::new();
429 let scoped = source_binding(binding, target)?;
430 if migration::apply_from(&scoped, source, &preview.sources[0].manifest_digest)
431 .await
432 .is_err()
433 {
434 result.failed(target, "migration_apply");
435 return Ok(result);
436 }
437 result.imported_projects.push(target.into());
438 link_members(binding, &preview.linking, &mut result).await;
439 Ok(result)
440}
441
442#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
448pub struct UpgradeManifest {
449 pub format: &'static str,
450 pub brain: PathBuf,
451 pub expected_project: String,
452 pub canonical_project: String,
453 pub mapping_digest: String,
454 pub brain_bytes: u64,
455 pub manifest_digest: String,
456 pub schema_validation: &'static str,
457 pub effect: &'static str,
458}
459
460pub fn preview_upgrade(binding: &MemoryBinding, expected_project: &str) -> Result<UpgradeManifest> {
461 let _source = source_binding(binding, expected_project)?;
462 let identity = report(binding)?;
463 let brain = binding
464 .brain_path()
465 .context("configured shared brain unavailable")?;
466 let (brain_bytes, content_digest) = upgrade_file_digest(brain)?;
467 let mut hash = Sha256::new();
468 hash_part(&mut hash, b"synaps-memory-scope-upgrade/1");
469 hash_part(&mut hash, brain.as_os_str().as_encoded_bytes());
470 hash_part(&mut hash, expected_project.as_bytes());
471 hash_part(&mut hash, identity.mapping_digest.as_bytes());
472 hash_part(&mut hash, content_digest.as_bytes());
473 Ok(UpgradeManifest { format: "synaps-memory-scope-upgrade/1", brain: brain.into(),
474 expected_project: expected_project.into(), canonical_project: identity.canonical_project,
475 mapping_digest: identity.mapping_digest, brain_bytes, manifest_digest: format!("{:x}", hash.finalize()),
476 schema_validation: "offline file preview only; service must validate existing pinned host schema and expected project before conversion",
477 effect: "convert existing pinned brain schema marker to multi-project; retain all original identities/data; no aliases, config change or import" })
478}
479
480pub async fn apply_upgrade(
481 binding: &MemoryBinding,
482 expected_project: &str,
483 digest: &str,
484) -> Result<ScopeInfo> {
485 let preview = preview_upgrade(binding, expected_project)?;
486 require_digest(digest, &preview.manifest_digest)?;
487 let current = preview_upgrade(binding, expected_project)?;
489 require_digest(digest, ¤t.manifest_digest)?;
490 let scoped = source_binding(binding, expected_project)?;
491 let value = scoped.rpc("scope_upgrade", json!({"expected_project":expected_project})).await
492 .context("upgrade outcome unconfirmed; inspect scope_info before retry; committed marker conversion changes the file digest; no import/config change performed")?;
493 let info = parse_scope_info(value, expected_project)?;
494 if info.mode != "multi_project" {
495 bail!("upgrade acknowledgement is not multi_project; reconcile before retry");
496 }
497 Ok(info)
498}
499
500fn upgrade_file_digest(path: &Path) -> Result<(u64, String)> {
501 use std::io::Read;
502 if path.extension().and_then(|s| s.to_str()) != Some("r8") {
503 bail!("upgrade requires the configured existing absolute .r8 brain");
504 }
505 require_checkpointed(path)?;
506 let mut file = migration::open_export(path)?;
507 if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
508 bail!("brain locked; stop writers before upgrade preview");
509 }
510 let before = file.metadata()?;
511 let mut hash = Sha256::new();
512 hash_part(&mut hash, &before.len().to_le_bytes());
513 let mut buffer = [0u8; 65536];
514 let mut bytes = 0u64;
515 loop {
516 let n = file.read(&mut buffer)?;
517 if n == 0 {
518 break;
519 }
520 bytes = bytes.checked_add(n as u64).context("brain size overflow")?;
521 if bytes > before.len() {
522 bail!("brain changed during preview; stop writers");
523 }
524 hash.update(&buffer[..n]);
525 }
526 let current = migration::open_export(path)?;
527 if bytes != before.len()
528 || !same_file(&before, &file.metadata()?)
529 || !same_file(&before, ¤t.metadata()?)
530 {
531 bail!("brain changed/replaced during preview; stop writers");
532 }
533 require_checkpointed(path)?;
534 Ok((bytes, format!("{:x}", hash.finalize())))
535}
536fn require_checkpointed(path: &Path) -> Result<()> {
537 for suffix in ["-wal", "-journal", "-shm"] {
538 let mut name = path.as_os_str().to_os_string();
539 name.push(suffix);
540 match migration::open_export(Path::new(&name)) {
541 Ok(file) if suffix == "-shm" || file.metadata()?.len() == 0 => {}
542 Ok(_) => bail!("upgrade preview requires stopped writers and checkpointed WAL/journal; no checkpoint performed automatically"),
543 Err(e) if e.downcast_ref::<std::io::Error>().is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
544 Err(_) => bail!("unsafe/unreadable brain sidecar; upgrade refused"),
545 }
546 }
547 Ok(())
548}
549#[cfg(unix)]
550fn same_file(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool {
551 use std::os::unix::fs::MetadataExt;
552 (
553 a.dev(),
554 a.ino(),
555 a.len(),
556 a.mtime(),
557 a.mtime_nsec(),
558 a.ctime(),
559 a.ctime_nsec(),
560 ) == (
561 b.dev(),
562 b.ino(),
563 b.len(),
564 b.mtime(),
565 b.mtime_nsec(),
566 b.ctime(),
567 b.ctime_nsec(),
568 )
569}
570#[cfg(not(unix))]
571fn same_file(_: &std::fs::Metadata, _: &std::fs::Metadata) -> bool {
572 false
573}