feat(federation): Phase 8 — hearth & limb role model
FederationRole (hearth/limb) in config; bridge carries role in device_announce; DeviceRegistry tracks role per peer with a split-brain guard (two hearths → ERROR!). Role defaults to hearth: a standalone machine is its own home.
This commit is contained in:
parent
055a3d7b38
commit
3629fa8b78
5 changed files with 77 additions and 6 deletions
|
|
@ -662,12 +662,41 @@ fn default_stale_timeout_secs() -> u64 { 90 }
|
||||||
|
|
||||||
// ── Federation ──
|
// ── Federation ──
|
||||||
|
|
||||||
|
/// A machine's role in the federation.
|
||||||
|
///
|
||||||
|
/// - `Hearth` — where the agent lives. Full engine always up, the autonomous
|
||||||
|
/// rhythm runs here, the memfs HEAD is authoritative. One per agent.
|
||||||
|
/// - `Limb` — a place she can reach to. At rest it's the lite listener; an
|
||||||
|
/// authorized summon wakes the full engine, it acts, reports up, and rests.
|
||||||
|
/// A limb does not run the general autonomous rhythm — that keeps one
|
||||||
|
/// heartbeat at the hearth and no split-brain across machines.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FederationRole {
|
||||||
|
#[default]
|
||||||
|
Hearth,
|
||||||
|
Limb,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationRole {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Hearth => "hearth",
|
||||||
|
Self::Limb => "limb",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FederationConfig {
|
pub struct FederationConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub instance_label: Option<String>,
|
pub instance_label: Option<String>,
|
||||||
|
/// This machine's role — hearth (the agent's home) or limb (a place she
|
||||||
|
/// reaches to). Defaults to hearth: a standalone machine is its own home.
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: FederationRole,
|
||||||
/// Peers this instance federates with. Each peer connection is a signed
|
/// Peers this instance federates with. Each peer connection is a signed
|
||||||
/// WS stream to the peer's federation endpoint.
|
/// WS stream to the peer's federation endpoint.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -690,6 +719,7 @@ impl Default for FederationConfig {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
instance_label: None,
|
instance_label: None,
|
||||||
|
role: FederationRole::Hearth,
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
authorized_summoners: Vec::new(),
|
authorized_summoners: Vec::new(),
|
||||||
auto_wake: false,
|
auto_wake: false,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::core::config::FederationRole;
|
||||||
use crate::core::nervous::SensorEvent;
|
use crate::core::nervous::SensorEvent;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
|
@ -5,6 +6,10 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn default_role() -> String {
|
||||||
|
"hearth".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// A peer device known to this federation. Updated on every `device_announce`
|
/// A peer device known to this federation. Updated on every `device_announce`
|
||||||
/// and `device_leave` event from the peer's bridge.
|
/// and `device_leave` event from the peer's bridge.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -15,6 +20,9 @@ pub struct PeerEntry {
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
/// The peer's federation endpoint (ws://host:port).
|
/// The peer's federation endpoint (ws://host:port).
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// The peer's federation role — "hearth" or "limb".
|
||||||
|
#[serde(default = "default_role")]
|
||||||
|
pub role: String,
|
||||||
/// First time we saw this peer's announce.
|
/// First time we saw this peer's announce.
|
||||||
pub first_seen: DateTime<Utc>,
|
pub first_seen: DateTime<Utc>,
|
||||||
/// Most recent announce.
|
/// Most recent announce.
|
||||||
|
|
@ -30,12 +38,14 @@ pub struct DeviceRegistry {
|
||||||
known_peers_path: PathBuf,
|
known_peers_path: PathBuf,
|
||||||
/// This instance's own seed_id (pubkey hex) — filters self-announcements.
|
/// This instance's own seed_id (pubkey hex) — filters self-announcements.
|
||||||
local_seed_id: String,
|
local_seed_id: String,
|
||||||
|
/// This machine's role — used to detect a hearth/hearth split-brain.
|
||||||
|
local_role: FederationRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRegistry {
|
impl DeviceRegistry {
|
||||||
/// `base_dir` = `~/.souveraine/`. The registry writes to
|
/// `base_dir` = `~/.souveraine/`. The registry writes to
|
||||||
/// `{base_dir}/federation/known_peers.json`.
|
/// `{base_dir}/federation/known_peers.json`.
|
||||||
pub fn new(base_dir: PathBuf, local_seed_id: String) -> Self {
|
pub fn new(base_dir: PathBuf, local_seed_id: String, local_role: FederationRole) -> Self {
|
||||||
let fed_dir = base_dir.join("federation");
|
let fed_dir = base_dir.join("federation");
|
||||||
let known_peers_path = fed_dir.join("known_peers.json");
|
let known_peers_path = fed_dir.join("known_peers.json");
|
||||||
let peers = DashMap::new();
|
let peers = DashMap::new();
|
||||||
|
|
@ -53,6 +63,7 @@ impl DeviceRegistry {
|
||||||
peers,
|
peers,
|
||||||
known_peers_path,
|
known_peers_path,
|
||||||
local_seed_id,
|
local_seed_id,
|
||||||
|
local_role,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,6 +92,24 @@ impl DeviceRegistry {
|
||||||
.and_then(|p| p.get("label"))
|
.and_then(|p| p.get("label"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
let role = event
|
||||||
|
.payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("role"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("hearth")
|
||||||
|
.to_string();
|
||||||
|
// Split-brain guard: two hearths for one agent diverge memory.
|
||||||
|
// We refuse to auto-resolve — surface it loudly for the human.
|
||||||
|
if role == "hearth" && self.local_role == FederationRole::Hearth {
|
||||||
|
tracing::error!(
|
||||||
|
peer = %seed_id,
|
||||||
|
"federation: HEARTH CONFLICT — this machine and {} both \
|
||||||
|
claim hearth. Only one machine should be the hearth; \
|
||||||
|
set [federation].role = \"limb\" on one of them.",
|
||||||
|
seed_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
// Preserve the original first_seen across re-announces.
|
// Preserve the original first_seen across re-announces.
|
||||||
let first_seen = self.peers.get(&seed_id)
|
let first_seen = self.peers.get(&seed_id)
|
||||||
|
|
@ -93,6 +122,7 @@ impl DeviceRegistry {
|
||||||
seed_id: seed_id.clone(),
|
seed_id: seed_id.clone(),
|
||||||
label,
|
label,
|
||||||
url,
|
url,
|
||||||
|
role,
|
||||||
first_seen,
|
first_seen,
|
||||||
last_seen: now,
|
last_seen: now,
|
||||||
alive: true,
|
alive: true,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use std::time::Duration;
|
||||||
use futures::SinkExt;
|
use futures::SinkExt;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
use crate::core::config::PeerConfig;
|
use crate::core::config::{FederationRole, PeerConfig};
|
||||||
use crate::core::identity::SeedId;
|
use crate::core::identity::SeedId;
|
||||||
use crate::core::nervous::{EventBus, SensorEvent};
|
use crate::core::nervous::{EventBus, SensorEvent};
|
||||||
|
|
||||||
|
|
@ -20,14 +20,16 @@ use super::types::SignedEvent;
|
||||||
pub struct FederationBridge {
|
pub struct FederationBridge {
|
||||||
event_bus: EventBus,
|
event_bus: EventBus,
|
||||||
seed: Arc<SeedId>,
|
seed: Arc<SeedId>,
|
||||||
|
role: FederationRole,
|
||||||
peers: Vec<PeerConfig>,
|
peers: Vec<PeerConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationBridge {
|
impl FederationBridge {
|
||||||
pub fn new(event_bus: EventBus, seed: Arc<SeedId>) -> Self {
|
pub fn new(event_bus: EventBus, seed: Arc<SeedId>, role: FederationRole) -> Self {
|
||||||
Self {
|
Self {
|
||||||
event_bus,
|
event_bus,
|
||||||
seed,
|
seed,
|
||||||
|
role,
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +47,7 @@ impl FederationBridge {
|
||||||
peer,
|
peer,
|
||||||
self.event_bus.clone(),
|
self.event_bus.clone(),
|
||||||
self.seed.clone(),
|
self.seed.clone(),
|
||||||
|
self.role,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +55,12 @@ impl FederationBridge {
|
||||||
|
|
||||||
/// Connect to one peer and forward signed events, reconnecting with
|
/// Connect to one peer and forward signed events, reconnecting with
|
||||||
/// exponential backoff whenever the link drops.
|
/// exponential backoff whenever the link drops.
|
||||||
async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<SeedId>) {
|
async fn peer_outbound_task(
|
||||||
|
peer: PeerConfig,
|
||||||
|
event_bus: EventBus,
|
||||||
|
seed: Arc<SeedId>,
|
||||||
|
role: FederationRole,
|
||||||
|
) {
|
||||||
let endpoint = federation_endpoint(&peer.url);
|
let endpoint = federation_endpoint(&peer.url);
|
||||||
let mut retry: u32 = 0;
|
let mut retry: u32 = 0;
|
||||||
|
|
||||||
|
|
@ -73,6 +81,7 @@ async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<See
|
||||||
"federation_url": endpoint,
|
"federation_url": endpoint,
|
||||||
"label": None::<String>,
|
"label": None::<String>,
|
||||||
"pubkey": seed.public_key_hex(),
|
"pubkey": seed.public_key_hex(),
|
||||||
|
"role": role.as_str(),
|
||||||
})),
|
})),
|
||||||
seed_id: None,
|
seed_id: None,
|
||||||
reply_to: None,
|
reply_to: None,
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ impl LiteListener {
|
||||||
|
|
||||||
// Outbound federation bridge — so this instance can also reach peers.
|
// Outbound federation bridge — so this instance can also reach peers.
|
||||||
if !config.peers.is_empty() {
|
if !config.peers.is_empty() {
|
||||||
let mut bridge = FederationBridge::new(event_bus.clone(), Arc::new(seed));
|
let mut bridge = FederationBridge::new(event_bus.clone(), Arc::new(seed), config.role);
|
||||||
for peer in &config.peers {
|
for peer in &config.peers {
|
||||||
bridge.add_peer(peer.clone());
|
bridge.add_peer(peer.clone());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,8 +220,9 @@ impl SouveraineServer {
|
||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
let sb_for_device_reg = souveraine_base.clone();
|
let sb_for_device_reg = souveraine_base.clone();
|
||||||
|
let local_role = config.federation.role;
|
||||||
let device_registry = local_seed_id.clone().map(|seed_id| {
|
let device_registry = local_seed_id.clone().map(|seed_id| {
|
||||||
let reg = Arc::new(DeviceRegistry::new(sb_for_device_reg, seed_id));
|
let reg = Arc::new(DeviceRegistry::new(sb_for_device_reg, seed_id, local_role));
|
||||||
// Subscribe the registry to the event bus for live updates.
|
// Subscribe the registry to the event bus for live updates.
|
||||||
let reg_clone = reg.clone();
|
let reg_clone = reg.clone();
|
||||||
let mut rx = event_bus.subscribe();
|
let mut rx = event_bus.subscribe();
|
||||||
|
|
@ -311,6 +312,7 @@ impl SouveraineServer {
|
||||||
let mut bridge = federation::FederationBridge::new(
|
let mut bridge = federation::FederationBridge::new(
|
||||||
self.event_bus.clone(),
|
self.event_bus.clone(),
|
||||||
Arc::new(seed),
|
Arc::new(seed),
|
||||||
|
fed.role,
|
||||||
);
|
);
|
||||||
for peer in fed.peers {
|
for peer in fed.peers {
|
||||||
bridge.add_peer(peer);
|
bridge.add_peer(peer);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue