initial commit

This commit is contained in:
Timur Gordon
2025-08-26 14:49:21 +02:00
commit 767c66fb6a
66 changed files with 22035 additions and 0 deletions

668
clients/openrpc/src/wasm.rs Normal file
View File

@@ -0,0 +1,668 @@
//! WASM-compatible OpenRPC client for Hero Supervisor
//!
//! This module provides a WASM-compatible client library for interacting with the Hero Supervisor
//! OpenRPC server using browser-native fetch APIs.
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response, Headers};
use serde::{Deserialize, Serialize};
// use std::collections::HashMap; // Unused
use thiserror::Error;
use uuid::Uuid;
// use js_sys::Promise; // Unused
/// WASM-compatible client for communicating with Hero Supervisor OpenRPC server
#[wasm_bindgen]
pub struct WasmSupervisorClient {
server_url: String,
}
/// Error types for WASM client operations
#[derive(Error, Debug)]
pub enum WasmClientError {
#[error("Network error: {0}")]
Network(String),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("JavaScript error: {0}")]
JavaScript(String),
#[error("Server error: {message}")]
Server { message: String },
#[error("Invalid response format")]
InvalidResponse,
}
/// Result type for WASM client operations
pub type WasmClientResult<T> = Result<T, WasmClientError>;
/// JSON-RPC request structure
#[derive(Serialize)]
struct JsonRpcRequest {
jsonrpc: String,
method: String,
params: serde_json::Value,
id: u32,
}
/// JSON-RPC response structure
#[derive(Deserialize)]
struct JsonRpcResponse {
jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
id: u32,
}
/// JSON-RPC error structure
#[derive(Deserialize)]
struct JsonRpcError {
code: i32,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>,
}
/// Types of runners supported by the supervisor
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[wasm_bindgen]
pub enum WasmRunnerType {
SALRunner,
OSISRunner,
VRunner,
}
/// Job type enumeration that maps to runner types
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[wasm_bindgen]
pub enum WasmJobType {
SAL,
OSIS,
V,
}
/// Job structure for creating and managing jobs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[wasm_bindgen]
pub struct WasmJob {
id: String,
caller_id: String,
context_id: String,
payload: String,
runner_name: String,
executor: String,
timeout_secs: u64,
env_vars: String, // JSON string of HashMap<String, String>
created_at: String,
updated_at: String,
}
#[wasm_bindgen]
impl WasmSupervisorClient {
/// Create a new WASM supervisor client
#[wasm_bindgen(constructor)]
pub fn new(server_url: String) -> Self {
console_log::init_with_level(log::Level::Info).ok();
Self { server_url }
}
/// Get the server URL
#[wasm_bindgen(getter)]
pub fn server_url(&self) -> String {
self.server_url.clone()
}
/// Test connection using OpenRPC discovery method
pub async fn discover(&self) -> Result<JsValue, JsValue> {
let result = self.call_method("rpc.discover", serde_json::Value::Null).await;
match result {
Ok(value) => Ok(wasm_bindgen::JsValue::from_str(&value.to_string())),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Register a new runner to the supervisor with secret authentication
pub async fn register_runner(&self, secret: &str, name: &str, queue: &str) -> Result<String, JsValue> {
let params = serde_json::json!([{
"secret": secret,
"name": name,
"queue": queue
}]);
match self.call_method("register_runner", params).await {
Ok(result) => {
// Extract the runner name from the result
if let Some(runner_name) = result.as_str() {
Ok(runner_name.to_string())
} else {
Err(JsValue::from_str("Invalid response format: expected runner name"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Create a job (fire-and-forget, non-blocking)
#[wasm_bindgen]
pub async fn create_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
let params = serde_json::json!([{
"secret": secret,
"job": {
"id": job.id,
"caller_id": job.caller_id,
"context_id": job.context_id,
"payload": job.payload,
"runner_name": job.runner_name,
"executor": job.executor,
"timeout": {
"secs": job.timeout_secs,
"nanos": 0
},
"env_vars": serde_json::from_str::<serde_json::Value>(&job.env_vars).unwrap_or(serde_json::json!({})),
"created_at": job.created_at,
"updated_at": job.updated_at
}
}]);
match self.call_method("create_job", params).await {
Ok(result) => {
if let Some(job_id) = result.as_str() {
Ok(job_id.to_string())
} else {
Ok(result.to_string())
}
}
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {:?}", e)))
}
}
/// Run a job on a specific runner (blocking, returns result)
#[wasm_bindgen]
pub async fn run_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
let params = serde_json::json!([{
"secret": secret,
"job": {
"id": job.id,
"caller_id": job.caller_id,
"context_id": job.context_id,
"payload": job.payload,
"runner_name": job.runner_name,
"executor": job.executor,
"timeout": {
"secs": job.timeout_secs,
"nanos": 0
},
"env_vars": serde_json::from_str::<serde_json::Value>(&job.env_vars).unwrap_or(serde_json::json!({})),
"created_at": job.created_at,
"updated_at": job.updated_at
}
}]);
match self.call_method("run_job", params).await {
Ok(result) => {
if let Some(result_str) = result.as_str() {
Ok(result_str.to_string())
} else {
Ok(result.to_string())
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// List all runner IDs
pub async fn list_runners(&self) -> Result<Vec<String>, JsValue> {
match self.call_method("list_runners", serde_json::Value::Null).await {
Ok(result) => {
if let Ok(runners) = serde_json::from_value::<Vec<String>>(result) {
Ok(runners)
} else {
Err(JsValue::from_str("Invalid response format for list_runners"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// List all job IDs from Redis
pub async fn list_jobs(&self) -> Result<Vec<String>, JsValue> {
match self.call_method("list_jobs", serde_json::Value::Null).await {
Ok(result) => {
if let Ok(jobs) = serde_json::from_value::<Vec<String>>(result) {
Ok(jobs)
} else {
Err(JsValue::from_str("Invalid response format for list_jobs"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Get a job by job ID
pub async fn get_job(&self, job_id: &str) -> Result<WasmJob, JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("get_job", params).await {
Ok(result) => {
// Convert the Job result to WasmJob
if let Ok(job_value) = serde_json::from_value::<serde_json::Value>(result) {
// Extract fields from the job
let id = job_value.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let caller_id = job_value.get("caller_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let context_id = job_value.get("context_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let payload = job_value.get("payload").and_then(|v| v.as_str()).unwrap_or("").to_string();
let runner_name = job_value.get("runner_name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let executor = job_value.get("executor").and_then(|v| v.as_str()).unwrap_or("").to_string();
let timeout_secs = job_value.get("timeout").and_then(|v| v.get("secs")).and_then(|v| v.as_u64()).unwrap_or(30);
let env_vars = job_value.get("env_vars").map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string());
let created_at = job_value.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
let updated_at = job_value.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
Ok(WasmJob {
id,
caller_id,
context_id,
payload,
runner_name,
executor,
timeout_secs,
env_vars,
created_at,
updated_at,
})
} else {
Err(JsValue::from_str("Invalid response format for get_job"))
}
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Ping a runner by dispatching a ping job to its queue
#[wasm_bindgen]
pub async fn ping_runner(&self, runner_id: &str) -> Result<String, JsValue> {
let params = serde_json::json!([runner_id]);
match self.call_method("ping_runner", params).await {
Ok(result) => {
if let Some(job_id) = result.as_str() {
Ok(job_id.to_string())
} else {
Ok(result.to_string())
}
}
Err(e) => Err(JsValue::from_str(&format!("Failed to ping runner: {:?}", e)))
}
}
/// Stop a job by ID
#[wasm_bindgen]
pub async fn stop_job(&self, job_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("stop_job", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&format!("Failed to stop job: {:?}", e)))
}
}
/// Delete a job by ID
#[wasm_bindgen]
pub async fn delete_job(&self, job_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("delete_job", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e)))
}
}
/// Remove a runner from the supervisor
pub async fn remove_runner(&self, actor_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([actor_id]);
match self.call_method("remove_runner", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Start a specific runner
pub async fn start_runner(&self, actor_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([actor_id]);
match self.call_method("start_runner", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Stop a specific runner
pub async fn stop_runner(&self, actor_id: &str, force: bool) -> Result<(), JsValue> {
let params = serde_json::json!([actor_id, force]);
self.call_method("stop_runner", params)
.await
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(())
}
/// Get a specific runner by ID
pub async fn get_runner(&self, actor_id: &str) -> Result<JsValue, JsValue> {
let params = serde_json::json!([actor_id]);
let result = self.call_method("get_runner", params)
.await
.map_err(|e| JsValue::from_str(&e.to_string()))?;
// Convert the serde_json::Value to a JsValue via string serialization
let json_string = serde_json::to_string(&result)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(js_sys::JSON::parse(&json_string)
.map_err(|e| JsValue::from_str("Failed to parse JSON"))?)
}
/// Add a secret to the supervisor
pub async fn add_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> {
let params = serde_json::json!([{
"admin_secret": admin_secret,
"secret_type": secret_type,
"secret_value": secret_value
}]);
match self.call_method("add_secret", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Remove a secret from the supervisor
pub async fn remove_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> {
let params = serde_json::json!([{
"admin_secret": admin_secret,
"secret_type": secret_type,
"secret_value": secret_value
}]);
match self.call_method("remove_secret", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// List secrets (returns supervisor info including secret counts)
pub async fn list_secrets(&self, admin_secret: &str) -> Result<JsValue, JsValue> {
let params = serde_json::json!([{
"admin_secret": admin_secret
}]);
match self.call_method("list_secrets", params).await {
Ok(result) => {
// Convert serde_json::Value to JsValue
let result_str = serde_json::to_string(&result)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(js_sys::JSON::parse(&result_str)
.map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?)
},
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Get supervisor information including secret counts
pub async fn get_supervisor_info(&self, admin_secret: &str) -> Result<JsValue, JsValue> {
let params = serde_json::json!({
"admin_secret": admin_secret
});
match self.call_method("get_supervisor_info", params).await {
Ok(result) => {
let result_str = serde_json::to_string(&result)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {:?}", e)))?;
Ok(js_sys::JSON::parse(&result_str)
.map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to get supervisor info: {:?}", e))),
}
}
/// List admin secrets (returns actual secret values)
pub async fn list_admin_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
let params = serde_json::json!({
"admin_secret": admin_secret
});
match self.call_method("list_admin_secrets", params).await {
Ok(result) => {
let secrets: Vec<String> = serde_json::from_value(result)
.map_err(|e| JsValue::from_str(&format!("Failed to parse admin secrets: {:?}", e)))?;
Ok(secrets)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to list admin secrets: {:?}", e))),
}
}
/// List user secrets (returns actual secret values)
pub async fn list_user_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
let params = serde_json::json!({
"admin_secret": admin_secret
});
match self.call_method("list_user_secrets", params).await {
Ok(result) => {
let secrets: Vec<String> = serde_json::from_value(result)
.map_err(|e| JsValue::from_str(&format!("Failed to parse user secrets: {:?}", e)))?;
Ok(secrets)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to list user secrets: {:?}", e))),
}
}
/// List register secrets (returns actual secret values)
pub async fn list_register_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
let params = serde_json::json!({
"admin_secret": admin_secret
});
match self.call_method("list_register_secrets", params).await {
Ok(result) => {
let secrets: Vec<String> = serde_json::from_value(result)
.map_err(|e| JsValue::from_str(&format!("Failed to parse register secrets: {:?}", e)))?;
Ok(secrets)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to list register secrets: {:?}", e))),
}
}
}
#[wasm_bindgen]
impl WasmJob {
/// Create a new job with default values
#[wasm_bindgen(constructor)]
pub fn new(id: String, payload: String, executor: String, runner_name: String) -> Self {
let now = js_sys::Date::new_0().to_iso_string().as_string().unwrap();
Self {
id,
caller_id: "wasm_client".to_string(),
context_id: "wasm_context".to_string(),
payload,
runner_name,
executor,
timeout_secs: 30,
env_vars: "{}".to_string(),
created_at: now.clone(),
updated_at: now,
}
}
/// Set the caller ID
#[wasm_bindgen(setter)]
pub fn set_caller_id(&mut self, caller_id: String) {
self.caller_id = caller_id;
}
/// Set the context ID
#[wasm_bindgen(setter)]
pub fn set_context_id(&mut self, context_id: String) {
self.context_id = context_id;
}
/// Set the timeout in seconds
#[wasm_bindgen(setter)]
pub fn set_timeout_secs(&mut self, timeout_secs: u64) {
self.timeout_secs = timeout_secs;
}
/// Set environment variables as JSON string
#[wasm_bindgen(setter)]
pub fn set_env_vars(&mut self, env_vars: String) {
self.env_vars = env_vars;
}
/// Generate a new UUID for the job
#[wasm_bindgen]
pub fn generate_id(&mut self) {
self.id = Uuid::new_v4().to_string();
}
/// Get the job ID
#[wasm_bindgen(getter)]
pub fn id(&self) -> String {
self.id.clone()
}
/// Get the caller ID
#[wasm_bindgen(getter)]
pub fn caller_id(&self) -> String {
self.caller_id.clone()
}
/// Get the context ID
#[wasm_bindgen(getter)]
pub fn context_id(&self) -> String {
self.context_id.clone()
}
/// Get the payload
#[wasm_bindgen(getter)]
pub fn payload(&self) -> String {
self.payload.clone()
}
/// Get the job type
#[wasm_bindgen(getter)]
pub fn executor(&self) -> String {
self.executor.clone()
}
/// Get the runner name
#[wasm_bindgen(getter)]
pub fn runner_name(&self) -> String {
self.runner_name.clone()
}
/// Get the timeout in seconds
#[wasm_bindgen(getter)]
pub fn timeout_secs(&self) -> u64 {
self.timeout_secs
}
/// Get the environment variables as JSON string
#[wasm_bindgen(getter)]
pub fn env_vars(&self) -> String {
self.env_vars.clone()
}
/// Get the created timestamp
#[wasm_bindgen(getter)]
pub fn created_at(&self) -> String {
self.created_at.clone()
}
/// Get the updated timestamp
#[wasm_bindgen(getter)]
pub fn updated_at(&self) -> String {
self.updated_at.clone()
}
}
impl WasmSupervisorClient {
/// Internal method to make JSON-RPC calls
async fn call_method(&self, method: &str, params: serde_json::Value) -> WasmClientResult<serde_json::Value> {
let request = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
method: method.to_string(),
params,
id: 1,
};
let body = serde_json::to_string(&request)?;
// Create headers
let headers = Headers::new().map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
headers.set("Content-Type", "application/json")
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
// Create request init
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_headers(&headers);
opts.set_body(&JsValue::from_str(&body));
opts.set_mode(RequestMode::Cors);
// Create request
let request = Request::new_with_str_and_init(&self.server_url, &opts)
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
// Get window and fetch
let window = web_sys::window().ok_or_else(|| WasmClientError::JavaScript("No window object".to_string()))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await
.map_err(|e| WasmClientError::Network(format!("{:?}", e)))?;
// Convert to Response
let resp: Response = resp_value.dyn_into()
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
// Check if response is ok
if !resp.ok() {
return Err(WasmClientError::Network(format!("HTTP {}: {}", resp.status(), resp.status_text())));
}
// Get response text
let text_promise = resp.text()
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
let text_value = JsFuture::from(text_promise).await
.map_err(|e| WasmClientError::Network(format!("{:?}", e)))?;
let text = text_value.as_string()
.ok_or_else(|| WasmClientError::InvalidResponse)?;
// Parse JSON-RPC response
let response: JsonRpcResponse = serde_json::from_str(&text)?;
if let Some(error) = response.error {
return Err(WasmClientError::Server {
message: format!("{}: {}", error.code, error.message),
});
}
// For void methods, null result is valid
Ok(response.result.unwrap_or(serde_json::Value::Null))
}
}
/// Initialize the WASM client library (call manually if needed)
pub fn init() {
console_log::init_with_level(log::Level::Info).ok();
log::info!("Hero Supervisor WASM OpenRPC Client initialized");
}
/// Utility function to create a job from JavaScript
/// Create a new job (convenience function for JavaScript)
#[wasm_bindgen]
pub fn create_job(id: String, payload: String, executor: String, runner_name: String) -> WasmJob {
WasmJob::new(id, payload, executor, runner_name)
}
/// Utility function to create a client from JavaScript
#[wasm_bindgen]
pub fn create_client(server_url: String) -> WasmSupervisorClient {
WasmSupervisorClient::new(server_url)
}