feat: Implement Iroh-based peer networking, join protocol, and bidirectional store synchronization.

This commit is contained in:
2025-12-22 21:11:34 +01:00
parent 7c8e5cfa3d
commit e942da49ff
22 changed files with 1933 additions and 57 deletions
+6
View File
@@ -11,6 +11,7 @@ path = "src/main.rs"
[dependencies]
lattice-core = { workspace = true }
lattice-net = { workspace = true }
rustyline = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
@@ -18,3 +19,8 @@ tokio = { workspace = true }
shlex = "1"
hostname = "0.4"
serde_json = "1"
iroh = { workspace = true }
prost = { workspace = true }
chrono = { workspace = true }
tokio-util = { version = "0.7", features = ["codec"] }
futures-util = "0.3"
+165
View File
@@ -0,0 +1,165 @@
//! Accept handler for incoming Iroh connections
use lattice_net::{MessageSink, MessageStream};
use crate::node::{StoreHandle, PeerStatus};
use iroh::Endpoint;
use iroh::endpoint::Connection;
use std::sync::Arc;
use tokio::sync::RwLock;
use lattice_core::proto::{PeerMessage, peer_message, JoinResponse};
/// Spawn the accept loop for incoming connections.
pub fn spawn_accept_loop(
endpoint: Endpoint,
shared_store: Arc<RwLock<Option<StoreHandle>>>,
) {
tokio::spawn(async move {
loop {
if let Some(incoming) = endpoint.accept().await {
match incoming.await {
Ok(conn) => {
let store = shared_store.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(conn, store).await {
eprintln!("[Accept] Error: {}", e);
}
});
}
Err(e) => eprintln!("[Accept] Handshake error: {:?}", e),
}
}
}
});
}
/// Handle a single incoming connection
async fn handle_connection(
conn: Connection,
shared_store: Arc<RwLock<Option<StoreHandle>>>,
) -> Result<(), String> {
let remote_id = conn.remote_id();
let remote_hex = hex::encode(remote_id.as_bytes());
println!("\n[Incoming] {} (ALPN: {})", remote_id.fmt_short(), String::from_utf8_lossy(conn.alpn()));
let store = {
let guard = shared_store.read().await;
match &*guard {
Some(s) => s.clone(),
None => return Err("No store available".to_string()),
}
};
let (send, recv) = conn.accept_bi().await
.map_err(|e| format!("Accept stream error: {}", e))?;
// Wrap in framed message streams
let mut sink = MessageSink::new(send);
let mut stream = MessageStream::new(recv);
// Read first message
let msg = stream.recv().await?
.ok_or_else(|| "Peer closed stream".to_string())?;
match msg.message {
Some(peer_message::Message::JoinRequest(req)) => {
// For join: verify peer is invited
verify_peer_status(&store, &remote_hex, PeerStatusCheck::Exactly(PeerStatus::Invited)).await?;
println!("[Peer] Verified as invited");
println!("[Join] Got JoinRequest from {}", hex::encode(&req.node_pubkey));
let resp = PeerMessage {
message: Some(peer_message::Message::JoinResponse(JoinResponse {
store_uuid: store.id().as_bytes().to_vec(),
inviter_pubkey: vec![],
})),
};
sink.send(&resp).await?;
sink.finish().await?;
// Set peer status to 'active' now that they've joined
let status_key = format!("/nodes/{}/status", remote_hex);
if let Err(e) = store.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await {
eprintln!("[Join] Warning: Failed to set peer status: {}", e);
}
println!("[Join] Sent JoinResponse, peer now active");
Ok(())
}
Some(peer_message::Message::SyncRequest(req)) => {
// For sync: verify peer is active (or invited for first sync after join)
verify_peer_status(&store, &remote_hex, PeerStatusCheck::ActiveOrInvited).await?;
println!("[Peer] Verified for sync");
handle_sync_request(sink, stream, req, &store).await
}
_ => Err("Unexpected message type".to_string()),
}
}
/// Expected peer status check mode
#[derive(Debug, Clone, Copy)]
enum PeerStatusCheck {
Exactly(PeerStatus),
ActiveOrInvited,
}
/// Verify a peer has the expected status
async fn verify_peer_status(store: &StoreHandle, remote_hex: &str, expected: PeerStatusCheck) -> Result<(), String> {
let status_key = format!("/nodes/{}/status", remote_hex);
let status = match store.get(status_key.as_bytes()).await {
Ok(Some(s)) => String::from_utf8_lossy(&s).to_string(),
Ok(None) => return Err(format!("Peer not found")),
Err(e) => return Err(format!("Error checking peer status: {}", e)),
};
let valid = match expected {
PeerStatusCheck::Exactly(ps) => status == ps.as_str(),
PeerStatusCheck::ActiveOrInvited => status == PeerStatus::Active.as_str() || status == PeerStatus::Invited.as_str(),
};
if valid {
Ok(())
} else {
Err(format!("Peer status is '{}', expected {:?}", status, expected))
}
}
/// Handle a sync request - bidirectional exchange of entries
async fn handle_sync_request(
mut sink: MessageSink,
mut stream: MessageStream,
peer_request: lattice_core::proto::SyncRequest,
store: &StoreHandle,
) -> Result<(), String> {
println!("[Sync] Received SyncRequest");
// Get our sync state
let my_state = store.sync_state().await
.map_err(|e| format!("Failed to get sync state: {}", e))?;
// 1. Send our sync state as response
let resp = PeerMessage {
message: Some(peer_message::Message::SyncResponse(lattice_core::proto::SyncResponse {
state: Some(my_state.to_proto()),
})),
};
sink.send(&resp).await?;
// 2. Send entries peer is missing
let peer_state = peer_request.state
.map(|s| lattice_core::sync_state::SyncState::from_proto(&s))
.unwrap_or_default();
let entries_sent = crate::sync_protocol::send_missing_entries(&mut sink, store, &my_state, &peer_state).await?;
println!("[Sync] Sent {} entries, now receiving from peer...", entries_sent);
// 3. Receive entries from requester (bidirectional)
let (entries_applied, _) = crate::sync_protocol::receive_entries(&mut stream, store).await?;
sink.finish().await?;
println!("[Sync] Applied {} entries from peer", entries_applied);
Ok(())
}
+378 -14
View File
@@ -1,7 +1,9 @@
//! CLI command handlers
use crate::node::{LatticeNode, StoreHandle};
use crate::node::{LatticeNode, StoreHandle, PeerStatus};
use lattice_core::Uuid;
use lattice_net::LatticeEndpoint;
use chrono::DateTime;
use std::time::Instant;
/// Result of a command that may switch stores
@@ -19,7 +21,7 @@ fn block_async<F: std::future::Future>(f: F) -> F::Output {
})
}
pub type Handler = fn(&LatticeNode, Option<&StoreHandle>, &[String]) -> CommandResult;
pub type Handler = fn(&LatticeNode, Option<&StoreHandle>, Option<&LatticeEndpoint>, &[String]) -> CommandResult;
pub struct Command {
pub name: &'static str,
@@ -112,6 +114,46 @@ pub fn commands() -> Vec<Command> {
max_args: 1,
handler: cmd_author_state,
},
Command {
name: "invite",
args: "<pubkey-hex>",
description: "Invite a peer node (writes to root store)",
min_args: 1,
max_args: 1,
handler: cmd_invite,
},
Command {
name: "peers",
args: "",
description: "List known peers from root store",
min_args: 0,
max_args: 0,
handler: cmd_peers,
},
Command {
name: "remove",
args: "<pubkey-hex>",
description: "Remove a peer (set status to removed)",
min_args: 1,
max_args: 1,
handler: cmd_remove,
},
Command {
name: "join",
args: "<nodeid-hex>",
description: "Join a mesh by connecting to a peer (requires no local store)",
min_args: 1,
max_args: 1,
handler: cmd_join,
},
Command {
name: "sync",
args: "[nodeid]",
description: "Sync entries with a peer (or all peers if none specified)",
min_args: 0,
max_args: 1,
handler: cmd_sync,
},
Command {
name: "help",
args: "",
@@ -125,7 +167,7 @@ pub fn commands() -> Vec<Command> {
// --- Store management ---
fn cmd_init(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
fn cmd_init(node: &LatticeNode, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
match block_async(node.init()) {
Ok((store_id, handle)) => {
println!("Initialized with root store: {}", store_id);
@@ -139,7 +181,7 @@ fn cmd_init(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String])
}
}
fn cmd_create_store(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
fn cmd_create_store(node: &LatticeNode, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
match node.create_store() {
Ok(store_id) => {
println!("Created store: {}", store_id);
@@ -161,7 +203,7 @@ fn cmd_create_store(node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[S
}
}
fn cmd_use_store(node: &LatticeNode, _store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
fn cmd_use_store(node: &LatticeNode, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store_id = match Uuid::parse_str(&args[0]) {
Ok(id) => id,
Err(_) => {
@@ -187,7 +229,7 @@ fn cmd_use_store(node: &LatticeNode, _store: Option<&StoreHandle>, args: &[Strin
}
}
fn cmd_list_stores(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
fn cmd_list_stores(node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let stores = match node.list_stores() {
Ok(s) => s,
Err(e) => {
@@ -210,7 +252,7 @@ fn cmd_list_stores(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[Str
// --- Info ---
fn cmd_help(_node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
fn cmd_help(_node: &LatticeNode, _store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
println!("\nCommands:");
for cmd in commands() {
if cmd.args.is_empty() {
@@ -224,7 +266,7 @@ fn cmd_help(_node: &LatticeNode, _store: Option<&StoreHandle>, _args: &[String])
CommandResult::Ok
}
fn cmd_status(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String]) -> CommandResult {
fn cmd_status(node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
println!("Node ID: {}", hex::encode(node.node_id()));
println!("Data: {}", node.data_path().display());
match node.root_store() {
@@ -236,6 +278,40 @@ fn cmd_status(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String])
println!("Store: {}", h.id());
println!("Log Seq: {}", block_async(h.log_seq()));
println!("Applied: {}", block_async(h.applied_seq()).unwrap_or(0));
// Show sync state summary
if let Ok(sync_state) = block_async(h.sync_state()) {
let authors = sync_state.authors();
let total_entries: u64 = authors.values().map(|a| a.seq).sum();
let num_authors = authors.len();
println!("Authors: {} ({} total entries)", num_authors, total_entries);
// Show per-author details
for (author, info) in authors {
println!(" {}...: seq={}, heads={}",
hex::encode(&author[..6]),
info.seq,
info.heads.len());
}
}
// Show log directory size
let logs_dir = node.data_path().join("stores").join(h.id().to_string()).join("logs");
if logs_dir.exists() {
let mut total_size = 0u64;
let mut file_count = 0;
if let Ok(entries) = std::fs::read_dir(&logs_dir) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
total_size += meta.len();
file_count += 1;
}
}
}
}
println!("Logs: {} files, {} bytes", file_count, total_size);
}
} else {
println!("Store: (none)");
}
@@ -244,7 +320,7 @@ fn cmd_status(node: &LatticeNode, store: Option<&StoreHandle>, _args: &[String])
// --- KV ---
fn cmd_put(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
fn cmd_put(_node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
@@ -257,7 +333,7 @@ fn cmd_put(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) ->
CommandResult::Ok
}
fn cmd_get(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
fn cmd_get(_node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
@@ -308,7 +384,7 @@ fn cmd_get(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) ->
CommandResult::Ok
}
fn cmd_delete(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
fn cmd_delete(_node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
@@ -321,7 +397,7 @@ fn cmd_delete(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String])
CommandResult::Ok
}
fn cmd_list(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
fn cmd_list(_node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let Some(h) = store else {
println!("No store selected. Use 'init' or 'use <uuid>'");
return CommandResult::Ok;
@@ -351,7 +427,13 @@ fn cmd_list(_node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -
}
}
} else {
println!("{} = {}", key_str, format_value(v));
// Check for multiple heads
let heads = block_async(h.get_heads(k)).unwrap_or_default();
if heads.len() > 1 {
println!("{} = {} (⚠ {} heads)", key_str, format_value(v), heads.len());
} else {
println!("{} = {}", key_str, format_value(v));
}
}
}
println!("({} keys, {:.2?})", entries.len(), start.elapsed());
@@ -366,7 +448,7 @@ fn format_value(v: &[u8]) -> String {
std::str::from_utf8(v).map(String::from).unwrap_or_else(|_| format!("0x{}", hex::encode(v)))
}
fn cmd_author_state(node: &LatticeNode, store: Option<&StoreHandle>, args: &[String]) -> CommandResult {
fn cmd_author_state(node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
@@ -407,3 +489,285 @@ fn cmd_author_state(node: &LatticeNode, store: Option<&StoreHandle>, args: &[Str
}
CommandResult::Ok
}
// --- Peer management ---
fn cmd_invite(node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Not in a store. Run 'use' or 'init' first.");
return CommandResult::Ok;
}
};
let pubkey_hex = &args[0];
let _peer_pubkey = match hex::decode(pubkey_hex) {
Ok(bytes) if bytes.len() == 32 => bytes,
_ => {
eprintln!("Invalid pubkey: expected 64 hex chars (32 bytes)");
return CommandResult::Ok;
}
};
// Write /nodes/{pubkey}/info with inviter info
let info_key = format!("/nodes/{}/info", pubkey_hex);
let inviter_hex = hex::encode(node.node_id());
let added_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let info = serde_json::json!({
"added_by": inviter_hex,
"added_at": added_at
});
match block_async(store.put(info_key.as_bytes(), info.to_string().as_bytes())) {
Ok(_) => {}
Err(e) => {
eprintln!("Error writing info: {}", e);
return CommandResult::Ok;
}
}
// Write /nodes/{pubkey}/status = invited (becomes active after sync)
let status_key = format!("/nodes/{}/status", pubkey_hex);
match block_async(store.put(status_key.as_bytes(), PeerStatus::Invited.as_str().as_bytes())) {
Ok(_) => {}
Err(e) => {
eprintln!("Error writing status: {}", e);
return CommandResult::Ok;
}
}
println!("Invited peer: {}", pubkey_hex);
println!(" /nodes/{}/info", pubkey_hex);
println!(" /nodes/{}/status = {} (will become active after sync)", pubkey_hex, PeerStatus::Invited.as_str());
CommandResult::Ok
}
fn cmd_peers(_node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, _args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Not in a store. Run 'use' or 'init' first.");
return CommandResult::Ok;
}
};
// List all keys under /nodes/
let all = match block_async(store.list()) {
Ok(v) => v,
Err(e) => {
eprintln!("Error listing: {}", e);
return CommandResult::Ok;
}
};
// Collect unique pubkeys with status
let mut peers: std::collections::HashMap<String, PeerStatus> = std::collections::HashMap::new();
for (key, value) in &all {
let key_str = String::from_utf8_lossy(key);
if key_str.ends_with("/status") {
if let Some(pubkey) = key_str.strip_prefix("/nodes/").and_then(|s| s.strip_suffix("/status")) {
let status_str = String::from_utf8_lossy(value);
if let Some(status) = PeerStatus::from_str(&status_str) {
peers.insert(pubkey.to_string(), status);
}
}
}
}
if peers.is_empty() {
println!("No peers found.");
} else {
// Group peers by status
let mut by_status: std::collections::HashMap<PeerStatus, Vec<(String, String, String)>> =
std::collections::HashMap::new();
for (pubkey, status) in &peers {
// Try to get info for name/added_at
let info_key = format!("/nodes/{}/info", pubkey);
let mut name = String::new();
let mut added = String::new();
if let Ok(Some(info_bytes)) = block_async(store.get(info_key.as_bytes())) {
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&info_bytes) {
if let Some(n) = info.get("name").and_then(|v| v.as_str()) {
name = n.to_string();
}
if let Some(ts) = info.get("added_at").and_then(|v| v.as_u64()) {
if let Some(dt) = DateTime::from_timestamp(ts as i64, 0) {
added = dt.format("%Y-%m-%d").to_string();
}
}
}
}
by_status.entry(*status)
.or_default()
.push((pubkey.clone(), name, added));
}
// Print grouped by status in order: active, invited, removed
let status_order = [PeerStatus::Active, PeerStatus::Invited, PeerStatus::Removed];
for status in &status_order {
if let Some(peer_list) = by_status.get(status) {
println!("\n[{}] ({}):", status.as_str(), peer_list.len());
let mut sorted = peer_list.clone();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (pubkey, name, added) in &sorted {
let info_str = match (name.is_empty(), added.is_empty()) {
(false, false) => format!(" {} ({})", name, added),
(false, true) => format!(" {}", name),
(true, false) => format!(" ({})", added),
_ => String::new(),
};
println!(" {}{}", pubkey, info_str);
}
}
}
}
CommandResult::Ok
}
fn cmd_remove(node: &LatticeNode, store: Option<&StoreHandle>, _endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let store = match store {
Some(s) => s,
None => {
eprintln!("Not in a store. Run 'use' or 'init' first.");
return CommandResult::Ok;
}
};
let pubkey_hex = &args[0];
// Validate pubkey format (should be 64 hex chars)
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(|c| c.is_ascii_hexdigit()) {
eprintln!("Invalid pubkey: expected 64 hex characters");
return CommandResult::Ok;
}
// Prevent self-removal
let my_pubkey = hex::encode(node.node_id());
if pubkey_hex == &my_pubkey {
eprintln!("Cannot remove yourself.");
return CommandResult::Ok;
}
// Check if peer exists
let status_key = format!("/nodes/{}/status", pubkey_hex);
match block_async(store.get(status_key.as_bytes())) {
Ok(Some(status)) => {
if status == PeerStatus::Removed.as_str().as_bytes() {
println!("Peer {} is already removed.", &pubkey_hex[..10]);
return CommandResult::Ok;
}
}
Ok(None) => {
eprintln!("Peer {} not found.", &pubkey_hex[..10]);
return CommandResult::Ok;
}
Err(e) => {
eprintln!("Error checking peer: {}", e);
return CommandResult::Ok;
}
}
// Set status to removed
match block_async(store.put(status_key.as_bytes(), PeerStatus::Removed.as_str().as_bytes())) {
Ok(_) => println!("Removed peer: {}...", &pubkey_hex[..10]),
Err(e) => eprintln!("Error removing peer: {}", e),
}
CommandResult::Ok
}
fn cmd_join(node: &LatticeNode, store: Option<&StoreHandle>, endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let endpoint = match endpoint {
Some(ep) => ep,
None => {
eprintln!("Iroh endpoint not started.");
return CommandResult::Ok;
}
};
if store.is_some() {
eprintln!("Already initialized. Use 'sync' to sync with peers.");
return CommandResult::Ok;
}
let peer_id = match lattice_net::parse_node_id(&args[0]) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid node ID: {}", e);
return CommandResult::Ok;
}
};
println!("Joining mesh via {}...", peer_id.fmt_short());
match block_async(crate::sync::join_mesh(node, endpoint, peer_id)) {
Ok(handle) => {
println!("Joined mesh! Use 'sync' command to sync entries.");
CommandResult::SwitchTo(handle)
}
Err(e) => {
eprintln!("Join failed: {}", e);
CommandResult::Ok
}
}
}
fn cmd_sync(node: &LatticeNode, store: Option<&StoreHandle>, endpoint: Option<&LatticeEndpoint>, args: &[String]) -> CommandResult {
let endpoint = match endpoint {
Some(ep) => ep,
None => {
eprintln!("Iroh endpoint not started.");
return CommandResult::Ok;
}
};
let store = match store {
Some(s) => s,
None => {
eprintln!("No store open. Use 'init' or 'join' first.");
return CommandResult::Ok;
}
};
if args.is_empty() {
// Sync with all active peers
match block_async(crate::sync::sync_all(node, endpoint, store)) {
Ok(results) => {
if results.is_empty() {
println!("No peers to sync with.");
} else {
let total: u64 = results.iter().map(|r| r.entries_applied).sum();
println!("\nSync complete! Applied {} entries from {} peer(s).", total, results.len());
}
}
Err(e) => eprintln!("Sync failed: {}", e),
}
} else {
// Sync with specific peer
let peer_id = match lattice_net::parse_node_id(&args[0]) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid node ID: {}", e);
return CommandResult::Ok;
}
};
println!("Syncing with {}...", peer_id.fmt_short());
match block_async(crate::sync::sync_with_peer(node, endpoint, store, peer_id)) {
Ok(result) => {
println!("Sync complete! Applied {} entries (peer sent {})",
result.entries_applied, result.entries_sent_by_peer);
}
Err(e) => eprintln!("Sync failed: {}", e),
}
}
CommandResult::Ok
}
+34 -2
View File
@@ -1,13 +1,19 @@
//! Lattice Interactive CLI
mod accept_handler;
mod node;
mod commands;
mod store_actor;
mod sync_protocol;
mod sync;
use accept_handler::spawn_accept_loop;
use commands::CommandResult;
use node::{LatticeNodeBuilder, StoreHandle};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::sync::Arc;
use tokio::sync::RwLock;
#[tokio::main]
async fn main() {
@@ -22,6 +28,26 @@ async fn main() {
}
};
// Start Iroh endpoint using same Ed25519 identity
let endpoint = match lattice_net::LatticeEndpoint::new(node.secret_key_bytes()).await {
Ok(ep) => {
println!("Iroh: {} (listening)", ep.public_key().fmt_short());
Some(ep)
}
Err(e) => {
eprintln!("Warning: Iroh failed to start: {}", e);
None
}
};
// Shared store handle for accept loop (updated when store is opened/changed)
let shared_store: Arc<RwLock<Option<StoreHandle>>> = Arc::new(RwLock::new(None));
// Spawn accept loop for incoming connections
if let Some(ref ep) = endpoint {
spawn_accept_loop(ep.endpoint().clone(), shared_store.clone());
}
let info = node.info();
println!("Node ID: {}", info.node_id);
println!("Data: {}", info.data_path);
@@ -37,6 +63,8 @@ async fn main() {
} else {
println!("Root: {}", open_info.store_id);
}
// Update shared store for accept loop
*shared_store.write().await = Some(h.clone());
Some(h)
}
Ok(None) => {
@@ -86,9 +114,13 @@ async fn main() {
if cmd_args.len() < cmd.min_args || cmd_args.len() > cmd.max_args {
println!("Usage: {} {}", cmd.name, cmd.args);
} else {
match (cmd.handler)(&node, current_store.as_ref(), cmd_args) {
match (cmd.handler)(&node, current_store.as_ref(), endpoint.as_ref(), cmd_args) {
CommandResult::Ok => {}
CommandResult::SwitchTo(h) => current_store = Some(h),
CommandResult::SwitchTo(h) => {
// Update shared store for accept loop
*shared_store.write().await = Some(h.clone());
current_store = Some(h);
}
}
}
}
+94 -4
View File
@@ -41,6 +41,36 @@ pub enum NodeError {
Actor(String),
}
/// Peer status values used across the system
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PeerStatus {
/// Peer has been invited but hasn't joined yet
Invited,
/// Peer is active and can sync
Active,
/// Peer has been removed from the mesh
Removed,
}
impl PeerStatus {
pub fn as_str(&self) -> &'static str {
match self {
PeerStatus::Invited => "invited",
PeerStatus::Active => "active",
PeerStatus::Removed => "removed",
}
}
pub fn from_str(s: &str) -> Option<PeerStatus> {
match s {
"invited" => Some(PeerStatus::Invited),
"active" => Some(PeerStatus::Active),
"removed" => Some(PeerStatus::Removed),
_ => None,
}
}
}
pub struct NodeInfo {
pub node_id: String,
pub data_path: String,
@@ -107,6 +137,11 @@ impl LatticeNode {
self.node.public_key_bytes()
}
/// Get the secret key bytes for Iroh integration (same Ed25519 key)
pub fn secret_key_bytes(&self) -> [u8; 32] {
self.node.secret_key_bytes()
}
pub fn data_path(&self) -> &Path {
self.data_dir.base()
}
@@ -153,7 +188,7 @@ impl LatticeNode {
// Write status = active
let status_key = format!("/nodes/{}/status", pubkey_hex);
handle.put(status_key.as_bytes(), b"active").await?;
handle.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await?;
Ok((store_id, handle))
}
@@ -164,6 +199,21 @@ impl LatticeNode {
pub fn create_store(&self) -> Result<Uuid, NodeError> {
let store_id = Uuid::new_v4();
self.create_store_internal(store_id)
}
/// Create a store with a specific UUID (for joining existing mesh)
pub fn create_store_with_uuid(&self, store_id: Uuid) -> Result<Uuid, NodeError> {
self.create_store_internal(store_id)
}
/// Set a store as the root store
pub fn set_root_store(&self, store_id: Uuid) -> Result<(), NodeError> {
self.meta.set_root_store(store_id)?;
Ok(())
}
fn create_store_internal(&self, store_id: Uuid) -> Result<Uuid, NodeError> {
self.data_dir.ensure_store_dirs(store_id)?;
let _ = Store::open(self.data_dir.store_state_db(store_id))?;
self.meta.add_store(store_id)?;
@@ -216,6 +266,16 @@ pub struct StoreHandle {
actor_handle: Option<std::thread::JoinHandle<()>>,
}
impl Clone for StoreHandle {
fn clone(&self) -> Self {
Self {
store_id: self.store_id,
tx: self.tx.clone(),
actor_handle: None, // Clones don't own the actor thread
}
}
}
impl StoreHandle {
pub fn id(&self) -> Uuid { self.store_id }
@@ -276,6 +336,36 @@ impl StoreHandle {
.map_err(NodeError::Store)
}
pub async fn sync_state(&self) -> Result<lattice_core::sync_state::SyncState, NodeError> {
use crate::store_actor::StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::SyncState { resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn read_entries_after(&self, author: &[u8; 32], from_hash: Option<[u8; 32]>) -> Result<Vec<lattice_core::proto::SignedEntry>, NodeError> {
use crate::store_actor::StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::ReadEntriesAfter { author: *author, from_hash, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn apply_entry(&self, entry: lattice_core::proto::SignedEntry) -> Result<(), NodeError> {
use crate::store_actor::StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
self.tx.send(StoreCmd::ApplyEntry { entry, resp: resp_tx }).await
.map_err(|_| NodeError::ChannelClosed)?;
resp_rx.await
.map_err(|_| NodeError::ChannelClosed)?
.map_err(NodeError::Store)
}
pub async fn put(&self, key: &[u8], value: &[u8]) -> Result<u64, NodeError> {
use crate::store_actor::StoreCmd;
let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
@@ -300,12 +390,12 @@ impl StoreHandle {
impl Drop for StoreHandle {
fn drop(&mut self) {
// Send shutdown command (non-blocking) and wait for actor to finish
// Use try_send to avoid panic in async context
let _ = self.tx.try_send(crate::store_actor::StoreCmd::Shutdown);
// Only send shutdown if we own the actor (non-cloned handle)
if let Some(handle) = self.actor_handle.take() {
let _ = self.tx.try_send(crate::store_actor::StoreCmd::Shutdown);
let _ = handle.join();
}
// Clones (actor_handle = None) don't send shutdown - actor keeps running
}
}
+82 -10
View File
@@ -1,7 +1,7 @@
//! Store Actor - dedicated thread that owns Store and processes commands via channel
use lattice_core::{
EntryBuilder, HeadInfo, Node, SigChain, Store, Uuid,
EntryBuilder, HeadInfo, Node, SigChain, SigChainManager, Store, Uuid,
hlc::HLC,
proto::AuthorState,
sigchain::SigChainError,
@@ -42,6 +42,19 @@ pub enum StoreCmd {
author: [u8; 32],
resp: oneshot::Sender<Result<Option<AuthorState>, StoreError>>,
},
// Sync-related commands
SyncState {
resp: oneshot::Sender<Result<lattice_core::sync_state::SyncState, StoreError>>,
},
ReadEntriesAfter {
author: [u8; 32],
from_hash: Option<[u8; 32]>,
resp: oneshot::Sender<Result<Vec<lattice_core::proto::SignedEntry>, StoreError>>,
},
ApplyEntry {
entry: lattice_core::proto::SignedEntry,
resp: oneshot::Sender<Result<(), StoreError>>,
},
Shutdown,
}
@@ -74,11 +87,11 @@ impl std::fmt::Display for StoreActorError {
impl std::error::Error for StoreActorError {}
/// The store actor - runs in its own thread, owns Store and SigChain
/// The store actor - runs in its own thread, owns Store and SigChainManager
pub struct StoreActor {
store_id: Uuid,
store: Store,
sigchain: SigChain,
chain_manager: SigChainManager, // Manages all authors' sigchains
node: Node,
rx: mpsc::Receiver<StoreCmd>,
}
@@ -92,10 +105,21 @@ impl StoreActor {
node: Node,
rx: mpsc::Receiver<StoreCmd>,
) -> Self {
// Derive logs_dir from sigchain's log file path
let logs_dir = sigchain.log_path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
// Create chain manager and register the local node's sigchain
let mut chain_manager = SigChainManager::new(&logs_dir, *store_id.as_bytes());
let local_author = node.public_key_bytes();
chain_manager.get_or_create(local_author); // Pre-initialize local chain
Self {
store_id,
store,
sigchain,
chain_manager,
node,
rx,
}
@@ -124,7 +148,11 @@ impl StoreActor {
let _ = resp.send(result);
}
StoreCmd::LogSeq { resp } => {
let _ = resp.send(self.sigchain.len());
let local_author = self.node.public_key_bytes();
let len = self.chain_manager.get(&local_author)
.map(|c| c.len())
.unwrap_or(0);
let _ = resp.send(len);
}
StoreCmd::AppliedSeq { resp } => {
let author = self.node.public_key_bytes();
@@ -135,6 +163,25 @@ impl StoreActor {
StoreCmd::AuthorState { author, resp } => {
let _ = resp.send(self.store.author_state(&author));
}
StoreCmd::SyncState { resp } => {
let _ = resp.send(self.store.sync_state());
}
StoreCmd::ReadEntriesAfter { author, from_hash, resp } => {
// Read entries from the log file for this author
let result = self.do_read_entries_after(&author, from_hash);
let _ = resp.send(result);
}
StoreCmd::ApplyEntry { entry, resp } => {
// Use SigChainManager to append to the correct author's log
if let Err(e) = self.chain_manager.append_entry(&entry) {
let _ = resp.send(Err(StoreError::from(e)));
continue;
}
// Then apply to store
let result = self.store.apply_entry(&entry);
let _ = resp.send(result);
}
StoreCmd::Shutdown => {
break;
}
@@ -147,7 +194,8 @@ impl StoreActor {
// Idempotency check (pure function)
if !Store::needs_put(&heads, value) {
return Ok(self.sigchain.len()); // Idempotent, no new entry
let local_author = self.node.public_key_bytes();
return Ok(self.chain_manager.get(&local_author).map(|c| c.len()).unwrap_or(0));
}
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
@@ -159,7 +207,8 @@ impl StoreActor {
// Idempotency check (pure function)
if !Store::needs_delete(&heads) {
return Ok(self.sigchain.len()); // Idempotent, no new entry
let local_author = self.node.public_key_bytes();
return Ok(self.chain_manager.get(&local_author).map(|c| c.len()).unwrap_or(0));
}
let parent_hashes: Vec<Vec<u8>> = heads.iter().map(|h| h.hash.clone()).collect();
@@ -170,8 +219,11 @@ impl StoreActor {
where
F: FnOnce(EntryBuilder) -> EntryBuilder,
{
let seq = self.sigchain.len() + 1;
let prev_hash = self.sigchain.last_hash();
let local_author = self.node.public_key_bytes();
let sigchain = self.chain_manager.get_or_create(local_author);
let seq = sigchain.len() + 1;
let prev_hash = *sigchain.last_hash();
let builder = EntryBuilder::new(seq, HLC::now())
.store_id(self.store_id.as_bytes().to_vec())
@@ -179,11 +231,31 @@ impl StoreActor {
.parent_hashes(parent_hashes);
let entry = build(builder).sign(&self.node);
self.sigchain.append(&entry)?;
// Append to local sigchain
let sigchain = self.chain_manager.get_or_create(local_author);
sigchain.append(&entry)?;
self.store.apply_entry(&entry)?;
Ok(seq)
}
fn do_read_entries_after(
&self,
author: &[u8; 32],
from_hash: Option<[u8; 32]>,
) -> Result<Vec<lattice_core::proto::SignedEntry>, StoreError> {
// Build log path for this author
let author_hex = hex::encode(author);
let log_path = self.chain_manager.logs_dir().join(format!("{}.log", author_hex));
if !log_path.exists() {
return Ok(Vec::new()); // No log file for this author
}
// Use lattice_core's read_entries_after
lattice_core::log::read_entries_after(&log_path, from_hash)
.map_err(StoreError::from)
}
}
/// Spawn a store actor in a new thread, returns (sender, join_handle)
+217
View File
@@ -0,0 +1,217 @@
//! Sync networking operations for LatticeNode
//!
//! Provides async methods for joining meshes and syncing with peers.
use lattice_net::{MessageSink, MessageStream};
use crate::node::{LatticeNode, NodeError, StoreHandle, PeerStatus};
use lattice_core::proto::{peer_message, PeerMessage, JoinRequest, SignedEntry};
use lattice_net::LatticeEndpoint;
use prost::Message;
/// Result of a sync operation with a peer
pub struct SyncResult {
pub entries_applied: u64,
pub entries_sent_by_peer: u64,
}
/// Join an existing mesh by connecting to a peer.
/// Returns the new StoreHandle on success.
/// After joining, automatically syncs with the peer to get initial data.
pub async fn join_mesh(
node: &LatticeNode,
endpoint: &LatticeEndpoint,
peer_id: iroh::PublicKey,
) -> Result<StoreHandle, NodeError> {
// Connect to peer
let conn = endpoint.connect(peer_id).await
.map_err(|e| NodeError::Actor(format!("Connection failed: {}", e)))?;
// Open stream
let (send, recv) = conn.open_bi().await
.map_err(|e| NodeError::Actor(format!("Failed to open stream: {}", e)))?;
let mut sink = MessageSink::new(send);
let mut stream = MessageStream::new(recv);
// Send JoinRequest
let req = PeerMessage {
message: Some(peer_message::Message::JoinRequest(JoinRequest {
node_pubkey: node.node_id().to_vec(),
})),
};
sink.send(&req).await
.map_err(|e| NodeError::Actor(format!("Failed to send: {}", e)))?;
sink.finish().await
.map_err(|e| NodeError::Actor(format!("Failed to finish: {}", e)))?;
// Receive JoinResponse
let msg = stream.recv().await
.map_err(|e| NodeError::Actor(format!("Recv error: {}", e)))?
.ok_or_else(|| NodeError::Actor("Peer closed stream".to_string()))?;
match msg.message {
Some(peer_message::Message::JoinResponse(resp)) => {
let store_uuid = lattice_core::Uuid::from_slice(&resp.store_uuid)
.map_err(|_| NodeError::Actor("Invalid UUID from peer".to_string()))?;
// Create local store with that UUID
node.create_store_with_uuid(store_uuid)?;
node.set_root_store(store_uuid)?;
let (handle, _) = node.open_store(store_uuid)?;
// Immediately sync with the peer to get initial data
println!("[Join] Syncing with peer to get initial data...");
match sync_with_peer(node, endpoint, &handle, peer_id).await {
Ok(result) => {
println!("[Join] Initial sync complete: applied {} entries", result.entries_applied);
}
Err(e) => {
eprintln!("[Join] Warning: Initial sync failed: {}", e);
// Don't fail join, just warn - peer might not have data yet
}
}
Ok(handle)
}
_ => Err(NodeError::Actor("Unexpected response message type".to_string())),
}
}
/// Sync with a specific peer (bidirectional).
/// Both sides exchange states and send missing entries to each other.
pub async fn sync_with_peer(
node: &LatticeNode,
endpoint: &LatticeEndpoint,
store: &StoreHandle,
peer_id: iroh::PublicKey,
) -> Result<SyncResult, NodeError> {
// Connect
let conn = endpoint.connect(peer_id).await
.map_err(|e| NodeError::Actor(format!("Connection failed: {}", e)))?;
// Open stream
let (send, recv) = conn.open_bi().await
.map_err(|e| NodeError::Actor(format!("Failed to open stream: {}", e)))?;
let mut sink = MessageSink::new(send);
let mut stream = MessageStream::new(recv);
// Get our sync state
let my_state = store.sync_state().await?;
// 1. Send SyncRequest with our state (don't finish yet - we'll send entries later)
let req = PeerMessage {
message: Some(peer_message::Message::SyncRequest(lattice_core::proto::SyncRequest {
state: Some(my_state.to_proto()),
full_sync: false,
})),
};
sink.send(&req).await
.map_err(|e| NodeError::Actor(format!("Failed to send: {}", e)))?;
// 2. Receive SyncResponse (peer's state) and entries until SyncDone
let mut entries_applied = 0u64;
let mut entries_sent_by_peer = 0u64;
let mut peer_state = lattice_core::sync_state::SyncState::default();
loop {
match stream.recv().await {
Ok(Some(msg)) => match msg.message {
Some(peer_message::Message::SyncResponse(resp)) => {
// Peer's sync state - we'll use this to compute what to send
if let Some(s) = resp.state {
peer_state = lattice_core::sync_state::SyncState::from_proto(&s);
}
}
Some(peer_message::Message::SyncEntry(entry)) => {
if let Ok(signed) = SignedEntry::decode(&entry.signed_entry[..]) {
if store.apply_entry(signed).await.is_ok() {
entries_applied += 1;
}
}
}
Some(peer_message::Message::SyncDone(done)) => {
entries_sent_by_peer = done.entries_sent;
break;
}
_ => {}
}
Ok(None) => break,
Err(_) => break,
}
}
// 3. Send entries peer is missing (using shared protocol)
let entries_sent = crate::sync_protocol::send_missing_entries(&mut sink, store, &my_state, &peer_state).await
.map_err(|e| NodeError::Actor(e))?;
sink.finish().await
.map_err(|e| NodeError::Actor(format!("Failed to finish: {}", e)))?;
// Update own node info if we applied entries
if entries_applied > 0 {
let pubkey_hex = hex::encode(node.node_id());
let info_key = format!("/nodes/{}/info", pubkey_hex);
let info_val = serde_json::json!({
"name": hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_default(),
"added_at": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}).to_string();
let _ = store.put(info_key.as_bytes(), info_val.as_bytes()).await;
// Set own status to 'active' (we're now a fully synced peer)
let status_key = format!("/nodes/{}/status", pubkey_hex);
let _ = store.put(status_key.as_bytes(), PeerStatus::Active.as_str().as_bytes()).await;
}
println!("[Sync] Applied {} entries, sent {} entries", entries_applied, entries_sent);
Ok(SyncResult {
entries_applied,
entries_sent_by_peer,
})
}
/// Sync with all active peers from the store.
pub async fn sync_all(
node: &LatticeNode,
endpoint: &LatticeEndpoint,
store: &StoreHandle,
) -> Result<Vec<SyncResult>, NodeError> {
let my_pubkey = hex::encode(node.node_id());
// Get all active peers (invited peers haven't joined yet)
let all_entries = store.list().await?;
let mut peer_ids = Vec::new();
for (key, value) in &all_entries {
let key_str = String::from_utf8_lossy(key);
if key_str.ends_with("/status") && value == PeerStatus::Active.as_str().as_bytes() {
if let Some(pubkey) = key_str.strip_prefix("/nodes/").and_then(|s| s.strip_suffix("/status")) {
if pubkey != my_pubkey {
if let Ok(id) = lattice_net::parse_node_id(pubkey) {
peer_ids.push(id);
}
}
}
}
}
// Sync with each peer
let mut results = Vec::new();
for peer_id in peer_ids {
match sync_with_peer(node, endpoint, store, peer_id).await {
Ok(result) => results.push(result),
Err(e) => {
// Log error but continue with other peers
eprintln!("Sync with {} failed: {}", peer_id.fmt_short(), e);
}
}
}
Ok(results)
}
+89
View File
@@ -0,0 +1,89 @@
//! Sync Protocol - shared logic for bidirectional sync
//!
//! Provides reusable functions for sending and receiving entries during sync.
//! Used by both accept_handler (incoming sync) and sync (outgoing sync).
use crate::node::StoreHandle;
use lattice_core::proto::{peer_message, PeerMessage, SignedEntry};
use lattice_core::sync_state::SyncState;
use lattice_net::{MessageSink, MessageStream};
use prost::Message;
use std::collections::VecDeque;
/// Send entries that peer is missing based on state diff.
/// Returns (entries_sent, optional_error).
pub async fn send_missing_entries(
sink: &mut MessageSink,
store: &StoreHandle,
my_state: &SyncState,
peer_state: &SyncState,
) -> Result<u64, String> {
let missing = peer_state.diff(my_state);
// Build queues for each author's entries
let mut author_entries: Vec<VecDeque<SignedEntry>> = Vec::new();
for range in missing {
let from_hash = if range.from_hash == [0u8; 32] { None } else { Some(range.from_hash) };
let entries = store.read_entries_after(&range.author, from_hash).await
.map_err(|e| format!("Failed to read entries: {}", e))?;
if !entries.is_empty() {
author_entries.push(entries.into());
}
}
// Stream entries in HLC (causal) order
let mut entries_sent = 0u64;
for entry in lattice_core::CausalEntryIter::new(author_entries) {
let sync_msg = PeerMessage {
message: Some(peer_message::Message::SyncEntry(lattice_core::proto::SyncEntry {
signed_entry: entry.encode_to_vec(),
hash: vec![],
})),
};
sink.send(&sync_msg).await?;
entries_sent += 1;
}
// Send SyncDone
let done = PeerMessage {
message: Some(peer_message::Message::SyncDone(lattice_core::proto::SyncDone {
entries_sent,
})),
};
sink.send(&done).await?;
Ok(entries_sent)
}
/// Receive and apply entries until SyncDone is received.
/// Returns (entries_applied, entries_reported_by_peer).
pub async fn receive_entries(
stream: &mut MessageStream,
store: &StoreHandle,
) -> Result<(u64, u64), String> {
let mut entries_applied = 0u64;
let mut entries_reported = 0u64;
loop {
match stream.recv().await {
Ok(Some(msg)) => match msg.message {
Some(peer_message::Message::SyncEntry(entry)) => {
if let Ok(signed) = SignedEntry::decode(&entry.signed_entry[..]) {
if store.apply_entry(signed).await.is_ok() {
entries_applied += 1;
}
}
}
Some(peer_message::Message::SyncDone(done)) => {
entries_reported = done.entries_sent;
break;
}
_ => {}
}
Ok(None) => break,
Err(_) => break,
}
}
Ok((entries_applied, entries_reported))
}