From bab13cc0a19080c4ead96581fb9e1ff313627566 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Wed, 20 May 2026 13:26:50 +0200 Subject: [PATCH] Live-sync authorized file roots --- AGENTS.md | 7 ++- README.md | 3 + crates/geth-node/src/lib.rs | 113 +++++++++++++++++++++++++++++++++++- docs/architecture.md | 10 +++- docs/roadmap.md | 2 + 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b4eb47..03186df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,9 +141,10 @@ Roadmap items should be actionable and checkable: to the working tree. `geth cas root sync ` can pull authorized remote tree metadata and CAS tree bytes, recording a peer-qualified remote root with path `remote::` without applying files or overwriting - same-named local roots. Durable local file-conflict records can be listed and - resolved manually; automatic cross-node conflict detection and file application - are still roadmap work. + same-named local roots. Background live-sync refreshes authorized remote file + roots from sync-status `cas-tree:` watermarks. Durable local + file-conflict records can be listed and resolved manually; automatic + cross-node conflict detection and file application are still roadmap work. - DB resources can be registered locally and report local-only status plus a read-only SQLite schema summary/hash and `crsql_changes` metadata when present. The DB crate and daemon can extract typed read-only `crsql_changes` diff --git a/README.md b/README.md index b0dc236..0bfd448 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,9 @@ Before probing individual modules, the daemon asks the peer for an authorized sync-status summary over Iroh. The peer only returns stream watermarks for resources where the caller already has the matching capability, letting the local daemon skip unchanged or unauthorized streams. +File roots advertise `cas-tree:` watermarks when the caller has +`cas.fetch`; background live-sync imports updated tree metadata and CAS tree +bytes into peer-qualified remote roots without writing files. Named KV stores participate in the same live-sync loop once they exist locally: manual `geth kv sync ` and background ticks require `kv.read` on the remote `resource:kv:` and import only remote entries that are not diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 6607026..0e9ae6d 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -917,6 +917,7 @@ async fn cas_root_sync_from_peer( } let store = Store::open(&node.paths.metadata_db())?; let mut tree_bytes_imported = false; + let mut imported_high_water = None; let root = root .map(|remote_root| { let stored_name = remote_file_root_name(&node_id, &remote_root.name); @@ -960,9 +961,18 @@ async fn cas_root_sync_from_peer( updated_at_ms: remote_root.updated_at_ms, }; store.upsert_file_root(&stored)?; + imported_high_water = Some(remote_root.updated_at_ms); Ok(file_root_from_stored(&stored)) }) .transpose()?; + if let Some(high_water_ms) = imported_high_water { + store_live_sync_cursor( + &store, + peer_node, + &format!("cas-tree:{response_name}"), + high_water_ms, + )?; + } Ok(ControlResponse::CasRootSynced { peer_node_id: node_id, peer_agent_id: agent_id, @@ -1844,6 +1854,18 @@ fn sync_watermarks_for_peer( } } } + for root in store.list_file_roots()? { + if root.path.starts_with("remote:") { + continue; + } + let resource = format!("resource:cas-tree:{}", root.name); + if can_sync_resource(store, &peer, &resource, "cas.fetch")? { + watermarks.push(SyncWatermark { + stream: format!("cas-tree:{}", root.name), + high_water: root.updated_at_ms, + }); + } + } watermarks.sort_by(|left, right| left.stream.cmp(&right.stream)); Ok(watermarks) } @@ -2176,6 +2198,22 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> { tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed"); } } + if let Some(remote_watermarks) = remote_watermarks.as_ref() { + for stream in remote_watermarks + .keys() + .filter(|stream| stream.starts_with("cas-tree:")) + { + if should_live_sync_stream(&store, &peer.peer_id, stream, Some(remote_watermarks))? + { + let name = stream.trim_start_matches("cas-tree:"); + if let Err(error) = + cas_root_sync_from_peer(node, &peer.peer_id, name, None).await + { + tracing::debug!(peer = %peer.peer_id, root = %name, %error, "file-root live sync failed"); + } + } + } + } for kv in &kv_stores { let stream = format!("kv:{}", kv.name); if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())? @@ -5358,9 +5396,32 @@ mod tests { path: db_path.display().to_string(), }) .expect("insert db"); + store + .upsert_file_root(&StoredFileRoot { + root_id: "file-root:shared".to_owned(), + resource_id: "resource:cas-tree:shared".to_owned(), + name: "shared".to_owned(), + path: dir.path().join("shared").display().to_string(), + latest_tree_hash: Some("abc".to_owned()), + latest_tree_json: None, + updated_at_ms: 123, + }) + .expect("insert file root"); + store + .upsert_file_root(&StoredFileRoot { + root_id: "file-root:remote:node:right:shared".to_owned(), + resource_id: "resource:cas-tree:shared".to_owned(), + name: "remote-node-right-shared".to_owned(), + path: "remote:node:right:shared".to_owned(), + latest_tree_hash: Some("def".to_owned()), + latest_tree_json: None, + updated_at_ms: 456, + }) + .expect("insert imported remote file root"); grant_test_capability(&store, "node:left", "resource:kv:prefs", "kv.read"); grant_test_capability(&store, "node:left", "resource:db:notes", "db.sync"); + grant_test_capability(&store, "node:left", "resource:cas-tree:shared", "cas.fetch"); let watermarks = sync_watermarks_for_peer(&store, "node:left").expect("watermarks"); @@ -5372,6 +5433,15 @@ mod tests { stream: "db:notes".to_owned(), high_water: 7, })); + assert!(watermarks.contains(&SyncWatermark { + stream: "cas-tree:shared".to_owned(), + high_water: 123, + })); + assert!( + !watermarks + .iter() + .any(|watermark| watermark.stream == "cas-tree:remote-node-right-shared") + ); assert!( !watermarks .iter() @@ -5475,7 +5545,7 @@ mod tests { &right, ControlRequest::CasRootAdd { name: "shared".to_owned(), - path: right_file_root, + path: right_file_root.clone(), }, ) .expect("right file root add"); @@ -6588,6 +6658,19 @@ mod tests { }, ) .expect("right live document set"); + std::fs::write(right_file_root.join("live.txt"), b"live file root sync") + .expect("write live file"); + let second_tree_hash = match handle_request( + &right, + ControlRequest::CasRootScan { + name: "shared".to_owned(), + }, + ) + .expect("right live file root scan") + { + ControlResponse::CasRootScanned { scan } => scan.tree.hash, + other => panic!("unexpected right live file root scan response: {other:?}"), + }; insert_mock_crsqlite_change(&right_db_path, 8, "live"); run_live_sync_once(&left) @@ -6622,6 +6705,34 @@ mod tests { .map(|document| document.state_json), Some(r#"{"title":"live"}"#.to_owned()) ); + let live_remote_root = left_store + .list_file_roots() + .expect("list live-synced file roots") + .into_iter() + .find(|root| root.path == format!("remote:{}:shared", right.node_id)) + .expect("live-synced remote root"); + assert_eq!( + live_remote_root.latest_tree_hash.as_deref(), + Some(second_tree_hash.as_str()) + ); + assert!( + LocalCas::new(left.paths.cas_dir()) + .has(&second_tree_hash) + .expect("left has live-synced tree object") + ); + let file_root_cursor = left_store + .get_module_state(&live_sync_cursor_key( + right_card.node_id.as_str(), + "cas-tree:shared", + )) + .expect("get live-synced file root cursor") + .expect("file root cursor exists"); + assert_eq!( + serde_json::from_str::(&file_root_cursor.state_json) + .expect("parse file root cursor") + .cursor_ms, + live_remote_root.updated_at_ms + ); let db_cursor = left_store .get_module_state(&live_sync_cursor_key( right_card.node_id.as_str(), diff --git a/docs/architecture.md b/docs/architecture.md index fcbe601..3fcb32e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,8 +131,9 @@ scans are local metadata only and never overwrite the working tree. A peer can pull authorized remote file-root tree metadata with `geth cas root sync ` when it has `cas.fetch` on `resource:cas-tree:`; sync imports the remote CAS tree bytes and records a peer-qualified remote root whose path is -`remote::` without applying files. The daemon also has durable local -file-conflict records with +`remote::` without applying files. File roots also participate in +the daemon background live-sync loop through authorized `cas-tree:` +watermarks. The daemon also has durable local file-conflict records with explicit resolution choices; future cross-node file sync will create those records automatically instead of silently applying ambiguous remote changes. @@ -235,7 +236,10 @@ authorization testing: non-owner subjects must hold `ssh_cert.*` capabilities on `resource:ssh:revocations` before requests, approval/import/read operations, or revocation publish/read/import operations are accepted. The live-sync loop first asks for authorized stream watermarks and -skips module pulls whose remote high-water value has not advanced. +skips module pulls whose remote high-water value has not advanced. File-root +live-sync uses only sync-status-advertised `cas-tree:` streams, because +the local node otherwise does not know which roots the peer is willing to +expose. ## Keychain, Auth, And Secrets diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d043cf..eabd515 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -517,6 +517,8 @@ and future group key evolution. - `[x]` Sync imports CAS tree bytes and records a peer-qualified remote root with path `remote::` without writing files into the working tree or overwriting same-named local roots. + - `[x]` Background live-sync imports updated authorized file-root trees from + sync-status `cas-tree:` watermarks and stores per-peer cursors. - `[ ]` Future completion applies file-root sync safely with conflict detection and explicit resolution.