move repos into monorepo

This commit is contained in:
Timur Gordon
2025-11-13 20:44:00 +01:00
commit 4b23e5eb7f
204 changed files with 33737 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
[package]
name = "hero-job-client"
version.workspace = true
edition.workspace = true
description = "Job client for Hero - Redis-based job management"
license = "MIT OR Apache-2.0"
[lib]
name = "hero_job_client"
path = "lib.rs"
[dependencies]
# Core dependencies
redis.workspace = true
tokio.workspace = true
chrono.workspace = true
thiserror.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
# Hero dependencies
hero-job = { path = "../../models/job" }

473
lib/clients/job/lib.rs Normal file
View File

@@ -0,0 +1,473 @@
//! Job client implementation for managing jobs in Redis
use chrono::Utc;
use redis::AsyncCommands;
use hero_job::{Job, JobStatus, JobError};
use thiserror::Error;
/// Client-specific error types
#[derive(Error, Debug)]
pub enum ClientError {
#[error("Redis error: {0}")]
Redis(#[from] redis::RedisError),
#[error("Job error: {0}")]
Job(#[from] JobError),
#[error("Invalid status: {0}")]
InvalidStatus(String),
#[error("Timeout waiting for job completion")]
Timeout,
}
/// Client for managing jobs in Redis
#[derive(Debug, Clone)]
pub struct Client {
redis_client: redis::Client,
namespace: String,
}
pub struct ClientBuilder {
/// Redis URL for connection
redis_url: String,
/// Namespace for queue keys
namespace: String,
}
impl ClientBuilder {
/// Create a new client builder
pub fn new() -> Self {
Self {
redis_url: "redis://localhost:6379".to_string(),
namespace: "".to_string(),
}
}
/// Set the Redis URL
pub fn redis_url<S: Into<String>>(mut self, url: S) -> Self {
self.redis_url = url.into();
self
}
/// Set the namespace for queue keys
pub fn namespace<S: Into<String>>(mut self, namespace: S) -> Self {
self.namespace = namespace.into();
self
}
/// Build the client
pub async fn build(self) -> Result<Client, ClientError> {
// Create Redis client
let redis_client = redis::Client::open(self.redis_url.as_str())
.map_err(|e| ClientError::Redis(e))?;
Ok(Client {
redis_client,
namespace: self.namespace,
})
}
}
impl Default for Client {
fn default() -> Self {
// Note: Default implementation creates an empty client
// Use Client::builder() for proper initialization
Self {
redis_client: redis::Client::open("redis://localhost:6379").unwrap(),
namespace: "".to_string(),
}
}
}
impl Client {
/// Create a new client builder
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
/// List all job IDs from Redis
pub async fn list_jobs(&self) -> Result<Vec<String>, ClientError> {
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let keys: Vec<String> = conn.keys(format!("{}:*", &self.jobs_key())).await
.map_err(|e| ClientError::Redis(e))?;
let job_ids: Vec<String> = keys
.into_iter()
.filter_map(|key| {
if key.starts_with(&format!("{}:", self.jobs_key())) {
key.strip_prefix(&format!("{}:", self.jobs_key()))
.map(|s| s.to_string())
} else {
None
}
})
.collect();
Ok(job_ids)
}
fn jobs_key(&self) -> String {
if self.namespace.is_empty() {
format!("job")
} else {
format!("{}:job", self.namespace)
}
}
pub fn job_key(&self, job_id: &str) -> String {
if self.namespace.is_empty() {
format!("job:{}", job_id)
} else {
format!("{}:job:{}", self.namespace, job_id)
}
}
pub fn job_reply_key(&self, job_id: &str) -> String {
if self.namespace.is_empty() {
format!("reply:{}", job_id)
} else {
format!("{}:reply:{}", self.namespace, job_id)
}
}
pub fn runner_key(&self, runner_name: &str) -> String {
if self.namespace.is_empty() {
format!("runner:{}", runner_name)
} else {
format!("{}:runner:{}", self.namespace, runner_name)
}
}
/// Set job error in Redis
pub async fn set_error(&self,
job_id: &str,
error: &str,
) -> Result<(), ClientError> {
let job_key = self.job_key(job_id);
let now = Utc::now();
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let _: () = conn.hset_multiple(&job_key, &[
("error", error),
("status", JobStatus::Error.as_str()),
("updated_at", &now.to_rfc3339()),
]).await
.map_err(|e| ClientError::Redis(e))?;
Ok(())
}
/// Set job status in Redis
pub async fn set_job_status(&self,
job_id: &str,
status: JobStatus,
) -> Result<(), ClientError> {
let job_key = self.job_key(job_id);
let now = Utc::now();
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let _: () = conn.hset_multiple(&job_key, &[
("status", status.as_str()),
("updated_at", &now.to_rfc3339()),
]).await
.map_err(|e| ClientError::Redis(e))?;
Ok(())
}
/// Get job status from Redis
pub async fn get_status(
&self,
job_id: &str,
) -> Result<JobStatus, ClientError> {
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let status_str: Option<String> = conn.hget(&self.job_key(job_id), "status").await
.map_err(|e| ClientError::Redis(e))?;
match status_str {
Some(s) => JobStatus::from_str(&s).ok_or_else(|| ClientError::InvalidStatus(s)),
None => Err(ClientError::Job(JobError::NotFound(job_id.to_string()))),
}
}
/// Delete job from Redis
pub async fn delete_from_redis(
&self,
job_id: &str,
) -> Result<(), ClientError> {
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let job_key = self.job_key(job_id);
let _: () = conn.del(&job_key).await
.map_err(|e| ClientError::Redis(e))?;
Ok(())
}
/// Store this job in Redis with the specified status
pub async fn store_job_in_redis_with_status(&self, job: &Job, status: JobStatus) -> Result<(), ClientError> {
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let job_key = self.job_key(&job.id);
// Serialize the job data
let job_data = serde_json::to_string(job)
.map_err(|e| JobError::Serialization(e))?;
// Store job data in Redis hash
let _: () = conn.hset_multiple(&job_key, &[
("data", job_data),
("status", status.as_str().to_string()),
("created_at", job.created_at.to_rfc3339()),
("updated_at", job.updated_at.to_rfc3339()),
]).await
.map_err(|e| ClientError::Redis(e))?;
// Set TTL for the job (24 hours)
let _: () = conn.expire(&job_key, 86400).await
.map_err(|e| ClientError::Redis(e))?;
Ok(())
}
/// Store this job in Redis (defaults to Dispatched status for backwards compatibility)
pub async fn store_job_in_redis(&self, job: &Job) -> Result<(), ClientError> {
self.store_job_in_redis_with_status(job, JobStatus::Dispatched).await
}
/// Load a job from Redis by ID
pub async fn load_job_from_redis(
&self,
job_id: &str,
) -> Result<Job, ClientError> {
let job_key = self.job_key(job_id);
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
// Get job data from Redis
let job_data: Option<String> = conn.hget(&job_key, "data").await
.map_err(|e| ClientError::Redis(e))?;
match job_data {
Some(data) => {
let job: Job = serde_json::from_str(&data)
.map_err(|e| JobError::Serialization(e))?;
Ok(job)
}
None => Err(ClientError::Job(JobError::NotFound(job_id.to_string()))),
}
}
/// Delete a job by ID
pub async fn delete_job(&mut self, job_id: &str) -> Result<(), ClientError> {
let mut conn = self.redis_client.get_multiplexed_async_connection().await
.map_err(|e| ClientError::Redis(e))?;
let job_key = self.job_key(job_id);
let deleted_count: i32 = conn.del(&job_key).await
.map_err(|e| ClientError::Redis(e))?;
if deleted_count == 0 {
return Err(ClientError::Job(JobError::NotFound(job_id.to_string())));
}
Ok(())
}
/// Set job result in Redis
pub async fn set_result(
&self,
job_id: &str,
result: &str,
) -> Result<(), ClientError> {
let job_key = self.job_key(&job_id);
let now = Utc::now();
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let _: () = conn.hset_multiple(&job_key, &[
("result", result),
("status", JobStatus::Finished.as_str()),
("updated_at", &now.to_rfc3339()),
]).await
.map_err(|e| ClientError::Redis(e))?;
Ok(())
}
/// Get job result from Redis
pub async fn get_result(
&self,
job_id: &str,
) -> Result<Option<String>, ClientError> {
let job_key = self.job_key(job_id);
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let result: Option<String> = conn.hget(&job_key, "result").await
.map_err(|e| ClientError::Redis(e))?;
Ok(result)
}
/// Get job result from Redis
pub async fn get_error(
&self,
job_id: &str,
) -> Result<Option<String>, ClientError> {
let job_key = self.job_key(job_id);
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let result: Option<String> = conn.hget(&job_key, "error").await
.map_err(|e| ClientError::Redis(e))?;
Ok(result)
}
/// Get a job ID from the work queue (blocking pop)
pub async fn get_job_id(&self, queue_key: &str) -> Result<Option<String>, ClientError> {
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
// Use BRPOP with a short timeout to avoid blocking indefinitely
let result: Option<(String, String)> = conn.brpop(queue_key, 1.0).await
.map_err(|e| ClientError::Redis(e))?;
Ok(result.map(|(_, job_id)| job_id))
}
/// Get a job by ID (alias for load_job_from_redis)
pub async fn get_job(&self, job_id: &str) -> Result<Job, ClientError> {
self.load_job_from_redis(job_id).await
}
/// Run a job by dispatching it to a runner's queue (fire-and-forget)
pub async fn job_run(&self, job_id: &str, runner_name: &str) -> Result<(), ClientError> {
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let queue_key = self.runner_key(runner_name);
// Push job ID to the runner's queue (LPUSH for FIFO with BRPOP)
let _: () = conn.lpush(&queue_key, job_id).await
.map_err(|e| ClientError::Redis(e))?;
Ok(())
}
/// Run a job and wait for completion
///
/// This is a convenience method that:
/// 1. Stores the job in Redis
/// 2. Dispatches it to the runner's queue
/// 3. Waits for the job to complete (polls status)
/// 4. Returns the result or error
///
/// # Arguments
/// * `job` - The job to run
/// * `runner_name` - The name of the runner to dispatch to
/// * `timeout_secs` - Maximum time to wait for job completion (in seconds)
///
/// # Returns
/// * `Ok(String)` - The job result if successful
/// * `Err(JobError)` - If the job fails, times out, or encounters an error
pub async fn job_run_wait(
&self,
job: &Job,
runner_name: &str,
timeout_secs: u64,
) -> Result<String, ClientError> {
use tokio::time::{Duration, timeout};
// Store the job in Redis
self.store_job_in_redis(job).await?;
// Dispatch to runner queue
self.job_run(&job.id, runner_name).await?;
// Wait for job to complete with timeout
let result = timeout(
Duration::from_secs(timeout_secs),
self.wait_for_job_completion(&job.id)
).await;
match result {
Ok(Ok(job_result)) => Ok(job_result),
Ok(Err(e)) => Err(e),
Err(_) => Err(ClientError::Timeout),
}
}
/// Wait for a job to complete by polling its status
///
/// This polls the job status every 500ms until it reaches a terminal state
/// (Finished or Error), then returns the result or error.
async fn wait_for_job_completion(&self, job_id: &str) -> Result<String, ClientError> {
use tokio::time::{sleep, Duration};
loop {
// Check job status
let status = self.get_status(job_id).await?;
match status {
JobStatus::Finished => {
// Job completed successfully, get the result
let result = self.get_result(job_id).await?;
return result.ok_or_else(|| {
ClientError::Job(JobError::InvalidData(format!("Job {} finished but has no result", job_id)))
});
}
JobStatus::Error => {
// Job failed, get the error message
let mut conn = self.redis_client
.get_multiplexed_async_connection()
.await
.map_err(|e| ClientError::Redis(e))?;
let error_msg: Option<String> = conn
.hget(&self.job_key(job_id), "error")
.await
.map_err(|e| ClientError::Redis(e))?;
return Err(ClientError::Job(JobError::InvalidData(
error_msg.unwrap_or_else(|| format!("Job {} failed with unknown error", job_id))
)));
}
JobStatus::Stopping => {
return Err(ClientError::Job(JobError::InvalidData(format!("Job {} was stopped", job_id))));
}
// Job is still running (Dispatched, WaitingForPrerequisites, Started)
_ => {
// Wait before polling again
sleep(Duration::from_millis(500)).await;
}
}
}
}
}

View File

@@ -0,0 +1,31 @@
[package]
name = "osiris-client"
version.workspace = true
edition.workspace = true
description = "Osiris client library"
license = "MIT OR Apache-2.0"
[dependencies]
# Core dependencies
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["json"] }
# Hero dependencies
hero-supervisor-openrpc-client = { path = "../supervisor" }
hero-job = { path = "../../models/job" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
uuid.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
uuid = { workspace = true, features = ["js"] }
getrandom.workspace = true
[dev-dependencies]
tokio.workspace = true

View File

@@ -0,0 +1,170 @@
//! Complete Osiris Client Example
//!
//! This example demonstrates the full CQRS pattern with Osiris:
//! - Commands (writes) via Rhai scripts through Supervisor
//! - Queries (reads) via REST API from Osiris server
//!
//! Prerequisites:
//! - Redis running on localhost:6379
//! - Supervisor running on localhost:3030
//! - Osiris server running on localhost:8080
//! - Osiris runner connected to Redis
use osiris_client::OsirisClient;
use hero_supervisor_openrpc_client::SupervisorClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Osiris Client - Complete Example\n");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
// Configuration
let admin_secret = "807470fd1e1ccc3fb997a1d4177cceb31a68cb355a4412c8fd6e66e517e902be";
let supervisor_url = "http://localhost:3030";
let osiris_url = "http://localhost:8080";
let runner_name = "osiris-queue";
// ========== Part 1: Setup Runner ==========
println!("📋 Part 1: Runner Setup\n");
let supervisor = SupervisorClient::builder()
.url(supervisor_url)
.secret(admin_secret)
.build()?;
// Register the runner
println!("1. Registering runner '{}'...", runner_name);
match supervisor.register_runner(runner_name).await {
Ok(result) => println!(" ✅ Runner registered: {}\n", result),
Err(e) => println!(" ⚠️ Registration failed (may already exist): {:?}\n", e),
}
// List all runners
println!("2. Listing all runners...");
match supervisor.list_runners().await {
Ok(runners) => {
println!(" ✅ Found {} runner(s):", runners.len());
for runner in runners {
println!(" - {}", runner);
}
println!();
}
Err(e) => println!(" ❌ Failed: {:?}\n", e),
}
// ========== Part 2: Initialize Osiris Client ==========
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
println!("📋 Part 2: Osiris Client (CQRS Pattern)\n");
let client = OsirisClient::builder()
.osiris_url(osiris_url)
.supervisor_url(supervisor_url)
.supervisor_secret(admin_secret)
.runner_name(runner_name)
.build()?;
println!("✅ Osiris client initialized");
println!(" - Osiris URL: {}", osiris_url);
println!(" - Supervisor URL: {}", supervisor_url);
println!(" - Runner: {}\n", runner_name);
// ========== Part 3: Execute Simple Script ==========
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
println!("📋 Part 3: Execute Rhai Script\n");
let script = r#"
print("Hello from Osiris!");
let result = 40 + 2;
print("The answer is: " + result);
result
"#;
println!("Executing script...");
match client.execute_script(script).await {
Ok(response) => {
println!(" ✅ Script executed successfully!");
println!(" Job ID: {}", response.job_id);
println!(" Status: {}\n", response.status);
}
Err(e) => {
println!(" ❌ Script execution failed: {}", e);
println!(" Make sure the runner is connected to Redis!\n");
}
}
// ========== Part 4: CQRS Operations (if runner is connected) ==========
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
println!("📋 Part 4: CQRS Operations (Commands + Queries)\n");
// Create an API Key (Command via Rhai)
println!("1. Creating API Key (Command via Rhai)...");
let api_key = format!("test-key-{}", chrono::Utc::now().timestamp());
match client.create_api_key(
api_key.clone(),
"Test Key".to_string(),
"admin".to_string()
).await {
Ok(response) => {
println!(" ✅ API Key created!");
println!(" Job ID: {}", response.job_id);
println!(" Status: {}\n", response.status);
}
Err(e) => println!(" ⚠️ Failed: {}\n", e),
}
// Query the API Key (Query via REST)
println!("2. Querying API Key (Query via REST)...");
match client.get_api_key(&api_key).await {
Ok(key) => {
println!(" ✅ API Key retrieved!");
println!(" Key: {:?}\n", key);
}
Err(e) => println!(" ⚠️ Not found yet: {}\n", e),
}
// List all API Keys (Query via REST)
println!("3. Listing all API Keys (Query via REST)...");
match client.list_api_keys().await {
Ok(keys) => {
println!(" ✅ Found {} API key(s)", keys.len());
for key in keys.iter().take(3) {
println!(" - {:?}", key);
}
if keys.len() > 3 {
println!(" ... and {} more", keys.len() - 3);
}
println!();
}
Err(e) => println!(" ⚠️ Failed: {}\n", e),
}
// ========== Part 5: Cleanup ==========
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
println!("📋 Part 5: Cleanup\n");
println!("Deleting test API Key (Command via Rhai)...");
match client.delete_api_key(api_key.clone()).await {
Ok(response) => {
println!(" ✅ API Key deleted!");
println!(" Job ID: {}", response.job_id);
println!(" Status: {}\n", response.status);
}
Err(e) => println!(" ⚠️ Failed: {}\n", e),
}
// ========== Summary ==========
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
println!("✅ Example Complete!\n");
println!("Summary:");
println!(" ✅ Runner registration working");
println!(" ✅ Osiris client initialized");
println!(" ✅ CQRS pattern demonstrated");
println!(" - Commands via Rhai scripts");
println!(" - Queries via REST API");
println!("\nNext steps:");
println!(" - Explore other CQRS methods (runners, jobs, etc.)");
println!(" - Use template-based script generation");
println!(" - Build your own Osiris-backed applications!");
Ok(())
}

View File

@@ -0,0 +1,100 @@
//! Communication methods (queries and commands)
use serde::{Deserialize, Serialize};
use crate::{OsirisClient, OsirisClientError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Verification {
pub id: String,
pub email: String,
pub code: String,
pub transport: String,
pub status: VerificationStatus,
pub created_at: i64,
pub expires_at: i64,
pub verified_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VerificationStatus {
Pending,
Verified,
Expired,
Failed,
}
// ========== Request/Response Models ==========
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendVerificationRequest {
pub email: String,
pub verification_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendVerificationResponse {
pub verification_id: String,
pub email: String,
pub expires_at: i64,
}
// ========== Client Methods ==========
impl OsirisClient {
// ========== Query Methods ==========
/// Get verification by ID (query)
pub async fn get_verification(&self, verification_id: &str) -> Result<Verification, OsirisClientError> {
self.get("verification", verification_id).await
}
/// Get verification by email (query)
pub async fn get_verification_by_email(&self, email: &str) -> Result<Vec<Verification>, OsirisClientError> {
self.query("verification", &format!("email={}", email)).await
}
/// Get verification status - alias for get_verification (query)
pub async fn get_verification_status(&self, verification_id: &str) -> Result<Verification, OsirisClientError> {
self.get_verification(verification_id).await
}
// ========== Command Methods ==========
/// Send verification email (command)
pub async fn send_verification_email(
&self,
request: SendVerificationRequest,
) -> Result<SendVerificationResponse, OsirisClientError> {
let email = &request.email;
let verification_url = request.verification_url.as_deref().unwrap_or("");
// Generate verification code
let verification_id = format!("ver_{}", uuid::Uuid::new_v4());
let code = format!("{:06}", (uuid::Uuid::new_v4().as_u128() % 1_000_000));
let script = format!(r#"
// Send email verification
let email = "{}";
let code = "{}";
let verification_url = "{}";
let verification_id = "{}";
// TODO: Implement actual email sending logic
print("Sending verification email to: " + email);
print("Verification code: " + code);
print("Verification URL: " + verification_url);
// Return verification details
verification_id
"#, email, code, verification_url, verification_id);
let _response = self.execute_script(&script).await?;
Ok(SendVerificationResponse {
verification_id,
email: request.email,
expires_at: chrono::Utc::now().timestamp() + 3600, // 1 hour
})
}
}

View File

@@ -0,0 +1,102 @@
//! KYC methods (queries and commands)
use serde::{Deserialize, Serialize};
use crate::{OsirisClient, OsirisClientError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KycSession {
pub id: String,
pub resident_id: String,
pub status: KycSessionStatus,
pub kyc_url: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub expires_at: i64,
pub verified_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KycSessionStatus {
Pending,
InProgress,
Completed,
Failed,
Expired,
}
// ========== Request/Response Models ==========
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KycVerificationRequest {
pub resident_id: String,
pub callback_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KycVerificationResponse {
pub session_id: String,
pub kyc_url: String,
pub expires_at: i64,
}
// ========== Client Methods ==========
impl OsirisClient {
// ========== Query Methods ==========
/// Get KYC session by ID
pub async fn get_kyc_session(&self, session_id: &str) -> Result<KycSession, OsirisClientError> {
self.get("kyc_session", session_id).await
}
/// List all KYC sessions for a resident
pub async fn list_kyc_sessions_by_resident(&self, resident_id: &str) -> Result<Vec<KycSession>, OsirisClientError> {
self.query("kyc_session", &format!("resident_id={}", resident_id)).await
}
// ========== Command Methods ==========
/// Start KYC verification (command)
pub async fn start_kyc_verification(
&self,
request: KycVerificationRequest,
) -> Result<KycVerificationResponse, OsirisClientError> {
let resident_id = &request.resident_id;
let callback_url = request.callback_url.as_deref().unwrap_or("");
// Generate session ID
let session_id = format!("kyc_{}", uuid::Uuid::new_v4());
let script = format!(r#"
// Start KYC verification
let resident_id = "{}";
let callback_url = "{}";
let session_id = "{}";
// TODO: Implement actual KYC provider integration
print("Starting KYC verification for resident: " + resident_id);
print("Session ID: " + session_id);
print("Callback URL: " + callback_url);
// Return session details
session_id
"#, resident_id, callback_url, session_id);
let _response = self.execute_script(&script).await?;
Ok(KycVerificationResponse {
session_id,
kyc_url: "https://kyc.example.com/verify".to_string(),
expires_at: chrono::Utc::now().timestamp() + 86400,
})
}
/// Check KYC status (query)
pub async fn check_kyc_status(
&self,
session_id: String,
) -> Result<KycSession, OsirisClientError> {
self.get_kyc_session(&session_id).await
}
}

View File

@@ -0,0 +1,439 @@
//! Osiris Client - Unified CQRS Client
//!
//! This client provides both:
//! - Commands (writes) via Rhai scripts to Hero Supervisor
//! - Queries (reads) via REST API to Osiris server
//!
//! Follows CQRS pattern with a single unified interface.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
pub mod kyc;
pub mod payment;
pub mod communication;
pub use kyc::*;
pub use payment::*;
pub use communication::*;
#[derive(Debug, Error)]
pub enum OsirisClientError {
#[error("HTTP request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Deserialization failed: {0}")]
DeserializationFailed(String),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Command execution failed: {0}")]
CommandFailed(String),
}
/// Osiris client with CQRS support
#[derive(Clone)]
pub struct OsirisClient {
// Query side (Osiris REST API)
osiris_url: String,
// Command side (Supervisor + Rhai)
supervisor_client: Option<hero_supervisor_openrpc_client::SupervisorClient>,
runner_name: String,
timeout: u64,
// HTTP client
client: reqwest::Client,
}
/// Builder for OsirisClient
#[derive(Clone, Debug, Default)]
pub struct OsirisClientBuilder {
osiris_url: Option<String>,
supervisor_url: Option<String>,
runner_name: Option<String>,
supervisor_secret: Option<String>,
timeout: u64,
}
impl OsirisClientBuilder {
/// Create a new builder
pub fn new() -> Self {
Self {
osiris_url: None,
supervisor_url: None,
runner_name: None,
supervisor_secret: None,
timeout: 30,
}
}
/// Set the Osiris server URL (for queries)
pub fn osiris_url(mut self, url: impl Into<String>) -> Self {
self.osiris_url = Some(url.into());
self
}
/// Set the Supervisor URL (for commands)
pub fn supervisor_url(mut self, url: impl Into<String>) -> Self {
self.supervisor_url = Some(url.into());
self
}
/// Set the runner name (default: "osiris")
pub fn runner_name(mut self, name: impl Into<String>) -> Self {
self.runner_name = Some(name.into());
self
}
/// Set the supervisor authentication secret
pub fn supervisor_secret(mut self, secret: impl Into<String>) -> Self {
self.supervisor_secret = Some(secret.into());
self
}
/// Set the timeout in seconds (default: 30)
pub fn timeout(mut self, timeout: u64) -> Self {
self.timeout = timeout;
self
}
/// Build the OsirisClient
pub fn build(self) -> Result<OsirisClient, OsirisClientError> {
let osiris_url = self.osiris_url
.ok_or_else(|| OsirisClientError::ConfigError("osiris_url is required".to_string()))?;
// Build supervisor client if URL and secret are provided
let supervisor_client = if let (Some(url), Some(secret)) = (self.supervisor_url, self.supervisor_secret) {
Some(
hero_supervisor_openrpc_client::SupervisorClient::builder()
.url(url)
.secret(secret)
.build()
.map_err(|e| OsirisClientError::ConfigError(format!("Failed to create supervisor client: {:?}", e)))?
)
} else {
None
};
Ok(OsirisClient {
osiris_url,
supervisor_client,
runner_name: self.runner_name.unwrap_or_else(|| "osiris".to_string()),
timeout: self.timeout,
client: reqwest::Client::new(),
})
}
}
impl OsirisClient {
/// Create a new Osiris client (query-only)
pub fn new(osiris_url: impl Into<String>) -> Self {
Self {
osiris_url: osiris_url.into(),
supervisor_client: None,
runner_name: "osiris".to_string(),
timeout: 30,
client: reqwest::Client::new(),
}
}
/// Create a builder for full CQRS configuration
pub fn builder() -> OsirisClientBuilder {
OsirisClientBuilder::new()
}
/// Generic GET request for any struct by ID
pub async fn get<T>(&self, struct_name: &str, id: &str) -> Result<T, OsirisClientError>
where
T: for<'de> Deserialize<'de>,
{
let url = format!("{}/api/{}/{}", self.osiris_url, struct_name, id);
let response = self.client
.get(&url)
.send()
.await?;
if response.status() == 404 {
return Err(OsirisClientError::NotFound(format!("{}/{}", struct_name, id)));
}
let data = response
.json::<T>()
.await
.map_err(|e| OsirisClientError::DeserializationFailed(e.to_string()))?;
Ok(data)
}
/// Generic LIST request for all instances of a struct
pub async fn list<T>(&self, struct_name: &str) -> Result<Vec<T>, OsirisClientError>
where
T: for<'de> Deserialize<'de>,
{
let url = format!("{}/api/{}", self.osiris_url, struct_name);
let response = self.client
.get(&url)
.send()
.await?;
let data = response
.json::<Vec<T>>()
.await
.map_err(|e| OsirisClientError::DeserializationFailed(e.to_string()))?;
Ok(data)
}
/// Generic QUERY request with filters
pub async fn query<T>(&self, struct_name: &str, query: &str) -> Result<Vec<T>, OsirisClientError>
where
T: for<'de> Deserialize<'de>,
{
let url = format!("{}/api/{}?{}", self.osiris_url, struct_name, query);
let response = self.client
.get(&url)
.send()
.await?;
let data = response
.json::<Vec<T>>()
.await
.map_err(|e| OsirisClientError::DeserializationFailed(e.to_string()))?;
Ok(data)
}
// ========== Command Methods (Supervisor + Rhai) ==========
// Commands are write operations that execute Rhai scripts via the supervisor
// to modify state in Osiris
/// Execute a Rhai script via the Supervisor
pub async fn execute_script(&self, script: &str) -> Result<RunJobResponse, OsirisClientError> {
let supervisor_client = self.supervisor_client.as_ref()
.ok_or_else(|| OsirisClientError::ConfigError("supervisor_client not configured for commands".to_string()))?;
// Use JobBuilder from supervisor client (which re-exports from hero-job)
use hero_supervisor_openrpc_client::JobBuilder;
let job = JobBuilder::new()
.caller_id("osiris-client")
.context_id("command-execution")
.runner(&self.runner_name)
.payload(script)
.executor("rhai")
.timeout(self.timeout)
.build()
.map_err(|e| OsirisClientError::CommandFailed(format!("Failed to build job: {}", e)))?;
// Use job_run method which returns JobRunResponse
// Secret is sent via Authorization header (configured during client creation)
let result = supervisor_client.job_run(job, Some(self.timeout))
.await
.map_err(|e| OsirisClientError::CommandFailed(format!("{:?}", e)))?;
// Convert JobRunResponse to our RunJobResponse
Ok(RunJobResponse {
job_id: result.job_id,
status: result.status,
})
}
/// Execute a Rhai script template with variable substitution
pub async fn execute_template(&self, template: &str, variables: &HashMap<String, String>) -> Result<RunJobResponse, OsirisClientError> {
let script = substitute_variables(template, variables);
self.execute_script(&script).await
}
// ========== Supervisor-specific CQRS Methods ==========
/// Create an API key (Command - via Rhai)
pub async fn create_api_key(&self, key: String, name: String, scope: String) -> Result<RunJobResponse, OsirisClientError> {
let script = format!(
r#"
let api_key = new_api_key("{}", "{}", "{}", "{}");
save_api_key(api_key);
"#,
self.get_namespace(),
key,
name,
scope
);
self.execute_script(&script).await
}
/// Get an API key by key value (Query - via REST)
pub async fn get_api_key(&self, key: &str) -> Result<Option<serde_json::Value>, OsirisClientError> {
// Query by indexed field
let results: Vec<serde_json::Value> = self.query("ApiKey", &format!("key={}", key)).await?;
Ok(results.into_iter().next())
}
/// List all API keys (Query - via REST)
pub async fn list_api_keys(&self) -> Result<Vec<serde_json::Value>, OsirisClientError> {
self.list("ApiKey").await
}
/// Delete an API key (Command - via Rhai)
pub async fn delete_api_key(&self, key: String) -> Result<RunJobResponse, OsirisClientError> {
let script = format!(
r#"
delete_api_key("{}");
"#,
key
);
self.execute_script(&script).await
}
/// Create a runner (Command - via Rhai)
pub async fn create_runner(&self, runner_id: String, name: String, queue: String, registered_by: String) -> Result<RunJobResponse, OsirisClientError> {
let script = format!(
r#"
let runner = new_runner("{}", "{}", "{}", "{}", "{}");
save_runner(runner);
"#,
self.get_namespace(),
runner_id,
name,
queue,
registered_by
);
self.execute_script(&script).await
}
/// Get a runner by ID (Query - via REST)
pub async fn get_runner(&self, runner_id: &str) -> Result<Option<serde_json::Value>, OsirisClientError> {
let results: Vec<serde_json::Value> = self.query("Runner", &format!("runner_id={}", runner_id)).await?;
Ok(results.into_iter().next())
}
/// List all runners (Query - via REST)
pub async fn list_runners(&self) -> Result<Vec<serde_json::Value>, OsirisClientError> {
self.list("Runner").await
}
/// Delete a runner (Command - via Rhai)
pub async fn delete_runner(&self, runner_id: String) -> Result<RunJobResponse, OsirisClientError> {
let script = format!(
r#"
delete_runner("{}");
"#,
runner_id
);
self.execute_script(&script).await
}
/// Create job metadata (Command - via Rhai)
pub async fn create_job_metadata(&self, job_id: String, runner: String, created_by: String, payload: String) -> Result<RunJobResponse, OsirisClientError> {
let script = format!(
r#"
let job = new_job_metadata("{}", "{}", "{}", "{}", "{}");
save_job_metadata(job);
"#,
self.get_namespace(),
job_id,
runner,
created_by,
payload
);
self.execute_script(&script).await
}
/// Get job metadata by ID (Query - via REST)
pub async fn get_job_metadata(&self, job_id: &str) -> Result<Option<serde_json::Value>, OsirisClientError> {
let results: Vec<serde_json::Value> = self.query("JobMetadata", &format!("job_id={}", job_id)).await?;
Ok(results.into_iter().next())
}
/// List all job metadata (Query - via REST)
pub async fn list_job_metadata(&self) -> Result<Vec<serde_json::Value>, OsirisClientError> {
self.list("JobMetadata").await
}
/// List jobs by runner (Query - via REST)
pub async fn list_jobs_by_runner(&self, runner: &str) -> Result<Vec<serde_json::Value>, OsirisClientError> {
self.query("JobMetadata", &format!("runner={}", runner)).await
}
/// List jobs by creator (Query - via REST)
pub async fn list_jobs_by_creator(&self, creator: &str) -> Result<Vec<serde_json::Value>, OsirisClientError> {
self.query("JobMetadata", &format!("created_by={}", creator)).await
}
// Helper method to get namespace
fn get_namespace(&self) -> &str {
"supervisor"
}
}
// ========== Helper Structures ==========
#[derive(Serialize)]
struct RunJobRequest {
runner_name: String,
script: String,
timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<HashMap<String, String>>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RunJobResponse {
pub job_id: String,
pub status: String,
}
/// Helper function to substitute variables in a Rhai script template
pub fn substitute_variables(template: &str, variables: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in variables {
let placeholder = format!("{{{{ {} }}}}", key);
result = result.replace(&placeholder, value);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = OsirisClient::new("http://localhost:8080");
assert_eq!(client.osiris_url, "http://localhost:8080");
}
#[test]
fn test_builder() {
let client = OsirisClient::builder()
.osiris_url("http://localhost:8081")
.supervisor_url("http://localhost:3030")
.supervisor_secret("test_secret")
.runner_name("osiris")
.build()
.unwrap();
assert_eq!(client.osiris_url, "http://localhost:8081");
assert_eq!(client.supervisor_url, Some("http://localhost:3030".to_string()));
assert_eq!(client.runner_name, "osiris");
}
#[test]
fn test_substitute_variables() {
let template = "let x = {{ value }}; let y = {{ name }};";
let mut vars = HashMap::new();
vars.insert("value".to_string(), "42".to_string());
vars.insert("name".to_string(), "\"test\"".to_string());
let result = substitute_variables(template, &vars);
assert_eq!(result, "let x = 42; let y = \"test\";");
}
}

View File

@@ -0,0 +1,39 @@
//! Payment query methods
use serde::{Deserialize, Serialize};
use crate::{OsirisClient, OsirisClientError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Payment {
pub id: String,
pub amount: f64,
pub currency: String,
pub status: PaymentStatus,
pub description: String,
pub payment_url: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub completed_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PaymentStatus {
Pending,
Processing,
Completed,
Failed,
Cancelled,
}
impl OsirisClient {
/// Get payment by ID
pub async fn get_payment(&self, payment_id: &str) -> Result<Payment, OsirisClientError> {
self.get("payment", payment_id).await
}
/// List all payments
pub async fn list_payments(&self) -> Result<Vec<Payment>, OsirisClientError> {
self.list("payment").await
}
}

View File

@@ -0,0 +1,37 @@
// KYC verification script template
// Variables: {{resident_id}}, {{callback_url}}
print("=== Starting KYC Verification ===");
print("Resident ID: {{resident_id}}");
// Get freezone context
let freezone_pubkey = "04e58314c13ea3f9caed882001a5090797b12563d5f9bbd7f16efe020e060c780b446862311501e2e9653416527d2634ff8a8050ff3a085baccd7ddcb94185ff56";
let freezone_ctx = get_context([freezone_pubkey]);
// Get KYC client from context
let kyc_client = freezone_ctx.get("kyc_client");
if kyc_client == () {
print("ERROR: KYC client not configured");
return #{
success: false,
error: "KYC client not configured"
};
}
// Create KYC session
let session = kyc_client.create_session(
"{{resident_id}}",
"{{callback_url}}"
);
print("✓ KYC session created");
print(" Session ID: " + session.session_id);
print(" KYC URL: " + session.kyc_url);
// Return response
#{
success: true,
session_id: session.session_id,
kyc_url: session.kyc_url,
expires_at: session.expires_at
}

2
lib/clients/supervisor/.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"

View File

@@ -0,0 +1,77 @@
[package]
name = "hero-supervisor-openrpc-client"
version.workspace = true
edition.workspace = true
description = "OpenRPC client for Hero Supervisor"
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
[dependencies]
# Common dependencies for both native and WASM
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
uuid.workspace = true
indexmap.workspace = true
hero-job = { path = "../../models/job" }
# Native JSON-RPC client (not WASM compatible)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
jsonrpsee = { workspace = true, features = ["http-client", "macros"] }
tokio.workspace = true
# hero-job-client removed - now part of supervisor
env_logger.workspace = true
http.workspace = true
# WASM-specific dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
js-sys.workspace = true
serde-wasm-bindgen.workspace = true
web-sys = { workspace = true, features = [
"console",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Headers",
"Window",
] }
console_log.workspace = true
getrandom.workspace = true
# Crypto for signing
secp256k1.workspace = true
sha2.workspace = true
hex.workspace = true
[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]
workspace = true
# Time handling (native)
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.chrono]
workspace = true
# WASM-compatible dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies.chrono]
workspace = true
features = ["wasmbind"]
[target.'cfg(target_arch = "wasm32")'.dependencies.uuid]
workspace = true
features = ["js"]
[dev-dependencies]
# Testing utilities
tokio-test = "0.4"

View File

@@ -0,0 +1,180 @@
# 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, JobBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create a client with admin secret
let client = SupervisorClient::new("http://127.0.0.1:3030", "your-admin-secret")?;
// Register a runner (runner must be started externally)
client.register_runner("admin-secret", "my_runner").await?;
// Create and run a job
let job = JobBuilder::new()
.caller_id("my_client")
.context_id("example_context")
.payload("echo 'Hello from Hero Supervisor!'")
.executor("bash")
.runner("my_runner")
.timeout(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
// Register a runner
client.register_runner("admin-secret", "my_runner").await?;
// Remove a runner
client.remove_runner("admin-secret", "my_runner").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("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
### Runner Management
Runners are expected to be started and managed externally. The supervisor only tracks which runners are registered and queues jobs to them via Redis.
### 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.

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();"

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>

View File

@@ -0,0 +1,102 @@
//! Builder pattern for WasmSupervisorClient to ensure proper configuration
//!
//! This module provides a type-safe builder that guarantees a client cannot be
//! created without a secret, preventing authentication issues.
use crate::wasm::WasmSupervisorClient;
/// Builder for WasmSupervisorClient that enforces secret requirement
#[derive(Clone)]
pub struct WasmSupervisorClientBuilder {
server_url: Option<String>,
secret: Option<String>,
}
impl WasmSupervisorClientBuilder {
/// Create a new builder
pub fn new() -> Self {
Self {
server_url: None,
secret: None,
}
}
/// Set the server URL
pub fn server_url(mut self, url: impl Into<String>) -> Self {
self.server_url = Some(url.into());
self
}
/// Set the authentication secret (required)
pub fn secret(mut self, secret: impl Into<String>) -> Self {
self.secret = Some(secret.into());
self
}
/// Build the client
///
/// Returns Err if server_url or secret is not set
pub fn build(self) -> Result<WasmSupervisorClient, String> {
let server_url = self.server_url.ok_or("Server URL is required")?;
let secret = self.secret.ok_or("Secret is required for authenticated client")?;
if secret.is_empty() {
return Err("Secret cannot be empty".to_string());
}
Ok(WasmSupervisorClient::new(server_url, secret))
}
}
impl Default for WasmSupervisorClientBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_requires_all_fields() {
let builder = WasmSupervisorClientBuilder::new();
assert!(builder.build().is_err());
let builder = WasmSupervisorClientBuilder::new()
.server_url("http://localhost:3030");
assert!(builder.build().is_err());
let builder = WasmSupervisorClientBuilder::new()
.secret("test-secret");
assert!(builder.build().is_err());
}
#[test]
fn test_builder_success() {
let builder = WasmSupervisorClientBuilder::new()
.server_url("http://localhost:3030")
.secret("test-secret");
assert!(builder.build().is_ok());
}
#[test]
fn test_build_error_messages() {
let result = WasmSupervisorClientBuilder::new().build();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Server URL is required");
let result = WasmSupervisorClientBuilder::new()
.server_url("http://localhost:3030")
.build();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Secret is required for authenticated client");
let result = WasmSupervisorClientBuilder::new()
.server_url("http://localhost:3030")
.secret("")
.build();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Secret cannot be empty");
}
}

View File

@@ -0,0 +1,695 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use serde_json;
// Import types from the main supervisor crate
// WASM-compatible client module
#[cfg(target_arch = "wasm32")]
pub mod wasm;
// Builder module for type-safe client construction
#[cfg(target_arch = "wasm32")]
pub mod builder;
// Re-export WASM types for convenience
#[cfg(target_arch = "wasm32")]
pub use wasm::{WasmSupervisorClient, WasmJobType, WasmRunnerType, create_job_canonical_repr, sign_job_canonical};
// Re-export builder for convenience
#[cfg(target_arch = "wasm32")]
pub use builder::WasmSupervisorClientBuilder;
// 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 http::{HeaderMap, HeaderName, HeaderValue};
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
/// Client for communicating with Hero Supervisor OpenRPC server
/// Requires authentication secret for all operations
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone)]
pub struct SupervisorClient {
client: HttpClient,
server_url: String,
secret: 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<wasm_bindgen::JsValue> for ClientError {
fn from(js_val: wasm_bindgen::JsValue) -> Self {
ClientError::JavaScript(format!("{:?}", js_val))
}
}
/// Result type for client operations
pub type ClientResult<T> = Result<T, ClientError>;
/// Request parameters for generating API keys (auto-generates key value)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateApiKeyParams {
pub name: String,
pub scope: String, // "admin", "registrar", or "user"
}
/// Configuration for a runner
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunnerConfig {
/// Name of the runner
pub name: String,
/// Command to run the runner (full command line)
pub command: String,
/// Optional environment variables
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, 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<String>,
pub completed_at: Option<String>,
}
/// Response from job.run (blocking execution)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobRunResponse {
pub job_id: String,
pub status: String,
pub result: Option<String>,
}
/// Response from job.start (non-blocking execution)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobStartResponse {
pub job_id: String,
pub status: String,
}
// Re-export Job types from hero-job crate (both native and WASM)
pub use hero_job::{Job, JobStatus, JobError, JobBuilder, JobSignature};
// Note: Job client is now part of hero-supervisor crate
// Re-exports removed - use hero_supervisor::job_client directly if needed
/// 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,
}
/// API Key information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKey {
pub key: String,
pub name: String,
pub scope: String,
pub created_at: String,
}
/// Auth verification response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthVerifyResponse {
pub scope: String,
pub name: Option<String>,
pub created_at: Option<String>,
}
/// Simple ProcessStatus type for native builds to avoid service manager dependency
#[cfg(not(target_arch = "wasm32"))]
pub type ProcessStatus = ProcessStatusWrapper;
// Types duplicated from supervisor-core to avoid cyclic dependency
// These match the types in hero-supervisor but are defined here independently
/// Runner status information (duplicated to avoid cyclic dependency)
#[cfg(not(target_arch = "wasm32"))]
pub type RunnerStatus = ProcessStatusWrapper;
/// Log information (duplicated to avoid cyclic dependency)
#[cfg(not(target_arch = "wasm32"))]
pub type LogInfo = LogInfoWrapper;
/// Type aliases for WASM compatibility
#[cfg(target_arch = "wasm32")]
pub type ProcessStatus = ProcessStatusWrapper;
#[cfg(target_arch = "wasm32")]
pub type RunnerStatus = ProcessStatusWrapper;
#[cfg(target_arch = "wasm32")]
pub type LogInfo = LogInfoWrapper;
/// Builder for SupervisorClient
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone)]
pub struct SupervisorClientBuilder {
url: Option<String>,
secret: Option<String>,
timeout: Option<std::time::Duration>,
}
#[cfg(not(target_arch = "wasm32"))]
impl SupervisorClientBuilder {
/// Create a new builder
pub fn new() -> Self {
Self {
url: None,
secret: None,
timeout: Some(std::time::Duration::from_secs(30)),
}
}
/// Set the server URL
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
/// Set the authentication secret
pub fn secret(mut self, secret: impl Into<String>) -> Self {
self.secret = Some(secret.into());
self
}
/// Set the request timeout (default: 30 seconds)
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = Some(timeout);
self
}
/// Build the SupervisorClient
pub fn build(self) -> ClientResult<SupervisorClient> {
let server_url = self.url
.ok_or_else(|| ClientError::Http("URL is required".to_string()))?;
let secret = self.secret
.ok_or_else(|| ClientError::Http("Secret is required".to_string()))?;
// Create headers with Authorization bearer token
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", secret);
headers.insert(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&auth_value)
.map_err(|e| ClientError::Http(format!("Invalid auth header: {}", e)))?
);
let client = HttpClientBuilder::default()
.request_timeout(self.timeout.unwrap_or(std::time::Duration::from_secs(30)))
.set_headers(headers)
.build(&server_url)
.map_err(|e| ClientError::Http(e.to_string()))?;
Ok(SupervisorClient {
client,
server_url,
secret,
})
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for SupervisorClientBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl SupervisorClient {
/// Create a builder for SupervisorClient
pub fn builder() -> SupervisorClientBuilder {
SupervisorClientBuilder::new()
}
/// 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<serde_json::Value> {
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
/// The runner name is also used as the queue name
/// Authentication via Authorization header (set during client creation)
pub async fn runner_create(
&self,
name: &str,
) -> ClientResult<()> {
let _: () = self
.client
.request("runner.create", rpc_params![name])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// Create a new job without queuing it to a runner
/// Authentication via Authorization header (set during client creation)
pub async fn job_create(
&self,
job: Job,
) -> ClientResult<String> {
let job_id: String = self
.client
.request("job.create", rpc_params![job])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(job_id)
}
/// List all jobs
pub async fn job_list(&self) -> ClientResult<Vec<Job>> {
let jobs: Vec<Job> = self
.client
.request("job.list", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(jobs)
}
/// Run a job on the appropriate runner and wait for the result (blocking)
/// This method queues the job and waits for completion before returning
/// The secret is sent via Authorization header (set during client creation)
pub async fn job_run(
&self,
job: Job,
timeout: Option<u64>,
) -> ClientResult<JobRunResponse> {
let mut params = serde_json::json!({
"job": job
});
if let Some(t) = timeout {
params["timeout"] = serde_json::json!(t);
}
let result: JobRunResponse = self
.client
.request("job.run", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
}
/// Start a job without waiting for the result (non-blocking)
/// This method queues the job and returns immediately with the job_id
/// Authentication via Authorization header (set during client creation)
pub async fn job_start(
&self,
job: Job,
) -> ClientResult<JobStartResponse> {
let params = serde_json::json!({
"job": job
});
let result: JobStartResponse = self
.client
.request("job.start", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
}
/// Get the current status of a job
pub async fn job_status(&self, job_id: &str) -> ClientResult<JobStatus> {
let status: JobStatus = self
.client
.request("job.status", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(status)
}
/// Get the result of a completed job (blocks until result is available)
pub async fn job_result(&self, job_id: &str) -> ClientResult<JobResult> {
let result: JobResult = self
.client
.request("job.result", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
}
/// Remove a runner from the supervisor
/// Authentication via Authorization header (set during client creation)
pub async fn runner_remove(&self, runner_id: &str) -> ClientResult<()> {
let _: () = self
.client
.request("runner.remove", rpc_params![runner_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// List all runner IDs
pub async fn runner_list(&self) -> ClientResult<Vec<String>> {
let runners: Vec<String> = self
.client
.request("runner.list", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(runners)
}
/// Start a specific runner
/// Authentication via Authorization header (set during client creation)
pub async fn start_runner(&self, actor_id: &str) -> ClientResult<()> {
let _: () = self
.client
.request("runner.start", rpc_params![actor_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// Add a runner to the supervisor
/// Authentication via Authorization header (set during client creation)
pub async fn add_runner(&self, config: RunnerConfig) -> ClientResult<()> {
let params = serde_json::json!({
"config": config
});
let _: () = self
.client
.request("runner.add", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// Get status of a specific runner
/// Authentication via Authorization header (set during client creation)
pub async fn get_runner_status(&self, actor_id: &str) -> ClientResult<RunnerStatus> {
let status: RunnerStatus = self
.client
.request("runner.status", rpc_params![actor_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(status)
}
/// Get logs for a specific runner
pub async fn get_runner_logs(
&self,
actor_id: &str,
lines: Option<usize>,
follow: bool,
) -> ClientResult<Vec<LogInfo>> {
let logs: Vec<LogInfo> = self
.client
.request("get_runner_logs", rpc_params![actor_id, lines, follow])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(logs)
}
/// 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<Option<String>> {
let params = serde_json::json!({
"runner": runner,
"job": job,
"timeout_secs": timeout_secs
});
let result: Option<String> = self
.client
.request("queue_and_wait", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
}
/// Run a job on a specific runner
pub async fn run_job(&self, job: Job) -> ClientResult<JobResult> {
let params = serde_json::json!({
"job": job
});
let result: JobResult = self
.client
.request("job.run", 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<Option<String>> {
let result: Option<String> = self
.client
.request("get_job_result", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(result)
}
/// Get status of all runners
pub async fn get_all_runner_status(&self) -> ClientResult<Vec<(String, RunnerStatus)>> {
let statuses: Vec<(String, RunnerStatus)> = self
.client
.request("get_all_runner_status", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(statuses)
}
/// Start all runners
pub async fn start_all(&self) -> ClientResult<Vec<(String, bool)>> {
let results: Vec<(String, bool)> = self
.client
.request("start_all", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(results)
}
/// Stop all runners
pub async fn stop_all(&self, force: bool) -> ClientResult<Vec<(String, bool)>> {
let results: Vec<(String, bool)> = self
.client
.request("stop_all", rpc_params![force])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(results)
}
/// Get status of all runners (alternative method)
pub async fn get_all_status(&self) -> ClientResult<Vec<(String, RunnerStatus)>> {
let statuses: Vec<(String, RunnerStatus)> = self
.client
.request("get_all_status", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(statuses)
}
/// Add a secret to the supervisor
pub async fn add_secret(
&self,
secret_type: &str,
secret_value: &str,
) -> ClientResult<()> {
let params = serde_json::json!({
"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,
secret_type: &str,
secret_value: &str,
) -> ClientResult<()> {
let params = serde_json::json!({
"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) -> ClientResult<SupervisorInfo> {
let params = serde_json::json!({});
let info: SupervisorInfo = self
.client
.request("list_secrets", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(info)
}
/// Stop a running job
pub async fn job_stop(&self, job_id: &str) -> ClientResult<()> {
let _: () = self.client
.request("job.stop", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// Delete a job from the system
pub async fn job_delete(&self, job_id: &str) -> ClientResult<()> {
let _: () = self.client
.request("job.delete", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// Get supervisor information including secret counts
pub async fn get_supervisor_info(&self) -> ClientResult<SupervisorInfo> {
let info: SupervisorInfo = self
.client
.request("supervisor.info", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(info)
}
/// Get a job by ID
pub async fn job_get(&self, job_id: &str) -> ClientResult<Job> {
let job: Job = self
.client
.request("job.get", rpc_params![job_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(job)
}
// ========== Auth/API Key Methods ==========
/// Verify the current API key
pub async fn auth_verify(&self) -> ClientResult<AuthVerifyResponse> {
let response: AuthVerifyResponse = self
.client
.request("auth.verify", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(response)
}
/// Create a new API key (admin only)
pub async fn key_create(&self, key: ApiKey) -> ClientResult<()> {
let _: () = self
.client
.request("key.create", rpc_params![key])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// Generate a new API key with auto-generated key value (admin only)
pub async fn key_generate(&self, params: GenerateApiKeyParams) -> ClientResult<ApiKey> {
let api_key: ApiKey = self
.client
.request("key.generate", rpc_params![params])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(api_key)
}
/// Remove an API key (admin only)
pub async fn key_delete(&self, key_id: String) -> ClientResult<()> {
let _: () = self
.client
.request("key.delete", rpc_params![key_id])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(())
}
/// List all API keys (admin only)
pub async fn key_list(&self) -> ClientResult<Vec<ApiKey>> {
let keys: Vec<ApiKey> = self
.client
.request("key.list", rpc_params![])
.await.map_err(|e| ClientError::JsonRpc(e))?;
Ok(keys)
}
}

View File

@@ -0,0 +1,859 @@
//! 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::{Headers, Request, RequestInit, RequestMode, Response};
use serde_json::json;
use secp256k1::{Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature};
use sha2::{Sha256, Digest};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
/// WASM-compatible client for communicating with Hero Supervisor OpenRPC server
/// Requires authentication secret for all operations
#[wasm_bindgen]
#[derive(Clone)]
pub struct WasmSupervisorClient {
server_url: String,
secret: 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>;
/// Auth verification response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthVerifyResponse {
pub valid: bool,
pub name: String,
pub scope: String,
}
/// 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 status enumeration
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum JobStatus {
Pending,
Running,
Finished,
Error,
}
/// Job error type
#[derive(Debug, Clone, thiserror::Error)]
pub enum JobError {
#[error("Validation error: {0}")]
Validation(String),
#[error("Execution error: {0}")]
Execution(String),
#[error("Timeout error")]
Timeout,
}
// Re-export JobBuilder from hero-job for convenience
pub use hero_job::JobBuilder;
#[wasm_bindgen]
impl WasmSupervisorClient {
/// Create a new WASM supervisor client with authentication secret
#[wasm_bindgen(constructor)]
pub fn new(server_url: String, secret: String) -> Self {
console_log::init_with_level(log::Level::Info).ok();
Self {
server_url,
secret,
}
}
/// Alias for new() to maintain backward compatibility
#[wasm_bindgen]
pub fn with_secret(server_url: String, secret: String) -> Self {
Self::new(server_url, secret)
}
/// 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())),
}
}
/// Verify an API key and return its metadata as JSON
/// The key is sent via Authorization header (Bearer token)
pub async fn auth_verify(&self, key: String) -> Result<JsValue, JsValue> {
// Create a temporary client with the key to verify
let temp_client = WasmSupervisorClient::with_secret(self.server_url.clone(), key);
// Send empty object as params - the key is in the Authorization header
let params = serde_json::json!({});
match temp_client.call_method("auth.verify", params).await {
Ok(result) => {
// Parse to AuthVerifyResponse to validate, then convert to JsValue
let auth_response: AuthVerifyResponse = serde_json::from_value(result)
.map_err(|e| JsValue::from_str(&format!("Failed to parse auth response: {}", e)))?;
// Convert to JsValue
serde_wasm_bindgen::to_value(&auth_response)
.map_err(|e| JsValue::from_str(&format!("Failed to convert to JsValue: {}", e)))
}
Err(e) => Err(JsValue::from_str(&format!("Failed to verify auth: {}", e))),
}
}
/// Verify the client's stored API key
/// Uses the secret that was set when creating the client
pub async fn auth_verify_self(&self) -> Result<JsValue, JsValue> {
self.auth_verify(self.secret.clone()).await
}
/// Create a new API key (admin only)
/// Returns the created API key with its key string
pub async fn auth_create_key(&self, name: String, scope: String) -> Result<JsValue, JsValue> {
let params = serde_json::json!({
"name": name,
"scope": scope
});
match self.call_method("auth.create_key", params).await {
Ok(result) => Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?),
Err(e) => Err(JsValue::from_str(&format!("Failed to create key: {}", e))),
}
}
/// List all API keys (admin only)
pub async fn auth_list_keys(&self) -> Result<JsValue, JsValue> {
match self.call_method("auth.list_keys", serde_json::Value::Null).await {
Ok(result) => Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?),
Err(e) => Err(JsValue::from_str(&format!("Failed to list keys: {}", e))),
}
}
/// Remove an API key (admin only)
pub async fn auth_remove_key(&self, key: String) -> Result<bool, JsValue> {
let params = serde_json::json!({
"key": key
});
match self.call_method("auth.remove_key", params).await {
Ok(result) => {
if let Some(success) = result.as_bool() {
Ok(success)
} else {
Err(JsValue::from_str("Invalid response format: expected boolean"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to remove key: {}", e))),
}
}
/// Register a new runner to the supervisor
/// The queue name is automatically set to match the runner name
/// Authentication uses the secret from Authorization header (set during client creation)
pub async fn register_runner(&self, name: String) -> Result<String, JsValue> {
// Secret is sent via Authorization header, not in params
let params = serde_json::json!({
"name": name
});
match self.call_method("register_runner", params).await {
Ok(result) => {
// Extract the runner name from the result
if let Some(runner) = result.as_str() {
Ok(runner.to_string())
} else {
Err(JsValue::from_str("Invalid response format: expected runner name"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to register runner: {}", e))),
}
}
/// Create a job (fire-and-forget, non-blocking) - DEPRECATED: Use create_job with API key auth
#[wasm_bindgen]
pub async fn create_job_with_secret(&self, secret: String, job: hero_job::Job) -> 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": job.runner,
"executor": job.executor,
"timeout": job.timeout,
"env_vars": serde_json::from_str::<serde_json::Value>(&serde_json::to_string(&job.env_vars).unwrap_or_else(|_| "{}".to_string())).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: hero_job::Job) -> 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": job.runner,
"executor": job.executor,
"timeout": job.timeout,
"env_vars": serde_json::from_str::<serde_json::Value>(&serde_json::to_string(&job.env_vars).unwrap_or_else(|_| "{}".to_string())).unwrap_or(serde_json::json!({})),
"created_at": job.created_at,
"updated_at": job.updated_at
}
}]);
match self.call_method("job.run", 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("Failed to parse runners list"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to list runners: {}", e)))
}
}
/// Get status of all runners
pub async fn get_all_runner_status(&self) -> Result<JsValue, JsValue> {
match self.call_method("get_all_runner_status", serde_json::Value::Null).await {
Ok(result) => {
// Convert serde_json::Value to JsValue
Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to get runner statuses: {}", e)))
}
}
/// Create a job from a JsValue (full Job object)
pub async fn create_job(&self, job: JsValue) -> Result<String, JsValue> {
// Convert JsValue to serde_json::Value
let job_value: serde_json::Value = serde_wasm_bindgen::from_value(job)
.map_err(|e| JsValue::from_str(&format!("Failed to parse job: {}", e)))?;
// Wrap in RunJobParams structure and pass as positional parameter
let params = serde_json::json!([{
"job": job_value
}]);
match self.call_method("jobs.create", params).await {
Ok(result) => {
if let Some(job_id) = result.as_str() {
Ok(job_id.to_string())
} else {
Err(JsValue::from_str("Invalid response format: expected job ID"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))),
}
}
/// Create a job with basic parameters (simplified version)
pub async fn create_simple_job(
&self,
runner: String,
caller_id: String,
context_id: String,
payload: String,
executor: String,
) -> Result<String, JsValue> {
// Generate a unique job ID
let job_id = format!("job-{}", uuid::Uuid::new_v4());
let job = serde_json::json!({
"id": job_id,
"runner": runner,
"caller_id": caller_id,
"context_id": context_id,
"payload": payload,
"executor": executor,
"timeout": 30,
"env": {}
});
let params = serde_json::json!({
"job": job
});
match self.call_method("jobs.create", params).await {
Ok(result) => {
if let Some(job_id) = result.as_str() {
Ok(job_id.to_string())
} else {
Err(JsValue::from_str("Invalid response format: expected job ID"))
}
},
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))),
}
}
/// List all jobs
pub async fn list_jobs(&self) -> Result<JsValue, JsValue> {
match self.call_method("jobs.list", serde_json::Value::Null).await {
Ok(result) => {
// Convert serde_json::Value to JsValue
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert jobs list: {}", e)))
},
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<hero_job::Job, JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("get_job", params).await {
Ok(result) => {
// Convert the Job result to hero_job::Job
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 = job_value.get("runner").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(hero_job::Job {
id,
caller_id,
context_id,
payload,
runner,
executor,
timeout: timeout_secs,
env_vars: serde_json::from_str(&env_vars).unwrap_or_default(),
created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now()),
updated_at: chrono::DateTime::parse_from_rfc3339(&updated_at)
.map(|dt| dt.with_timezone(&chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now()),
signatures: Vec::new(),
})
} 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": job_id
}]);
match self.call_method("job.delete", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e)))
}
}
/// Get logs for a specific job
#[wasm_bindgen]
pub async fn get_job_logs(&self, job_id: &str, lines: Option<usize>) -> Result<JsValue, JsValue> {
let params = if let Some(n) = lines {
serde_json::json!([job_id, n])
} else {
serde_json::json!([job_id, serde_json::Value::Null])
};
match self.call_method("get_job_logs", params).await {
Ok(result) => {
// Convert Vec<String> to JsValue
Ok(serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to convert logs: {}", e)))?)
},
Err(e) => Err(JsValue::from_str(&format!("Failed to get job logs: {:?}", 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))),
}
}
/// Start a previously created job by queuing it to its assigned runner
pub async fn start_job(&self, job_id: &str) -> Result<(), JsValue> {
let params = serde_json::json!([{
"job_id": job_id
}]);
match self.call_method("job.start", params).await {
Ok(_) => Ok(()),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Get the status of a job
pub async fn get_job_status(&self, job_id: &str) -> Result<JsValue, JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("job.status", params).await {
Ok(result) => serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {:?}", e))),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// Get the result of a completed job
pub async fn get_job_result(&self, job_id: &str) -> Result<JsValue, JsValue> {
let params = serde_json::json!([job_id]);
match self.call_method("job.result", params).await {
Ok(result) => serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {:?}", e))),
Err(e) => Err(JsValue::from_str(&e.to_string())),
}
}
/// 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)))?;
// Add Authorization header with secret
let auth_value = format!("Bearer {}", self.secret);
headers.set("Authorization", &auth_value)
.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 client from JavaScript
#[wasm_bindgen]
pub fn create_client(server_url: String, secret: String) -> WasmSupervisorClient {
WasmSupervisorClient::new(server_url, secret)
}
/// Sign a job's canonical representation with a private key
/// Returns a tuple of (public_key_hex, signature_hex)
#[wasm_bindgen]
pub fn sign_job_canonical(
canonical_repr: String,
private_key_hex: String,
) -> Result<JsValue, JsValue> {
// Decode private key from hex
let secret_bytes = hex::decode(&private_key_hex)
.map_err(|e| JsValue::from_str(&format!("Invalid private key hex: {}", e)))?;
let secret_key = SecretKey::from_slice(&secret_bytes)
.map_err(|e| JsValue::from_str(&format!("Invalid private key: {}", e)))?;
// Get the public key (uncompressed format)
let secp = Secp256k1::new();
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
let public_key_hex = hex::encode(public_key.serialize_uncompressed());
// Hash the canonical representation
let mut hasher = Sha256::new();
hasher.update(canonical_repr.as_bytes());
let hash = hasher.finalize();
// Create message from hash
let message = Message::from_digest_slice(&hash)
.map_err(|e| JsValue::from_str(&format!("Invalid message: {}", e)))?;
// Sign the message
let signature = secp.sign_ecdsa(&message, &secret_key);
let signature_hex = hex::encode(signature.serialize_compact());
// Return as JS object
let result = serde_json::json!({
"public_key": public_key_hex,
"signature": signature_hex
});
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Failed to serialize result: {}", e)))
}
/// Create canonical representation of a job for signing
/// This matches the format used in runner_rust Job::canonical_representation
#[wasm_bindgen]
pub fn create_job_canonical_repr(
id: String,
caller_id: String,
context_id: String,
payload: String,
runner: String,
executor: String,
timeout: u64,
env_vars_json: String,
) -> Result<String, JsValue> {
// Parse env_vars from JSON
let env_vars: std::collections::HashMap<String, String> = serde_json::from_str(&env_vars_json)
.map_err(|e| JsValue::from_str(&format!("Invalid env_vars JSON: {}", e)))?;
// Sort env_vars keys for deterministic ordering
let mut env_vars_sorted: Vec<_> = env_vars.iter().collect();
env_vars_sorted.sort_by_key(|&(k, _)| k);
// Create canonical representation (matches Job::canonical_representation in runner_rust)
let canonical = format!(
"{}:{}:{}:{}:{}:{}:{}:{:?}",
id,
caller_id,
context_id,
payload,
runner,
executor,
timeout,
env_vars_sorted
);
Ok(canonical)
}