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

@@ -25,6 +25,10 @@ hero-job = { path = "../../models/job" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
jsonrpsee = { workspace = true, features = ["http-client", "macros"] }
tokio.workspace = true
async-trait.workspace = true
reqwest = { version = "0.12", features = ["json"] }
base64 = "0.22"
tracing.workspace = true
# hero-job-client removed - now part of supervisor
env_logger.workspace = true
http.workspace = true

View File

@@ -2,6 +2,13 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;
use serde_json;
#[cfg(not(target_arch = "wasm32"))]
use async_trait::async_trait;
// Transport implementations
#[cfg(not(target_arch = "wasm32"))]
pub mod transports;
// Import types from the main supervisor crate
@@ -32,19 +39,69 @@ use jsonrpsee::{
#[cfg(not(target_arch = "wasm32"))]
use http::{HeaderMap, HeaderName, HeaderValue};
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
/// Client for communicating with Hero Supervisor OpenRPC server
/// Requires authentication secret for all operations
/// Transport abstraction for supervisor communication
/// Allows different transport layers (HTTP, Mycelium, etc.)
#[cfg(not(target_arch = "wasm32"))]
#[async_trait]
pub trait SupervisorTransport: Send + Sync {
/// Send a JSON-RPC request and await the response
async fn call(
&self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, ClientError>;
}
/// HTTP transport implementation using jsonrpsee
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub struct SupervisorClient {
pub struct HttpTransport {
client: HttpClient,
server_url: String,
}
#[cfg(not(target_arch = "wasm32"))]
#[async_trait]
impl SupervisorTransport for HttpTransport {
async fn call(
&self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, ClientError> {
// params is already an array from the caller
// jsonrpsee expects params as an array, so pass it directly
let result: serde_json::Value = if params.is_array() {
// Use the array directly with rpc_params
let arr = params.as_array().unwrap();
match arr.len() {
0 => self.client.request(method, rpc_params![]).await?,
1 => self.client.request(method, rpc_params![&arr[0]]).await?,
_ => {
// For multiple params, we need to pass them as a slice
self.client.request(method, rpc_params![arr]).await?
}
}
} else {
// Single param not in array
self.client.request(method, rpc_params![&params]).await?
};
Ok(result)
}
}
/// Client for communicating with Hero Supervisor OpenRPC server
/// Generic over transport layer (HTTP, Mycelium, etc.)
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub struct SupervisorClient<T: SupervisorTransport = HttpTransport> {
transport: T,
secret: String,
}
/// Legacy type alias for backward compatibility
#[cfg(not(target_arch = "wasm32"))]
pub type HttpSupervisorClient = SupervisorClient<HttpTransport>;
/// Error types for client operations
#[cfg(not(target_arch = "wasm32"))]
#[derive(Error, Debug)]
@@ -258,8 +315,8 @@ impl SupervisorClientBuilder {
self
}
/// Build the SupervisorClient
pub fn build(self) -> ClientResult<SupervisorClient> {
/// Build the SupervisorClient with HTTP transport
pub fn build(self) -> ClientResult<SupervisorClient<HttpTransport>> {
let server_url = self.url
.ok_or_else(|| ClientError::Http("URL is required".to_string()))?;
let secret = self.secret
@@ -280,9 +337,10 @@ impl SupervisorClientBuilder {
.build(&server_url)
.map_err(|e| ClientError::Http(e.to_string()))?;
let transport = HttpTransport { client };
Ok(SupervisorClient {
client,
server_url,
transport,
secret,
})
}
@@ -296,25 +354,24 @@ impl Default for SupervisorClientBuilder {
}
#[cfg(not(target_arch = "wasm32"))]
impl SupervisorClient {
/// Create a builder for SupervisorClient
impl SupervisorClient<HttpTransport> {
/// Create a builder for HTTP-based SupervisorClient
pub fn builder() -> SupervisorClientBuilder {
SupervisorClientBuilder::new()
}
/// Get the server URL
pub fn server_url(&self) -> &str {
&self.server_url
}
#[cfg(not(target_arch = "wasm32"))]
impl<T: SupervisorTransport> SupervisorClient<T> {
/// Create a new client with a custom transport
pub fn new(transport: T, secret: String) -> Self {
Self { transport, secret }
}
/// Test connection using OpenRPC discovery method
/// This calls the standard `rpc.discover` method that should be available on any OpenRPC server
pub async fn discover(&self) -> ClientResult<serde_json::Value> {
let result: serde_json::Value = self
.client
.request("rpc.discover", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
self.transport.call("rpc.discover", serde_json::json!([])).await
}
/// Register a new runner to the supervisor
@@ -324,11 +381,8 @@ impl SupervisorClient {
&self,
name: &str,
) -> ClientResult<()> {
let _: () = self
.client
.request("runner.create", rpc_params![name])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
let result = self.transport.call("runner.create", serde_json::json!([name])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Create a new job without queuing it to a runner
@@ -337,20 +391,14 @@ impl SupervisorClient {
&self,
job: Job,
) -> ClientResult<String> {
let job_id: String = self
.client
.request("job.create", rpc_params![job])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(job_id)
let result = self.transport.call("job.create", serde_json::json!([job])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// List all jobs
pub async fn job_list(&self) -> ClientResult<Vec<Job>> {
let jobs: Vec<Job> = self
.client
.request("job.list", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(jobs)
let result = self.transport.call("job.list", serde_json::json!([])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Run a job on the appropriate runner and wait for the result (blocking)
@@ -369,11 +417,8 @@ impl SupervisorClient {
params["timeout"] = serde_json::json!(t);
}
let result: JobRunResponse = self
.client
.request("job.run", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
let result = self.transport.call("job.run", serde_json::json!([params])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Start a job without waiting for the result (non-blocking)
@@ -387,58 +432,40 @@ impl SupervisorClient {
"job": job
});
let result: JobStartResponse = self
.client
.request("job.start", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
let result = self.transport.call("job.start", serde_json::json!([params])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get the current status of a job
pub async fn job_status(&self, job_id: &str) -> ClientResult<JobStatus> {
let status: JobStatus = self
.client
.request("job.status", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(status)
let result = self.transport.call("job.status", serde_json::json!([job_id])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get the result of a completed job (blocks until result is available)
pub async fn job_result(&self, job_id: &str) -> ClientResult<JobResult> {
let result: JobResult = self
.client
.request("job.result", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
let result = self.transport.call("job.result", serde_json::json!([job_id])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Remove a runner from the supervisor
/// Authentication via Authorization header (set during client creation)
pub async fn runner_remove(&self, runner_id: &str) -> ClientResult<()> {
let _: () = self
.client
.request("runner.remove", rpc_params![runner_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
let result = self.transport.call("runner.remove", serde_json::json!([runner_id])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// List all runner IDs
pub async fn runner_list(&self) -> ClientResult<Vec<String>> {
let runners: Vec<String> = self
.client
.request("runner.list", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(runners)
let result = self.transport.call("runner.list", serde_json::json!([])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Start a specific runner
/// Authentication via Authorization header (set during client creation)
pub async fn start_runner(&self, actor_id: &str) -> ClientResult<()> {
let _: () = self
.client
.request("runner.start", rpc_params![actor_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
let result = self.transport.call("runner.start", serde_json::json!([actor_id])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Add a runner to the supervisor
@@ -447,21 +474,21 @@ impl SupervisorClient {
let params = serde_json::json!({
"config": config
});
let _: () = self
.client
.request("runner.add", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
let result = self
.transport
.call("runner.add", serde_json::json!([params]))
.await?;
Ok(())
}
/// Get status of a specific runner
/// Authentication via Authorization header (set during client creation)
pub async fn get_runner_status(&self, actor_id: &str) -> ClientResult<RunnerStatus> {
let status: RunnerStatus = self
.client
.request("runner.status", rpc_params![actor_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(status)
let result = self
.transport
.call("runner.status", serde_json::json!([actor_id]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get logs for a specific runner
@@ -471,11 +498,11 @@ impl SupervisorClient {
lines: Option<usize>,
follow: bool,
) -> ClientResult<Vec<LogInfo>> {
let logs: Vec<LogInfo> = self
.client
.request("get_runner_logs", rpc_params![actor_id, lines, follow])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(logs)
let result = self
.transport
.call("get_runner_logs", serde_json::json!([actor_id, lines, follow]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Queue a job to a specific runner
@@ -485,10 +512,10 @@ impl SupervisorClient {
"job": job
});
let _: () = self
.client
.request("queue_job_to_runner", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
let result = self
.transport
.call("queue_job_to_runner", serde_json::json!([params]))
.await?;
Ok(())
}
@@ -500,11 +527,11 @@ impl SupervisorClient {
"timeout_secs": timeout_secs
});
let result: Option<String> = self
.client
.request("queue_and_wait", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
let result = self
.transport
.call("queue_and_wait", serde_json::json!([params]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Run a job on a specific runner
@@ -513,56 +540,56 @@ impl SupervisorClient {
"job": job
});
let result: JobResult = self
.client
.request("job.run", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
let result = self
.transport
.call("job.run", serde_json::json!([params]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get job result by job ID
pub async fn get_job_result(&self, job_id: &str) -> ClientResult<Option<String>> {
let result: Option<String> = self
.client
.request("get_job_result", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
let result = self
.transport
.call("get_job_result", serde_json::json!([job_id]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get status of all runners
pub async fn get_all_runner_status(&self) -> ClientResult<Vec<(String, RunnerStatus)>> {
let statuses: Vec<(String, RunnerStatus)> = self
.client
.request("get_all_runner_status", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(statuses)
let result = self
.transport
.call("get_all_runner_status", serde_json::json!([]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Start all runners
pub async fn start_all(&self) -> ClientResult<Vec<(String, bool)>> {
let results: Vec<(String, bool)> = self
.client
.request("start_all", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(results)
let result = self
.transport
.call("start_all", serde_json::json!([]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Stop all runners
pub async fn stop_all(&self, force: bool) -> ClientResult<Vec<(String, bool)>> {
let results: Vec<(String, bool)> = self
.client
.request("stop_all", rpc_params![force])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(results)
let result = self
.transport
.call("stop_all", serde_json::json!([force]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get status of all runners (alternative method)
pub async fn get_all_status(&self) -> ClientResult<Vec<(String, RunnerStatus)>> {
let statuses: Vec<(String, RunnerStatus)> = self
.client
.request("get_all_status", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(statuses)
let result = self
.transport
.call("get_all_status", serde_json::json!([]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Add a secret to the supervisor
@@ -576,10 +603,10 @@ impl SupervisorClient {
"secret_value": secret_value
});
let _: () = self
.client
.request("add_secret", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
let result = self
.transport
.call("add_secret", serde_json::json!([params]))
.await?;
Ok(())
}
@@ -594,10 +621,10 @@ impl SupervisorClient {
"secret_value": secret_value
});
let _: () = self
.client
.request("remove_secret", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
let result = self
.transport
.call("remove_secret", serde_json::json!([params]))
.await?;
Ok(())
}
@@ -605,91 +632,87 @@ impl SupervisorClient {
pub async fn list_secrets(&self) -> ClientResult<SupervisorInfo> {
let params = serde_json::json!({});
let info: SupervisorInfo = self
.client
.request("list_secrets", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(info)
let result = self
.transport
.call("list_secrets", serde_json::json!([params]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Stop a running job
pub async fn job_stop(&self, job_id: &str) -> ClientResult<()> {
let _: () = self.client
.request("job.stop", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
let result = self.transport.call("job.stop", serde_json::json!([job_id])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Delete a job from the system
pub async fn job_delete(&self, job_id: &str) -> ClientResult<()> {
let _: () = self.client
.request("job.delete", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
let result = self.transport.call("job.delete", serde_json::json!([job_id])).await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get supervisor information including secret counts
pub async fn get_supervisor_info(&self) -> ClientResult<SupervisorInfo> {
let info: SupervisorInfo = self
.client
.request("supervisor.info", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(info)
let result = self
.transport
.call("supervisor.info", serde_json::json!([]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Get a job by ID
pub async fn job_get(&self, job_id: &str) -> ClientResult<Job> {
let job: Job = self
.client
.request("job.get", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(job)
let result = self
.transport
.call("job.get", serde_json::json!([job_id]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
// ========== Auth/API Key Methods ==========
/// Verify the current API key
pub async fn auth_verify(&self) -> ClientResult<AuthVerifyResponse> {
let response: AuthVerifyResponse = self
.client
.request("auth.verify", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(response)
let result = self
.transport
.call("auth.verify", serde_json::json!([]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Create a new API key (admin only)
pub async fn key_create(&self, key: ApiKey) -> ClientResult<()> {
let _: () = self
.client
.request("key.create", rpc_params![key])
.await.map_err(|e| ClientError::JsonRpc(e))?;
let result = self
.transport
.call("key.create", serde_json::json!([key]))
.await?;
Ok(())
}
/// Generate a new API key with auto-generated key value (admin only)
pub async fn key_generate(&self, params: GenerateApiKeyParams) -> ClientResult<ApiKey> {
let api_key: ApiKey = self
.client
.request("key.generate", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(api_key)
let result = self
.transport
.call("key.generate", serde_json::json!([params]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
/// Remove an API key (admin only)
pub async fn key_delete(&self, key_id: String) -> ClientResult<()> {
let _: () = self
.client
.request("key.delete", rpc_params![key_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
let result = self
.transport
.call("key.delete", serde_json::json!([key_id]))
.await?;
Ok(())
}
/// List all API keys (admin only)
pub async fn key_list(&self) -> ClientResult<Vec<ApiKey>> {
let keys: Vec<ApiKey> = self
.client
.request("key.list", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(keys)
let result = self
.transport
.call("key.list", serde_json::json!([]))
.await?;
serde_json::from_value(result).map_err(ClientError::Serialization)
}
}

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() })
}
}