//! OpenRPC client for Hero Supervisor //! //! This crate provides a client library for interacting with the Hero Supervisor //! OpenRPC server. It offers a simple, async interface for managing actors and jobs. //! //! ## Features //! //! - **Native client**: Full-featured client for native Rust applications //! - **WASM client**: Browser-compatible client using fetch APIs //! //! ## Usage //! //! ### Native Client //! ```rust //! use hero_supervisor_openrpc_client::SupervisorClient; //! //! let client = SupervisorClient::new("http://localhost:3030")?; //! let runners = client.list_runners().await?; //! ``` //! //! ### WASM Client //! ```rust //! use hero_supervisor_openrpc_client::wasm::WasmSupervisorClient; //! //! let client = WasmSupervisorClient::new("http://localhost:3030".to_string()); //! let runners = client.list_runners().await?; //! ``` use serde::{Deserialize, Serialize}; use thiserror::Error; use serde_json; // Import types from the main supervisor crate #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] use std::path::PathBuf; // WASM-compatible client module #[cfg(target_arch = "wasm32")] pub mod wasm; // Re-export WASM types for convenience #[cfg(target_arch = "wasm32")] pub use wasm::{WasmSupervisorClient, WasmJob, WasmJobType, WasmRunnerType}; // Native client dependencies #[cfg(not(target_arch = "wasm32"))] use jsonrpsee::{ core::client::ClientT, http_client::{HttpClient, HttpClientBuilder}, rpc_params, }; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; /// Client for communicating with Hero Supervisor OpenRPC server #[cfg(not(target_arch = "wasm32"))] pub struct SupervisorClient { client: HttpClient, server_url: String, } /// Error types for client operations #[cfg(not(target_arch = "wasm32"))] #[derive(Error, Debug)] pub enum ClientError { #[error("JSON-RPC error: {0}")] JsonRpc(#[from] jsonrpsee::core::ClientError), #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), #[error("HTTP client error: {0}")] Http(String), #[error("Server error: {message}")] Server { message: String }, } /// Error types for WASM client operations #[cfg(target_arch = "wasm32")] #[derive(Error, Debug)] pub enum ClientError { #[error("JSON-RPC error: {0}")] JsonRpc(String), #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), #[error("HTTP client error: {0}")] Http(String), #[error("Server error: {message}")] Server { message: String }, #[error("JavaScript error: {0}")] JavaScript(String), #[error("Network error: {0}")] Network(String), } // Implement From for jsonrpsee ClientError for WASM #[cfg(target_arch = "wasm32")] impl From for ClientError { fn from(js_val: wasm_bindgen::JsValue) -> Self { ClientError::JavaScript(format!("{:?}", js_val)) } } /// Result type for client operations pub type ClientResult = Result; /// Types of runners supported by the supervisor #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum RunnerType { /// SAL Runner for system abstraction layer operations SALRunner, /// OSIS Runner for operating system interface operations OSISRunner, /// V Runner for virtualization operations VRunner, /// Python Runner for Python-based actors PyRunner, } /// Process manager type for WASM compatibility #[cfg(target_arch = "wasm32")] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ProcessManagerType { Simple, Tmux(String), } /// Configuration for an actor runner #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RunnerConfig { /// Unique identifier for the actor pub actor_id: String, /// Type of runner pub runner_type: RunnerType, /// Path to the actor binary pub binary_path: PathBuf, /// Database path for the actor pub db_path: String, /// Redis URL for job queue pub redis_url: String, } /// Job result response #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum JobResult { Success { success: String }, Error { error: String }, } /// Job status response #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JobStatusResponse { pub job_id: String, pub status: String, pub created_at: String, pub started_at: Option, pub completed_at: Option, } // Re-export Job types from shared crate pub use hero_job::{Job, JobStatus, JobError, JobBuilder}; /// Process status wrapper for OpenRPC serialization (matches server response) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ProcessStatusWrapper { Running, Stopped, Starting, Stopping, Error(String), } /// Log information wrapper for OpenRPC serialization (matches server response) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogInfoWrapper { pub timestamp: String, pub level: String, pub message: String, } /// Supervisor information response containing secret counts and server details #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SupervisorInfo { pub server_url: String, pub admin_secrets_count: usize, pub user_secrets_count: usize, pub register_secrets_count: usize, pub runners_count: usize, } /// Type aliases for compatibility #[cfg(target_arch = "wasm32")] pub type ProcessStatus = ProcessStatusWrapper; #[cfg(target_arch = "wasm32")] pub type LogInfo = LogInfoWrapper; /// Simple ProcessStatus type for native builds to avoid service manager dependency #[cfg(not(target_arch = "wasm32"))] pub type ProcessStatus = ProcessStatusWrapper; /// Re-export types from supervisor crate for native builds #[cfg(not(target_arch = "wasm32"))] pub use hero_supervisor::{ProcessManagerType, RunnerStatus}; #[cfg(not(target_arch = "wasm32"))] pub use hero_supervisor::runner::LogInfo; /// Type aliases for WASM compatibility #[cfg(target_arch = "wasm32")] pub type RunnerStatus = ProcessStatusWrapper; #[cfg(target_arch = "wasm32")] pub type LogInfo = LogInfoWrapper; #[cfg(not(target_arch = "wasm32"))] impl SupervisorClient { /// Create a new supervisor client pub fn new(server_url: impl Into) -> ClientResult { let server_url = server_url.into(); let client = HttpClientBuilder::default() .request_timeout(std::time::Duration::from_secs(30)) .build(&server_url) .map_err(|e| ClientError::Http(e.to_string()))?; Ok(Self { client, server_url, }) } /// Get the server URL pub fn server_url(&self) -> &str { &self.server_url } /// 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 { let result: serde_json::Value = self .client .request("rpc.discover", rpc_params![]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(result) } /// Register a new runner to the supervisor with secret authentication pub async fn register_runner( &self, secret: &str, name: &str, queue: &str, ) -> ClientResult<()> { let _: () = self .client .request( "register_runner", rpc_params![secret, name, queue], ) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Create a new job without queuing it to a runner pub async fn jobs_create( &self, secret: &str, job: Job, ) -> ClientResult { let params = serde_json::json!({ "secret": secret, "job": job }); let job_id: String = self .client .request("jobs.create", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(job_id) } /// List all jobs pub async fn jobs_list(&self) -> ClientResult> { let jobs: Vec = self .client .request("jobs.list", rpc_params![]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(jobs) } /// Run a job on the appropriate runner and return the result pub async fn job_run( &self, secret: &str, job: Job, ) -> ClientResult { let params = serde_json::json!({ "secret": secret, "job": job }); let result: JobResult = self .client .request("job.run", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(result) } /// Start a previously created job by queuing it to its assigned runner pub async fn job_start( &self, secret: &str, job_id: &str, ) -> ClientResult<()> { let params = serde_json::json!({ "secret": secret, "job_id": job_id }); let _: () = self .client .request("job.start", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Get the current status of a job pub async fn job_status(&self, job_id: &str) -> ClientResult { let status: JobStatusResponse = self .client .request("job.status", rpc_params![job_id]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(status) } /// Get the result of a completed job (blocks until result is available) pub async fn job_result(&self, job_id: &str) -> ClientResult { let result: JobResult = self .client .request("job.result", rpc_params![job_id]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(result) } /// Remove a runner from the supervisor pub async fn remove_runner(&self, actor_id: &str) -> ClientResult<()> { let _: () = self .client .request("remove_runner", rpc_params![actor_id]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// List all runner IDs pub async fn list_runners(&self) -> ClientResult> { let runners: Vec = self .client .request("list_runners", rpc_params![]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(runners) } /// Start a specific runner pub async fn start_runner(&self, actor_id: &str) -> ClientResult<()> { let _: () = self .client .request("start_runner", rpc_params![actor_id]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Stop a specific runner pub async fn stop_runner(&self, actor_id: &str, force: bool) -> ClientResult<()> { let _: () = self .client .request("stop_runner", rpc_params![actor_id, force]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Add a runner to the supervisor pub async fn add_runner(&self, config: RunnerConfig, process_manager: ProcessManagerType) -> ClientResult<()> { let _: () = self .client .request("add_runner", rpc_params![config, process_manager]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Get status of a specific runner pub async fn get_runner_status(&self, actor_id: &str) -> ClientResult { let status: RunnerStatus = self .client .request("get_runner_status", rpc_params![actor_id]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(status) } /// Get logs for a specific runner pub async fn get_runner_logs( &self, actor_id: &str, lines: Option, follow: bool, ) -> ClientResult> { let logs: Vec = self .client .request("get_runner_logs", rpc_params![actor_id, lines, follow]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(logs) } /// Queue a job to a specific runner pub async fn queue_job_to_runner(&self, runner: &str, job: Job) -> ClientResult<()> { let params = serde_json::json!({ "runner": runner, "job": job }); let _: () = self .client .request("queue_job_to_runner", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Queue a job and wait for completion pub async fn queue_and_wait(&self, runner: &str, job: Job, timeout_secs: u64) -> ClientResult> { let params = serde_json::json!({ "runner": runner, "job": job, "timeout_secs": timeout_secs }); let result: Option = self .client .request("queue_and_wait", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(result) } /// Run a job on a specific runner pub async fn run_job(&self, secret: &str, job: Job) -> ClientResult { let params = serde_json::json!({ "secret": secret, "job": job }); let result: JobResult = self .client .request("run_job", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(result) } /// Get job result by job ID pub async fn get_job_result(&self, job_id: &str) -> ClientResult> { let result: Option = self .client .request("get_job_result", rpc_params![job_id]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(result) } /// Get status of all runners pub async fn get_all_runner_status(&self) -> ClientResult> { let statuses: Vec<(String, RunnerStatus)> = self .client .request("get_all_runner_status", rpc_params![]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(statuses) } /// Start all runners pub async fn start_all(&self) -> ClientResult> { let results: Vec<(String, bool)> = self .client .request("start_all", rpc_params![]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(results) } /// Stop all runners pub async fn stop_all(&self, force: bool) -> ClientResult> { let results: Vec<(String, bool)> = self .client .request("stop_all", rpc_params![force]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(results) } /// Get status of all runners (alternative method) pub async fn get_all_status(&self) -> ClientResult> { let statuses: Vec<(String, RunnerStatus)> = self .client .request("get_all_status", rpc_params![]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(statuses) } /// Add a secret to the supervisor pub async fn add_secret( &self, admin_secret: &str, secret_type: &str, secret_value: &str, ) -> ClientResult<()> { let params = serde_json::json!({ "admin_secret": admin_secret, "secret_type": secret_type, "secret_value": secret_value }); let _: () = self .client .request("add_secret", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// Remove a secret from the supervisor pub async fn remove_secret( &self, admin_secret: &str, secret_type: &str, secret_value: &str, ) -> ClientResult<()> { let params = serde_json::json!({ "admin_secret": admin_secret, "secret_type": secret_type, "secret_value": secret_value }); let _: () = self .client .request("remove_secret", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(()) } /// List secrets (returns supervisor info including secret counts) pub async fn list_secrets(&self, admin_secret: &str) -> ClientResult { let params = serde_json::json!({ "admin_secret": admin_secret }); let info: SupervisorInfo = self .client .request("list_secrets", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(info) } /// Stop a running job pub async fn job_stop(&self, secret: &str, job_id: &str) -> ClientResult<()> { let params = serde_json::json!({ "secret": secret, "job_id": job_id }); self.client .request("job.stop", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e)) } /// Delete a job from the system pub async fn job_delete(&self, secret: &str, job_id: &str) -> ClientResult<()> { let params = serde_json::json!({ "secret": secret, "job_id": job_id }); self.client .request("job.delete", rpc_params![params]) .await.map_err(|e| ClientError::JsonRpc(e)) } /// Get supervisor information including secret counts pub async fn get_supervisor_info(&self, admin_secret: &str) -> ClientResult { let info: SupervisorInfo = self .client .request("get_supervisor_info", rpc_params![admin_secret]) .await.map_err(|e| ClientError::JsonRpc(e))?; Ok(info) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_client_creation() { let client = SupervisorClient::new("http://127.0.0.1:3030"); assert!(client.is_ok()); let client = client.unwrap(); assert_eq!(client.server_url(), "http://127.0.0.1:3030"); } #[test] fn test_job_builder() { let job = JobBuilder::new() .caller_id("test_client") .context_id("test_context") .payload("print('Hello, World!');") .executor("osis") .runner("test_runner") .timeout(60) .env_var("TEST_VAR", "test_value") .build(); assert!(job.is_ok()); let job = job.unwrap(); assert_eq!(job.caller_id, "test_client"); assert_eq!(job.context_id, "test_context"); assert_eq!(job.payload, "print('Hello, World!');"); assert_eq!(job.executor, "osis"); assert_eq!(job.runner, "test_runner"); assert_eq!(job.timeout, 60); assert_eq!(job.env_vars.get("TEST_VAR"), Some(&"test_value".to_string())); } #[test] fn test_job_builder_validation() { // Missing caller_id let result = JobBuilder::new() .context_id("test") .payload("test") .runner("test") .build(); assert!(result.is_err()); // Missing context_id let result = JobBuilder::new() .caller_id("test") .payload("test") .runner("test") .build(); assert!(result.is_err()); // Missing payload let result = JobBuilder::new() .caller_id("test") .context_id("test") .runner("test") .executor("test") .build(); assert!(result.is_err()); // Missing runner let result = JobBuilder::new() .caller_id("test") .context_id("test") .payload("test") .executor("test") .build(); assert!(result.is_err()); // Missing executor let result = JobBuilder::new() .caller_id("test") .context_id("test") .payload("test") .runner("test") .build(); assert!(result.is_err()); } } #[cfg(test)] mod client_tests { use super::*; #[cfg(not(target_arch = "wasm32"))] mod native_tests { use super::*; #[test] fn test_client_creation() { let client = SupervisorClient::new("http://localhost:3030"); assert!(client.is_ok()); let client = client.unwrap(); assert_eq!(client.server_url(), "http://localhost:3030"); } #[test] fn test_client_creation_invalid_url() { let client = SupervisorClient::new("invalid-url"); // HTTP client builder validates URLs and should fail on invalid ones assert!(client.is_err()); } #[test] fn test_process_status_wrapper_serialization() { let status = ProcessStatusWrapper::Running; let serialized = serde_json::to_string(&status).unwrap(); assert_eq!(serialized, "\"Running\""); let status = ProcessStatusWrapper::Error("test error".to_string()); let serialized = serde_json::to_string(&status).unwrap(); assert!(serialized.contains("Error")); assert!(serialized.contains("test error")); } #[test] fn test_log_info_wrapper_serialization() { let log = LogInfoWrapper { timestamp: "2023-01-01T00:00:00Z".to_string(), level: "INFO".to_string(), message: "test message".to_string(), }; let serialized = serde_json::to_string(&log).unwrap(); assert!(serialized.contains("2023-01-01T00:00:00Z")); assert!(serialized.contains("INFO")); assert!(serialized.contains("test message")); let deserialized: LogInfoWrapper = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized.timestamp, log.timestamp); assert_eq!(deserialized.level, log.level); assert_eq!(deserialized.message, log.message); } #[test] fn test_runner_type_serialization() { let runner_type = RunnerType::SALRunner; let serialized = serde_json::to_string(&runner_type).unwrap(); assert_eq!(serialized, "\"SALRunner\""); let deserialized: RunnerType = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized, RunnerType::SALRunner); } #[test] fn test_job_type_conversion() { assert_eq!(JobType::SAL, JobType::SAL); assert_eq!(JobType::OSIS, JobType::OSIS); assert_eq!(JobType::V, JobType::V); assert_eq!(JobType::Python, JobType::Python); } #[test] fn test_job_status_serialization() { let status = JobStatus::Started; let serialized = serde_json::to_string(&status).unwrap(); assert_eq!(serialized, "\"Started\""); let deserialized: JobStatus = serde_json::from_str(&serialized).unwrap(); assert_eq!(deserialized, JobStatus::Started); } } #[cfg(target_arch = "wasm32")] mod wasm_tests { use super::*; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] fn test_wasm_client_creation() { let client = crate::wasm::WasmSupervisorClient::new("http://localhost:3030".to_string()); assert_eq!(client.server_url(), "http://localhost:3030"); } #[wasm_bindgen_test] fn test_wasm_job_creation() { let job = crate::wasm::WasmJob::new( "test-id".to_string(), "test payload".to_string(), "SAL".to_string(), "test-runner".to_string(), ); assert_eq!(job.id(), "test-id"); assert_eq!(job.payload(), "test payload"); assert_eq!(job.job_type(), "SAL"); assert_eq!(job.runner(), "test-runner"); assert_eq!(job.caller_id(), "wasm_client"); assert_eq!(job.context_id(), "wasm_context"); assert_eq!(job.timeout_secs(), 30); } #[wasm_bindgen_test] fn test_wasm_job_setters() { let mut job = crate::wasm::WasmJob::new( "test-id".to_string(), "test payload".to_string(), "SAL".to_string(), "test-runner".to_string(), ); job.set_caller_id("custom-caller".to_string()); job.set_context_id("custom-context".to_string()); job.set_timeout_secs(60); job.set_env_vars("{\"KEY\":\"VALUE\"}".to_string()); assert_eq!(job.caller_id(), "custom-caller"); assert_eq!(job.context_id(), "custom-context"); assert_eq!(job.timeout_secs(), 60); assert_eq!(job.env_vars(), "{\"KEY\":\"VALUE\"}"); } #[wasm_bindgen_test] fn test_wasm_job_id_generation() { let mut job = crate::wasm::WasmJob::new( "original-id".to_string(), "test payload".to_string(), "SAL".to_string(), "test-runner".to_string(), ); let original_id = job.id(); job.generate_id(); let new_id = job.id(); assert_ne!(original_id, new_id); assert!(new_id.len() > 0); } #[wasm_bindgen_test] fn test_create_job_function() { let job = crate::wasm::create_job( "func-test-id".to_string(), "func test payload".to_string(), "OSIS".to_string(), "func-test-runner".to_string(), ); assert_eq!(job.id(), "func-test-id"); assert_eq!(job.payload(), "func test payload"); assert_eq!(job.job_type(), "OSIS"); assert_eq!(job.runner(), "func-test-runner"); } #[wasm_bindgen_test] fn test_wasm_job_type_enum() { use crate::wasm::WasmJobType; // Test that enum variants exist and can be created let sal = WasmJobType::SAL; let osis = WasmJobType::OSIS; let v = WasmJobType::V; // Test equality assert_eq!(sal, WasmJobType::SAL); assert_eq!(osis, WasmJobType::OSIS); assert_eq!(v, WasmJobType::V); // Test inequality assert_ne!(sal, osis); assert_ne!(osis, v); assert_ne!(v, sal); } } // Common tests that work on both native and WASM #[test] fn test_process_status_wrapper_variants() { let running = ProcessStatusWrapper::Running; let stopped = ProcessStatusWrapper::Stopped; let starting = ProcessStatusWrapper::Starting; let stopping = ProcessStatusWrapper::Stopping; let error = ProcessStatusWrapper::Error("test".to_string()); // Test that all variants can be created assert_eq!(running, ProcessStatusWrapper::Running); assert_eq!(stopped, ProcessStatusWrapper::Stopped); assert_eq!(starting, ProcessStatusWrapper::Starting); assert_eq!(stopping, ProcessStatusWrapper::Stopping); if let ProcessStatusWrapper::Error(msg) = error { assert_eq!(msg, "test"); } else { panic!("Expected Error variant"); } } #[test] fn test_job_type_variants() { assert_eq!(JobType::SAL, JobType::SAL); assert_eq!(JobType::OSIS, JobType::OSIS); assert_eq!(JobType::V, JobType::V); assert_eq!(JobType::Python, JobType::Python); assert_ne!(JobType::SAL, JobType::OSIS); assert_ne!(JobType::OSIS, JobType::V); assert_ne!(JobType::V, JobType::Python); } #[test] fn test_job_status_variants() { assert_eq!(JobStatus::Created, JobStatus::Created); assert_eq!(JobStatus::Dispatched, JobStatus::Dispatched); assert_eq!(JobStatus::Started, JobStatus::Started); assert_eq!(JobStatus::Finished, JobStatus::Finished); assert_eq!(JobStatus::Error, JobStatus::Error); assert_ne!(JobStatus::Created, JobStatus::Dispatched); assert_ne!(JobStatus::Started, JobStatus::Finished); } #[test] fn test_runner_type_variants() { assert_eq!(RunnerType::SALRunner, RunnerType::SALRunner); assert_eq!(RunnerType::OSISRunner, RunnerType::OSISRunner); assert_eq!(RunnerType::VRunner, RunnerType::VRunner); assert_eq!(RunnerType::PyRunner, RunnerType::PyRunner); assert_ne!(RunnerType::SALRunner, RunnerType::OSISRunner); assert_ne!(RunnerType::VRunner, RunnerType::PyRunner); } #[test] fn test_process_manager_type_variants() { let simple = ProcessManagerType::Simple; let tmux = ProcessManagerType::Tmux("test-session".to_string()); assert_eq!(simple, ProcessManagerType::Simple); if let ProcessManagerType::Tmux(session) = tmux { assert_eq!(session, "test-session"); } else { panic!("Expected Tmux variant"); } } }