Admin UI Features:
- Complete job lifecycle: create, run, view status, view output, delete
- Job table with sorting, filtering, and real-time status updates
- Status polling with countdown timers for running jobs
- Job output modal with result/error display
- API keys management: create keys, list keys with secrets visible
- Sidebar toggle between runners and keys views
- Toast notifications for errors
- Modern dark theme UI with responsive design
Supervisor Improvements:
- Fixed job status persistence using client methods
- Refactored get_job_result to use client.get_status, get_result, get_error
- Changed runner_rust dependency from git to local path
- Authentication system with API key scopes (admin, user, register)
- Job listing with status fetching from Redis
- Services module for job and auth operations
OpenRPC Client:
- Added auth_list_keys method for fetching API keys
- WASM bindings for browser usage
- Proper error handling and type conversions
Build Status: ✅ All components build successfully
1047 lines
36 KiB
Rust
1047 lines
36 KiB
Rust
//! 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
|
|
#[wasm_bindgen]
|
|
#[derive(Clone)]
|
|
pub struct WasmSupervisorClient {
|
|
server_url: String,
|
|
secret: Option<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,
|
|
}
|
|
|
|
/// Job builder for WASM
|
|
pub struct JobBuilder {
|
|
id: Option<String>,
|
|
caller_id: Option<String>,
|
|
context_id: Option<String>,
|
|
payload: Option<String>,
|
|
runner: Option<String>,
|
|
executor: Option<String>,
|
|
timeout_secs: Option<u64>,
|
|
env_vars: Option<String>,
|
|
}
|
|
|
|
impl JobBuilder {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
id: None,
|
|
caller_id: None,
|
|
context_id: None,
|
|
payload: None,
|
|
runner: None,
|
|
executor: None,
|
|
timeout_secs: None,
|
|
env_vars: None,
|
|
}
|
|
}
|
|
|
|
pub fn caller_id(mut self, caller_id: &str) -> Self {
|
|
self.caller_id = Some(caller_id.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn context_id(mut self, context_id: &str) -> Self {
|
|
self.context_id = Some(context_id.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn payload(mut self, payload: &str) -> Self {
|
|
self.payload = Some(payload.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn runner(mut self, runner: &str) -> Self {
|
|
self.runner = Some(runner.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn executor(mut self, executor: &str) -> Self {
|
|
self.executor = Some(executor.to_string());
|
|
self
|
|
}
|
|
|
|
pub fn timeout(mut self, timeout_secs: u64) -> Self {
|
|
self.timeout_secs = Some(timeout_secs);
|
|
self
|
|
}
|
|
|
|
pub fn build(self) -> Result<Job, JobError> {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
Ok(Job {
|
|
id: self.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
|
caller_id: self.caller_id.ok_or_else(|| JobError::Validation("caller_id is required".to_string()))?,
|
|
context_id: self.context_id.ok_or_else(|| JobError::Validation("context_id is required".to_string()))?,
|
|
payload: self.payload.ok_or_else(|| JobError::Validation("payload is required".to_string()))?,
|
|
runner: self.runner.ok_or_else(|| JobError::Validation("runner is required".to_string()))?,
|
|
executor: self.executor.ok_or_else(|| JobError::Validation("executor is required".to_string()))?,
|
|
timeout_secs: self.timeout_secs.unwrap_or(30),
|
|
env_vars: self.env_vars.unwrap_or_else(|| "{}".to_string()),
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Job structure for creating and managing jobs (alias for WasmJob)
|
|
pub type Job = WasmJob;
|
|
|
|
/// Job structure for creating and managing jobs
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[wasm_bindgen]
|
|
pub struct WasmJob {
|
|
id: String,
|
|
caller_id: String,
|
|
context_id: String,
|
|
payload: String,
|
|
runner: String,
|
|
executor: String,
|
|
timeout_secs: u64,
|
|
env_vars: String, // JSON string of HashMap<String, String>
|
|
created_at: String,
|
|
updated_at: String,
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl WasmSupervisorClient {
|
|
/// Create a new WASM supervisor client without authentication
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new(server_url: String) -> Self {
|
|
console_log::init_with_level(log::Level::Info).ok();
|
|
Self {
|
|
server_url,
|
|
secret: None,
|
|
}
|
|
}
|
|
|
|
/// Create a new WASM supervisor client with authentication secret
|
|
#[wasm_bindgen]
|
|
pub fn with_secret(server_url: String, secret: String) -> Self {
|
|
console_log::init_with_level(log::Level::Info).ok();
|
|
Self {
|
|
server_url,
|
|
secret: Some(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 with with_secret()
|
|
pub async fn auth_verify_self(&self) -> Result<JsValue, JsValue> {
|
|
let key = self.secret.as_ref()
|
|
.ok_or_else(|| JsValue::from_str("Client not authenticated - use with_secret() to create authenticated client"))?;
|
|
|
|
self.auth_verify(key.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: WasmJob) -> Result<String, JsValue> {
|
|
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
|
|
let params = serde_json::json!([{
|
|
"secret": secret,
|
|
"job": {
|
|
"id": job.id,
|
|
"caller_id": job.caller_id,
|
|
"context_id": job.context_id,
|
|
"payload": job.payload,
|
|
"runner": job.runner,
|
|
"executor": job.executor,
|
|
"timeout": {
|
|
"secs": job.timeout_secs,
|
|
"nanos": 0
|
|
},
|
|
"env_vars": serde_json::from_str::<serde_json::Value>(&job.env_vars).unwrap_or(serde_json::json!({})),
|
|
"created_at": job.created_at,
|
|
"updated_at": job.updated_at
|
|
}
|
|
}]);
|
|
|
|
match self.call_method("create_job", params).await {
|
|
Ok(result) => {
|
|
if let Some(job_id) = result.as_str() {
|
|
Ok(job_id.to_string())
|
|
} else {
|
|
Ok(result.to_string())
|
|
}
|
|
}
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {:?}", e)))
|
|
}
|
|
}
|
|
|
|
/// Run a job on a specific runner (blocking, returns result)
|
|
#[wasm_bindgen]
|
|
pub async fn run_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
|
|
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
|
|
let params = serde_json::json!([{
|
|
"secret": secret,
|
|
"job": {
|
|
"id": job.id,
|
|
"caller_id": job.caller_id,
|
|
"context_id": job.context_id,
|
|
"payload": job.payload,
|
|
"runner": job.runner,
|
|
"executor": job.executor,
|
|
"timeout": {
|
|
"secs": job.timeout_secs,
|
|
"nanos": 0
|
|
},
|
|
"env_vars": serde_json::from_str::<serde_json::Value>(&job.env_vars).unwrap_or(serde_json::json!({})),
|
|
"created_at": job.created_at,
|
|
"updated_at": job.updated_at
|
|
}
|
|
}]);
|
|
|
|
match self.call_method("jobs.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("Invalid response format for list_runners"))
|
|
}
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// 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))),
|
|
}
|
|
}
|
|
|
|
/// Get a job by job ID
|
|
pub async fn get_job(&self, job_id: &str) -> Result<WasmJob, JsValue> {
|
|
let params = serde_json::json!([job_id]);
|
|
match self.call_method("get_job", params).await {
|
|
Ok(result) => {
|
|
// Convert the Job result to WasmJob
|
|
if let Ok(job_value) = serde_json::from_value::<serde_json::Value>(result) {
|
|
// Extract fields from the job
|
|
let id = job_value.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let caller_id = job_value.get("caller_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let context_id = job_value.get("context_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let payload = job_value.get("payload").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let runner = 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(WasmJob {
|
|
id,
|
|
caller_id,
|
|
context_id,
|
|
payload,
|
|
runner,
|
|
executor,
|
|
timeout_secs,
|
|
env_vars,
|
|
created_at,
|
|
updated_at,
|
|
})
|
|
} else {
|
|
Err(JsValue::from_str("Invalid response format for get_job"))
|
|
}
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Ping a runner by dispatching a ping job to its queue
|
|
#[wasm_bindgen]
|
|
pub async fn ping_runner(&self, runner_id: &str) -> Result<String, JsValue> {
|
|
let params = serde_json::json!([runner_id]);
|
|
|
|
match self.call_method("ping_runner", params).await {
|
|
Ok(result) => {
|
|
if let Some(job_id) = result.as_str() {
|
|
Ok(job_id.to_string())
|
|
} else {
|
|
Ok(result.to_string())
|
|
}
|
|
}
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to ping runner: {:?}", e)))
|
|
}
|
|
}
|
|
|
|
/// Stop a job by ID
|
|
#[wasm_bindgen]
|
|
pub async fn stop_job(&self, job_id: &str) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([job_id]);
|
|
|
|
match self.call_method("stop_job", params).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to stop job: {:?}", e)))
|
|
}
|
|
}
|
|
|
|
/// Delete a job by ID
|
|
#[wasm_bindgen]
|
|
pub async fn delete_job(&self, job_id: &str) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([{
|
|
"job_id": job_id
|
|
}]);
|
|
|
|
match self.call_method("job.delete", params).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e)))
|
|
}
|
|
}
|
|
|
|
/// Remove a runner from the supervisor
|
|
pub async fn remove_runner(&self, actor_id: &str) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([actor_id]);
|
|
match self.call_method("remove_runner", params).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Start a specific runner
|
|
pub async fn start_runner(&self, actor_id: &str) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([actor_id]);
|
|
match self.call_method("start_runner", params).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Stop a specific runner
|
|
pub async fn stop_runner(&self, actor_id: &str, force: bool) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([actor_id, force]);
|
|
self.call_method("stop_runner", params)
|
|
.await
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get a specific runner by ID
|
|
pub async fn get_runner(&self, actor_id: &str) -> Result<JsValue, JsValue> {
|
|
let params = serde_json::json!([actor_id]);
|
|
let result = self.call_method("get_runner", params)
|
|
.await
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
// Convert the serde_json::Value to a JsValue via string serialization
|
|
let json_string = serde_json::to_string(&result)
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
Ok(js_sys::JSON::parse(&json_string)
|
|
.map_err(|e| JsValue::from_str("Failed to parse JSON"))?)
|
|
}
|
|
|
|
/// Add a secret to the supervisor
|
|
pub async fn add_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([{
|
|
"admin_secret": admin_secret,
|
|
"secret_type": secret_type,
|
|
"secret_value": secret_value
|
|
}]);
|
|
match self.call_method("add_secret", params).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Remove a secret from the supervisor
|
|
pub async fn remove_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> {
|
|
let params = serde_json::json!([{
|
|
"admin_secret": admin_secret,
|
|
"secret_type": secret_type,
|
|
"secret_value": secret_value
|
|
}]);
|
|
match self.call_method("remove_secret", params).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// List secrets (returns supervisor info including secret counts)
|
|
pub async fn list_secrets(&self, admin_secret: &str) -> Result<JsValue, JsValue> {
|
|
let params = serde_json::json!([{
|
|
"admin_secret": admin_secret
|
|
}]);
|
|
match self.call_method("list_secrets", params).await {
|
|
Ok(result) => {
|
|
// Convert serde_json::Value to JsValue
|
|
let result_str = serde_json::to_string(&result)
|
|
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
|
Ok(js_sys::JSON::parse(&result_str)
|
|
.map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?)
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Get supervisor information including secret counts
|
|
pub async fn get_supervisor_info(&self, admin_secret: &str) -> Result<JsValue, JsValue> {
|
|
let params = serde_json::json!({
|
|
"admin_secret": admin_secret
|
|
});
|
|
|
|
match self.call_method("get_supervisor_info", params).await {
|
|
Ok(result) => {
|
|
let result_str = serde_json::to_string(&result)
|
|
.map_err(|e| JsValue::from_str(&format!("Serialization error: {:?}", e)))?;
|
|
Ok(js_sys::JSON::parse(&result_str)
|
|
.map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?)
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to get supervisor info: {:?}", e))),
|
|
}
|
|
}
|
|
|
|
/// List admin secrets (returns actual secret values)
|
|
pub async fn list_admin_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
|
|
let params = serde_json::json!({
|
|
"admin_secret": admin_secret
|
|
});
|
|
|
|
match self.call_method("list_admin_secrets", params).await {
|
|
Ok(result) => {
|
|
let secrets: Vec<String> = serde_json::from_value(result)
|
|
.map_err(|e| JsValue::from_str(&format!("Failed to parse admin secrets: {:?}", e)))?;
|
|
Ok(secrets)
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to list admin secrets: {:?}", e))),
|
|
}
|
|
}
|
|
|
|
/// List user secrets (returns actual secret values)
|
|
pub async fn list_user_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
|
|
let params = serde_json::json!({
|
|
"admin_secret": admin_secret
|
|
});
|
|
|
|
match self.call_method("list_user_secrets", params).await {
|
|
Ok(result) => {
|
|
let secrets: Vec<String> = serde_json::from_value(result)
|
|
.map_err(|e| JsValue::from_str(&format!("Failed to parse user secrets: {:?}", e)))?;
|
|
Ok(secrets)
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to list user secrets: {:?}", e))),
|
|
}
|
|
}
|
|
|
|
/// List register secrets (returns actual secret values)
|
|
pub async fn list_register_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
|
|
let params = serde_json::json!({
|
|
"admin_secret": admin_secret
|
|
});
|
|
|
|
match self.call_method("list_register_secrets", params).await {
|
|
Ok(result) => {
|
|
let secrets: Vec<String> = serde_json::from_value(result)
|
|
.map_err(|e| JsValue::from_str(&format!("Failed to parse register secrets: {:?}", e)))?;
|
|
Ok(secrets)
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&format!("Failed to list register secrets: {:?}", e))),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl WasmJob {
|
|
/// Create a new job with default values
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new(id: String, payload: String, executor: String, runner: String) -> Self {
|
|
let now = js_sys::Date::new_0().to_iso_string().as_string().unwrap();
|
|
Self {
|
|
id,
|
|
caller_id: "wasm_client".to_string(),
|
|
context_id: "wasm_context".to_string(),
|
|
payload,
|
|
runner,
|
|
executor,
|
|
timeout_secs: 30,
|
|
env_vars: "{}".to_string(),
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
}
|
|
}
|
|
|
|
/// Set the caller ID
|
|
#[wasm_bindgen(setter)]
|
|
pub fn set_caller_id(&mut self, caller_id: String) {
|
|
self.caller_id = caller_id;
|
|
}
|
|
|
|
/// Set the context ID
|
|
#[wasm_bindgen(setter)]
|
|
pub fn set_context_id(&mut self, context_id: String) {
|
|
self.context_id = context_id;
|
|
}
|
|
|
|
/// Set the timeout in seconds
|
|
#[wasm_bindgen(setter)]
|
|
pub fn set_timeout_secs(&mut self, timeout_secs: u64) {
|
|
self.timeout_secs = timeout_secs;
|
|
}
|
|
|
|
/// Set environment variables as JSON string
|
|
#[wasm_bindgen(setter)]
|
|
pub fn set_env_vars(&mut self, env_vars: String) {
|
|
self.env_vars = env_vars;
|
|
}
|
|
|
|
/// Generate a new UUID for the job
|
|
#[wasm_bindgen]
|
|
pub fn generate_id(&mut self) {
|
|
self.id = Uuid::new_v4().to_string();
|
|
}
|
|
|
|
/// Get the job ID
|
|
#[wasm_bindgen(getter)]
|
|
pub fn id(&self) -> String {
|
|
self.id.clone()
|
|
}
|
|
|
|
/// Get the caller ID
|
|
#[wasm_bindgen(getter)]
|
|
pub fn caller_id(&self) -> String {
|
|
self.caller_id.clone()
|
|
}
|
|
|
|
/// Get the context ID
|
|
#[wasm_bindgen(getter)]
|
|
pub fn context_id(&self) -> String {
|
|
self.context_id.clone()
|
|
}
|
|
|
|
/// Get the payload
|
|
#[wasm_bindgen(getter)]
|
|
pub fn payload(&self) -> String {
|
|
self.payload.clone()
|
|
}
|
|
|
|
/// Get the job type
|
|
#[wasm_bindgen(getter)]
|
|
pub fn executor(&self) -> String {
|
|
self.executor.clone()
|
|
}
|
|
|
|
/// Get the runner name
|
|
#[wasm_bindgen(getter)]
|
|
pub fn runner(&self) -> String {
|
|
self.runner.clone()
|
|
}
|
|
|
|
/// Get the timeout in seconds
|
|
#[wasm_bindgen(getter)]
|
|
pub fn timeout_secs(&self) -> u64 {
|
|
self.timeout_secs
|
|
}
|
|
|
|
/// Get the environment variables as JSON string
|
|
#[wasm_bindgen(getter)]
|
|
pub fn env_vars(&self) -> String {
|
|
self.env_vars.clone()
|
|
}
|
|
|
|
/// Get the created timestamp
|
|
#[wasm_bindgen(getter)]
|
|
pub fn created_at(&self) -> String {
|
|
self.created_at.clone()
|
|
}
|
|
|
|
/// Get the updated timestamp
|
|
#[wasm_bindgen(getter)]
|
|
pub fn updated_at(&self) -> String {
|
|
self.updated_at.clone()
|
|
}
|
|
}
|
|
|
|
impl WasmSupervisorClient {
|
|
/// List all jobs (returns full job objects as Vec<serde_json::Value>)
|
|
/// This is not exposed to WASM directly due to type limitations
|
|
pub async fn list_jobs(&self) -> Result<Vec<serde_json::Value>, JsValue> {
|
|
let params = serde_json::json!([]);
|
|
match self.call_method("jobs.list", params).await {
|
|
Ok(result) => {
|
|
if let Ok(jobs) = serde_json::from_value::<Vec<serde_json::Value>>(result) {
|
|
Ok(jobs)
|
|
} else {
|
|
Err(JsValue::from_str("Invalid response format for jobs.list"))
|
|
}
|
|
},
|
|
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// 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<serde_json::Value, JsValue> {
|
|
let params = serde_json::json!([job_id]);
|
|
|
|
match self.call_method("job.status", params).await {
|
|
Ok(result) => Ok(result),
|
|
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<serde_json::Value, JsValue> {
|
|
let params = serde_json::json!([job_id]);
|
|
|
|
match self.call_method("job.result", params).await {
|
|
Ok(result) => Ok(result),
|
|
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 if secret is present
|
|
if let Some(secret) = &self.secret {
|
|
headers.set("Authorization", &format!("Bearer {}", secret))
|
|
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
|
}
|
|
|
|
// Create request init
|
|
let opts = RequestInit::new();
|
|
opts.set_method("POST");
|
|
opts.set_headers(&headers);
|
|
opts.set_body(&JsValue::from_str(&body));
|
|
opts.set_mode(RequestMode::Cors);
|
|
|
|
// Create request
|
|
let request = Request::new_with_str_and_init(&self.server_url, &opts)
|
|
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
|
|
|
// Get window and fetch
|
|
let window = web_sys::window().ok_or_else(|| WasmClientError::JavaScript("No window object".to_string()))?;
|
|
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await
|
|
.map_err(|e| WasmClientError::Network(format!("{:?}", e)))?;
|
|
|
|
// Convert to Response
|
|
let resp: Response = resp_value.dyn_into()
|
|
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
|
|
|
// Check if response is ok
|
|
if !resp.ok() {
|
|
return Err(WasmClientError::Network(format!("HTTP {}: {}", resp.status(), resp.status_text())));
|
|
}
|
|
|
|
// Get response text
|
|
let text_promise = resp.text()
|
|
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
|
let text_value = JsFuture::from(text_promise).await
|
|
.map_err(|e| WasmClientError::Network(format!("{:?}", e)))?;
|
|
let text = text_value.as_string()
|
|
.ok_or_else(|| WasmClientError::InvalidResponse)?;
|
|
|
|
// Parse JSON-RPC response
|
|
let response: JsonRpcResponse = serde_json::from_str(&text)?;
|
|
|
|
if let Some(error) = response.error {
|
|
return Err(WasmClientError::Server {
|
|
message: format!("{}: {}", error.code, error.message),
|
|
});
|
|
}
|
|
|
|
// For void methods, null result is valid
|
|
Ok(response.result.unwrap_or(serde_json::Value::Null))
|
|
}
|
|
}
|
|
|
|
/// Initialize the WASM client library (call manually if needed)
|
|
pub fn init() {
|
|
console_log::init_with_level(log::Level::Info).ok();
|
|
log::info!("Hero Supervisor WASM OpenRPC Client initialized");
|
|
}
|
|
|
|
/// Utility function to create a job from JavaScript
|
|
/// Create a new job (convenience function for JavaScript)
|
|
#[wasm_bindgen]
|
|
pub fn create_job(id: String, payload: String, executor: String, runner: String) -> WasmJob {
|
|
WasmJob::new(id, payload, executor, runner)
|
|
}
|
|
|
|
/// Utility function to create a client from JavaScript
|
|
#[wasm_bindgen]
|
|
pub fn create_client(server_url: String) -> WasmSupervisorClient {
|
|
WasmSupervisorClient::new(server_url)
|
|
}
|
|
|
|
/// 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
|
|
let secp = Secp256k1::new();
|
|
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
|
|
let public_key_hex = hex::encode(public_key.serialize());
|
|
|
|
// 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)
|
|
}
|