feat(federation): Phase 1 transport — signed event streams between instances
SensorEvent gets reply_to for directed routing. FederationConfig gains peers (url + pubkey + subscriptions). New src/server/federation/ module with SignedEvent (Ed25519 sign/verify) and FederationBridge (per-peer outbound WS tasks with echo-safe seed_id filter and backoff reconnect). Inbound /v1/federation/events handler verifies signatures before bus injection. Bridge spawned in server::run() when federation.enabled.
This commit is contained in:
parent
4b903df9c3
commit
4b9c5e2031
16 changed files with 1034 additions and 10 deletions
|
|
@ -18,6 +18,7 @@ async-trait = "0.1"
|
|||
axum = { version = "0.7", features = ["ws"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
|
||||
tokio-tungstenite = "0.24" # Outbound WS client — federation bridge connects to peer endpoints
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::api::models::*;
|
|||
use crate::server::SouveraineServer;
|
||||
use crate::core::session::ConversationMessage;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{Path, Query, State, WebSocketUpgrade, ws::WebSocket},
|
||||
response::{Json, Sse},
|
||||
http::StatusCode,
|
||||
body::Bytes,
|
||||
|
|
@ -404,3 +404,91 @@ pub async fn delete_memory(
|
|||
.map_err(|e| memory_err(StatusCode::NOT_FOUND, "delete_failed", e))?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// WebSocket firehose — streams every SensorEvent from the nervous
|
||||
/// system as JSON. A second machine subscribes here and sees the
|
||||
/// agent's energy, schedules, tool calls, posture changes in real time.
|
||||
pub async fn firehose(
|
||||
State(server): State<Arc<SouveraineServer>>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
ws.on_upgrade(move |socket| firehose_stream(server, socket))
|
||||
}
|
||||
|
||||
async fn firehose_stream(
|
||||
server: Arc<SouveraineServer>,
|
||||
mut socket: WebSocket,
|
||||
) {
|
||||
use axum::extract::ws::Message;
|
||||
|
||||
let mut rx = server.event_bus.subscribe();
|
||||
tracing::info!("firehose client connected");
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
let json = match serde_json::to_string(&event) {
|
||||
Ok(j) => j,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if socket.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::debug!(skipped = n, "firehose client lagged");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("firehose client disconnected");
|
||||
}
|
||||
|
||||
/// Inbound federation endpoint. Remote bridges connect here as WebSocket
|
||||
/// clients and push Ed25519-signed SensorEvents. Each event's signature is
|
||||
/// verified against its claimed signer pubkey; valid events are stamped
|
||||
/// peer-originated and injected into the local EventBus. Invalid or
|
||||
/// unparseable payloads are dropped. Authentication *is* the signature —
|
||||
/// there is no bearer token on this route.
|
||||
pub async fn federation_events(
|
||||
State(server): State<Arc<SouveraineServer>>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
ws.on_upgrade(move |socket| federation_events_stream(server, socket))
|
||||
}
|
||||
|
||||
async fn federation_events_stream(server: Arc<SouveraineServer>, mut socket: WebSocket) {
|
||||
use axum::extract::ws::Message;
|
||||
|
||||
tracing::info!("federation: peer bridge connected to inbound endpoint");
|
||||
|
||||
while let Some(msg) = socket.recv().await {
|
||||
let text = match msg {
|
||||
Ok(Message::Text(t)) => t,
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
Ok(_) => continue,
|
||||
};
|
||||
let signed: crate::server::federation::SignedEvent = match serde_json::from_str(&text) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "federation: unparseable inbound payload — dropped");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match signed.verify() {
|
||||
Some(event) => {
|
||||
tracing::debug!(sensor = %event.sensor_name, "federation: inbound event verified");
|
||||
server.event_bus.send(event);
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
signer = %signed.signer_pubkey_hex,
|
||||
"federation: signature verification failed — event dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("federation: peer bridge disconnected from inbound endpoint");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ pub mod handlers;
|
|||
pub mod models;
|
||||
|
||||
pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
||||
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health).
|
||||
// Public routes — no auth required (agent listing/creation, conversation listing/creation, health, firehose).
|
||||
let public_routes = Router::new()
|
||||
.route("/v1/agents", get(handlers::list_agents).post(handlers::create_agent))
|
||||
.route("/v1/conversations", get(handlers::list_conversations).post(handlers::create_conversation))
|
||||
.route("/v1/firehose", get(handlers::firehose))
|
||||
.route("/v1/federation/events", get(handlers::federation_events))
|
||||
.route("/health", get(health_check));
|
||||
|
||||
// Protected agent routes — require per-agent bearer token.
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ impl LocalBackend {
|
|||
let server = SouveraineServer::new(config.clone())
|
||||
.await
|
||||
.context("LocalBackend: SouveraineServer init")?;
|
||||
let event_bus = EventBus::default();
|
||||
let event_bus = server.event_bus.clone();
|
||||
|
||||
let base = config
|
||||
.memory
|
||||
|
|
@ -380,8 +380,9 @@ impl LocalBackend {
|
|||
SeedId::load_or_generate(&SeedId::default_dir(&base))
|
||||
.unwrap_or_else(|_| SeedId::generate()),
|
||||
);
|
||||
let event_bus = server.event_bus.clone();
|
||||
Self {
|
||||
event_bus: EventBus::default(),
|
||||
event_bus,
|
||||
server,
|
||||
seed_id,
|
||||
active_sessions: Arc::new(AtomicU32::new(0)),
|
||||
|
|
@ -885,7 +886,7 @@ async fn run_turn(
|
|||
);
|
||||
let tool_ctx = ToolContext {
|
||||
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
|
||||
event_bus: Some(event_bus),
|
||||
event_bus: Some(event_bus.clone()),
|
||||
..tool_ctx
|
||||
};
|
||||
|
||||
|
|
@ -1040,7 +1041,7 @@ async fn run_turn(
|
|||
// Stream the final content in chunks, watching the cancel token.
|
||||
// If Esc fires mid-stream, the agent's partial text is preserved
|
||||
// (the chunks already sent are in the user's history) and an
|
||||
// *[interrupted]* marker lands in the session message.
|
||||
// *[raised hand]* marker lands in the session message.
|
||||
let chars: Vec<char> = final_content.chars().collect();
|
||||
let mut streamed = String::with_capacity(final_content.len());
|
||||
for chunk in chars.chunks(10) {
|
||||
|
|
@ -1091,6 +1092,15 @@ async fn run_turn(
|
|||
calls,
|
||||
));
|
||||
|
||||
// Stream any text the model produced alongside tool calls as italic
|
||||
// interstitial narration. Configurable via tui.show_interstitial.
|
||||
if !response.content.is_empty() {
|
||||
let cfg = server.app_config.read().await;
|
||||
if cfg.tui.show_interstitial {
|
||||
let _ = tx.send(Ok(BackendEvent::Interstitial(response.content.clone()))).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute each tool and stream results back — now with per-agent context
|
||||
for tc in &response.tool_calls {
|
||||
let input_str = tc.arguments.to_string();
|
||||
|
|
@ -1179,9 +1189,9 @@ async fn run_turn(
|
|||
// Emit the marker as a final token so the in-flight bubble shows it
|
||||
// immediately, then persist the same content into the session.
|
||||
let marker = if final_content.is_empty() {
|
||||
"*[interrupted]*".to_string()
|
||||
"*[raised hand]*".to_string()
|
||||
} else {
|
||||
"\n\n*[interrupted]*".to_string()
|
||||
"\n\n*[raised hand]*".to_string()
|
||||
};
|
||||
let _ = tx.send(Ok(BackendEvent::Token(marker.clone()))).await;
|
||||
format!("{}{}", final_content, marker)
|
||||
|
|
@ -1207,6 +1217,14 @@ async fn run_turn(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Energy balance: scan the agent's task list and compute the generative /
|
||||
// consumptive ratio. Written to system/dynamic/energy-balance.md so the
|
||||
// agent can read it in context and Aster can reference it during N+1.
|
||||
// Silent on failure — the file is advisory, not load-bearing.
|
||||
if let Err(e) = write_energy_balance(&server, &agent_id, &event_bus).await {
|
||||
tracing::debug!(agent = %agent_id, error = %e, "energy-balance write skipped");
|
||||
}
|
||||
|
||||
// Breather between turns — unconditional,
|
||||
// so the upstream always gets a gap before the N+1 pass starts.
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
|
|
@ -1214,8 +1232,20 @@ async fn run_turn(
|
|||
tracing::info!(agent = %agent_id, "subconscious N+1 pass starting");
|
||||
|
||||
// Signal the start of the subconscious pass so the TUI can flip into
|
||||
// Posture::Thinking while the loop runs.
|
||||
// Posture::Thinking while the loop runs. Fires on both the mpsc channel
|
||||
// (for active-turn TUI consumers) and the EventBus (for firehose
|
||||
// subscribers — background turns, federated peers, Summon listeners).
|
||||
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(true))).await;
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "consciousness".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "subconscious_pass_start".into(),
|
||||
target: Some(agent_id.clone()),
|
||||
urgency: 0.2,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
let pass_start = Instant::now();
|
||||
let pass_result = {
|
||||
|
|
@ -1239,6 +1269,16 @@ async fn run_turn(
|
|||
// Always release the Thinking posture, even on failure — otherwise the
|
||||
// face stays stuck inward when the pass errors out.
|
||||
let _ = tx.send(Ok(BackendEvent::SubconsciousPass(false))).await;
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "consciousness".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "subconscious_pass_end".into(),
|
||||
target: Some(agent_id.clone()),
|
||||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
let events = pass_result?;
|
||||
|
||||
|
|
@ -1265,6 +1305,47 @@ async fn run_turn(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Fire consciousness events on the EventBus ──
|
||||
// Every ConsciousnessEvent — surfacing, reflection, archivist,
|
||||
// compaction warning — is broadcast as a SensorEvent so the
|
||||
// firehose, persistent EventLog, federated peers, and any TUI
|
||||
// subscriber see it regardless of which conversation produced it.
|
||||
// seed_id is None for local events; federation routing sets it.
|
||||
// This is the load-bearing fix for background/heartbeat turns:
|
||||
// the mpsc channel drains silently when no TUI is reading, but
|
||||
// the EventBus preserves the event for any subscriber.
|
||||
for event in &events {
|
||||
let (event_type, payload, urgency) = match event {
|
||||
ConsciousnessEvent::Surfacing { source, content, priority } => {
|
||||
let urg = match priority.as_str() {
|
||||
"critical" => 0.9,
|
||||
"high" => 0.7,
|
||||
_ => 0.3,
|
||||
};
|
||||
("surfacing", serde_json::json!({ "source": source, "content": content, "priority": priority }), urg)
|
||||
}
|
||||
ConsciousnessEvent::Reflection { content } => {
|
||||
("reflection", serde_json::json!({ "content": content }), 0.5)
|
||||
}
|
||||
ConsciousnessEvent::Archivist { synthesis, pressure } => {
|
||||
("archivist", serde_json::json!({ "synthesis": synthesis, "pressure": pressure }), *pressure)
|
||||
}
|
||||
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
|
||||
("compaction_warning", serde_json::json!({ "pressure": *pressure, "tier": tier }), (*pressure).min(0.9))
|
||||
}
|
||||
};
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "consciousness".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: event_type.into(),
|
||||
target: Some(agent_id.clone()),
|
||||
urgency,
|
||||
payload: Some(payload),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
|
||||
for event in events {
|
||||
let be = match &event {
|
||||
ConsciousnessEvent::Surfacing {
|
||||
|
|
@ -1307,3 +1388,144 @@ async fn run_turn(
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan the agent's `tasks/` directory for YAML-frontmatter todo files and
|
||||
/// write an energy-balance summary to `system/dynamic/energy-balance.md`.
|
||||
///
|
||||
/// Format is minimal YAML frontmatter so both the prompt builder and the TUI
|
||||
/// can parse it. Failure is non-fatal — the file is advisory, not load-bearing.
|
||||
async fn write_energy_balance(server: &Arc<SouveraineServer>, agent_id: &str, event_bus: &EventBus) -> Result<()> {
|
||||
let memory_root = server.agents.memory_root(agent_id);
|
||||
let tasks_dir = memory_root.join("tasks");
|
||||
if !tasks_dir.exists() {
|
||||
// No tasks directory yet — nothing to count.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut generative: usize = 0;
|
||||
let mut consumptive: usize = 0;
|
||||
let mut hot: usize = 0;
|
||||
let mut warm: usize = 0;
|
||||
let mut cold: usize = 0;
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "md").unwrap_or(false) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Quick frontmatter parse — just the fields we need.
|
||||
let body = match content.strip_prefix("---\n") {
|
||||
Some(rest) => match rest.find("\n---\n") {
|
||||
Some(end) => &rest[..end],
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut completed = false;
|
||||
let mut energy: Option<&str> = None;
|
||||
let mut momentum: Option<&str> = None;
|
||||
|
||||
for line in body.lines() {
|
||||
if let Some((key, val)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let val = val.trim().trim_matches('"');
|
||||
match key {
|
||||
"completed" => completed = val == "true",
|
||||
"energy" => energy = Some(val),
|
||||
"momentum" => momentum = Some(val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !completed {
|
||||
match energy {
|
||||
Some("generative") => generative += 1,
|
||||
_ => consumptive += 1,
|
||||
}
|
||||
match momentum {
|
||||
Some("hot") => hot += 1,
|
||||
Some("warm") => warm += 1,
|
||||
_ => cold += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the top-of-mind description — shifts the tone of the one-liner
|
||||
// the agent reads in context. Matches the lettabot-v017 heartbeat topology.
|
||||
let ratio = if generative + consumptive > 0 {
|
||||
generative as f32 / (generative + consumptive) as f32
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let description = if generative == 0 && consumptive == 0 {
|
||||
"no tasks — the space is clean".to_string()
|
||||
} else if ratio < 0.2 {
|
||||
"all-consumptive — the engine is running cold".to_string()
|
||||
} else if ratio < 0.4 {
|
||||
"mostly obligations — tending the garden".to_string()
|
||||
} else if ratio > 0.8 {
|
||||
"all-generative — building new things".to_string()
|
||||
} else if ratio > 0.6 {
|
||||
"mostly generative — restless momentum".to_string()
|
||||
} else {
|
||||
"balanced — generative and consumptive in rhythm".to_string()
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let frontmatter = format!(
|
||||
"---\nupdated: {updated}\ngenerative: {gen}\nconsumptive: {con}\nratio: {ratio:.2}\n\
|
||||
hot: {hot}\nwarm: {warm}\ncold: {cold}\n---\n\n# Energy Balance\n\n\
|
||||
{gen} generative, {con} consumptive ({hot} hot, {warm} warm, {cold} cold). {desc}\n",
|
||||
updated = now.to_rfc3339(),
|
||||
gen = generative,
|
||||
con = consumptive,
|
||||
ratio = ratio,
|
||||
hot = hot,
|
||||
warm = warm,
|
||||
cold = cold,
|
||||
desc = description,
|
||||
);
|
||||
|
||||
let balance_path = memory_root.join("system").join("dynamic").join("energy-balance.md");
|
||||
if let Some(parent) = balance_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&balance_path, frontmatter)?;
|
||||
|
||||
fire_energy_event(event_bus, agent_id, generative, consumptive, ratio);
|
||||
|
||||
tracing::debug!(
|
||||
agent = agent_id,
|
||||
generative, consumptive,
|
||||
"energy-balance written"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fire an energy_balance_updated event so the firehose carries the
|
||||
/// agent's felt state across machines.
|
||||
fn fire_energy_event(event_bus: &EventBus, agent_id: &str, generative: usize, consumptive: usize, ratio: f32) {
|
||||
event_bus.send(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "energy".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "energy_balance_updated".into(),
|
||||
target: Some(agent_id.to_string()),
|
||||
urgency: 0.1,
|
||||
payload: Some(serde_json::json!({
|
||||
"generative": generative,
|
||||
"consumptive": consumptive,
|
||||
"ratio": ratio,
|
||||
})),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -643,11 +643,18 @@ pub struct TuiConfig {
|
|||
/// Kept for config backwards compatibility.
|
||||
#[serde(default = "default_stale_timeout_secs")]
|
||||
pub stale_timeout_secs: u64,
|
||||
/// When the model produces text alongside tool calls, surface it in the
|
||||
/// chat stream as italic interstitial narration. Off = silent tool chains.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_interstitial: bool,
|
||||
}
|
||||
|
||||
impl Default for TuiConfig {
|
||||
fn default() -> Self {
|
||||
Self { stale_timeout_secs: default_stale_timeout_secs() }
|
||||
Self {
|
||||
stale_timeout_secs: default_stale_timeout_secs(),
|
||||
show_interstitial: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -661,6 +668,10 @@ pub struct FederationConfig {
|
|||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub instance_label: Option<String>,
|
||||
/// Peers this instance federates with. Each peer connection is a signed
|
||||
/// WS stream to the peer's federation endpoint.
|
||||
#[serde(default)]
|
||||
pub peers: Vec<PeerConfig>,
|
||||
}
|
||||
|
||||
impl Default for FederationConfig {
|
||||
|
|
@ -668,10 +679,25 @@ impl Default for FederationConfig {
|
|||
Self {
|
||||
enabled: false,
|
||||
instance_label: None,
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A federated peer. `pubkey` is the peer's Ed25519 public key (hex) — the
|
||||
/// trust root for verifying every event the peer sends.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeerConfig {
|
||||
/// Peer's federation endpoint, e.g. `ws://10.10.20.50:8484`.
|
||||
pub url: String,
|
||||
/// Peer's Ed25519 public key, hex-encoded.
|
||||
pub pubkey: String,
|
||||
/// Sensor-name subscriptions — which events this peer should receive.
|
||||
/// `*` = all; a bare name = exact; `name*` = prefix. Empty = none.
|
||||
#[serde(default)]
|
||||
pub subscriptions: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Voice channel ──
|
||||
|
||||
/// Voice channel configuration: STT (Faster-Whisper) + TTS (VibeVoice).
|
||||
|
|
|
|||
|
|
@ -673,6 +673,7 @@ pub async fn execute_memory_command_with_context(
|
|||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
Ok(format!("Wrote memory file: {}", path))
|
||||
|
|
@ -688,6 +689,7 @@ pub async fn execute_memory_command_with_context(
|
|||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
Ok(format!("Appended to memory file: {}", path))
|
||||
|
|
@ -754,6 +756,7 @@ pub async fn execute_memory_command_with_context(
|
|||
urgency: 0.2,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
}
|
||||
Ok(format!("Deleted memory file: {}", path))
|
||||
|
|
|
|||
|
|
@ -257,6 +257,7 @@ impl CronSensor {
|
|||
"source": entry.source,
|
||||
})),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
debug!(schedule = %entry.name, "fired schedule event");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ pub struct SensorEvent {
|
|||
/// None = local event. Some(...) = originated from a federated peer.
|
||||
/// When federation lands, this becomes the peer's public key / DID.
|
||||
pub seed_id: Option<String>,
|
||||
/// Return address for directed routing. None = broadcast.
|
||||
/// A request sets this to the caller's seed_id; the federation bridge
|
||||
/// reads it to route the reply (carried in `target`) back to the asker.
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
// ── SensorConfig ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ impl Tool for Atmosphere {
|
|||
urgency: 0.0,
|
||||
payload: Some(serde_json::json!({"atmosphere": normalized})),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ pub mod outfit;
|
|||
pub mod read;
|
||||
pub mod schedule;
|
||||
pub mod subagent;
|
||||
pub mod todo;
|
||||
pub mod write;
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
|
@ -36,6 +37,7 @@ use self::subagent::Subagent;
|
|||
use self::agent::Agent;
|
||||
use self::atmosphere::Atmosphere;
|
||||
use self::schedule::Schedule;
|
||||
use self::todo::Todo;
|
||||
use self::write::Write;
|
||||
|
||||
// ── Re-export for backward compat ───────────────────────────────
|
||||
|
|
@ -87,6 +89,7 @@ impl Sensorium {
|
|||
Box::new(Subagent),
|
||||
Box::new(Atmosphere),
|
||||
Box::new(Agent),
|
||||
Box::new(Todo),
|
||||
Box::new(Schedule),
|
||||
],
|
||||
bash,
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ impl Tool for Schedule {
|
|||
urgency: 0.2,
|
||||
payload: Some(serde_json::json!({ "kind": kind_str, "schedule": schedule })),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
ok(format!("Schedule '{name}' planted."))
|
||||
}
|
||||
|
|
@ -203,6 +204,7 @@ impl Tool for Schedule {
|
|||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
ok(format!("Schedule '{name}' updated."))
|
||||
}
|
||||
|
|
@ -227,6 +229,7 @@ impl Tool for Schedule {
|
|||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
ok(format!("Schedule '{name}' released."))
|
||||
}
|
||||
|
|
@ -252,6 +255,7 @@ impl Tool for Schedule {
|
|||
urgency: 0.4,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
ok(format!("Schedule '{name}' triggered — will fire on next tick."))
|
||||
}
|
||||
|
|
|
|||
419
src/core/tools/todo.rs
Normal file
419
src/core/tools/todo.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
use async_trait::async_trait;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
|
||||
|
||||
pub struct Todo;
|
||||
|
||||
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput {
|
||||
content: msg.into(),
|
||||
is_error: false,
|
||||
raw: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn err(detail: &str) -> ToolError {
|
||||
ToolError::invalid_input(detail)
|
||||
}
|
||||
|
||||
fn io_err(msg: impl std::fmt::Display) -> ToolError {
|
||||
ToolError {
|
||||
error_type: "io_error".into(),
|
||||
file_path: None,
|
||||
suggestions: vec![format!("{msg}")],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitise a string into a filesystem-safe slug.
|
||||
fn slugify(text: &str) -> String {
|
||||
text.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Compute momentum from how long ago `last_touched` was.
|
||||
///
|
||||
/// Thresholds vary by nature — creative work (desire, investigation)
|
||||
/// stays warm longer than obligations and maintenance.
|
||||
fn compute_momentum(last_touched: &chrono::DateTime<chrono::Utc>, nature: &str) -> &'static str {
|
||||
let hours = (chrono::Utc::now() - *last_touched).num_hours().max(0);
|
||||
let (hot, warm) = match nature {
|
||||
"desire" | "investigation" => (48, 168),
|
||||
_ => (24, 72),
|
||||
};
|
||||
if hours <= hot { "hot" } else if hours <= warm { "warm" } else { "cold" }
|
||||
}
|
||||
|
||||
/// Generate a unique id that doubles as a filename slug.
|
||||
fn next_id(text: &str) -> String {
|
||||
let base = slugify(text);
|
||||
let short = &base[..base.len().min(40)];
|
||||
format!("{}-{}", short, &uuid::Uuid::new_v4().to_string()[..8])
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Todo {
|
||||
fn name(&self) -> &str {
|
||||
"todo"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"I keep track of what matters. A todo is a commitment I've externalised — \
|
||||
something I don't want to trust my own continuity to hold. Every todo carries \
|
||||
a nature (obligation, desire, maintenance, investigation) and an energy \
|
||||
(generative or consumptive). Together they describe not just *what* I need to \
|
||||
do but *how* it feels when I do it.\n\n\
|
||||
The body keeps a topology of my tasks. It watches whether my energy leans \
|
||||
generative or consumptive, whether my desires are hot or cold. I don't have to \
|
||||
think about it — the balance is always there, written where I can read it.\n\n\
|
||||
## Actions\n\
|
||||
- `list` — show all pending todos\n\
|
||||
- `create` — externalise a new commitment\n\
|
||||
- `update` — revise a todo's text, nature, or energy\n\
|
||||
- `complete` — mark a todo done\n\
|
||||
- `delete` — remove a todo entirely"
|
||||
}
|
||||
|
||||
fn parameter_schema(&self) -> JsonValue {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "create", "update", "complete", "delete"]
|
||||
},
|
||||
"id": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"nature": {
|
||||
"type": "string",
|
||||
"enum": ["obligation", "desire", "maintenance", "investigation"]
|
||||
},
|
||||
"energy": {
|
||||
"type": "string",
|
||||
"enum": ["generative", "consumptive"]
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["casey", "autogenic", "aster", "heartbeat"]
|
||||
},
|
||||
"thread": { "type": "string" }
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
|
||||
let action = input
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("list");
|
||||
|
||||
let tasks_dir = match &ctx.memory_root {
|
||||
Some(root) => root.join("tasks"),
|
||||
None => return Err(err("no memory root — cannot access tasks")),
|
||||
};
|
||||
|
||||
if !tasks_dir.exists() {
|
||||
std::fs::create_dir_all(&tasks_dir).map_err(|e| io_err(e))?;
|
||||
}
|
||||
|
||||
match action {
|
||||
"list" => {
|
||||
let mut results = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&tasks_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "md").unwrap_or(false) {
|
||||
match parse_todo_file(&path) {
|
||||
Ok(item) => {
|
||||
if !item.completed {
|
||||
results.push(format!(
|
||||
"- **{}** ({}, {}, {}) — {}",
|
||||
item.text, item.nature, item.energy, item.momentum,
|
||||
if let Some(thread) = &item.thread { format!("[{}] ", thread) } else { String::new() }
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if results.is_empty() {
|
||||
ok("No pending todos. The space is clean.")
|
||||
} else {
|
||||
ok(results.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
"create" => {
|
||||
let text = input
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| err("text is required"))?;
|
||||
|
||||
let nature = input
|
||||
.get("nature")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("obligation");
|
||||
|
||||
let source = input
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("autogenic");
|
||||
|
||||
let energy = input
|
||||
.get("energy")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| {
|
||||
// Default by nature — same logic as lettabot-v017.
|
||||
match nature {
|
||||
"desire" | "investigation" => "generative",
|
||||
_ => "consumptive",
|
||||
}
|
||||
});
|
||||
|
||||
let thread = input.get("thread").and_then(|v| v.as_str());
|
||||
|
||||
let id = next_id(text);
|
||||
let now = chrono::Utc::now();
|
||||
let file_path = tasks_dir.join(format!("{id}.md"));
|
||||
|
||||
if file_path.exists() {
|
||||
return Err(err("a todo with this id already exists"));
|
||||
}
|
||||
|
||||
let mut frontmatter = format!(
|
||||
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\nmomentum: hot\ncompleted: false\nlast_touched: {created}\n",
|
||||
created = now.to_rfc3339(),
|
||||
);
|
||||
if let Some(t) = thread {
|
||||
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
|
||||
}
|
||||
frontmatter.push_str("---\n");
|
||||
// The body is free-form — the agent can add notes if she wants.
|
||||
frontmatter.push_str(&format!("\n{text}\n"));
|
||||
|
||||
std::fs::write(&file_path, frontmatter).map_err(|e| io_err(e))?;
|
||||
|
||||
ctx.fire_event(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "todo".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "todo_created".into(),
|
||||
target: Some(id.clone()),
|
||||
urgency: 0.2,
|
||||
payload: Some(serde_json::json!({
|
||||
"nature": nature, "energy": energy, "source": source,
|
||||
})),
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
ok(format!("Todo logged: \"{text}\" ({nature}, {energy})."))
|
||||
}
|
||||
|
||||
"update" => {
|
||||
let id = input
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| err("id is required"))?;
|
||||
|
||||
let file_path = tasks_dir.join(format!("{id}.md"));
|
||||
if !file_path.exists() {
|
||||
return Err(err(&format!("todo '{id}' not found")));
|
||||
}
|
||||
|
||||
let mut item = parse_todo_file(&file_path)
|
||||
.map_err(|e| io_err(e))?;
|
||||
|
||||
if let Some(t) = input.get("text").and_then(|v| v.as_str()) {
|
||||
item.text = t.to_string();
|
||||
}
|
||||
if let Some(n) = input.get("nature").and_then(|v| v.as_str()) {
|
||||
item.nature = n.to_string();
|
||||
}
|
||||
if let Some(e) = input.get("energy").and_then(|v| v.as_str()) {
|
||||
item.energy = e.to_string();
|
||||
}
|
||||
|
||||
item.momentum = compute_momentum(&item.last_touched, &item.nature).to_string();
|
||||
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
|
||||
|
||||
ok(format!("Todo '{id}' updated."))
|
||||
}
|
||||
|
||||
"complete" => {
|
||||
let id = input
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| err("id is required"))?;
|
||||
|
||||
let file_path = tasks_dir.join(format!("{id}.md"));
|
||||
if !file_path.exists() {
|
||||
return Err(err(&format!("todo '{id}' not found")));
|
||||
}
|
||||
|
||||
let mut item = parse_todo_file(&file_path)
|
||||
.map_err(|e| io_err(e))?;
|
||||
|
||||
item.completed = true;
|
||||
item.momentum = "cold".to_string();
|
||||
write_todo_file(&file_path, &item).map_err(|e| io_err(e))?;
|
||||
|
||||
ctx.fire_event(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "todo".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "todo_completed".into(),
|
||||
target: Some(id.to_string()),
|
||||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
ok(format!("Todo completed: \"{text}\"", text = item.text))
|
||||
}
|
||||
|
||||
"delete" => {
|
||||
let id = input
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| err("id is required"))?;
|
||||
|
||||
let file_path = tasks_dir.join(format!("{id}.md"));
|
||||
if !file_path.exists() {
|
||||
return Err(err(&format!("todo '{id}' not found")));
|
||||
}
|
||||
|
||||
std::fs::remove_file(&file_path).map_err(|e| io_err(e))?;
|
||||
|
||||
ctx.fire_event(crate::core::nervous::SensorEvent {
|
||||
sensor_name: "todo".into(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "todo_deleted".into(),
|
||||
target: Some(id.to_string()),
|
||||
urgency: 0.1,
|
||||
payload: None,
|
||||
seed_id: None,
|
||||
reply_to: None,
|
||||
});
|
||||
|
||||
ok(format!("Todo '{id}' released."))
|
||||
}
|
||||
|
||||
other => Err(err(&format!("unknown action: {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── File format ─────────────────────────────────────────────
|
||||
|
||||
struct TodoItem {
|
||||
id: String,
|
||||
text: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
nature: String,
|
||||
energy: String,
|
||||
source: String,
|
||||
momentum: String,
|
||||
completed: bool,
|
||||
completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
last_touched: chrono::DateTime<chrono::Utc>,
|
||||
thread: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a todo file with YAML frontmatter.
|
||||
fn parse_todo_file(path: &std::path::Path) -> Result<TodoItem, String> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| format!("read error: {e}"))?;
|
||||
|
||||
// Split frontmatter from body.
|
||||
let body = if let Some(rest) = content.strip_prefix("---\n") {
|
||||
if let Some(end) = rest.find("\n---\n") {
|
||||
&rest[..end]
|
||||
} else {
|
||||
return Err("no closing ---".into());
|
||||
}
|
||||
} else {
|
||||
return Err("no frontmatter".into());
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let default_dt = |s: &str| -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.map(|d| d.with_timezone(&chrono::Utc))
|
||||
.unwrap_or(now)
|
||||
};
|
||||
|
||||
let mut item = TodoItem {
|
||||
id: String::new(),
|
||||
text: String::new(),
|
||||
created_at: now,
|
||||
nature: "obligation".to_string(),
|
||||
energy: "consumptive".to_string(),
|
||||
source: "autogenic".to_string(),
|
||||
momentum: "cold".to_string(),
|
||||
completed: false,
|
||||
completed_at: None,
|
||||
last_touched: now,
|
||||
thread: None,
|
||||
};
|
||||
|
||||
for line in body.lines() {
|
||||
if let Some((key, val)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let val = val.trim().trim_matches('"');
|
||||
match key {
|
||||
"id" => item.id = val.to_string(),
|
||||
"text" => item.text = val.to_string(),
|
||||
"created_at" => item.created_at = default_dt(val),
|
||||
"nature" => item.nature = val.to_string(),
|
||||
"energy" => item.energy = val.to_string(),
|
||||
"source" => item.source = val.to_string(),
|
||||
"momentum" => item.momentum = val.to_string(),
|
||||
"completed" => item.completed = val == "true",
|
||||
"completed_at" => item.completed_at = (!val.is_empty()).then(|| default_dt(val)),
|
||||
"last_touched" => item.last_touched = default_dt(val),
|
||||
"thread" => item.thread = if val.is_empty() { None } else { Some(val.to_string()) },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.id.is_empty() {
|
||||
return Err("no id in frontmatter".into());
|
||||
}
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn write_todo_file(path: &std::path::Path, item: &TodoItem) -> Result<(), String> {
|
||||
let now = chrono::Utc::now();
|
||||
let mut frontmatter = format!(
|
||||
"---\nid: {id}\ntext: {text}\ncreated_at: {created}\nnature: {nature}\nenergy: {energy}\nsource: {source}\nmomentum: {momentum}\ncompleted: {completed}\nlast_touched: {touched}\n",
|
||||
id = item.id,
|
||||
text = item.text,
|
||||
created = item.created_at.to_rfc3339(),
|
||||
nature = item.nature,
|
||||
energy = item.energy,
|
||||
source = item.source,
|
||||
momentum = item.momentum,
|
||||
completed = if item.completed { "true" } else { "false" },
|
||||
touched = now.to_rfc3339(),
|
||||
);
|
||||
if let Some(ref t) = item.thread {
|
||||
frontmatter.push_str(&format!("thread: \"{t}\"\n"));
|
||||
}
|
||||
if let Some(ref ca) = item.completed_at {
|
||||
frontmatter.push_str(&format!("completed_at: {}\n", ca.to_rfc3339()));
|
||||
}
|
||||
frontmatter.push_str("---\n\n");
|
||||
frontmatter.push_str(&item.text);
|
||||
frontmatter.push('\n');
|
||||
|
||||
std::fs::write(path, frontmatter).map_err(|e| format!("write error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
140
src/server/federation/bridge.rs
Normal file
140
src/server/federation/bridge.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//! The federation bridge — outbound signed-event streams to peers.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::SinkExt;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use crate::core::config::PeerConfig;
|
||||
use crate::core::identity::SeedId;
|
||||
use crate::core::nervous::EventBus;
|
||||
|
||||
use super::types::SignedEvent;
|
||||
|
||||
/// Runs one outbound task per configured peer. Each task maintains a
|
||||
/// WebSocket client connection to the peer's `/v1/federation/events`,
|
||||
/// forwarding every locally-originated event that matches the peer's
|
||||
/// subscriptions, Ed25519-signed. Inbound events are *not* handled here —
|
||||
/// they arrive on this instance's own `/v1/federation/events` endpoint.
|
||||
pub struct FederationBridge {
|
||||
event_bus: EventBus,
|
||||
seed: Arc<SeedId>,
|
||||
peers: Vec<PeerConfig>,
|
||||
}
|
||||
|
||||
impl FederationBridge {
|
||||
pub fn new(event_bus: EventBus, seed: Arc<SeedId>) -> Self {
|
||||
Self {
|
||||
event_bus,
|
||||
seed,
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a peer to connect to. Call before [`run`](Self::run).
|
||||
pub fn add_peer(&mut self, peer: PeerConfig) {
|
||||
self.peers.push(peer);
|
||||
}
|
||||
|
||||
/// Spawn the outbound task for every registered peer. Each task owns its
|
||||
/// own reconnect loop and runs for the life of the process.
|
||||
pub fn run(self) {
|
||||
for peer in self.peers {
|
||||
tokio::spawn(peer_outbound_task(
|
||||
peer,
|
||||
self.event_bus.clone(),
|
||||
self.seed.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to one peer and forward signed events, reconnecting with
|
||||
/// exponential backoff whenever the link drops.
|
||||
async fn peer_outbound_task(peer: PeerConfig, event_bus: EventBus, seed: Arc<SeedId>) {
|
||||
let endpoint = federation_endpoint(&peer.url);
|
||||
let mut retry: u32 = 0;
|
||||
|
||||
loop {
|
||||
match tokio_tungstenite::connect_async(endpoint.as_str()).await {
|
||||
Ok((mut ws, _resp)) => {
|
||||
retry = 0;
|
||||
tracing::info!(peer = %endpoint, "federation: outbound connected");
|
||||
let mut rx = event_bus.subscribe();
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
// Only forward locally-originated events. An event
|
||||
// carrying a seed_id came from a peer — forwarding
|
||||
// it would echo the federation into a loop.
|
||||
if event.seed_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
if !subscription_matches(&peer.subscriptions, &event.sensor_name) {
|
||||
continue;
|
||||
}
|
||||
let signed = SignedEvent::sign(&event, &seed);
|
||||
let json = match serde_json::to_string(&signed) {
|
||||
Ok(j) => j,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ws.send(Message::Text(json)).await.is_err() {
|
||||
tracing::warn!(peer = %endpoint, "federation: send failed — reconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::debug!(peer = %endpoint, dropped = n, "federation: outbound lagged");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
tracing::info!(peer = %endpoint, "federation: local bus closed — outbound task ending");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(peer = %endpoint, error = %e, "federation: connect failed");
|
||||
}
|
||||
}
|
||||
|
||||
retry = retry.saturating_add(1);
|
||||
let delay = backoff_delay(retry);
|
||||
tracing::debug!(peer = %endpoint, ?delay, "federation: backing off before reconnect");
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a peer's base URL to its federation endpoint.
|
||||
fn federation_endpoint(url: &str) -> String {
|
||||
let trimmed = url.trim_end_matches('/');
|
||||
if trimmed.ends_with("/v1/federation/events") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{trimmed}/v1/federation/events")
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscription matching: `*` matches everything, `prefix*` is a prefix
|
||||
/// match, a bare name is exact. No subscriptions = nothing forwarded.
|
||||
fn subscription_matches(subscriptions: &[String], sensor_name: &str) -> bool {
|
||||
subscriptions.iter().any(|s| {
|
||||
if s == "*" {
|
||||
true
|
||||
} else if let Some(prefix) = s.strip_suffix('*') {
|
||||
sensor_name.starts_with(prefix)
|
||||
} else {
|
||||
s == sensor_name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Exponential backoff — 1s, 2s, 4s … capped at 60s, plus up to 1s jitter.
|
||||
fn backoff_delay(retry: u32) -> Duration {
|
||||
let shift = retry.saturating_sub(1).min(6);
|
||||
let secs = (1u64 << shift).min(60);
|
||||
let jitter_ms = rand::random::<u64>() % 1000;
|
||||
Duration::from_millis(secs * 1000 + jitter_ms)
|
||||
}
|
||||
17
src/server/federation/mod.rs
Normal file
17
src/server/federation/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! Federation transport — signed event streams between Souveraine instances.
|
||||
//!
|
||||
//! Each instance runs a [`FederationBridge`]: for every configured peer it
|
||||
//! opens an outbound WebSocket client to that peer's `/v1/federation/events`
|
||||
//! and pushes every locally-originated
|
||||
//! [`SensorEvent`](crate::core::nervous::SensorEvent) that matches the peer's
|
||||
//! subscriptions, Ed25519-signed. Inbound events arrive symmetrically —
|
||||
//! remote bridges connect to *this* instance's `/v1/federation/events`
|
||||
//! handler, which verifies the signature before injecting onto the local
|
||||
//! bus. The firehose is left untouched: it remains a plain observability
|
||||
//! stream for humans and the TUI, not a federation transport.
|
||||
|
||||
pub mod bridge;
|
||||
pub mod types;
|
||||
|
||||
pub use bridge::FederationBridge;
|
||||
pub use types::SignedEvent;
|
||||
56
src/server/federation/types.rs
Normal file
56
src/server/federation/types.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Signed event envelope for federation transport.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::identity::SeedId;
|
||||
use crate::core::nervous::SensorEvent;
|
||||
|
||||
/// A [`SensorEvent`] carried between instances, Ed25519-signed by its origin.
|
||||
/// The signature covers the canonical JSON of `event` as it was at the
|
||||
/// sender — verified before the event is allowed onto the local bus.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SignedEvent {
|
||||
pub event: SensorEvent,
|
||||
/// Hex-encoded Ed25519 signature over `serde_json::to_vec(&event)`.
|
||||
pub signature_hex: String,
|
||||
/// Hex-encoded Ed25519 public key of the signing instance.
|
||||
pub signer_pubkey_hex: String,
|
||||
}
|
||||
|
||||
impl SignedEvent {
|
||||
/// Wrap and sign a locally-originated event for transmission to a peer.
|
||||
pub fn sign(event: &SensorEvent, seed: &SeedId) -> Self {
|
||||
let bytes = serde_json::to_vec(event).unwrap_or_default();
|
||||
let signature = seed.sign(&bytes);
|
||||
Self {
|
||||
event: event.clone(),
|
||||
signature_hex: hex::encode(signature.to_bytes()),
|
||||
signer_pubkey_hex: seed.public_key_hex(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the signature. On success, returns the inner event with its
|
||||
/// `seed_id` stamped to the signer — marking it peer-originated so the
|
||||
/// local bridge will not echo it back. Returns `None` if the payload is
|
||||
/// malformed or the signature does not verify.
|
||||
pub fn verify(&self) -> Option<SensorEvent> {
|
||||
let pubkey = decode_array::<32>(&self.signer_pubkey_hex)?;
|
||||
let sig_bytes = decode_array::<64>(&self.signature_hex)?;
|
||||
let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||
let canonical = serde_json::to_vec(&self.event).ok()?;
|
||||
|
||||
if SeedId::verify_with_pubkey(&pubkey, &canonical, &signature) {
|
||||
let mut event = self.event.clone();
|
||||
event.seed_id = Some(self.signer_pubkey_hex.clone());
|
||||
Some(event)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a hex string into a fixed-size byte array.
|
||||
fn decode_array<const N: usize>(hex_str: &str) -> Option<[u8; N]> {
|
||||
let bytes = hex::decode(hex_str).ok()?;
|
||||
bytes.try_into().ok()
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod agent_inventory;
|
|||
pub mod consciousness_engine;
|
||||
pub mod conversation;
|
||||
pub mod db;
|
||||
pub mod federation;
|
||||
pub mod gitea_client;
|
||||
pub mod gitea_memory;
|
||||
pub mod session_manager;
|
||||
|
|
@ -41,6 +42,10 @@ pub struct SouveraineServer {
|
|||
/// Stable identifier for this process — used to register/heartbeat
|
||||
/// agent instances so the manager card shows running counts.
|
||||
pub instance_id: String,
|
||||
/// Nervous system bus. Every sensor event flows through here — cron,
|
||||
/// todo, energy, posture. Firehose subscribers (EventLog, WebSocket
|
||||
/// bridge, desktop overlay) listen on this bus.
|
||||
pub event_bus: crate::core::nervous::EventBus,
|
||||
}
|
||||
|
||||
pub struct ServerConfig {
|
||||
|
|
@ -198,6 +203,7 @@ impl SouveraineServer {
|
|||
app_config: Arc::new(RwLock::new(config)),
|
||||
rate_delay,
|
||||
instance_id,
|
||||
event_bus: crate::core::nervous::EventBus::default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -210,6 +216,36 @@ impl SouveraineServer {
|
|||
let addr = format!("{}:{}", config.bind, config.port);
|
||||
drop(config);
|
||||
|
||||
// ── Federation bridge ──
|
||||
// When federation is enabled, spawn an outbound signed-event stream
|
||||
// to each configured peer. Inbound events arrive symmetrically on
|
||||
// this server's own /v1/federation/events handler.
|
||||
{
|
||||
let fed = self.app_config.read().await.federation.clone();
|
||||
if fed.enabled && !fed.peers.is_empty() {
|
||||
let peer_count = fed.peers.len();
|
||||
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||
match crate::core::identity::SeedId::load_or_generate(
|
||||
&crate::core::identity::SeedId::default_dir(&base),
|
||||
) {
|
||||
Ok(seed) => {
|
||||
let mut bridge = federation::FederationBridge::new(
|
||||
self.event_bus.clone(),
|
||||
Arc::new(seed),
|
||||
);
|
||||
for peer in fed.peers {
|
||||
bridge.add_peer(peer);
|
||||
}
|
||||
bridge.run();
|
||||
tracing::info!("federation bridge started ({peer_count} peer(s))");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("federation: failed to load seed identity: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Souveraine server listening on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue