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

2
clients/openrpc/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
pkg
target

View File

@@ -0,0 +1,59 @@
[package]
name = "hero-supervisor-openrpc-client-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM-compatible OpenRPC client for Hero Supervisor"
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# WASM bindings
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
# Web APIs
web-sys = { version = "0.3", features = [
"console",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Window",
"Headers",
"AbortController",
"AbortSignal",
] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde-wasm-bindgen = "0.6"
# Error handling
thiserror = "1.0"
# UUID for job IDs
uuid = { version = "1.0", features = ["v4", "serde", "js"] }
# Time handling
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
# Collections
indexmap = "2.0"
# Logging for WASM
log = "0.4"
console_log = "1.0"
# Async utilities
futures = "0.3"
[dependencies.getrandom]
version = "0.2"
features = ["js"]
[dev-dependencies]
wasm-bindgen-test = "0.3"

2710
clients/openrpc/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
[package]
name = "hero-supervisor-openrpc-client"
version = "0.1.0"
edition = "2021"
description = "OpenRPC client for Hero Supervisor"
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# Common dependencies for both native and WASM
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
log = "0.4"
uuid = { version = "1.0", features = ["v4", "serde"] }
# Native JSON-RPC client (not WASM compatible)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
jsonrpsee = { version = "0.24", features = ["http-client", "macros"] }
tokio = { version = "1.0", features = ["full"] }
hero-supervisor = { path = "../.." }
env_logger = "0.11"
# WASM-specific dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"console",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Headers",
"Window",
] }
console_log = "1.0"
getrandom = { version = "0.2", features = ["js"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
# UUID for job IDs (native)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.uuid]
version = "1.0"
features = ["v4", "serde"]
# Time handling (native)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.chrono]
version = "0.4"
features = ["serde"]
# WASM-compatible dependencies (already defined above)
[target.'cfg(target_arch = "wasm32")'.dependencies.chrono]
version = "0.4"
features = ["serde", "wasmbind"]
[target.'cfg(target_arch = "wasm32")'.dependencies.uuid]
version = "1.0"
features = ["v4", "serde", "js"]
# Collections
indexmap = "2.0"
# Interactive CLI
crossterm = "0.27"
ratatui = "0.28"
# Command line parsing
clap = { version = "4.0", features = ["derive"] }
[[bin]]
name = "openrpc-cli"
path = "cmd/main.rs"
[dev-dependencies]
# Testing utilities
tokio-test = "0.4"

196
clients/openrpc/README.md Normal file
View File

@@ -0,0 +1,196 @@
# Hero Supervisor OpenRPC Client
A Rust client library for interacting with the Hero Supervisor OpenRPC server. This crate provides a simple, async interface for managing actors and jobs remotely.
## Features
- **Async API**: Built on `tokio` and `jsonrpsee` for high-performance async operations
- **Type Safety**: Full Rust type safety with serde serialization/deserialization
- **Job Builder**: Fluent API for creating jobs with validation
- **Comprehensive Coverage**: All supervisor operations available via client
- **Error Handling**: Detailed error types with proper error propagation
## Installation
Add this to your `Cargo.toml`:
```toml
[dependencies]
hero-supervisor-openrpc-client = "0.1.0"
tokio = { version = "1.0", features = ["full"] }
```
## Quick Start
```rust
use hero_supervisor_openrpc_client::{
SupervisorClient, RunnerConfig, RunnerType, ProcessManagerType, JobBuilder, JobType
};
use std::path::PathBuf;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a client
let client = SupervisorClient::new("http://127.0.0.1:3030")?;
// Add a runner
let config = RunnerConfig {
actor_id: "my_actor".to_string(),
runner_type: RunnerType::OSISRunner,
binary_path: PathBuf::from("/path/to/actor/binary"),
db_path: "/path/to/db".to_string(),
redis_url: "redis://localhost:6379".to_string(),
};
client.add_runner(config, ProcessManagerType::Simple).await?;
// Start the runner
client.start_runner("my_actor").await?;
// Create and queue a job
let job = JobBuilder::new()
.caller_id("my_client")
.context_id("example_context")
.payload("print('Hello from Hero Supervisor!');")
.job_type(JobType::OSIS)
.runner_name("my_actor")
.timeout(Duration::from_secs(60))
.build()?;
client.queue_job_to_runner("my_actor", job).await?;
// Check runner status
let status = client.get_runner_status("my_actor").await?;
println!("Runner status: {:?}", status);
// List all runners
let runners = client.list_runners().await?;
println!("Active runners: {:?}", runners);
Ok(())
}
```
## API Reference
### Client Creation
```rust
let client = SupervisorClient::new("http://127.0.0.1:3030")?;
```
### Runner Management
```rust
// Add a runner
client.add_runner(config, ProcessManagerType::Simple).await?;
// Remove a runner
client.remove_runner("actor_id").await?;
// List all runners
let runners = client.list_runners().await?;
// Start/stop runners
client.start_runner("actor_id").await?;
client.stop_runner("actor_id", false).await?; // force = false
// Get runner status
let status = client.get_runner_status("actor_id").await?;
// Get runner logs
let logs = client.get_runner_logs("actor_id", Some(100), false).await?;
```
### Job Management
```rust
// Create a job using the builder
let job = JobBuilder::new()
.caller_id("client_id")
.context_id("context_id")
.payload("script_content")
.job_type(JobType::OSIS)
.runner_name("target_actor")
.timeout(Duration::from_secs(300))
.env_var("KEY", "value")
.build()?;
// Queue the job
client.queue_job_to_runner("actor_id", job).await?;
```
### Bulk Operations
```rust
// Start all runners
let results = client.start_all().await?;
// Stop all runners
let results = client.stop_all(false).await?; // force = false
// Get status of all runners
let statuses = client.get_all_runner_status().await?;
```
## Types
### RunnerType
- `SALRunner` - System abstraction layer operations
- `OSISRunner` - Operating system interface operations
- `VRunner` - Virtualization operations
- `PyRunner` - Python-based actors
### JobType
- `SAL` - SAL job type
- `OSIS` - OSIS job type
- `V` - V job type
- `Python` - Python job type
### ProcessManagerType
- `Simple` - Direct process spawning
- `Tmux(String)` - Tmux session-based management
### ProcessStatus
- `Running` - Process is active
- `Stopped` - Process is stopped
- `Failed` - Process failed
- `Unknown` - Status unknown
## Error Handling
The client uses the `ClientError` enum for error handling:
```rust
use hero_supervisor_openrpc_client::ClientError;
match client.start_runner("actor_id").await {
Ok(()) => println!("Runner started successfully"),
Err(ClientError::JsonRpc(e)) => println!("JSON-RPC error: {}", e),
Err(ClientError::Server { message }) => println!("Server error: {}", message),
Err(e) => println!("Other error: {}", e),
}
```
## Examples
See the `examples/` directory for complete usage examples:
- `basic_client.rs` - Basic client usage
- `job_management.rs` - Job creation and management
- `runner_lifecycle.rs` - Complete runner lifecycle management
## Requirements
- Rust 1.70+
- Hero Supervisor server running with OpenRPC feature enabled
- Network access to the supervisor server
## License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.

29
clients/openrpc/build-wasm.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Build script for WASM-compatible OpenRPC client
set -e
echo "Building WASM OpenRPC client..."
# Check if wasm-pack is installed
if ! command -v wasm-pack &> /dev/null; then
echo "wasm-pack is not installed. Installing..."
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
fi
# Build the WASM package
echo "Building WASM package..."
wasm-pack build --target web --out-dir pkg-wasm
echo "WASM build complete! Package available in pkg-wasm/"
echo ""
echo "To use in a web project:"
echo "1. Copy the pkg-wasm directory to your web project"
echo "2. Import the module in your JavaScript:"
echo " import init, { WasmSupervisorClient, create_client, create_job } from './pkg-wasm/hero_supervisor_openrpc_client_wasm.js';"
echo "3. Initialize the WASM module:"
echo " await init();"
echo "4. Create and use the client:"
echo " const client = create_client('http://localhost:3030');"
echo " const runners = await client.list_runners();"

872
clients/openrpc/cmd/main.rs Normal file
View File

@@ -0,0 +1,872 @@
//! Interactive CLI for Hero Supervisor OpenRPC Client
//!
//! This CLI provides an interactive interface to explore and test OpenRPC methods
//! with arrow key navigation, parameter input, and response display.
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal,
};
use serde_json::json;
use std::io;
use chrono;
use hero_supervisor_openrpc_client::{SupervisorClient, RunnerConfig, RunnerType, ProcessManagerType};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "openrpc-cli")]
#[command(about = "Interactive CLI for Hero Supervisor OpenRPC")]
struct Cli {
/// OpenRPC server URL
#[arg(short, long, default_value = "http://127.0.0.1:3030")]
url: String,
}
#[derive(Debug, Clone)]
struct RpcMethod {
name: String,
description: String,
params: Vec<RpcParam>,
}
#[derive(Debug, Clone)]
struct RpcParam {
name: String,
param_type: String,
required: bool,
description: String,
}
struct App {
client: SupervisorClient,
methods: Vec<RpcMethod>,
list_state: ListState,
current_screen: Screen,
selected_method: Option<RpcMethod>,
param_inputs: Vec<String>,
current_param_index: usize,
response: Option<String>,
error_message: Option<String>,
}
#[derive(Debug, PartialEq)]
enum Screen {
MethodList,
ParamInput,
Response,
}
impl App {
async fn new(url: String) -> Result<Self, Box<dyn std::error::Error>> {
let client = SupervisorClient::new(&url)?;
// Test connection to OpenRPC server using the standard rpc.discover method
// This is the proper OpenRPC way to test server connectivity and discover available methods
let discovery_result = client.discover().await;
match discovery_result {
Ok(discovery_info) => {
println!("✓ Connected to OpenRPC server at {}", url);
if let Some(info) = discovery_info.get("info") {
if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
println!(" Server: {}", title);
}
if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
println!(" Version: {}", version);
}
}
}
Err(e) => {
return Err(format!("Failed to connect to OpenRPC server at {}: {}\nMake sure the supervisor is running with OpenRPC enabled.", url, e).into());
}
}
let methods = vec![
RpcMethod {
name: "list_runners".to_string(),
description: "List all registered runners".to_string(),
params: vec![],
},
RpcMethod {
name: "register_runner".to_string(),
description: "Register a new runner to the supervisor with secret authentication".to_string(),
params: vec![
RpcParam {
name: "secret".to_string(),
param_type: "String".to_string(),
required: true,
description: "Secret required for runner registration".to_string(),
},
RpcParam {
name: "name".to_string(),
param_type: "String".to_string(),
required: true,
description: "Name of the runner".to_string(),
},
RpcParam {
name: "queue".to_string(),
param_type: "String".to_string(),
required: true,
description: "Queue name for the runner to listen to".to_string(),
},
],
},
RpcMethod {
name: "run_job".to_string(),
description: "Run a job on the appropriate runner".to_string(),
params: vec![
RpcParam {
name: "secret".to_string(),
param_type: "String".to_string(),
required: true,
description: "Secret required for job execution".to_string(),
},
RpcParam {
name: "job_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "Job ID".to_string(),
},
RpcParam {
name: "runner_name".to_string(),
param_type: "String".to_string(),
required: true,
description: "Name of the runner to execute the job".to_string(),
},
RpcParam {
name: "payload".to_string(),
param_type: "String".to_string(),
required: true,
description: "Job payload/script content".to_string(),
},
],
},
RpcMethod {
name: "remove_runner".to_string(),
description: "Remove a runner from the supervisor".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner to remove".to_string(),
},
],
},
RpcMethod {
name: "start_runner".to_string(),
description: "Start a specific runner".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner to start".to_string(),
},
],
},
RpcMethod {
name: "stop_runner".to_string(),
description: "Stop a specific runner".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner to stop".to_string(),
},
RpcParam {
name: "force".to_string(),
param_type: "bool".to_string(),
required: true,
description: "Whether to force stop the runner".to_string(),
},
],
},
RpcMethod {
name: "get_runner_status".to_string(),
description: "Get the status of a specific runner".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner".to_string(),
},
],
},
RpcMethod {
name: "get_all_runner_status".to_string(),
description: "Get status of all runners".to_string(),
params: vec![],
},
RpcMethod {
name: "start_all".to_string(),
description: "Start all runners".to_string(),
params: vec![],
},
RpcMethod {
name: "stop_all".to_string(),
description: "Stop all runners".to_string(),
params: vec![
RpcParam {
name: "force".to_string(),
param_type: "bool".to_string(),
required: true,
description: "Whether to force stop all runners".to_string(),
},
],
},
RpcMethod {
name: "get_all_status".to_string(),
description: "Get status of all components".to_string(),
params: vec![],
},
];
let mut list_state = ListState::default();
list_state.select(Some(0));
Ok(App {
client,
methods,
list_state,
current_screen: Screen::MethodList,
selected_method: None,
param_inputs: vec![],
current_param_index: 0,
response: None,
error_message: None,
})
}
fn next_method(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.methods.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
fn previous_method(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
self.methods.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
fn select_method(&mut self) {
if let Some(i) = self.list_state.selected() {
let method = self.methods[i].clone();
if method.params.is_empty() {
// No parameters needed, call directly
self.selected_method = Some(method);
self.current_screen = Screen::Response;
} else {
// Parameters needed, go to input screen
self.selected_method = Some(method.clone());
self.param_inputs = vec!["".to_string(); method.params.len()];
self.current_param_index = 0;
self.current_screen = Screen::ParamInput;
}
}
}
fn next_param(&mut self) {
if let Some(method) = &self.selected_method {
if self.current_param_index < method.params.len() - 1 {
self.current_param_index += 1;
}
}
}
fn previous_param(&mut self) {
if self.current_param_index > 0 {
self.current_param_index -= 1;
}
}
fn add_char_to_current_param(&mut self, c: char) {
if self.current_param_index < self.param_inputs.len() {
self.param_inputs[self.current_param_index].push(c);
}
}
fn remove_char_from_current_param(&mut self) {
if self.current_param_index < self.param_inputs.len() {
self.param_inputs[self.current_param_index].pop();
}
}
async fn execute_method(&mut self) {
if let Some(method) = &self.selected_method {
self.error_message = None;
self.response = None;
// Build parameters
let mut params = json!({});
if !method.params.is_empty() {
for (i, param) in method.params.iter().enumerate() {
let input = &self.param_inputs[i];
if input.is_empty() && param.required {
self.error_message = Some(format!("Required parameter '{}' is empty", param.name));
return;
}
if !input.is_empty() {
let value = match param.param_type.as_str() {
"bool" => {
match input.to_lowercase().as_str() {
"true" | "1" | "yes" => json!(true),
"false" | "0" | "no" => json!(false),
_ => {
self.error_message = Some(format!("Invalid boolean value for '{}': {}", param.name, input));
return;
}
}
}
"i32" | "i64" | "u32" | "u64" => {
match input.parse::<i64>() {
Ok(n) => json!(n),
Err(_) => {
self.error_message = Some(format!("Invalid number for '{}': {}", param.name, input));
return;
}
}
}
_ => json!(input),
};
if method.name == "register_runner" {
// Special handling for register_runner method
match param.name.as_str() {
"secret" => params["secret"] = value,
"name" => params["name"] = value,
"queue" => params["queue"] = value,
_ => {}
}
} else if method.name == "run_job" {
// Special handling for run_job method
match param.name.as_str() {
"secret" => params["secret"] = value,
"job_id" => params["job_id"] = value,
"runner_name" => params["runner_name"] = value,
"payload" => params["payload"] = value,
_ => {}
}
} else {
params[&param.name] = value;
}
}
}
}
// Execute the method
let result: Result<serde_json::Value, hero_supervisor_openrpc_client::ClientError> = match method.name.as_str() {
"list_runners" => {
match self.client.list_runners().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"get_all_runner_status" => {
match self.client.get_all_runner_status().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"start_all" => {
match self.client.start_all().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"get_all_status" => {
match self.client.get_all_status().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"stop_all" => {
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
match self.client.stop_all(force).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"start_runner" => {
if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) {
match self.client.start_runner(actor_id).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter"))
))
}
}
"stop_runner" => {
if let (Some(actor_id), Some(force)) = (
params.get("actor_id").and_then(|v| v.as_str()),
params.get("force").and_then(|v| v.as_bool())
) {
match self.client.stop_runner(actor_id, force).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing parameters"))
))
}
}
"remove_runner" => {
if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) {
match self.client.remove_runner(actor_id).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter"))
))
}
}
"get_runner_status" => {
if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) {
match self.client.get_runner_status(actor_id).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter"))
))
}
}
"register_runner" => {
if let (Some(secret), Some(name), Some(queue)) = (
params.get("secret").and_then(|v| v.as_str()),
params.get("name").and_then(|v| v.as_str()),
params.get("queue").and_then(|v| v.as_str())
) {
match self.client.register_runner(secret, name, queue).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing required parameters: secret, name, queue"))
))
}
}
"run_job" => {
if let (Some(secret), Some(job_id), Some(runner_name), Some(payload)) = (
params.get("secret").and_then(|v| v.as_str()),
params.get("job_id").and_then(|v| v.as_str()),
params.get("runner_name").and_then(|v| v.as_str()),
params.get("payload").and_then(|v| v.as_str())
) {
// Create a job object
let job = serde_json::json!({
"id": job_id,
"caller_id": "cli_user",
"context_id": "cli_context",
"payload": payload,
"job_type": "SAL",
"runner_name": runner_name,
"timeout": 30000000000u64, // 30 seconds in nanoseconds
"env_vars": {},
"created_at": chrono::Utc::now().to_rfc3339(),
"updated_at": chrono::Utc::now().to_rfc3339()
});
match self.client.run_job(secret, job).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing required parameters: secret, job_id, runner_name, payload"))
))
}
}
_ => Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Method not implemented in CLI"))
)),
};
match result {
Ok(response) => {
self.response = Some(format!("{:#}", response));
}
Err(e) => {
self.error_message = Some(format!("Error: {}", e));
}
}
self.current_screen = Screen::Response;
}
}
fn back_to_methods(&mut self) {
self.current_screen = Screen::MethodList;
self.selected_method = None;
self.param_inputs.clear();
self.current_param_index = 0;
self.response = None;
self.error_message = None;
}
}
fn ui(f: &mut Frame, app: &mut App) {
match app.current_screen {
Screen::MethodList => draw_method_list(f, app),
Screen::ParamInput => draw_param_input(f, app),
Screen::Response => draw_response(f, app),
}
}
fn draw_method_list(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Min(0)].as_ref())
.split(f.area());
let items: Vec<ListItem> = app
.methods
.iter()
.map(|method| {
let content = vec![Line::from(vec![
Span::styled(&method.name, Style::default().fg(Color::Yellow)),
Span::raw(" - "),
Span::raw(&method.description),
])];
ListItem::new(content)
})
.collect();
let items = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("OpenRPC Methods (↑↓ to navigate, Enter to select, q to quit)"),
)
.highlight_style(
Style::default()
.bg(Color::LightGreen)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(items, chunks[0], &mut app.list_state);
}
fn draw_param_input(f: &mut Frame, app: &mut App) {
if let Some(method) = &app.selected_method {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new(format!("Parameters for: {}", method.name))
.block(Block::default().borders(Borders::ALL).title("Method"));
f.render_widget(title, chunks[0]);
// Parameters - create proper form layout with separate label and input areas
let param_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(5); method.params.len()])
.split(chunks[1]);
for (i, param) in method.params.iter().enumerate() {
let is_current = i == app.current_param_index;
// Split each parameter into label and input areas
let param_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Length(3)])
.split(param_chunks[i]);
// Parameter label and description
let label_style = if is_current {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let label_text = vec![
Line::from(vec![
Span::styled(&param.name, label_style),
Span::raw(if param.required { " (required)" } else { " (optional)" }),
Span::raw(format!(" [{}]", param.param_type)),
]),
Line::from(Span::styled(&param.description, Style::default().fg(Color::Gray))),
];
let label_widget = Paragraph::new(label_text)
.block(Block::default().borders(Borders::NONE));
f.render_widget(label_widget, param_layout[0]);
// Input field
let empty_string = String::new();
let input_value = app.param_inputs.get(i).unwrap_or(&empty_string);
let input_display = if is_current {
if input_value.is_empty() {
"".to_string() // Show cursor when active and empty
} else {
format!("{}", input_value) // Show cursor at end when active
}
} else {
if input_value.is_empty() {
" ".to_string() // Empty space for inactive empty fields
} else {
input_value.clone()
}
};
let input_style = if is_current {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().fg(Color::White).bg(Color::DarkGray)
};
let border_style = if is_current {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let input_widget = Paragraph::new(Line::from(Span::styled(input_display, input_style)))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(if is_current { " INPUT " } else { "" }),
);
f.render_widget(input_widget, param_layout[1]);
}
// Instructions
let instructions = Paragraph::new("↑↓ to navigate params, type to edit, Enter to execute, Esc to go back")
.block(Block::default().borders(Borders::ALL).title("Instructions"));
f.render_widget(instructions, chunks[2]);
}
}
fn draw_response(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let method_name = app.selected_method.as_ref().map(|m| m.name.as_str()).unwrap_or("Unknown");
let title = Paragraph::new(format!("Response for: {}", method_name))
.block(Block::default().borders(Borders::ALL).title("Response"));
f.render_widget(title, chunks[0]);
// Response content
let content = if let Some(error) = &app.error_message {
Text::from(error.clone()).style(Style::default().fg(Color::Red))
} else if let Some(response) = &app.response {
Text::from(response.clone()).style(Style::default().fg(Color::Green))
} else {
Text::from("Executing...").style(Style::default().fg(Color::Yellow))
};
let response_widget = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(response_widget, chunks[1]);
// Instructions
let instructions = Paragraph::new("Esc to go back to methods")
.block(Block::default().borders(Borders::ALL).title("Instructions"));
f.render_widget(instructions, chunks[2]);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app
let mut app = match App::new(cli.url).await {
Ok(app) => app,
Err(e) => {
// Cleanup terminal before showing error
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
eprintln!("Failed to connect to OpenRPC server: {}", e);
eprintln!("Make sure the supervisor is running with OpenRPC enabled.");
std::process::exit(1);
}
};
// Main loop
loop {
terminal.draw(|f| ui(f, &mut app))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match app.current_screen {
Screen::MethodList => {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Down => app.next_method(),
KeyCode::Up => app.previous_method(),
KeyCode::Enter => {
app.select_method();
// If the selected method has no parameters, execute it immediately
if let Some(method) = &app.selected_method {
if method.params.is_empty() {
app.execute_method().await;
}
}
},
_ => {}
}
}
Screen::ParamInput => {
match key.code {
KeyCode::Esc => app.back_to_methods(),
KeyCode::Up => app.previous_param(),
KeyCode::Down => app.next_param(),
KeyCode::Enter => {
app.execute_method().await;
}
KeyCode::Backspace => app.remove_char_from_current_param(),
KeyCode::Char(c) => app.add_char_to_current_param(c),
_ => {}
}
}
Screen::Response => {
match key.code {
KeyCode::Esc => app.back_to_methods(),
_ => {}
}
}
}
}
}
}
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}

View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hero Supervisor WASM OpenRPC Client Example</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.container {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin: 10px 0;
}
button {
background: #007cba;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin: 5px;
}
button:hover {
background: #005a87;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
input, textarea {
width: 100%;
padding: 8px;
margin: 5px 0;
border: 1px solid #ddd;
border-radius: 4px;
}
.output {
background: #fff;
border: 1px solid #ddd;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
white-space: pre-wrap;
font-family: monospace;
max-height: 200px;
overflow-y: auto;
}
.error {
color: #d32f2f;
}
.success {
color: #2e7d32;
}
</style>
</head>
<body>
<h1>Hero Supervisor WASM OpenRPC Client</h1>
<div class="container">
<h2>Connection</h2>
<input type="text" id="serverUrl" placeholder="Server URL" value="http://localhost:3030">
<button onclick="testConnection()">Test Connection</button>
<div id="connectionOutput" class="output"></div>
</div>
<div class="container">
<h2>Runner Management</h2>
<button onclick="listRunners()">List Runners</button>
<div id="runnersOutput" class="output"></div>
<h3>Register Runner</h3>
<input type="text" id="registerSecret" placeholder="Secret" value="admin123">
<input type="text" id="runnerName" placeholder="Runner Name" value="wasm_runner">
<input type="text" id="runnerQueue" placeholder="Queue Name" value="wasm_queue">
<button onclick="registerRunner()">Register Runner</button>
<div id="registerOutput" class="output"></div>
</div>
<div class="container">
<h2>Job Execution</h2>
<input type="text" id="jobSecret" placeholder="Secret" value="admin123">
<input type="text" id="jobId" placeholder="Job ID" value="">
<input type="text" id="jobRunnerName" placeholder="Runner Name" value="wasm_runner">
<textarea id="jobPayload" placeholder="Job Payload" rows="3">echo "Hello from WASM client!"</textarea>
<button onclick="runJob()">Run Job</button>
<div id="jobOutput" class="output"></div>
</div>
<script type="module">
import init, {
WasmSupervisorClient,
WasmJob,
create_client,
create_job
} from './pkg-wasm/hero_supervisor_openrpc_client_wasm.js';
let client = null;
// Initialize WASM module
async function initWasm() {
try {
await init();
console.log('WASM module initialized');
document.getElementById('connectionOutput').textContent = 'WASM module loaded successfully';
document.getElementById('connectionOutput').className = 'output success';
} catch (error) {
console.error('Failed to initialize WASM:', error);
document.getElementById('connectionOutput').textContent = `Failed to initialize WASM: ${error}`;
document.getElementById('connectionOutput').className = 'output error';
}
}
// Test connection to supervisor
window.testConnection = async function() {
try {
const serverUrl = document.getElementById('serverUrl').value;
client = create_client(serverUrl);
const result = await client.discover();
document.getElementById('connectionOutput').textContent = `Connection successful!\n${JSON.stringify(result, null, 2)}`;
document.getElementById('connectionOutput').className = 'output success';
} catch (error) {
document.getElementById('connectionOutput').textContent = `Connection failed: ${error}`;
document.getElementById('connectionOutput').className = 'output error';
}
};
// List all runners
window.listRunners = async function() {
try {
if (!client) {
throw new Error('Client not initialized. Test connection first.');
}
const runners = await client.list_runners();
document.getElementById('runnersOutput').textContent = `Runners:\n${JSON.stringify(runners, null, 2)}`;
document.getElementById('runnersOutput').className = 'output success';
} catch (error) {
document.getElementById('runnersOutput').textContent = `Failed to list runners: ${error}`;
document.getElementById('runnersOutput').className = 'output error';
}
};
// Register a new runner
window.registerRunner = async function() {
try {
if (!client) {
throw new Error('Client not initialized. Test connection first.');
}
const secret = document.getElementById('registerSecret').value;
const name = document.getElementById('runnerName').value;
const queue = document.getElementById('runnerQueue').value;
await client.register_runner(secret, name, queue);
document.getElementById('registerOutput').textContent = `Runner '${name}' registered successfully!`;
document.getElementById('registerOutput').className = 'output success';
} catch (error) {
document.getElementById('registerOutput').textContent = `Failed to register runner: ${error}`;
document.getElementById('registerOutput').className = 'output error';
}
};
// Run a job
window.runJob = async function() {
try {
if (!client) {
throw new Error('Client not initialized. Test connection first.');
}
const secret = document.getElementById('jobSecret').value;
let jobId = document.getElementById('jobId').value;
const runnerName = document.getElementById('jobRunnerName').value;
const payload = document.getElementById('jobPayload').value;
// Generate job ID if not provided
if (!jobId) {
jobId = 'job_' + Math.random().toString(36).substr(2, 9);
document.getElementById('jobId').value = jobId;
}
const job = create_job(jobId, payload, "SAL", runnerName);
const result = await client.run_job(secret, job);
document.getElementById('jobOutput').textContent = `Job executed successfully!\nJob ID: ${jobId}\nResult: ${result}`;
document.getElementById('jobOutput').className = 'output success';
} catch (error) {
document.getElementById('jobOutput').textContent = `Failed to run job: ${error}`;
document.getElementById('jobOutput').className = 'output error';
}
};
// Initialize on page load
initWasm();
</script>
</body>
</html>

1037
clients/openrpc/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff

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