Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-05-15 14:04:19 -04:00
commit a2b9b131b1
4 changed files with 249 additions and 3 deletions

View file

@ -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;

View 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);
}
}
}

View file

@ -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 {

View file

@ -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,
})
}