feat: Implement Iroh-based peer networking, join protocol, and bidirectional store synchronization.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user