Skip to main content

agent_engine/memory_backend/
repository_migration.rs

1//! Explicit repository-scope continuity for the local operator, never model tools.
2//!
3//! Identity reporting and mapping digests inspect no memory. Built-in migration
4//! imports each verified path scope under its ORIGINAL key, then links membership
5//! to the repository key. Neither capture payloads nor archive identities are
6//! rewritten. Every import remains a separate <=24 MiB atomic operation; a batch
7//! and its final alias link are NOT a global transaction. Sources/config remain
8//! untouched, and no session/private-history discovery is implied.
9
10use 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/// Metadata only: a verified root and its canonical or original path-scope key.
26#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27pub struct ScopeMember {
28    pub project: String,
29    pub root: PathBuf,
30}
31
32/// Safe operator output. No note IDs, bodies, capture evidence or archive notes.
33#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34pub struct IdentityReport {
35    pub format: &'static str,
36    pub canonical_project: String,
37    /// Current proof root, independent of a historical source path with the same key.
38    pub canonical_root: PathBuf,
39    pub legacy_project: String,
40    pub members: Vec<ScopeMember>,
41    pub mapping_digest: String,
42}
43
44/// Construct an identity report only from host-verified scopes. Callers must use
45/// repository discovery, not infer aliases from project-key prefixes or names.
46/// Sorting makes the mapping digest independent of worktree registration order.
47/// Include the canonical member even when it has no legacy data of its own.
48pub 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    // The random canonical key is distinct from all legacy path scopes. Include
62    // it even without legacy data; preserve explicitly supplied historical roots.
63    // Source reads use keys, not historical root existence.
64    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    // Hash native path bytes, not lossy display strings. A cwd within another
75    // registered worktree must yield the same mapping consent, so legacy_project
76    // is reported but is not part of the mapping's digest.
77    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/// Offline built-in batch preview; each source preview carries its own original
96/// scope, digest, migration ID and frame bound. Empty verified scopes are included
97/// so new source data appearing between preview/apply invalidates the batch.
98#[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
138/// Report the immutable identity captured by the production binding. This never
139/// reads a brain, changes configuration, or reinterprets the current directory.
140pub 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/// Link preview is offline. Consent binds the exact mapping, destination, and
161/// selected original scope keys. It does not claim to snapshot live DB contents.
162#[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
210/// Select an original scope from captured host evidence, including historical
211/// paths that no longer exist. Never reconstruct/recanonicalize a source root.
212pub 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
232/// Strict metadata projection: never forward arbitrary service output to stdout.
233pub 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/// No global rollback is promised. Error reports intentionally omit raw service
277/// errors/bodies and retain acknowledged progress for safe reconciliation.
278#[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            // Register an empty source scope if necessary. This is explicit apply,
327            // never a report/preview side effect and never imports private files.
328            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/// The combined consent binds imports plus the subsequent explicit alias plan.
359#[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    // Preflight every source and bound before the first dispatch. Individual
387    // applies re-read again. Uncooperative writers still require operator quiescence.
388    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
406/// Plugin mapping is per source: exporter alias -> explicit verified original
407/// host scope. Native host exports must target their own source scope exactly.
408pub 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/// Digest-gated schema-marker conversion of the configured existing brain, NOT
443/// an import or alias grant. Offline preview hashes the complete quiescent file;
444/// the service validates its pinned host schema and exact expected pin at apply.
445/// Stop/checkpoint writers first. File locks are advisory; the small host-check
446/// to service-dispatch gap cannot protect against uncooperative external writers.
447#[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    // A second independent read also catches path replacement between snapshots.
488    let current = preview_upgrade(binding, expected_project)?;
489    require_digest(digest, &current.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, &current.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}