refactor coordinator to use shared lib models and client

This commit is contained in:
Timur Gordon
2025-11-13 21:56:33 +01:00
parent 4b23e5eb7f
commit 84545f0d75
16 changed files with 729 additions and 1973 deletions

View File

@@ -0,0 +1,13 @@
/// Mycelium transport for supervisor communication
#[cfg(not(target_arch = "wasm32"))]
pub mod mycelium;
#[cfg(not(target_arch = "wasm32"))]
pub use mycelium::{
Destination,
MyceliumClient,
MyceliumClientError,
MyceliumTransport,
SupervisorHub,
TransportStatus,
};

View File

@@ -0,0 +1,366 @@
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use thiserror::Error;
use tokio::sync::{Mutex, oneshot};
use tokio::time::timeout;
use crate::{SupervisorTransport, ClientError};
/// Destination for Mycelium messages
#[derive(Clone, Debug)]
pub enum Destination {
Ip(IpAddr),
/// 64-hex public key of the receiver node
Pk(String),
}
/// Transport status from Mycelium
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportStatus {
Pending,
Sent,
Delivered,
Failed,
Timeout,
}
/// Lightweight client for Mycelium JSON-RPC (send + query status)
#[derive(Clone)]
pub struct MyceliumClient {
base_url: String, // e.g. http://127.0.0.1:8990
http: HttpClient,
id_counter: Arc<AtomicU64>,
}
#[derive(Debug, Error)]
pub enum MyceliumClientError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Transport timed out waiting for a reply (408)")]
TransportTimeout,
#[error("JSON-RPC error: {0}")]
RpcError(String),
#[error("Invalid response: {0}")]
InvalidResponse(String),
}
impl From<MyceliumClientError> for ClientError {
fn from(e: MyceliumClientError) -> Self {
match e {
MyceliumClientError::Http(err) => ClientError::Http(err.to_string()),
MyceliumClientError::Json(err) => ClientError::Serialization(err),
MyceliumClientError::TransportTimeout => ClientError::Server { message: "Transport timeout".to_string() },
MyceliumClientError::RpcError(msg) => ClientError::Server { message: msg },
MyceliumClientError::InvalidResponse(msg) => ClientError::Server { message: msg },
}
}
}
impl MyceliumClient {
pub fn new(base_url: impl Into<String>) -> Result<Self, MyceliumClientError> {
let url = base_url.into();
let http = HttpClient::builder().build()?;
Ok(Self {
base_url: url,
http,
id_counter: Arc::new(AtomicU64::new(1)),
})
}
fn next_id(&self) -> u64 {
self.id_counter.fetch_add(1, Ordering::Relaxed)
}
async fn jsonrpc(&self, method: &str, params: Value) -> Result<Value, MyceliumClientError> {
let req = json!({
"jsonrpc": "2.0",
"id": self.next_id(),
"method": method,
"params": [ params ]
});
tracing::info!(%req, "jsonrpc");
let resp = self.http.post(&self.base_url).json(&req).send().await?;
let status = resp.status();
let body: Value = resp.json().await?;
if let Some(err) = body.get("error") {
let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(0);
let msg = err
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
if code == 408 {
return Err(MyceliumClientError::TransportTimeout);
}
return Err(MyceliumClientError::RpcError(format!(
"code={code} msg={msg}"
)));
}
if !status.is_success() {
return Err(MyceliumClientError::RpcError(format!(
"HTTP {status}, body {body}"
)));
}
Ok(body)
}
/// Call messageStatus with an outbound message id (hex string)
pub async fn message_status(
&self,
id_hex: &str,
) -> Result<TransportStatus, MyceliumClientError> {
let params = json!(id_hex);
let body = self.jsonrpc("getMessageInfo", params).await?;
let result = body.get("result").ok_or_else(|| {
MyceliumClientError::InvalidResponse(format!("missing result in response: {body}"))
})?;
// Accept both { state: "..."} and bare "..."
let status_str = if let Some(s) = result.get("state").and_then(|v| v.as_str()) {
s.to_string()
} else if let Some(s) = result.as_str() {
s.to_string()
} else {
return Err(MyceliumClientError::InvalidResponse(format!(
"expected string or object with state, got {result}"
)));
};
match status_str.as_str() {
"pending" => Ok(TransportStatus::Pending),
"sent" => Ok(TransportStatus::Sent),
"delivered" => Ok(TransportStatus::Delivered),
"failed" => Ok(TransportStatus::Failed),
"timeout" => Ok(TransportStatus::Timeout),
_ => Err(MyceliumClientError::InvalidResponse(format!(
"unknown status: {status_str}"
))),
}
}
/// Push a message via Mycelium
pub async fn push_message(
&self,
dst: Value,
topic: &str,
payload: &str,
) -> Result<String, MyceliumClientError> {
let params = json!({
"dst": dst,
"topic": BASE64_STANDARD.encode(topic.as_bytes()),
"payload": payload,
});
let body = self.jsonrpc("pushMessage", params).await?;
let result = body.get("result").ok_or_else(|| {
MyceliumClientError::InvalidResponse(format!("missing result in pushMessage response"))
})?;
// Extract message ID
result
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
MyceliumClientError::InvalidResponse(format!("missing id in result: {result}"))
})
}
/// Pop a message from a topic
pub async fn pop_message(&self, topic: &str) -> Result<Option<Value>, MyceliumClientError> {
let params = json!({
"topic": BASE64_STANDARD.encode(topic.as_bytes()),
});
let body = self.jsonrpc("popMessage", params).await?;
let result = body.get("result").ok_or_else(|| {
MyceliumClientError::InvalidResponse(format!("missing result in popMessage response"))
})?;
if result.is_null() {
Ok(None)
} else {
Ok(Some(result.clone()))
}
}
}
/// Hub that manages request/reply correlation for supervisor calls via Mycelium
pub struct SupervisorHub {
mycelium: Arc<MyceliumClient>,
topic: String,
id_counter: Arc<AtomicU64>,
waiters: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>,
}
impl SupervisorHub {
pub fn new_with_client(mycelium: Arc<MyceliumClient>, topic: impl Into<String>) -> Arc<Self> {
let hub = Arc::new(Self {
mycelium,
topic: topic.into(),
id_counter: Arc::new(AtomicU64::new(1)),
waiters: Arc::new(Mutex::new(HashMap::new())),
});
// Spawn background listener
let hub_clone = hub.clone();
tokio::spawn(async move {
hub_clone.listen_loop().await;
});
hub
}
pub fn next_id(&self) -> u64 {
self.id_counter.fetch_add(1, Ordering::Relaxed)
}
pub async fn register_waiter(&self, id: u64) -> oneshot::Receiver<Value> {
let (tx, rx) = oneshot::channel();
self.waiters.lock().await.insert(id, tx);
rx
}
async fn listen_loop(&self) {
loop {
match self.mycelium.pop_message(&self.topic).await {
Ok(Some(envelope)) => {
if let Err(e) = self.handle_message(envelope).await {
tracing::warn!("Failed to handle message: {}", e);
}
}
Ok(None) => {
// No message, wait a bit
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(e) => {
tracing::error!("Error popping message: {}", e);
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
async fn handle_message(&self, envelope: Value) -> Result<(), String> {
// Decode payload
let payload_b64 = envelope
.get("payload")
.and_then(|v| v.as_str())
.ok_or_else(|| "missing payload".to_string())?;
let payload_bytes = BASE64_STANDARD
.decode(payload_b64)
.map_err(|e| format!("base64 decode error: {}", e))?;
let payload_str = String::from_utf8(payload_bytes)
.map_err(|e| format!("utf8 decode error: {}", e))?;
let reply: Value = serde_json::from_str(&payload_str)
.map_err(|e| format!("json parse error: {}", e))?;
// Extract ID
let id = reply
.get("id")
.and_then(|v| v.as_u64())
.ok_or_else(|| "missing or invalid id in reply".to_string())?;
// Notify waiter
if let Some(tx) = self.waiters.lock().await.remove(&id) {
let _ = tx.send(reply);
}
Ok(())
}
}
/// Mycelium transport implementation for SupervisorClient
pub struct MyceliumTransport {
hub: Arc<SupervisorHub>,
destination: Destination,
timeout_secs: u64,
}
impl MyceliumTransport {
pub fn new(hub: Arc<SupervisorHub>, destination: Destination) -> Self {
Self {
hub,
destination,
timeout_secs: 10,
}
}
pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
self.timeout_secs = timeout_secs;
self
}
fn build_dst(&self) -> Value {
match &self.destination {
Destination::Ip(ip) => json!({ "ip": ip.to_string() }),
Destination::Pk(pk) => json!({ "pk": pk }),
}
}
}
#[async_trait]
impl SupervisorTransport for MyceliumTransport {
async fn call(
&self,
method: &str,
params: Value,
) -> Result<Value, ClientError> {
let inner_id = self.hub.next_id();
// Register waiter before sending
let rx = self.hub.register_waiter(inner_id).await;
// Build JSON-RPC payload
let inner = json!({
"jsonrpc": "2.0",
"id": inner_id,
"method": method,
"params": params,
});
// Encode and send
let payload_str = serde_json::to_string(&inner)
.map_err(ClientError::Serialization)?;
let payload_b64 = BASE64_STANDARD.encode(payload_str.as_bytes());
let _msg_id = self.hub.mycelium
.push_message(self.build_dst(), &self.hub.topic, &payload_b64)
.await
.map_err(|e| ClientError::from(e))?;
// Wait for reply
let reply = timeout(Duration::from_secs(self.timeout_secs), rx)
.await
.map_err(|_| ClientError::Server { message: "Timeout waiting for reply".to_string() })?
.map_err(|_| ClientError::Server { message: "Reply channel closed".to_string() })?;
// Check for JSON-RPC error
if let Some(error) = reply.get("error") {
let msg = error.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ClientError::Server { message: msg.to_string() });
}
// Extract result
reply.get("result")
.cloned()
.ok_or_else(|| ClientError::Server { message: "Missing result in reply".to_string() })
}
}