refactor coordinator to use shared lib models and client
This commit is contained in:
13
lib/clients/supervisor/src/transports/mod.rs
Normal file
13
lib/clients/supervisor/src/transports/mod.rs
Normal 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,
|
||||
};
|
||||
366
lib/clients/supervisor/src/transports/mycelium.rs
Normal file
366
lib/clients/supervisor/src/transports/mycelium.rs
Normal 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() })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user