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:
parent
8cfa992da2
commit
a62379ad8a
1 changed files with 150 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue