Watch
1
0
Fork
You've already forked souveraine
0

memory sync: manual push/fetch against the shared memfs remote

Per-instance branch (instance/{machine-pubkey-prefix} until commission
labels exist) on the origin remote. Fetch + push only — never touches
the working tree; merge policy is the next step. Shells out to system
git so creds work like everywhere else. Loud without a remote;
provisioning stays explicit.
This commit is contained in:
Fimeg 2026-07-16 11:11:49 -04:00
commit a62379ad8a

View file

@ -93,6 +93,8 @@ pub enum MemoryCommand {
Compact { strategy: Option<String> },
/// Delete a memory file.
Delete { path: String },
/// Push/fetch against the shared memfs remote (per-instance branch).
Sync,
}
// ── Git-backed Memory Repository ───────────────────────────────────────────
@ -553,6 +555,88 @@ impl MemoryRepo {
head.shorthand().map(|s| s.to_string())
}
/// Sync this memfs against its shared remote: fetch everything, then
/// push HEAD to this instance's branch (`instance/{label}`).
///
/// This is the manual floor of federated memory transport
/// (FEDERATION.md): every instance writes its own branch on one bare
/// remote; reconciliation/merge is a separate, later step — sync never
/// touches the working tree. Shells out to system git so credentials
/// (ssh config, credential store) work the way they do everywhere else.
///
/// Loud on every failure. Requires an `origin` remote — provisioning is
/// deliberately explicit until the node commission ceremony exists.
pub async fn sync(&self, instance_label: &str) -> Result<String> {
if instance_label.is_empty()
|| instance_label.len() > 64
|| !instance_label
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(anyhow!("invalid instance label: {:?}", instance_label));
}
let repo = self.open_git()?;
if repo.find_remote("origin").is_err() {
return Err(anyhow!(
"no shared remote configured for this memfs. Provision one \
(bare repo on the Gitea) and run:\n git -C {} remote add origin <url>",
self.root.display()
));
}
drop(repo);
let fetch = self.git(&["fetch", "origin", "--prune"]).await?;
let branch = format!("instance/{}", instance_label);
let push_ref = format!("HEAD:refs/heads/{}", branch);
let push = self.git(&["push", "origin", &push_ref]).await?;
let instances = self
.git(&[
"for-each-ref",
"--format=%(refname:short) %(objectname:short)",
"refs/remotes/origin/instance/",
])
.await
.unwrap_or_default();
let head = self.head_commit_hex().unwrap_or_else(|| "(no head)".into());
let mut out = format!("Synced. This instance is {} @ {}\n", branch, &head[..12.min(head.len())]);
if !fetch.trim().is_empty() {
out.push_str(&format!("Fetched:\n{}\n", fetch.trim()));
}
if !push.trim().is_empty() {
out.push_str(&format!("Pushed:\n{}\n", push.trim()));
}
if !instances.trim().is_empty() {
out.push_str(&format!("Instances on the remote:\n{}\n", instances.trim()));
}
Ok(out)
}
/// Run a git subcommand against this repo, capturing combined output.
/// Errors carry git's stderr — loud, never swallowed.
async fn git(&self, args: &[&str]) -> Result<String> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(&self.root)
.args(args)
.output()
.await
.context("running git — memory sync needs the git binary on PATH")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(anyhow!(
"git {} failed ({}):\n{}",
args.first().unwrap_or(&"?"),
output.status,
stderr.trim()
));
}
Ok(format!("{}{}", stdout, stderr))
}
/// Get the root path of the memory repo.
pub fn root(&self) -> &Path {
&self.root
@ -822,6 +906,18 @@ pub async fn execute_memory_command_with_context(
}
Ok(format!("Deleted memory file: {}", path))
}
MemoryCommand::Sync => {
// The instance branch is named by machine identity — no
// identity, no sync. Interim label: machine pubkey prefix;
// commission-ceremony labels come later (FEDERATION.md flag).
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
let (pubkey, _source) =
crate::machined::client::machine_pubkey_with_fallback(&base).context(
"memory sync needs a machine identity to name this instance's branch",
)?;
let label: String = pubkey.chars().take(12).collect();
repo.sync(&label).await
}
}
}
@ -879,12 +975,13 @@ pub async fn handle_memory_tool_with_context(
let strategy = input.get("strategy").and_then(|v| v.as_str()).map(|s| s.to_string());
MemoryCommand::Compact { strategy }
}
"sync" => MemoryCommand::Sync,
_ => {
return ToolResult {
tool_use_id,
tool_name: tool_name.to_string(),
output: format!(
"Unknown memory subcommand: {}. Available: read, write, append, ls, status, init, delete, compact",
"Unknown memory subcommand: {}. Available: read, write, append, ls, status, init, delete, compact, sync",
command
),
is_error: true,
@ -935,6 +1032,7 @@ Subcommands:
init — Initialize a new memory repo for a given agent ID.
delete — Delete a memory file (validates read_only first).
compact — Compact the memory window to free context space.
sync — Push my instance branch to the shared memfs remote and fetch what my other instances wrote. My memory travels; this is how.
Paths are relative to my memory directory. Frontmatter description is required on create. Read-only files protect themselves. Every write is a git commit.".to_string(),
input_schema: serde_json::json!({
@ -942,7 +1040,7 @@ Paths are relative to my memory directory. Frontmatter description is required o
"properties": {
"command": {
"type": "string",
"enum": ["read", "write", "append", "ls", "status", "init", "delete", "compact"],
"enum": ["read", "write", "append", "ls", "status", "init", "delete", "compact", "sync"],
"description": "What to do with my memory"
},
"path": {
@ -1021,6 +1119,56 @@ mod tests {
assert_eq!(file.body, "Hello memory world");
}
#[tokio::test]
async fn sync_without_remote_errors_with_provisioning_hint() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
let err = repo.sync("abc123").await.unwrap_err().to_string();
assert!(err.contains("remote add origin"), "got: {err}");
}
#[tokio::test]
async fn sync_rejects_invalid_instance_labels() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
for bad in ["", "a/b", "a:b", "a b", &"x".repeat(65)] {
assert!(repo.sync(bad).await.is_err(), "label {bad:?} should be rejected");
}
}
#[tokio::test]
async fn sync_pushes_instance_branch_to_bare_remote() {
let dir = TempDir::new().unwrap();
let repo = MemoryRepo::new("test-agent", dir.path());
repo.init().await.unwrap();
repo.write("journal/entry", "first thought").await.unwrap();
let remote_dir = TempDir::new().unwrap();
let bare = remote_dir.path().join("memfs.git");
git2::Repository::init_bare(&bare).unwrap();
git2::Repository::open(repo.root())
.unwrap()
.remote("origin", bare.to_str().unwrap())
.unwrap();
let report = repo.sync("deadbeef0123").await.unwrap();
assert!(report.contains("instance/deadbeef0123"), "got: {report}");
// The bare remote must now hold this instance's branch at our head.
let remote_repo = git2::Repository::open_bare(&bare).unwrap();
let branch = remote_repo
.find_reference("refs/heads/instance/deadbeef0123")
.unwrap();
assert_eq!(
branch.target().unwrap().to_string(),
repo.head_commit_hex().unwrap()
);
}
#[tokio::test]
async fn test_memory_repo_read_only() {
let dir = TempDir::new().unwrap();