feat(federation): Phase 2 — device registry, peer discovery, and CLI
DeviceRegistry tracks known federated peers from device_announce/device_leave SensorEvents on the bus, persisted to ~/.souveraine/federation/known_peers.json for CLI access. Bridge emits device_announce on connect. New `souveraine peers` subcommand lists known peers. Wired into SouveraineServer at construction.
This commit is contained in:
parent
4b9c5e2031
commit
a2b9b131b1
4 changed files with 249 additions and 3 deletions
54
src/main.rs
54
src/main.rs
|
|
@ -207,6 +207,14 @@ enum Commands {
|
|||
host: bool,
|
||||
},
|
||||
|
||||
/// Show known federated peers
|
||||
#[command(long_about = "Show all peers tracked by the device registry, from federation announcements.")]
|
||||
Peers {
|
||||
/// Output as JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Query the event firehose log
|
||||
Events {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -358,6 +366,11 @@ async fn main() -> anyhow::Result<()> {
|
|||
return run_events(action, cli.json).await;
|
||||
}
|
||||
|
||||
// Handle peers early — reads known_peers.json, no backend needed
|
||||
if let Some(Commands::Peers { json }) = &cli.command {
|
||||
return run_peers(*json).await;
|
||||
}
|
||||
|
||||
let config = load_config().await?;
|
||||
let config = Arc::new(RwLock::new(config));
|
||||
|
||||
|
|
@ -373,7 +386,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
Commands::Reflect { conversation } => {
|
||||
run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await?
|
||||
}
|
||||
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Events { .. } => unreachable!(),
|
||||
Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -642,6 +655,45 @@ async fn run_identity(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_peers(json: bool) -> anyhow::Result<()> {
|
||||
let base = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".souveraine");
|
||||
let known_path = base.join("federation").join("known_peers.json");
|
||||
|
||||
if !known_path.exists() {
|
||||
if json {
|
||||
println!("[]");
|
||||
} else {
|
||||
println!("No federated peers known yet.");
|
||||
println!(" Configure peers in [federation] in souveraine.toml and start the server.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&known_path).await?;
|
||||
let peers: Vec<crate::server::device_registry::PeerEntry> = serde_json::from_str(&content)?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&peers)?);
|
||||
} else if peers.is_empty() {
|
||||
println!("No federated peers known yet.");
|
||||
} else {
|
||||
println!("\n── Federated Peers ──────────────────────");
|
||||
for p in &peers {
|
||||
let status = if p.alive { "alive" } else { "offline" };
|
||||
println!(" {} {}", p.seed_id.get(..16).unwrap_or(&p.seed_id), status);
|
||||
if let Some(label) = &p.label {
|
||||
println!(" label: {label}");
|
||||
}
|
||||
println!(" url: {}", p.url);
|
||||
println!(" seen: {}", p.last_seen.format("%Y-%m-%d %H:%M:%S UTC"));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_events(action: &EventsAction, json: bool) -> anyhow::Result<()> {
|
||||
use crate::core::nervous::event_log;
|
||||
|
||||
|
|
|
|||
136
src/server/device_registry.rs
Normal file
136
src/server/device_registry.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
use crate::core::nervous::SensorEvent;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A peer device known to this federation. Updated on every `device_announce`
|
||||
/// and `device_leave` event from the peer's bridge.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerEntry {
|
||||
/// The peer's Ed25519 public key (hex) — also its seed_id.
|
||||
pub seed_id: String,
|
||||
/// Instance label from the peer's FederationConfig.
|
||||
pub label: Option<String>,
|
||||
/// The peer's federation endpoint (ws://host:port).
|
||||
pub url: String,
|
||||
/// First time we saw this peer's announce.
|
||||
pub first_seen: DateTime<Utc>,
|
||||
/// Most recent announce.
|
||||
pub last_seen: DateTime<Utc>,
|
||||
/// Is the peer considered alive?
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
/// Tracks known federated peers for the local instance. Written to a JSON file
|
||||
/// so the CLI (`souveraine peers list`) can query without needing the server.
|
||||
pub struct DeviceRegistry {
|
||||
peers: DashMap<String, PeerEntry>,
|
||||
known_peers_path: PathBuf,
|
||||
/// This instance's own seed_id (pubkey hex) — filters self-announcements.
|
||||
local_seed_id: String,
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
/// `base_dir` = `~/.souveraine/`. The registry writes to
|
||||
/// `{base_dir}/federation/known_peers.json`.
|
||||
pub fn new(base_dir: PathBuf, local_seed_id: String) -> Self {
|
||||
let fed_dir = base_dir.join("federation");
|
||||
let known_peers_path = fed_dir.join("known_peers.json");
|
||||
let peers = DashMap::new();
|
||||
|
||||
// Seed from disk if the file exists (CLI path where server isn't running).
|
||||
if let Ok(content) = std::fs::read_to_string(&known_peers_path) {
|
||||
if let Ok(disk_peers) = serde_json::from_str::<Vec<PeerEntry>>(&content) {
|
||||
for entry in disk_peers {
|
||||
peers.insert(entry.seed_id.clone(), entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
peers,
|
||||
known_peers_path,
|
||||
local_seed_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a `device_announce` or `device_leave` SensorEvent from the bus.
|
||||
/// Returns true if the registry changed.
|
||||
pub fn handle_event(&self, event: &SensorEvent) -> bool {
|
||||
if event.seed_id.as_deref() == Some(&self.local_seed_id) {
|
||||
return false; // Ignore our own announcements.
|
||||
}
|
||||
match event.event_type.as_str() {
|
||||
"device_announce" => {
|
||||
let seed_id = match &event.seed_id {
|
||||
Some(id) => id.clone(),
|
||||
None => return false,
|
||||
};
|
||||
let url = event
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("federation_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let label = event
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("label"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let now = Utc::now();
|
||||
let was_new = !self.peers.contains_key(&seed_id);
|
||||
self.peers.insert(
|
||||
seed_id.clone(),
|
||||
PeerEntry {
|
||||
seed_id: seed_id.clone(),
|
||||
label,
|
||||
url,
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
alive: true,
|
||||
},
|
||||
);
|
||||
self.persist();
|
||||
was_new
|
||||
}
|
||||
"device_leave" => {
|
||||
let seed_id = match &event.seed_id {
|
||||
Some(id) => id.clone(),
|
||||
None => return false,
|
||||
};
|
||||
if let Some(mut entry) = self.peers.get_mut(&seed_id) {
|
||||
entry.alive = false;
|
||||
entry.last_seen = Utc::now();
|
||||
drop(entry);
|
||||
self.persist();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all known peers.
|
||||
pub fn list(&self) -> Vec<PeerEntry> {
|
||||
let mut entries: Vec<PeerEntry> = self.peers.iter().map(|e| e.value().clone()).collect();
|
||||
entries.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
entries
|
||||
}
|
||||
|
||||
/// Persist known peers to disk (for CLI access).
|
||||
fn persist(&self) {
|
||||
if let Some(dir) = self.known_peers_path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let entries: Vec<PeerEntry> = self.list();
|
||||
if let Ok(json) = serde_json::to_string(&entries) {
|
||||
let _ = std::fs::write(&self.known_peers_path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use tokio_tungstenite::tungstenite::Message;
|
|||
|
||||
use crate::core::config::PeerConfig;
|
||||
use crate::core::identity::SeedId;
|
||||
use crate::core::nervous::EventBus;
|
||||
use crate::core::nervous::{EventBus, SensorEvent};
|
||||
|
||||
use super::types::SignedEvent;
|
||||
|
||||
|
|
@ -61,6 +61,27 @@ async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<See
|
|||
Ok((mut ws, _resp)) => {
|
||||
retry = 0;
|
||||
tracing::info!(peer = %endpoint, "federation: outbound connected");
|
||||
|
||||
// Announce our presence to the peer.
|
||||
let announce = SensorEvent {
|
||||
sensor_name: "federation".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "device_announce".into(),
|
||||
target: None,
|
||||
urgency: 0.0,
|
||||
payload: Some(serde_json::json!({
|
||||
"federation_url": endpoint,
|
||||
"label": None::<String>,
|
||||
"pubkey": seed.public_key_hex(),
|
||||
})),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
};
|
||||
let signed = SignedEvent::sign(&announce, &seed);
|
||||
if let Ok(json) = serde_json::to_string(&signed) {
|
||||
let _ = ws.send(Message::Text(json)).await;
|
||||
}
|
||||
|
||||
let mut rx = event_bus.subscribe();
|
||||
|
||||
loop {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod agent_inventory;
|
|||
pub mod consciousness_engine;
|
||||
pub mod conversation;
|
||||
pub mod db;
|
||||
pub mod device_registry;
|
||||
pub mod federation;
|
||||
pub mod gitea_client;
|
||||
pub mod gitea_memory;
|
||||
|
|
@ -18,6 +19,7 @@ pub mod session_manager;
|
|||
|
||||
pub use agent_inventory::AgentInventory;
|
||||
pub use consciousness_engine::{ConsciousnessEngine, ConsciousnessEvent};
|
||||
pub use device_registry::DeviceRegistry;
|
||||
pub use session_manager::SessionManager;
|
||||
|
||||
// Server-side memory backend (Send-safe, HTTP-only via Gitea API).
|
||||
|
|
@ -46,6 +48,12 @@ pub struct SouveraineServer {
|
|||
/// todo, energy, posture. Firehose subscribers (EventLog, WebSocket
|
||||
/// bridge, desktop overlay) listen on this bus.
|
||||
pub event_bus: crate::core::nervous::EventBus,
|
||||
/// Tracks known federated peers. Updated by `device_announce`/`device_leave`
|
||||
/// events on the bus. Persisted to disk for CLI access.
|
||||
pub device_registry: Option<Arc<DeviceRegistry>>,
|
||||
/// This instance's Ed25519 public key hex — used to filter self-announcements
|
||||
/// from the device registry. Loaded at construction; None if seed unavailable.
|
||||
pub local_seed_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ServerConfig {
|
||||
|
|
@ -102,6 +110,7 @@ impl SouveraineServer {
|
|||
});
|
||||
}
|
||||
|
||||
let event_bus = crate::core::nervous::EventBus::default();
|
||||
let sessions = Arc::new(SessionManager::with_persistence(data_dir.join("agents")));
|
||||
|
||||
let primary = &config.bifrost.primary_model;
|
||||
|
|
@ -191,6 +200,32 @@ impl SouveraineServer {
|
|||
gitea_url: std::env::var("SOUVERAINE_GITEA_URL").ok(),
|
||||
};
|
||||
|
||||
// ── Device registry ──
|
||||
let souveraine_base = dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join(".souveraine");
|
||||
let local_seed_id = match crate::core::identity::SeedId::load_or_generate(
|
||||
&crate::core::identity::SeedId::default_dir(&souveraine_base),
|
||||
) {
|
||||
Ok(seed) => {
|
||||
let pubkey = seed.public_key_hex();
|
||||
Some(pubkey)
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let device_registry = local_seed_id.clone().map(|seed_id| {
|
||||
let reg = Arc::new(DeviceRegistry::new(souveraine_base, seed_id));
|
||||
// Subscribe the registry to the event bus for live updates.
|
||||
let reg_clone = reg.clone();
|
||||
let mut rx = event_bus.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
reg_clone.handle_event(&event);
|
||||
}
|
||||
});
|
||||
reg
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
agents,
|
||||
sessions,
|
||||
|
|
@ -203,7 +238,9 @@ impl SouveraineServer {
|
|||
app_config: Arc::new(RwLock::new(config)),
|
||||
rate_delay,
|
||||
instance_id,
|
||||
event_bus: crate::core::nervous::EventBus::default(),
|
||||
event_bus,
|
||||
device_registry,
|
||||
local_seed_id,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue