move repos into monorepo
This commit is contained in:
23
lib/clients/job/Cargo.toml
Normal file
23
lib/clients/job/Cargo.toml
Normal 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
473
lib/clients/job/lib.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
lib/clients/osiris/Cargo.toml
Normal file
31
lib/clients/osiris/Cargo.toml
Normal 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
|
||||
170
lib/clients/osiris/examples/complete.rs
Normal file
170
lib/clients/osiris/examples/complete.rs
Normal 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(())
|
||||
}
|
||||
100
lib/clients/osiris/src/communication.rs
Normal file
100
lib/clients/osiris/src/communication.rs
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
102
lib/clients/osiris/src/kyc.rs
Normal file
102
lib/clients/osiris/src/kyc.rs
Normal 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
|
||||
}
|
||||
}
|
||||
439
lib/clients/osiris/src/lib.rs
Normal file
439
lib/clients/osiris/src/lib.rs
Normal 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\";");
|
||||
}
|
||||
}
|
||||
39
lib/clients/osiris/src/payment.rs
Normal file
39
lib/clients/osiris/src/payment.rs
Normal 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
|
||||
}
|
||||
}
|
||||
37
lib/clients/osiris/src/scripts/kyc_verification.rhai
Normal file
37
lib/clients/osiris/src/scripts/kyc_verification.rhai
Normal 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
2
lib/clients/supervisor/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pkg
|
||||
target
|
||||
59
lib/clients/supervisor/Cargo-wasm.toml
Normal file
59
lib/clients/supervisor/Cargo-wasm.toml
Normal 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"
|
||||
77
lib/clients/supervisor/Cargo.toml
Normal file
77
lib/clients/supervisor/Cargo.toml
Normal 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"
|
||||
180
lib/clients/supervisor/README.md
Normal file
180
lib/clients/supervisor/README.md
Normal 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.
|
||||
29
lib/clients/supervisor/build-wasm.sh
Executable file
29
lib/clients/supervisor/build-wasm.sh
Executable 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();"
|
||||
202
lib/clients/supervisor/example-wasm.html
Normal file
202
lib/clients/supervisor/example-wasm.html
Normal 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>
|
||||
102
lib/clients/supervisor/src/builder.rs
Normal file
102
lib/clients/supervisor/src/builder.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
695
lib/clients/supervisor/src/lib.rs
Normal file
695
lib/clients/supervisor/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
859
lib/clients/supervisor/src/wasm.rs
Normal file
859
lib/clients/supervisor/src/wasm.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user