Compare commits

..

11 Commits
logger ... main

Author SHA1 Message Date
Maxime Van Hees
54b1b0adf5 generate keys and test rpc functions with python script 2025-08-14 16:33:41 +02:00
Maxime Van Hees
0ebda7c1aa Updates 2025-08-14 14:14:34 +02:00
Timur Gordon
04a1af2423 terminal ui better job refreshing 2025-08-07 16:07:49 +02:00
Timur Gordon
337ec2f660 terminal ui fixes 2025-08-07 15:49:35 +02:00
Timur Gordon
69e612e521 clean up debug logging and restore normal tree behavior 2025-08-07 15:45:09 +02:00
Timur Gordon
0df79e78c6 update terminal ui to show nested examples 2025-08-07 15:36:55 +02:00
Timur Gordon
b31651cfeb make func pub 2025-08-07 13:41:19 +02:00
Timur Gordon
831b25dbfa implement unix and ws using jsonrpsee 2025-08-07 11:56:49 +02:00
Timur Gordon
ce76f0a2f7 implement actor terminal ui 2025-08-07 10:26:11 +02:00
Timur Gordon
6c5c97e647 actor execute job fix 2025-08-06 12:56:25 +02:00
Timur Gordon
dcf0f41bb8 actor trait improvements and ui implementation 2025-08-06 12:48:32 +02:00
91 changed files with 13279 additions and 2903 deletions

891
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,14 +6,14 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
hero_logger = { path = "core/logger" } env_logger = "0.10"
hero_supervisor = { path = "core/supervisor" } hero_supervisor = { path = "core/supervisor" }
hero_websocket_server = { path = "interfaces/websocket/server" } hero_websocket_server = { path = "interfaces/websocket/server" }
log = "0.4"
redis = { version = "0.25.0", features = ["tokio-comp"] } redis = { version = "0.25.0", features = ["tokio-comp"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] }
tracing = "0.1"
rhai = "1.21.0" rhai = "1.21.0"
[dev-dependencies] [dev-dependencies]
@ -48,23 +48,18 @@ serde_json = "1.0"
sha3 = "0.10" sha3 = "0.10"
thiserror = "1.0" thiserror = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry", "fmt"] }
tracing-appender = "0.2"
url = "2.5" url = "2.5"
uuid = { version = "1.6", features = ["v4", "serde"] } uuid = { version = "1.6", features = ["v4", "serde"] }
[workspace] [workspace]
members = [ members = [
"interfaces/unix/client",
"interfaces/unix/server",
"interfaces/websocket/client", "interfaces/websocket/client",
"interfaces/websocket/server", "interfaces/websocket/server",
"core/supervisor", "core/supervisor",
"core/actor", "core/actor",
"core/job", "core/job", "interfaces/websocket/examples",
"core/logger",
"interfaces/websocket/examples",
"proxies/http", "proxies/http",
"interfaces/openrpc/client",
"interfaces/openrpc/server",
] ]
resolver = "2" # Recommended for new workspaces resolver = "2" # Recommended for new workspaces

View File

@ -22,7 +22,7 @@ Both examples demonstrate the ping/pong functionality built into the Hero actors
2. **Rust Environment**: Make sure you can build the actor binaries 2. **Rust Environment**: Make sure you can build the actor binaries
```bash ```bash
cd /path/to/herocode/hero/core/actor cd /path/to/herocode/baobab/core/actor
cargo build --bin osis --bin system cargo build --bin osis --bin system
``` ```

View File

@ -0,0 +1,87 @@
1. Generate a keypair locally (public key is safe to share)
- `python tools/gen_auth.py --nonce init`
- Copy PUBLIC_HEX (compressed 33-byte hex, 66 chars). PRIVATE_HEX is your secret—keep it safe.
- Example output:
```
PRIVATE_HEX=5d38d57c83ef1845032fdee1c954958b66912218744ea31d0bc61a07115b6b93
PUBLIC_HEX=0270c0fe3599e82f7142d349fc88e47b07077a43fa00b0fe218ee7bdef4b42d316
NONCE=init
SIGNATURE_HEX=1b109a464c8a6326e66e7bd2caf4c537611f24c6e5e74b0003dc2d5025b6cd6ed180417eacf540938fb306d46d8ebeeed1e6e6c6b69f536d62144baf4a13a139
```
2. Fetch a real nonce from the server
- In hero-openrpc-client menu, choose fetch_nonce
- Paste PUBLIC_HEX when prompted
- Copy the returned nonce string (the exact ASCII hex string)
- Example output:
```
7428f639c215b5ab655283632a39fbd8dc713805cc3b7b0a84c99a5f0e7d5465
```
3. Sign the nonce locally
- python tools/gen_auth.py --nonce "PASTE_NONCE" --priv "YOUR_PRIVATE_HEX"
- Copy SIGNATURE_HEX
- Example output:
```
PRIVATE_HEX=5d38d57c83ef1845032fdee1c954958b66912218744ea31d0bc61a07115b6b93
PUBLIC_HEX=0270c0fe3599e82f7142d349fc88e47b07077a43fa00b0fe218ee7bdef4b42d316
NONCE=7428f639c215b5ab655283632a39fbd8dc713805cc3b7b0a84c99a5f0e7d5465
SIGNATURE_HEX=47dca63f191f328ca9404843a1b3229e4e2affb85ff41dad8125320be3ee07507222c809876d5faa93bfafebdff9e9aef9e17d0b7792d7fcac4d19c92a4b303f
```
4. Authenticate
- In hero-openrpc-client menu, choose authenticate
- Public key (hex): PUBLIC_HEX
- Signature (hex): SIGNATURE_HEX
- Nonce (hex): PASTE_NONCE
After success, whoami should return an authenticated state (basic placeholder in this phase) rust.interfaces/openrpc/server/src/lib.rs.
5. Run `python tools/rpc_smoke_test.py`
- Example output:
```
[rpc] URL: http://127.0.0.1:9944
[rpc] fetch_nonce(pubkey=03fc656cda...): OK
nonce: 4317af6ef04605c7e61ec4759611345f7288497564784cc08afc158553e5ecf1
[rpc] whoami(): OK
whoami: {"authenticated":true,"user_id":"anonymous"}
[rpc] list_jobs(): OK
total: 3
[0] 5f8b4951-35de-4568-8906-a5e9598729e1
[1] 8a0ee6ea-c053-4b72-807a-568c959f5188
[2] 1f929972-3aa5-40c6-af46-6cb81f5a0bae
[rpc] get_job_status(5f8b4951-35de-4568-8906-a5e9598729e1): OK
status: Finished
[rpc] get_job_output(5f8b4951-35de-4568-8906-a5e9598729e1): OK
output: 17
[rpc] get_job_logs(5f8b4951-35de-4568-8906-a5e9598729e1): OK
logs: (no logs)
[rpc] get_job_status(8a0ee6ea-c053-4b72-807a-568c959f5188): OK
status: Finished
[rpc] get_job_output(8a0ee6ea-c053-4b72-807a-568c959f5188): OK
output: 43
[rpc] get_job_logs(8a0ee6ea-c053-4b72-807a-568c959f5188): OK
logs: (no logs)
[rpc] get_job_status(1f929972-3aa5-40c6-af46-6cb81f5a0bae): OK
status: Finished
[rpc] get_job_output(1f929972-3aa5-40c6-af46-6cb81f5a0bae): OK
output: 43
[rpc] get_job_logs(1f929972-3aa5-40c6-af46-6cb81f5a0bae): OK
logs: (no logs)
Smoke tests complete.
Summary:
whoami tested
fetch_nonce tested (pubkey provided/generated)
list_jobs tested (count printed)
detailed queries for up to 3 job(s) (status/output/logs)
```

View File

@ -24,4 +24,4 @@ tls = false
# OSIS Actor Configuration # OSIS Actor Configuration
# Handles OSIS (HeroScript) execution # Handles OSIS (HeroScript) execution
[osis_actor] [osis_actor]
binary_path = "actor_osis" binary_path = "/home/maxime/actor_osis/target/debug/actor_osis"

View File

@ -4,11 +4,17 @@ use std::time::Duration;
use hero_supervisor::{SupervisorBuilder, SupervisorError}; use hero_supervisor::{SupervisorBuilder, SupervisorError};
use hero_websocket_server::ServerBuilder; use hero_websocket_server::ServerBuilder;
use tokio::signal; use tokio::signal;
use tracing::{info, error}; use log::{info, error};
use env_logger::Builder;
/// The main entry point of the Hero Supervisor. /// The main entry point of the Hero Supervisor.
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
info!("Hero Supervisor starting up..."); info!("Hero Supervisor starting up...");
// Get config path from command line arguments or use default // Get config path from command line arguments or use default
@ -35,17 +41,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let actor_configs = supervisor.get_actor_configs()?; let actor_configs = supervisor.get_actor_configs()?;
info!("Loaded {} actor configurations from TOML", actor_configs.len()); info!("Loaded {} actor configurations from TOML", actor_configs.len());
// Initialize the system logger with all components
let mut system_components = vec!["supervisor".to_string()];
for config in &actor_configs {
system_components.push(config.name.clone()); // e.g., "osis_actor_1"
}
// Initialize the logger for all system components
let _logger_guards = hero_logger::init_system_logger("logs", &system_components)?;
info!(target: "supervisor", "System logger initialized with {} components", system_components.len());
// Spawn the background lifecycle manager with 5-minute health check interval // Spawn the background lifecycle manager with 5-minute health check interval
let health_check_interval = Duration::from_secs(5 * 60); // 5 minutes let health_check_interval = Duration::from_secs(5 * 60); // 5 minutes
let mut lifecycle_handle = supervisor.clone().spawn_lifecycle_manager(actor_configs, health_check_interval); let mut lifecycle_handle = supervisor.clone().spawn_lifecycle_manager(actor_configs, health_check_interval);

View File

@ -6,6 +6,11 @@ edition = "2021"
[lib] [lib]
name = "baobab_actor" # Can be different from package name, or same name = "baobab_actor" # Can be different from package name, or same
path = "src/lib.rs" path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "baobab-actor-tui"
path = "cmd/terminal_ui_main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -15,21 +20,26 @@ rhai = { version = "1.21.0", features = ["std", "sync", "decimal", "internals"]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
tracing = { version = "0.1", features = ["log"] } log = "0.4"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } env_logger = "0.10"
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
uuid = { version = "1.6", features = ["v4", "serde"] } # Though task_id is string, uuid might be useful uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
toml = "0.8" toml = "0.8"
thiserror = "1.0" thiserror = "1.0"
async-trait = "0.1" async-trait = "0.1"
# TUI dependencies
anyhow = "1.0"
crossterm = "0.28"
ratatui = "0.28"
hero_supervisor = { path = "../supervisor" } hero_supervisor = { path = "../supervisor" }
hero_job = { path = "../job" } hero_job = { path = "../job" }
hero_logger = { path = "../logger" }
heromodels = { git = "https://git.ourworld.tf/herocode/db.git" } heromodels = { git = "https://git.ourworld.tf/herocode/db.git" }
heromodels_core = { git = "https://git.ourworld.tf/herocode/db.git" } heromodels_core = { git = "https://git.ourworld.tf/herocode/db.git" }
heromodels-derive = { git = "https://git.ourworld.tf/herocode/db.git" } heromodels-derive = { git = "https://git.ourworld.tf/herocode/db.git" }
[features] [features]
default = ["calendar", "finance"] default = ["calendar", "finance"]
calendar = [] calendar = []
@ -38,3 +48,4 @@ flow = []
legal = [] legal = []
projects = [] projects = []
biz = [] biz = []

View File

@ -73,3 +73,12 @@ Key dependencies include:
- `clap`: For command-line argument parsing. - `clap`: For command-line argument parsing.
- `tokio`: For the asynchronous runtime. - `tokio`: For the asynchronous runtime.
- `log`, `env_logger`: For logging. - `log`, `env_logger`: For logging.
## TUI Example
```bash
cargo run --example baobab-actor-tui -- --id osis --path /Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis/target/debug/actor_osis --example-dir /Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis/examples/scripts
```
The TUI will allow you to monitor the actor's job queue and dispatch new jobs to it.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,146 @@
//! Simplified main function for Baobab Actor TUI
//!
//! This binary provides a clean entry point for the actor monitoring and job dispatch interface.
use anyhow::{Result, Context};
use baobab_actor::terminal_ui::{App, setup_and_run_tui};
use clap::Parser;
use log::{info, warn, error};
use std::path::PathBuf;
use std::process::{Child, Command};
use tokio::signal;
#[derive(Parser)]
#[command(name = "baobab-actor-tui")]
#[command(about = "Terminal UI for Baobab Actor - Monitor and dispatch jobs to a single actor")]
struct Args {
/// Actor ID to monitor
#[arg(short, long)]
id: String,
/// Path to actor binary
#[arg(short, long)]
path: PathBuf,
/// Directory containing example .rhai scripts
#[arg(short, long)]
example_dir: Option<PathBuf>,
/// Redis URL for job queue
#[arg(short, long, default_value = "redis://localhost:6379")]
redis_url: String,
/// Enable verbose logging
#[arg(short, long)]
verbose: bool,
}
/// Initialize logging based on verbosity level
fn init_logging(verbose: bool) {
if verbose {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.init();
} else {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
}
}
/// Create and configure the TUI application
fn create_app(args: &Args) -> Result<App> {
App::new(
args.id.clone(),
args.path.clone(),
args.redis_url.clone(),
args.example_dir.clone(),
)
}
/// Spawn the actor binary as a background process
fn spawn_actor_process(args: &Args) -> Result<Child> {
info!("🎬 Spawning actor process: {}", args.path.display());
let mut cmd = Command::new(&args.path);
// Redirect stdout and stderr to null to prevent logs from interfering with TUI
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// Spawn the process
let child = cmd
.spawn()
.with_context(|| format!("Failed to spawn actor process: {}", args.path.display()))?;
info!("✅ Actor process spawned with PID: {}", child.id());
Ok(child)
}
/// Cleanup function to terminate actor process
fn cleanup_actor_process(mut actor_process: Child) {
info!("🧹 Cleaning up actor process...");
match actor_process.try_wait() {
Ok(Some(status)) => {
info!("Actor process already exited with status: {}", status);
}
Ok(None) => {
info!("Terminating actor process...");
if let Err(e) = actor_process.kill() {
error!("Failed to kill actor process: {}", e);
} else {
match actor_process.wait() {
Ok(status) => info!("Actor process terminated with status: {}", status),
Err(e) => error!("Failed to wait for actor process: {}", e),
}
}
}
Err(e) => {
error!("Failed to check actor process status: {}", e);
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
// Initialize logging
init_logging(args.verbose);
info!("🚀 Starting Baobab Actor TUI...");
info!("Actor ID: {}", args.id);
info!("Actor Path: {}", args.path.display());
info!("Redis URL: {}", args.redis_url);
if let Some(ref example_dir) = args.example_dir {
info!("Example Directory: {}", example_dir.display());
}
// Spawn the actor process first
let actor_process = spawn_actor_process(&args)?;
// Give the actor a moment to start up
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Create app and run TUI
let app = create_app(&args)?;
// Set up signal handling for graceful shutdown
let result = tokio::select! {
tui_result = setup_and_run_tui(app) => {
info!("TUI exited");
tui_result
}
_ = signal::ctrl_c() => {
info!("Received Ctrl+C, shutting down...");
Ok(())
}
};
// Clean up the actor process
cleanup_actor_process(actor_process);
result
}

View File

@ -27,16 +27,17 @@
//! └───────────────┘ //! └───────────────┘
//! ``` //! ```
use hero_job::Job; use hero_job::{Job, ScriptType};
use tracing::{debug, error, info}; use hero_job::keys;
use log::{debug, error, info};
use redis::AsyncCommands; use redis::AsyncCommands;
use rhai::Engine;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::{initialize_redis_connection, NAMESPACE_PREFIX, BLPOP_TIMEOUT_SECONDS}; use crate::{initialize_redis_connection, BLPOP_TIMEOUT_SECONDS};
/// Configuration for actor instances /// Configuration for actor instances
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -123,11 +124,14 @@ pub trait Actor: Send + Sync + 'static {
tokio::spawn(async move { tokio::spawn(async move {
let actor_id = self.actor_id(); let actor_id = self.actor_id();
let redis_url = self.redis_url(); let redis_url = self.redis_url();
let queue_key = format!("{}{}", NAMESPACE_PREFIX, actor_id); // Canonical work queue based on script type (instance/group selection can be added later)
let script_type = derive_script_type_from_actor_id(actor_id);
let queue_key = keys::work_type(&script_type);
info!( info!(
"{} Actor '{}' starting. Connecting to Redis at {}. Listening on queue: {}", "{} Actor '{}' starting. Type {:?}. Connecting to Redis at {}. Listening on queue: {}",
self.actor_type(), self.actor_type(),
actor_id, actor_id,
script_type,
redis_url, redis_url,
queue_key queue_key
); );
@ -254,78 +258,18 @@ pub fn spawn_actor<W: Actor>(
actor.spawn(shutdown_rx) actor.spawn(shutdown_rx)
} }
#[cfg(test)] fn derive_script_type_from_actor_id(actor_id: &str) -> ScriptType {
mod tests { let lower = actor_id.to_lowercase();
use super::*; if lower.contains("sal") {
use crate::engine::create_heromodels_engine; ScriptType::SAL
} else if lower.contains("osis") {
// Mock actor for testing ScriptType::OSIS
struct MockActor; } else if lower.contains("python") {
ScriptType::Python
#[async_trait::async_trait] } else if lower.contains("v") {
impl Actor for MockActor { ScriptType::V
async fn process_job( } else {
&self, // Default to OSIS when uncertain
_job: Job, ScriptType::OSIS
_redis_conn: &mut redis::aio::MultiplexedConnection,
) {
// Mock implementation - do nothing
// Engine would be owned by the actor implementation as a field
}
fn actor_type(&self) -> &'static str {
"Mock"
}
fn actor_id(&self) -> &str {
"mock_actor"
}
fn redis_url(&self) -> &str {
"redis://localhost:6379"
}
}
#[tokio::test]
async fn test_actor_config_creation() {
let config = ActorConfig::new(
"test_actor".to_string(),
"/tmp".to_string(),
"redis://localhost:6379".to_string(),
false,
);
assert_eq!(config.actor_id, "test_actor");
assert_eq!(config.db_path, "/tmp");
assert_eq!(config.redis_url, "redis://localhost:6379");
assert!(!config.preserve_tasks);
assert!(config.default_timeout.is_none());
}
#[tokio::test]
async fn test_actor_config_with_timeout() {
let timeout = Duration::from_secs(300);
let config = ActorConfig::new(
"test_actor".to_string(),
"/tmp".to_string(),
"redis://localhost:6379".to_string(),
false,
).with_default_timeout(timeout);
assert_eq!(config.default_timeout, Some(timeout));
}
#[tokio::test]
async fn test_spawn_actor_function() {
let (_shutdown_tx, shutdown_rx) = mpsc::channel(1);
let actor = Arc::new(MockActor);
let handle = spawn_actor(actor, shutdown_rx);
// The actor should be created successfully
assert!(!handle.is_finished());
// Abort the actor for cleanup
handle.abort();
} }
} }

0
core/actor/src/config.rs Normal file
View File

View File

@ -1,5 +1,6 @@
use hero_job::{Job, JobStatus}; use hero_job::{Job, JobStatus, ScriptType};
use tracing::log::{debug, error, info}; use hero_job::keys;
use log::{debug, error, info};
use redis::AsyncCommands; use redis::AsyncCommands;
use rhai::{Dynamic, Engine}; use rhai::{Dynamic, Engine};
use tokio::sync::mpsc; // For shutdown signal use tokio::sync::mpsc; // For shutdown signal
@ -8,6 +9,9 @@ use tokio::task::JoinHandle;
/// Actor trait abstraction for unified actor interface /// Actor trait abstraction for unified actor interface
pub mod actor_trait; pub mod actor_trait;
/// Terminal UI module for actor monitoring and job dispatch
pub mod terminal_ui;
const NAMESPACE_PREFIX: &str = "hero:job:"; const NAMESPACE_PREFIX: &str = "hero:job:";
const BLPOP_TIMEOUT_SECONDS: usize = 5; const BLPOP_TIMEOUT_SECONDS: usize = 5;
@ -33,7 +37,7 @@ pub async fn initialize_redis_connection(
} }
/// Load job from Redis using Job struct /// Load job from Redis using Job struct
pub async fn load_job_from_redis( pub(crate) async fn load_job_from_redis(
redis_conn: &mut redis::aio::MultiplexedConnection, redis_conn: &mut redis::aio::MultiplexedConnection,
job_id: &str, job_id: &str,
actor_id: &str, actor_id: &str,
@ -113,6 +117,29 @@ async fn execute_script_and_update_status(
} }
} }
/// Execute a job with the given engine, setting proper job context
///
/// This function sets up the engine with job context (DB_PATH, CALLER_ID, CONTEXT_ID)
/// and evaluates the script. It returns the result or error without updating Redis.
/// This allows actors to handle Redis updates according to their own patterns.
pub async fn execute_job_with_engine(
engine: &mut Engine,
job: &Job,
db_path: &str,
) -> Result<Dynamic, Box<rhai::EvalAltResult>> {
// Set up job context in the engine
let mut db_config = rhai::Map::new();
db_config.insert("DB_PATH".into(), db_path.to_string().into());
db_config.insert("CALLER_ID".into(), job.caller_id.clone().into());
db_config.insert("CONTEXT_ID".into(), job.context_id.clone().into());
engine.set_default_tag(Dynamic::from(db_config));
debug!("Actor for Context ID '{}': Evaluating script with Rhai engine (job context set).", job.context_id);
// Execute the script with the configured engine
engine.eval::<Dynamic>(&job.script)
}
/// Clean up job from Redis if preserve_tasks is false /// Clean up job from Redis if preserve_tasks is false
async fn cleanup_job( async fn cleanup_job(
redis_conn: &mut redis::aio::MultiplexedConnection, redis_conn: &mut redis::aio::MultiplexedConnection,
@ -191,10 +218,11 @@ pub fn spawn_rhai_actor(
preserve_tasks: bool, preserve_tasks: bool,
) -> JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>> { ) -> JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
tokio::spawn(async move { tokio::spawn(async move {
let queue_key = format!("{}{}", NAMESPACE_PREFIX, actor_id); let script_type = derive_script_type_from_actor_id(&actor_id);
let queue_key = keys::work_type(&script_type);
info!( info!(
"Rhai Actor for Actor ID '{}' starting. Connecting to Redis at {}. Listening on queue: {}. Waiting for tasks or shutdown signal.", "Rhai Actor '{}' starting. Type {:?}. Connecting to Redis at {}. Listening on queue: {}. Waiting for tasks or shutdown signal.",
actor_id, redis_url, queue_key actor_id, script_type, redis_url, queue_key
); );
let mut redis_conn = initialize_redis_connection(&actor_id, &redis_url).await?; let mut redis_conn = initialize_redis_connection(&actor_id, &redis_url).await?;
@ -233,6 +261,23 @@ pub fn spawn_rhai_actor(
}) })
} }
// Helper to derive script type from actor_id for canonical queue selection
fn derive_script_type_from_actor_id(actor_id: &str) -> ScriptType {
let lower = actor_id.to_lowercase();
if lower.contains("sal") {
ScriptType::SAL
} else if lower.contains("osis") {
ScriptType::OSIS
} else if lower.contains("python") {
ScriptType::Python
} else if lower == "v" || lower.contains(":v") || lower.contains(" v") {
ScriptType::V
} else {
// Default to OSIS when uncertain
ScriptType::OSIS
}
}
// Re-export the main trait-based interface for convenience // Re-export the main trait-based interface for convenience
pub use actor_trait::{Actor, ActorConfig, spawn_actor}; pub use actor_trait::{Actor, ActorConfig, spawn_actor};

38
core/actor/src/main.rs Normal file
View File

@ -0,0 +1,38 @@
#[cfg(feature = "wasm")]
use baobab_actor::ui::App;
#[cfg(feature = "wasm")]
use yew::prelude::*;
#[cfg(feature = "wasm")]
fn main() {
console_log::init_with_level(log::Level::Debug).expect("Failed to initialize logger");
// Get configuration from URL parameters or local storage
let window = web_sys::window().expect("No global window exists");
let location = window.location();
let search = location.search().unwrap_or_default();
// Parse URL parameters for actor configuration
let url_params = web_sys::UrlSearchParams::new_with_str(&search).unwrap();
let actor_id = url_params.get("id").unwrap_or_else(|| "default_actor".to_string());
let actor_path = url_params.get("path").unwrap_or_else(|| "/path/to/actor".to_string());
let example_dir = url_params.get("example_dir");
let redis_url = url_params.get("redis_url").unwrap_or_else(|| "redis://localhost:6379".to_string());
log::info!("Starting Baobab Actor UI with actor_id: {}", actor_id);
yew::Renderer::<App>::with_props(baobab_actor::ui::app::AppProps {
actor_id,
actor_path,
example_dir,
redis_url,
}).render();
}
#[cfg(not(feature = "wasm"))]
fn main() {
eprintln!("This binary is only available with the 'wasm' feature enabled.");
eprintln!("Please compile with: cargo build --features wasm --target wasm32-unknown-unknown");
std::process::exit(1);
}

File diff suppressed because it is too large Load Diff

View File

@ -76,6 +76,11 @@ impl JobBuilder {
self self
} }
pub fn caller_id(mut self, caller_id: &str) -> Self {
self.caller_id = caller_id.to_string();
self
}
pub fn script(mut self, script: &str) -> Self { pub fn script(mut self, script: &str) -> Self {
self.script = script.to_string(); self.script = script.to_string();
self self

View File

@ -7,6 +7,7 @@ use redis::AsyncCommands;
use thiserror::Error; use thiserror::Error;
mod builder; mod builder;
pub use builder::JobBuilder;
/// Redis namespace prefix for all Hero job-related keys /// Redis namespace prefix for all Hero job-related keys
pub const NAMESPACE_PREFIX: &str = "hero:job:"; pub const NAMESPACE_PREFIX: &str = "hero:job:";
@ -386,3 +387,47 @@ impl Job {
Ok(job_ids) Ok(job_ids)
} }
} }
// Canonical Redis key builders for queues and hashes
pub mod keys {
use super::{NAMESPACE_PREFIX, ScriptType};
// hero:job:{job_id}
pub fn job_hash(job_id: &str) -> String {
format!("{}{}", NAMESPACE_PREFIX, job_id)
}
// hero:q:reply:{job_id}
pub fn reply(job_id: &str) -> String {
format!("hero:q:reply:{}", job_id)
}
// hero:q:work:type:{script_type}
pub fn work_type(script_type: &ScriptType) -> String {
format!("hero:q:work:type:{}", script_type.actor_queue_suffix())
}
// hero:q:work:type:{script_type}:group:{group}
pub fn work_group(script_type: &ScriptType, group: &str) -> String {
format!(
"hero:q:work:type:{}:group:{}",
script_type.actor_queue_suffix(),
group
)
}
// hero:q:work:type:{script_type}:group:{group}:inst:{instance}
pub fn work_instance(script_type: &ScriptType, group: &str, instance: &str) -> String {
format!(
"hero:q:work:type:{}:group:{}:inst:{}",
script_type.actor_queue_suffix(),
group,
instance
)
}
// hero:q:ctl:type:{script_type}
pub fn stop_type(script_type: &ScriptType) -> String {
format!("hero:q:ctl:type:{}", script_type.actor_queue_suffix())
}
}

View File

@ -1,23 +0,0 @@
[package]
name = "hero_logger"
version = "0.1.0"
edition = "2021"
description = "Hierarchical logging system for the Hero project with system and per-job isolation"
authors = ["Hero Team"]
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry", "fmt"] }
tracing-appender = "0.2"
tokio = { version = "1", features = ["fs", "time", "rt", "rt-multi-thread", "macros"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
anyhow = "1.0"
rhai = "1.21.0"
[dev-dependencies]
tempfile = "3.0"
tokio-test = "0.4"
tracing-test = "0.2"

View File

@ -1,292 +0,0 @@
# Hero Logger
A hierarchical logging system for the Hero project that provides system-level and per-job logging with complete isolation using the `tracing` ecosystem.
## Features
- **Hierarchical Organization**: Physical separation of logs by component and job
- **System Logger**: Global logging for all non-job-specific events
- **Per-Job Logger**: Isolated logging for individual job execution
- **Custom Log Format**: Readable format with precise formatting rules
- **Hourly Rotation**: Automatic log file rotation every hour
- **Rhai Integration**: Capture Rhai script `print()` and `debug()` calls
- **High Performance**: Async logging with efficient filtering
- **Structured Logging**: Rich context and metadata support
## Custom Log Format
Hero Logger uses a custom format designed for readability and consistency:
```
21:23:42
system - This is a normal log message
system - This is a multi-line message
second line with proper indentation
third line maintaining alignment
E error_cat - This is an error message
E second line of error
E third line of error
```
### Format Rules
- **Time stamps (HH:MM:SS)** are written once per second when the log time changes
- **Categories** are:
- Limited to 10 characters maximum
- Padded with spaces to exactly 10 characters
- Any `-` in category names are converted to `_`
- **Each line starts with either:**
- ` ` (space) for normal logs (INFO, WARN, DEBUG, TRACE)
- `E` for error logs
- **Multi-line messages** maintain consistent indentation (14 spaces after the prefix)
## Architecture
The logging system uses a hybrid approach with two main components:
### System Logger (Global)
- Long-lived logger initialized at application startup
- Routes logs to different files based on tracing targets
- Supports multiple components simultaneously
### Per-Job Logger (Dynamic)
- Created on-demand for each job execution
- Provides complete isolation for job-specific logs
- Automatically disposed after job completion
## Directory Structure
```
logs/
├── supervisor/ # System logs for supervisor
│ └── 2025-08-06-11.log
└── actor/
├── osis/
│ ├── 2025-08-06-11.log # General OSIS actor logs
│ ├── job-a1b2c3d4/ # Job-specific logs
│ │ └── 2025-08-06-11.log
│ └── job-9a8b7c6d/
│ └── 2025-08-06-12.log
└── sal/
├── 2025-08-06-13.log # General SAL actor logs
└── job-f1e2d3c4/
└── 2025-08-06-13.log
```
## Quick Start
### 1. Initialize System Logger
```rust
use hero_logger;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Define your system components
let components = vec![
"supervisor".to_string(),
"osis_actor".to_string(),
"sal_actor".to_string(),
];
// Initialize the system logger
let _guards = hero_logger::init_system_logger("logs", &components)?;
// Now you can use tracing macros with targets
tracing::info!(target: "supervisor", "System started");
tracing::info!(target: "osis_actor", "Actor ready");
Ok(())
}
```
### 2. Per-Job Logging
```rust
use hero_logger::create_job_logger;
use tracing::subscriber::with_default;
async fn process_job(job_id: &str, actor_type: &str) {
// Create job-specific logger
let job_logger = create_job_logger("logs", actor_type, job_id)?;
// Execute job within logging context
with_default(job_logger, || {
tracing::info!(target: "osis_actor", "Job {} started", job_id);
// All tracing calls here go to the job-specific log
tracing::debug!(target: "osis_actor", "Processing data...");
tracing::info!(target: "osis_actor", "Job {} completed", job_id);
});
}
```
### 3. Rhai Script Integration
```rust
use hero_logger::rhai_integration::configure_rhai_logging;
use rhai::Engine;
fn setup_rhai_engine() -> Engine {
let mut engine = Engine::new();
// Configure Rhai to capture print/debug calls
configure_rhai_logging(&mut engine, "osis_actor");
engine
}
// Now Rhai scripts can use print() and debug()
let script = r#"
print("Hello from Rhai!");
debug("Debug information");
42
"#;
let result = engine.eval::<i64>(script)?;
```
## API Reference
### Core Functions
#### `init_system_logger(logs_root, components)`
Initialize the global system logger with component-based filtering.
**Parameters:**
- `logs_root`: Root directory for log files
- `components`: List of component names for dedicated logging
**Returns:** Vector of `WorkerGuard`s that must be kept alive
#### `create_job_logger(logs_root, actor_type, job_id)`
Create a per-job logger for isolated logging.
**Parameters:**
- `logs_root`: Root directory for log files
- `actor_type`: Type of actor (e.g., "osis", "sal")
- `job_id`: Unique job identifier
**Returns:** Boxed subscriber for use with `with_default()`
### Rhai Integration
#### `configure_rhai_logging(engine, target)`
Configure a Rhai engine to capture print/debug output.
#### `add_custom_logging_functions(engine, target)`
Add custom logging functions (`log_info`, `log_debug`, etc.) to Rhai.
#### `create_logging_enabled_engine(target, include_custom)`
Create a new Rhai engine with full logging integration.
### Utilities
#### `ensure_log_directories(logs_root, components)`
Ensure the log directory structure exists.
#### `extract_actor_type(component)`
Extract actor type from component name.
#### `cleanup_old_logs(directory, pattern, max_age_days)`
Clean up old log files based on age.
## Configuration
### Log Levels
The system supports standard tracing log levels:
- `ERROR`: Critical errors
- `WARN`: Warning messages
- `INFO`: Informational messages
- `DEBUG`: Debug information
- `TRACE`: Detailed trace information
### Environment Variables
- `RUST_LOG`: Set log level filtering (e.g., `RUST_LOG=debug`)
### File Rotation
- **Hourly**: Default rotation every hour
- **Daily**: Optional daily rotation
- **Never**: Single file (no rotation)
## Examples
### Basic Usage
```bash
cargo run --example logging_demo
```
### Custom Format Demo
```bash
cargo run --example custom_format_demo
```
### Integration with Actor System
```rust
// In your actor implementation
async fn process_job(&self, job: &Job) {
let job_logger = hero_logger::create_job_logger(
"logs",
&self.actor_type,
&job.id
).unwrap();
let job_task = async move {
tracing::info!(target: &self.actor_type, "Job processing started");
// Configure Rhai engine for this job
let mut engine = Engine::new();
hero_logger::rhai_integration::configure_rhai_logging(
&mut engine,
&self.actor_type
);
// Execute Rhai script - print/debug calls captured
let result = engine.eval::<String>(&job.script)?;
tracing::info!(target: &self.actor_type, "Job finished: {}", result);
Ok(result)
};
// Execute with job-specific logging
tracing::subscriber::with_default(job_logger, job_task).await;
}
```
## Performance Considerations
- **Async Logging**: All file I/O is asynchronous
- **Efficient Filtering**: Target-based filtering minimizes overhead
- **Memory Usage**: Per-job loggers are short-lived and automatically cleaned up
- **File Handles**: Automatic rotation prevents excessive file handle usage
## Troubleshooting
### Common Issues
1. **Logs not appearing**: Ensure `WorkerGuard`s are kept alive
2. **Permission errors**: Check write permissions on log directory
3. **Missing directories**: Use `ensure_log_directories()` before logging
4. **Rhai output not captured**: Verify `configure_rhai_logging()` is called
### Debug Mode
Enable debug logging to see internal logger operations:
```bash
RUST_LOG=hero_logger=debug cargo run
```
## Testing
Run the test suite:
```bash
cargo test
```
Run the demo example:
```bash
cargo run --example logging_demo
```
## License
This project is part of the Hero ecosystem and follows the same licensing terms.

View File

@ -1,142 +0,0 @@
//! Logging System Demo
//!
//! This example demonstrates the Hero logging system functionality including:
//! - System logger initialization
//! - Per-job logger creation
//! - Rhai script integration with logging
//! - Directory structure creation
use hero_logger::{
init_system_logger, create_job_logger, rhai_integration::configure_rhai_logging,
};
use tracing::{info, debug, warn, error};
use tracing::subscriber::with_default;
use rhai::Engine;
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 Hero Logging System Demo");
println!("============================");
// 1. Initialize the system logger
println!("\n📋 Step 1: Initializing system logger...");
let components = vec![
"supervisor".to_string(),
"osis_actor".to_string(),
"sal_actor".to_string(),
];
let _guards = init_system_logger("demo_logs", &components)?;
println!("✅ System logger initialized with {} components", components.len());
// 2. Test system-level logging
println!("\n📝 Step 2: Testing system-level logging...");
info!(target: "supervisor", "Supervisor started successfully");
info!(target: "osis_actor", "OSIS actor is ready");
info!(target: "sal_actor", "SAL actor is ready");
warn!(target: "supervisor", "This is a warning message");
error!(target: "supervisor", "This is an error message for testing");
// Give time for async logging
sleep(Duration::from_millis(100)).await;
println!("✅ System logs written to demo_logs/supervisor/ and demo_logs/actor/*/");
// 3. Test per-job logging
println!("\n🔄 Step 3: Testing per-job logging...");
// Create job loggers for different jobs
let job1_logger = create_job_logger("demo_logs", "osis", "demo-job-001")?;
let job2_logger = create_job_logger("demo_logs", "sal", "demo-job-002")?;
// Execute logging within job contexts
with_default(job1_logger, || {
info!(target: "osis_actor", "Job demo-job-001 started");
debug!(target: "osis_actor", "Processing OSIS data");
info!(target: "osis_actor", "Job demo-job-001 completed successfully");
});
with_default(job2_logger, || {
info!(target: "sal_actor", "Job demo-job-002 started");
debug!(target: "sal_actor", "Processing SAL data");
warn!(target: "sal_actor", "Minor issue detected but continuing");
info!(target: "sal_actor", "Job demo-job-002 completed successfully");
});
sleep(Duration::from_millis(100)).await;
println!("✅ Per-job logs written to demo_logs/actor/*/job-*/");
// 4. Test Rhai integration
println!("\n🔧 Step 4: Testing Rhai script logging integration...");
let job3_logger = create_job_logger("demo_logs", "osis", "rhai-demo-003")?;
with_default(job3_logger, || {
let mut engine = Engine::new();
configure_rhai_logging(&mut engine, "osis_actor");
info!(target: "osis_actor", "Starting Rhai script execution");
// Execute a Rhai script that uses print and debug
let script = r#"
print("Hello from Rhai script!");
debug("This is a debug message from Rhai");
let result = 42 + 8;
print("Calculation result: " + result);
result
"#;
match engine.eval::<i64>(script) {
Ok(result) => {
info!(target: "osis_actor", "Rhai script completed with result: {}", result);
}
Err(e) => {
error!(target: "osis_actor", "Rhai script failed: {:?}", e);
}
}
});
sleep(Duration::from_millis(100)).await;
println!("✅ Rhai script logs captured in per-job logger");
// 5. Display directory structure
println!("\n📁 Step 5: Generated directory structure:");
display_directory_structure("demo_logs", 0)?;
println!("\n🎉 Demo completed successfully!");
println!("Check the 'demo_logs' directory to see the generated log files.");
println!("Each component and job has its own isolated log files with hourly rotation.");
Ok(())
}
/// Recursively display directory structure
fn display_directory_structure(path: &str, depth: usize) -> Result<(), Box<dyn std::error::Error>> {
let path = std::path::Path::new(path);
if !path.exists() {
return Ok(());
}
let indent = " ".repeat(depth);
if path.is_dir() {
println!("{}📁 {}/", indent, path.file_name().unwrap_or_default().to_string_lossy());
let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let entry_path = entry.path();
if entry_path.is_dir() {
display_directory_structure(&entry_path.to_string_lossy(), depth + 1)?;
} else {
println!("{}📄 {}", " ".repeat(depth + 1), entry.file_name().to_string_lossy());
}
}
}
Ok(())
}

View File

@ -1,234 +0,0 @@
//! Custom Hero Logger Formatter
//!
//! This module implements a custom formatter for the Hero logging system that provides:
//! - Time stamps (HH:MM:SS) written once per second when the log time changes
//! - Categories limited to 10 characters maximum, padded with spaces, dashes converted to underscores
//! - Each line starts with either space (normal logs) or E (error logs)
//! - Multi-line messages maintain consistent indentation (14 spaces after the prefix)
use std::fmt;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::{format::Writer, FmtContext, FormatEvent, FormatFields};
use tracing_subscriber::registry::LookupSpan;
use chrono::{DateTime, Local};
/// Custom formatter for Hero logging system
pub struct HeroFormatter {
/// Tracks the last written timestamp to avoid duplicate timestamps
last_timestamp: Arc<Mutex<Option<String>>>,
}
impl HeroFormatter {
/// Create a new Hero formatter
pub fn new() -> Self {
Self {
last_timestamp: Arc::new(Mutex::new(None)),
}
}
/// Format a category name according to Hero rules:
/// - Convert dashes to underscores
/// - Limit to 10 characters maximum
/// - Pad with spaces to exactly 10 characters
fn format_category(&self, target: &str) -> String {
let processed = target.replace('-', "_");
let truncated = if processed.len() > 10 {
&processed[..10]
} else {
&processed
};
format!("{:<10}", truncated)
}
/// Get the log level prefix (space for normal, E for error)
fn get_level_prefix(&self, level: &Level) -> char {
match *level {
Level::ERROR => 'E',
_ => ' ',
}
}
/// Get current timestamp in HH:MM:SS format
fn get_current_timestamp(&self) -> String {
let now: DateTime<Local> = Local::now();
now.format("%H:%M:%S").to_string()
}
/// Check if we need to write a timestamp and update the last timestamp
fn should_write_timestamp(&self, current_timestamp: &str) -> bool {
let mut last_ts = self.last_timestamp.lock().unwrap();
match last_ts.as_ref() {
Some(last) if last == current_timestamp => false,
_ => {
*last_ts = Some(current_timestamp.to_string());
true
}
}
}
/// Format a multi-line message with proper indentation
fn format_message(&self, prefix: char, category: &str, message: &str) -> String {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return format!("{} {} - \n", prefix, category);
}
let mut result = String::new();
// First line: prefix + category + " - " + message
result.push_str(&format!("{} {} - {}\n", prefix, category, lines[0]));
// Subsequent lines: prefix + 14 spaces + message
for line in lines.iter().skip(1) {
result.push_str(&format!("{} {}\n", prefix, line));
}
result
}
}
impl Default for HeroFormatter {
fn default() -> Self {
Self::new()
}
}
impl<S, N> FormatEvent<S, N> for HeroFormatter
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
_ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
// Get current timestamp
let current_timestamp = self.get_current_timestamp();
// Write timestamp if it has changed
if self.should_write_timestamp(&current_timestamp) {
writeln!(writer, "{}", current_timestamp)?;
}
// Get event metadata
let metadata = event.metadata();
let level = metadata.level();
let target = metadata.target();
// Format category and get prefix
let category = self.format_category(target);
let prefix = self.get_level_prefix(level);
// Capture the message
let mut message_visitor = MessageVisitor::new();
event.record(&mut message_visitor);
let message = message_visitor.message;
// Format and write the message
let formatted = self.format_message(prefix, &category, &message);
write!(writer, "{}", formatted)?;
Ok(())
}
}
/// Visitor to extract the message from tracing events
struct MessageVisitor {
message: String,
}
impl MessageVisitor {
fn new() -> Self {
Self {
message: String::new(),
}
}
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
// Remove surrounding quotes if present
if self.message.starts_with('"') && self.message.ends_with('"') {
self.message = self.message[1..self.message.len()-1].to_string();
}
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.message = value.to_string();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing::{info, error};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use std::sync::{Arc, Mutex};
use std::io::Cursor;
#[test]
fn test_format_category() {
let formatter = HeroFormatter::new();
// Test normal category
assert_eq!(formatter.format_category("system"), "system ");
// Test category with dashes
assert_eq!(formatter.format_category("osis-actor"), "osis_actor");
// Test long category (truncation)
assert_eq!(formatter.format_category("very-long-category-name"), "very_long_");
// Test exact 10 characters
assert_eq!(formatter.format_category("exactly10c"), "exactly10c");
}
#[test]
fn test_get_level_prefix() {
let formatter = HeroFormatter::new();
assert_eq!(formatter.get_level_prefix(&Level::ERROR), 'E');
assert_eq!(formatter.get_level_prefix(&Level::WARN), ' ');
assert_eq!(formatter.get_level_prefix(&Level::INFO), ' ');
assert_eq!(formatter.get_level_prefix(&Level::DEBUG), ' ');
assert_eq!(formatter.get_level_prefix(&Level::TRACE), ' ');
}
#[test]
fn test_format_message() {
let formatter = HeroFormatter::new();
// Test single line message
let result = formatter.format_message(' ', "system ", "Hello world");
assert_eq!(result, " system - Hello world\n");
// Test multi-line message
let result = formatter.format_message('E', "error_cat ", "Line 1\nLine 2\nLine 3");
let expected = "E error_cat - Line 1\nE Line 2\nE Line 3\n";
assert_eq!(result, expected);
}
#[test]
fn test_timestamp_tracking() {
let formatter = HeroFormatter::new();
let timestamp = "12:34:56";
// First call should return true (write timestamp)
assert!(formatter.should_write_timestamp(timestamp));
// Second call with same timestamp should return false
assert!(!formatter.should_write_timestamp(timestamp));
// Call with different timestamp should return true
assert!(formatter.should_write_timestamp("12:34:57"));
}
}

View File

@ -1,285 +0,0 @@
//! Custom File Appender Implementation
//!
//! This module provides custom file appender functionality with enhanced
//! rotation and directory management capabilities.
use crate::{LoggerError, Result};
use std::path::{Path, PathBuf};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
/// Create a custom rolling file appender with enhanced configuration
pub fn create_rolling_appender<P: AsRef<Path>>(
directory: P,
file_name_prefix: &str,
rotation: AppenderRotation,
) -> Result<RollingFileAppender> {
let directory = directory.as_ref();
// Ensure directory exists
std::fs::create_dir_all(directory)
.map_err(|e| LoggerError::DirectoryCreation(
format!("Failed to create directory {}: {}", directory.display(), e)
))?;
let rotation = match rotation {
AppenderRotation::Hourly => Rotation::HOURLY,
AppenderRotation::Daily => Rotation::DAILY,
AppenderRotation::Never => Rotation::NEVER,
};
let appender = tracing_appender::rolling::Builder::new()
.rotation(rotation)
.filename_prefix(file_name_prefix)
.filename_suffix("log")
.build(directory)
.map_err(|e| LoggerError::Config(format!("Failed to create rolling appender: {}", e)))?;
Ok(appender)
}
/// Enhanced rotation configuration
#[derive(Debug, Clone, Copy)]
pub enum AppenderRotation {
/// Rotate files every hour
Hourly,
/// Rotate files every day
Daily,
/// Never rotate (single file)
Never,
}
/// File appender builder for more complex configurations
pub struct FileAppenderBuilder {
directory: PathBuf,
file_prefix: String,
file_suffix: String,
rotation: AppenderRotation,
max_files: Option<usize>,
}
impl FileAppenderBuilder {
/// Create a new file appender builder
pub fn new<P: AsRef<Path>>(directory: P) -> Self {
Self {
directory: directory.as_ref().to_path_buf(),
file_prefix: "log".to_string(),
file_suffix: "log".to_string(),
rotation: AppenderRotation::Hourly,
max_files: None,
}
}
/// Set the file prefix
pub fn file_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
self.file_prefix = prefix.into();
self
}
/// Set the file suffix
pub fn file_suffix<S: Into<String>>(mut self, suffix: S) -> Self {
self.file_suffix = suffix.into();
self
}
/// Set the rotation policy
pub fn rotation(mut self, rotation: AppenderRotation) -> Self {
self.rotation = rotation;
self
}
/// Set maximum number of files to keep (for cleanup)
pub fn max_files(mut self, max: usize) -> Self {
self.max_files = Some(max);
self
}
/// Build the file appender
pub fn build(self) -> Result<RollingFileAppender> {
// Ensure directory exists
std::fs::create_dir_all(&self.directory)
.map_err(|e| LoggerError::DirectoryCreation(
format!("Failed to create directory {}: {}", self.directory.display(), e)
))?;
let rotation = match self.rotation {
AppenderRotation::Hourly => Rotation::HOURLY,
AppenderRotation::Daily => Rotation::DAILY,
AppenderRotation::Never => Rotation::NEVER,
};
let appender = tracing_appender::rolling::Builder::new()
.rotation(rotation)
.filename_prefix(&self.file_prefix)
.filename_suffix(&self.file_suffix)
.build(&self.directory)
.map_err(|e| LoggerError::Config(format!("Failed to create rolling appender: {}", e)))?;
// Perform cleanup if max_files is set
if let Some(max_files) = self.max_files {
if let Err(e) = cleanup_old_files(&self.directory, &self.file_prefix, max_files) {
tracing::warn!("Failed to cleanup old log files: {}", e);
}
}
Ok(appender)
}
}
/// Clean up old log files, keeping only the most recent ones
fn cleanup_old_files<P: AsRef<Path>>(
directory: P,
file_prefix: &str,
max_files: usize,
) -> Result<()> {
let directory = directory.as_ref();
let mut log_files = Vec::new();
// Read directory and collect log files
let entries = std::fs::read_dir(directory)
.map_err(|e| LoggerError::Io(e))?;
for entry in entries {
let entry = entry.map_err(|e| LoggerError::Io(e))?;
let path = entry.path();
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
if file_name.starts_with(file_prefix) && file_name.ends_with(".log") {
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
log_files.push((path, modified));
}
}
}
}
}
// Sort by modification time (newest first)
log_files.sort_by(|a, b| b.1.cmp(&a.1));
// Remove old files if we exceed max_files
if log_files.len() > max_files {
for (old_file, _) in log_files.iter().skip(max_files) {
if let Err(e) = std::fs::remove_file(old_file) {
tracing::warn!("Failed to remove old log file {}: {}", old_file.display(), e);
} else {
tracing::debug!("Removed old log file: {}", old_file.display());
}
}
}
Ok(())
}
/// Utility function to get the current log file path for a given configuration
pub fn get_current_log_file<P: AsRef<Path>>(
directory: P,
file_prefix: &str,
rotation: AppenderRotation,
) -> PathBuf {
let directory = directory.as_ref();
match rotation {
AppenderRotation::Hourly => {
let now = chrono::Utc::now();
let timestamp = now.format("%Y-%m-%d-%H");
directory.join(format!("{}.{}.log", file_prefix, timestamp))
}
AppenderRotation::Daily => {
let now = chrono::Utc::now();
let timestamp = now.format("%Y-%m-%d");
directory.join(format!("{}.{}.log", file_prefix, timestamp))
}
AppenderRotation::Never => {
directory.join(format!("{}.log", file_prefix))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::time::Duration;
#[test]
fn test_create_rolling_appender() {
let temp_dir = TempDir::new().unwrap();
let directory = temp_dir.path().join("logs");
let appender = create_rolling_appender(&directory, "test", AppenderRotation::Hourly).unwrap();
// Verify directory was created
assert!(directory.exists());
}
#[test]
fn test_file_appender_builder() {
let temp_dir = TempDir::new().unwrap();
let directory = temp_dir.path().join("logs");
let appender = FileAppenderBuilder::new(&directory)
.file_prefix("custom")
.file_suffix("txt")
.rotation(AppenderRotation::Daily)
.max_files(5)
.build()
.unwrap();
assert!(directory.exists());
}
#[test]
fn test_get_current_log_file() {
let temp_dir = TempDir::new().unwrap();
let directory = temp_dir.path();
// Test hourly rotation
let hourly_file = get_current_log_file(directory, "test", AppenderRotation::Hourly);
assert!(hourly_file.to_string_lossy().contains("test."));
assert!(hourly_file.extension().unwrap() == "log");
// Test daily rotation
let daily_file = get_current_log_file(directory, "test", AppenderRotation::Daily);
assert!(daily_file.to_string_lossy().contains("test."));
assert!(daily_file.extension().unwrap() == "log");
// Test never rotation
let never_file = get_current_log_file(directory, "test", AppenderRotation::Never);
assert_eq!(never_file, directory.join("test.log"));
}
#[test]
fn test_cleanup_old_files() {
let temp_dir = TempDir::new().unwrap();
let directory = temp_dir.path();
// Create some test log files
for i in 0..10 {
let file_path = directory.join(format!("test.{}.log", i));
std::fs::write(&file_path, "test content").unwrap();
// Sleep briefly to ensure different modification times
std::thread::sleep(Duration::from_millis(10));
}
// Cleanup, keeping only 5 files
cleanup_old_files(directory, "test", 5).unwrap();
// Count remaining files
let remaining_files: Vec<_> = std::fs::read_dir(directory)
.unwrap()
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with("test.") && name.ends_with(".log") {
Some(name)
} else {
None
}
})
.collect();
assert_eq!(remaining_files.len(), 5);
}
}

View File

@ -1,306 +0,0 @@
//! Per-Job Logger Implementation
//!
//! This module implements the per-job logging functionality that creates
//! temporary, isolated loggers for individual job execution.
use crate::{LoggerError, Result, custom_formatter::HeroFormatter};
use std::path::{Path, PathBuf};
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
fmt,
layer::SubscriberExt,
util::SubscriberInitExt,
Layer, Registry,
};
use tracing_appender::{non_blocking::WorkerGuard, rolling};
/// Create a per-job logger for isolated job logging
///
/// This creates a temporary tracing subscriber that writes exclusively
/// to a job-specific directory. The subscriber is designed to be used
/// with `tracing::subscriber::with_default()` to scope all logging within a job.
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `actor_type` - Type of actor (e.g., "osis", "sal")
/// * `job_id` - Unique job identifier
///
/// # Returns
///
/// Returns a boxed subscriber that can be used with `with_default()`
/// The WorkerGuard is managed internally and will be dropped when the subscriber is dropped.
pub fn create_job_logger<P: AsRef<Path>>(
logs_root: P,
actor_type: &str,
job_id: &str,
) -> Result<Box<dyn tracing::Subscriber + Send + Sync>> {
let (subscriber, _guard) = create_job_logger_with_guard(logs_root, actor_type, job_id)?;
// Note: The guard is intentionally dropped here because the job logger
// is meant to be short-lived. In practice, the job execution should be
// fast enough that logs are flushed before the guard is dropped.
// For longer-running jobs, use create_job_logger_with_guard instead.
Ok(subscriber)
}
/// Create a job logger that returns both the subscriber and the guard
///
/// This variant returns both the subscriber and the worker guard, giving
/// the caller control over the guard's lifetime for proper log flushing.
pub fn create_job_logger_with_guard<P: AsRef<Path>>(
logs_root: P,
actor_type: &str,
job_id: &str,
) -> Result<(Box<dyn tracing::Subscriber + Send + Sync>, WorkerGuard)> {
let logs_root = logs_root.as_ref();
// Create job-specific directory: logs/actor/<type>/job-<job_id>/
let job_dir = logs_root
.join("actor")
.join(actor_type)
.join(format!("job-{}", job_id));
// Ensure the job directory exists
std::fs::create_dir_all(&job_dir)
.map_err(|e| LoggerError::DirectoryCreation(format!("Failed to create job directory {}: {}", job_dir.display(), e)))?;
// Create hourly rolling file appender for the job
let file_appender = rolling::hourly(&job_dir, "log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
// Create a formatted layer for the job with custom Hero formatter
let layer = fmt::layer()
.with_writer(non_blocking)
.event_format(HeroFormatter::new())
.with_ansi(false) // No ANSI colors in log files
.with_filter(
EnvFilter::new("trace") // Capture all logs within the job context
.add_directive(LevelFilter::TRACE.into())
);
// Create a registry with the job layer
let subscriber = Registry::default()
.with(layer);
tracing::debug!(
target: "hero_logger",
"Created job logger for actor_type={}, job_id={}, log_dir={}",
actor_type,
job_id,
job_dir.display()
);
Ok((Box::new(subscriber), guard))
}
/// Create a job logger with custom configuration
///
/// This allows for more fine-grained control over the job logger configuration.
pub fn create_job_logger_with_config<P: AsRef<Path>>(
logs_root: P,
actor_type: &str,
job_id: &str,
config: JobLoggerConfig,
) -> Result<(Box<dyn tracing::Subscriber + Send + Sync>, WorkerGuard)> {
let logs_root = logs_root.as_ref();
// Create job-specific directory
let job_dir = logs_root
.join("actor")
.join(actor_type)
.join(format!("job-{}", job_id));
std::fs::create_dir_all(&job_dir)
.map_err(|e| LoggerError::DirectoryCreation(format!("Failed to create job directory {}: {}", job_dir.display(), e)))?;
// Create file appender based on config
let file_appender = match config.rotation {
RotationConfig::Hourly => rolling::hourly(&job_dir, &config.file_prefix),
RotationConfig::Daily => rolling::daily(&job_dir, &config.file_prefix),
RotationConfig::Never => rolling::never(&job_dir, format!("{}.log", config.file_prefix)),
};
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
// Create layer with custom configuration and Hero formatter
let mut layer = fmt::layer()
.with_writer(non_blocking)
.event_format(HeroFormatter::new())
.with_ansi(false);
// Apply level filter
let layer = layer.with_filter(
EnvFilter::new(&config.level_filter)
.add_directive(config.max_level.into())
);
let subscriber = Registry::default()
.with(layer);
Ok((Box::new(subscriber), guard))
}
/// Configuration for job logger creation
#[derive(Debug, Clone)]
pub struct JobLoggerConfig {
/// File prefix for log files
pub file_prefix: String,
/// Log rotation configuration
pub rotation: RotationConfig,
/// Maximum log level to capture
pub max_level: LevelFilter,
/// Level filter string (e.g., "debug", "info", "trace")
pub level_filter: String,
/// Include target in log output
pub include_target: bool,
/// Include thread IDs in log output
pub include_thread_ids: bool,
/// Include file location in log output
pub include_file_location: bool,
/// Include line numbers in log output
pub include_line_numbers: bool,
}
impl Default for JobLoggerConfig {
fn default() -> Self {
Self {
file_prefix: "job".to_string(),
rotation: RotationConfig::Hourly,
max_level: LevelFilter::TRACE,
level_filter: "trace".to_string(),
include_target: true,
include_thread_ids: true,
include_file_location: true,
include_line_numbers: true,
}
}
}
/// Log file rotation configuration
#[derive(Debug, Clone)]
pub enum RotationConfig {
/// Rotate logs hourly
Hourly,
/// Rotate logs daily
Daily,
/// Never rotate logs (single file)
Never,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tracing::{info, debug, error};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
async fn test_job_logger_creation() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let job_logger = create_job_logger(logs_root, "osis", "test-job-123").unwrap();
// Verify job directory was created
let job_dir = logs_root.join("actor/osis/job-test-job-123");
assert!(job_dir.exists());
// Test logging within the job context
tracing::subscriber::with_default(job_logger, || {
info!(target: "osis_actor", "Job started");
debug!(target: "osis_actor", "Processing data");
info!(target: "osis_actor", "Job completed");
});
// Give some time for async writing
sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
async fn test_job_logger_with_guard() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let (job_logger, _guard) = create_job_logger_with_guard(logs_root, "sal", "test-job-456").unwrap();
// Verify job directory was created
let job_dir = logs_root.join("actor/sal/job-test-job-456");
assert!(job_dir.exists());
// Test logging
tracing::subscriber::with_default(job_logger, || {
error!(target: "sal_actor", "Job failed with error");
});
sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
async fn test_job_logger_with_custom_config() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let config = JobLoggerConfig {
file_prefix: "custom".to_string(),
rotation: RotationConfig::Never,
max_level: LevelFilter::INFO,
level_filter: "info".to_string(),
include_target: false,
include_thread_ids: false,
include_file_location: false,
include_line_numbers: false,
};
let (job_logger, _guard) = create_job_logger_with_config(
logs_root,
"python",
"custom-job",
config
).unwrap();
// Verify job directory was created
let job_dir = logs_root.join("actor/python/job-custom-job");
assert!(job_dir.exists());
// Test logging
tracing::subscriber::with_default(job_logger, || {
info!(target: "python_actor", "Custom job logging");
});
sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
async fn test_multiple_job_loggers() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
// Create multiple job loggers
let job1 = create_job_logger(logs_root, "osis", "job-1").unwrap();
let job2 = create_job_logger(logs_root, "osis", "job-2").unwrap();
let job3 = create_job_logger(logs_root, "sal", "job-3").unwrap();
// Verify all directories were created
assert!(logs_root.join("actor/osis/job-job-1").exists());
assert!(logs_root.join("actor/osis/job-job-2").exists());
assert!(logs_root.join("actor/sal/job-job-3").exists());
// Test isolated logging
tracing::subscriber::with_default(job1, || {
info!(target: "osis_actor", "Job 1 message");
});
tracing::subscriber::with_default(job2, || {
info!(target: "osis_actor", "Job 2 message");
});
tracing::subscriber::with_default(job3, || {
info!(target: "sal_actor", "Job 3 message");
});
sleep(Duration::from_millis(100)).await;
}
}

View File

@ -1,234 +0,0 @@
//! # Hero Logger
//!
//! A hierarchical logging system for the Hero project that provides:
//! - System-level logging with component-based filtering
//! - Per-job logging with complete isolation
//! - Hourly log rotation
//! - Integration with the tracing ecosystem
//!
//! ## Architecture
//!
//! The logging system uses a hybrid approach:
//! - **System Logger**: Long-lived, captures all non-job-specific logs
//! - **Per-Job Logger**: Short-lived, captures all logs for a single job
//!
//! ## Usage
//!
//! ```rust
//! use hero_logger;
//!
//! // Initialize system logger (once at startup)
//! let components = vec!["supervisor".to_string(), "osis_actor".to_string()];
//! hero_logger::init_system_logger("logs", &components)?;
//!
//! // Use system logging
//! tracing::info!(target: "supervisor", "System started");
//!
//! // Create per-job logger for isolated logging
//! let job_logger = hero_logger::create_job_logger("logs", "osis", "job-123")?;
//! tracing::subscriber::with_default(job_logger, || {
//! tracing::info!(target: "osis_actor", "Job processing started");
//! });
//! ```
use std::path::{Path, PathBuf};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
use tracing_appender::non_blocking::WorkerGuard;
mod system_logger;
mod job_logger;
mod file_appender;
mod utils;
mod custom_formatter;
pub mod rhai_integration;
pub use system_logger::*;
pub use job_logger::*;
pub use file_appender::*;
pub use utils::*;
/// Errors that can occur during logging operations
#[derive(thiserror::Error, Debug)]
pub enum LoggerError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Tracing error: {0}")]
Tracing(String),
#[error("Invalid configuration: {0}")]
Config(String),
#[error("Directory creation failed: {0}")]
DirectoryCreation(String),
}
/// Result type for logger operations
pub type Result<T> = std::result::Result<T, LoggerError>;
/// Initialize the system logger with component-based filtering
///
/// This function sets up the global tracing subscriber with multiple file appenders,
/// each filtered by component target. It should be called once at application startup.
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `components` - List of component names that will have dedicated log directories
///
/// # Returns
///
/// Returns a vector of `WorkerGuard`s that must be kept alive for the duration
/// of the application to ensure proper log flushing.
///
/// # Example
///
/// ```rust
/// let components = vec![
/// "supervisor".to_string(),
/// "osis_actor".to_string(),
/// "sal_actor".to_string(),
/// ];
/// let _guards = hero_logger::init_system_logger("logs", &components)?;
/// ```
pub fn init_system_logger<P: AsRef<Path>>(
logs_root: P,
components: &[String],
) -> Result<Vec<WorkerGuard>> {
system_logger::init_system_logger(logs_root, components)
}
/// Create a per-job logger for isolated job logging
///
/// This function creates a temporary tracing subscriber that writes exclusively
/// to a job-specific directory. The subscriber should be used with
/// `tracing::subscriber::with_default()` to scope all logging within a job.
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `actor_type` - Type of actor (e.g., "osis", "sal")
/// * `job_id` - Unique job identifier
///
/// # Returns
///
/// Returns a boxed subscriber that can be used with `with_default()`
///
/// # Example
///
/// ```rust
/// let job_logger = hero_logger::create_job_logger("logs", "osis", "job-abc123")?;
///
/// tracing::subscriber::with_default(job_logger, || {
/// tracing::info!(target: "osis_actor", "Job started");
/// // All tracing calls here go to the job-specific log
/// });
/// ```
pub fn create_job_logger<P: AsRef<Path>>(
logs_root: P,
actor_type: &str,
job_id: &str,
) -> Result<Box<dyn tracing::Subscriber + Send + Sync>> {
job_logger::create_job_logger(logs_root, actor_type, job_id)
}
/// Create a job logger that returns both the subscriber and the guard
///
/// This variant returns both the subscriber and the worker guard, giving
/// the caller control over the guard's lifetime.
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `actor_type` - Type of actor (e.g., "osis", "sal")
/// * `job_id` - Unique job identifier
///
/// # Returns
///
/// Returns a tuple of (subscriber, guard) where the guard must be kept alive
/// for proper log flushing.
pub fn create_job_logger_with_guard<P: AsRef<Path>>(
logs_root: P,
actor_type: &str,
job_id: &str,
) -> Result<(Box<dyn tracing::Subscriber + Send + Sync>, WorkerGuard)> {
job_logger::create_job_logger_with_guard(logs_root, actor_type, job_id)
}
/// Ensure the log directory structure exists
///
/// Creates the necessary directory structure for the logging system:
/// - `logs/supervisor/`
/// - `logs/actor/osis/`
/// - `logs/actor/sal/`
/// - etc.
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `components` - List of component names
pub fn ensure_log_directories<P: AsRef<Path>>(
logs_root: P,
components: &[String],
) -> Result<()> {
utils::ensure_log_directories(logs_root, components)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tracing::info;
#[tokio::test]
async fn test_system_logger_initialization() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let components = vec![
"supervisor".to_string(),
"test_actor".to_string(),
];
let _guards = init_system_logger(logs_root, &components).unwrap();
// Verify directories were created
assert!(logs_root.join("supervisor").exists());
assert!(logs_root.join("actor/test_actor").exists());
}
#[tokio::test]
async fn test_job_logger_creation() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let job_logger = create_job_logger(logs_root, "test", "job-123").unwrap();
// Verify job directory was created
assert!(logs_root.join("actor/test/job-job-123").exists());
// Test that we can use the logger
tracing::subscriber::with_default(job_logger, || {
info!(target: "test_actor", "Test log message");
});
}
#[tokio::test]
async fn test_directory_creation() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let components = vec![
"supervisor".to_string(),
"osis_actor".to_string(),
"sal_actor".to_string(),
];
ensure_log_directories(logs_root, &components).unwrap();
// Verify all directories exist
assert!(logs_root.join("supervisor").exists());
assert!(logs_root.join("actor/osis_actor").exists());
assert!(logs_root.join("actor/sal_actor").exists());
}
}

View File

@ -1,411 +0,0 @@
//! Rhai Engine Integration for Logging
//!
//! This module provides integration between Rhai scripts and the tracing logging system,
//! allowing Rhai print() and debug() calls to be captured in the logging infrastructure.
use rhai::{Engine, Dynamic};
use tracing::{info, debug, warn, error};
/// Configure a Rhai engine to capture print and debug output through tracing
///
/// This function sets up custom print and debug hooks that route Rhai script
/// output through the tracing system, allowing it to be captured by both
/// system and per-job loggers.
///
/// # Arguments
///
/// * `engine` - Mutable reference to the Rhai engine to configure
/// * `target` - Target name for tracing (e.g., "osis_actor", "sal_actor")
///
/// # Example
///
/// ```rust
/// use rhai::Engine;
/// use hero_logger::rhai_integration::configure_rhai_logging;
///
/// let mut engine = Engine::new();
/// configure_rhai_logging(&mut engine, "osis_actor");
///
/// // Now when Rhai scripts call print() or debug(), they will be logged
/// engine.eval::<()>(r#"print("Hello from Rhai!");"#).unwrap();
/// ```
pub fn configure_rhai_logging(engine: &mut Engine, target: &str) {
// Use a macro to create the logging functions with constant targets
match target {
"supervisor" => {
engine.on_print(|text| {
info!(target: "supervisor", "[Rhai Script] {}", text);
});
engine.on_debug(|text, source, pos| {
if let Some(source) = source {
if pos.is_none() {
debug!(target: "supervisor", "[Rhai Debug] {} (from {})", text, source);
} else {
debug!(target: "supervisor", "[Rhai Debug] {} (from {} at {:?})", text, source, pos);
}
} else {
debug!(target: "supervisor", "[Rhai Debug] {}", text);
}
});
}
"osis_actor" => {
engine.on_print(|text| {
info!(target: "osis_actor", "[Rhai Script] {}", text);
});
engine.on_debug(|text, source, pos| {
if let Some(source) = source {
if pos.is_none() {
debug!(target: "osis_actor", "[Rhai Debug] {} (from {})", text, source);
} else {
debug!(target: "osis_actor", "[Rhai Debug] {} (from {} at {:?})", text, source, pos);
}
} else {
debug!(target: "osis_actor", "[Rhai Debug] {}", text);
}
});
}
"sal_actor" => {
engine.on_print(|text| {
info!(target: "sal_actor", "[Rhai Script] {}", text);
});
engine.on_debug(|text, source, pos| {
if let Some(source) = source {
if pos.is_none() {
debug!(target: "sal_actor", "[Rhai Debug] {} (from {})", text, source);
} else {
debug!(target: "sal_actor", "[Rhai Debug] {} (from {} at {:?})", text, source, pos);
}
} else {
debug!(target: "sal_actor", "[Rhai Debug] {}", text);
}
});
}
"v_actor" => {
engine.on_print(|text| {
info!(target: "v_actor", "[Rhai Script] {}", text);
});
engine.on_debug(|text, source, pos| {
if let Some(source) = source {
if pos.is_none() {
debug!(target: "v_actor", "[Rhai Debug] {} (from {})", text, source);
} else {
debug!(target: "v_actor", "[Rhai Debug] {} (from {} at {:?})", text, source, pos);
}
} else {
debug!(target: "v_actor", "[Rhai Debug] {}", text);
}
});
}
"python_actor" => {
engine.on_print(|text| {
info!(target: "python_actor", "[Rhai Script] {}", text);
});
engine.on_debug(|text, source, pos| {
if let Some(source) = source {
if pos.is_none() {
debug!(target: "python_actor", "[Rhai Debug] {} (from {})", text, source);
} else {
debug!(target: "python_actor", "[Rhai Debug] {} (from {} at {:?})", text, source, pos);
}
} else {
debug!(target: "python_actor", "[Rhai Debug] {}", text);
}
});
}
_ => {
// Default fallback
engine.on_print(|text| {
info!("[Rhai Script] {}", text);
});
engine.on_debug(|text, source, pos| {
if let Some(source) = source {
if pos.is_none() {
debug!("[Rhai Debug] {} (from {})", text, source);
} else {
debug!("[Rhai Debug] {} (from {} at {:?})", text, source, pos);
}
} else {
debug!("[Rhai Debug] {}", text);
}
});
}
}
}
/// Configure a Rhai engine with enhanced logging capabilities
///
/// This function provides more advanced logging configuration, including
/// custom log levels and structured logging support.
///
/// # Arguments
///
/// * `engine` - Mutable reference to the Rhai engine to configure
/// * `config` - Configuration for Rhai logging behavior
pub fn configure_rhai_logging_advanced(engine: &mut Engine, config: RhaiLoggingConfig) {
// For now, use the basic configuration since tracing requires constant targets
configure_rhai_logging(engine, &config.target);
}
/// Configuration for Rhai logging behavior
#[derive(Debug, Clone)]
pub struct RhaiLoggingConfig {
/// Target name for tracing
pub target: String,
/// Log level for print() calls ("error", "warn", "info", "debug")
pub print_level: String,
/// Log level for debug() calls ("error", "warn", "info", "debug")
pub debug_level: String,
/// Whether to include source file and position information
pub include_source_info: bool,
/// Prefix for all Rhai log messages
pub message_prefix: Option<String>,
}
impl Default for RhaiLoggingConfig {
fn default() -> Self {
Self {
target: "rhai_script".to_string(),
print_level: "info".to_string(),
debug_level: "debug".to_string(),
include_source_info: true,
message_prefix: None,
}
}
}
impl RhaiLoggingConfig {
/// Create a new configuration with the specified target
pub fn new(target: &str) -> Self {
Self {
target: target.to_string(),
..Default::default()
}
}
/// Set the log level for print() calls
pub fn print_level(mut self, level: &str) -> Self {
self.print_level = level.to_string();
self
}
/// Set the log level for debug() calls
pub fn debug_level(mut self, level: &str) -> Self {
self.debug_level = level.to_string();
self
}
/// Set whether to include source information
pub fn include_source_info(mut self, include: bool) -> Self {
self.include_source_info = include;
self
}
/// Set a prefix for all log messages
pub fn message_prefix(mut self, prefix: &str) -> Self {
self.message_prefix = Some(prefix.to_string());
self
}
}
/// Add custom logging functions to a Rhai engine
///
/// This function adds custom logging functions (log_info, log_debug, log_warn, log_error)
/// that Rhai scripts can call directly for more granular logging control.
///
/// # Arguments
///
/// * `engine` - Mutable reference to the Rhai engine
/// * `target` - Target name for tracing
pub fn add_custom_logging_functions(engine: &mut Engine, target: &str) {
// Use match to handle different targets with constant strings
match target {
"supervisor" => {
engine.register_fn("log_info", |message: &str| {
info!(target: "supervisor", "[Rhai] {}", message);
});
engine.register_fn("log_debug", |message: &str| {
debug!(target: "supervisor", "[Rhai] {}", message);
});
engine.register_fn("log_warn", |message: &str| {
warn!(target: "supervisor", "[Rhai] {}", message);
});
engine.register_fn("log_error", |message: &str| {
error!(target: "supervisor", "[Rhai] {}", message);
});
}
"osis_actor" => {
engine.register_fn("log_info", |message: &str| {
info!(target: "osis_actor", "[Rhai] {}", message);
});
engine.register_fn("log_debug", |message: &str| {
debug!(target: "osis_actor", "[Rhai] {}", message);
});
engine.register_fn("log_warn", |message: &str| {
warn!(target: "osis_actor", "[Rhai] {}", message);
});
engine.register_fn("log_error", |message: &str| {
error!(target: "osis_actor", "[Rhai] {}", message);
});
}
"sal_actor" => {
engine.register_fn("log_info", |message: &str| {
info!(target: "sal_actor", "[Rhai] {}", message);
});
engine.register_fn("log_debug", |message: &str| {
debug!(target: "sal_actor", "[Rhai] {}", message);
});
engine.register_fn("log_warn", |message: &str| {
warn!(target: "sal_actor", "[Rhai] {}", message);
});
engine.register_fn("log_error", |message: &str| {
error!(target: "sal_actor", "[Rhai] {}", message);
});
}
"v_actor" => {
engine.register_fn("log_info", |message: &str| {
info!(target: "v_actor", "[Rhai] {}", message);
});
engine.register_fn("log_debug", |message: &str| {
debug!(target: "v_actor", "[Rhai] {}", message);
});
engine.register_fn("log_warn", |message: &str| {
warn!(target: "v_actor", "[Rhai] {}", message);
});
engine.register_fn("log_error", |message: &str| {
error!(target: "v_actor", "[Rhai] {}", message);
});
}
"python_actor" => {
engine.register_fn("log_info", |message: &str| {
info!(target: "python_actor", "[Rhai] {}", message);
});
engine.register_fn("log_debug", |message: &str| {
debug!(target: "python_actor", "[Rhai] {}", message);
});
engine.register_fn("log_warn", |message: &str| {
warn!(target: "python_actor", "[Rhai] {}", message);
});
engine.register_fn("log_error", |message: &str| {
error!(target: "python_actor", "[Rhai] {}", message);
});
}
_ => {
// Default fallback
engine.register_fn("log_info", |message: &str| {
info!("[Rhai] {}", message);
});
engine.register_fn("log_debug", |message: &str| {
debug!("[Rhai] {}", message);
});
engine.register_fn("log_warn", |message: &str| {
warn!("[Rhai] {}", message);
});
engine.register_fn("log_error", |message: &str| {
error!("[Rhai] {}", message);
});
}
}
}
/// Create a Rhai engine with full logging integration
///
/// This is a convenience function that creates a new Rhai engine and configures
/// it with comprehensive logging support.
///
/// # Arguments
///
/// * `target` - Target name for tracing
/// * `include_custom_functions` - Whether to include custom logging functions
///
/// # Returns
///
/// Returns a configured Rhai engine ready for use with logging
pub fn create_logging_enabled_engine(target: &str, include_custom_functions: bool) -> Engine {
let mut engine = Engine::new();
// Configure basic logging
configure_rhai_logging(&mut engine, target);
// Add custom logging functions if requested
if include_custom_functions {
add_custom_logging_functions(&mut engine, target);
}
engine
}
#[cfg(test)]
mod tests {
use super::*;
use tracing_test::traced_test;
#[traced_test]
#[test]
fn test_configure_rhai_logging() {
let mut engine = Engine::new();
configure_rhai_logging(&mut engine, "test_actor");
// Test print output
engine.eval::<()>(r#"print("Hello from Rhai!");"#).unwrap();
// Verify that the log was captured (tracing_test will capture it)
// In a real test, you would check the captured logs
}
#[traced_test]
#[test]
fn test_configure_rhai_logging_advanced() {
let mut engine = Engine::new();
let config = RhaiLoggingConfig::new("test_actor")
.print_level("warn")
.debug_level("info")
.include_source_info(false);
configure_rhai_logging_advanced(&mut engine, config);
// Test print and debug output
engine.eval::<()>(r#"
print("This is a print message");
debug("This is a debug message");
"#).unwrap();
}
#[traced_test]
#[test]
fn test_add_custom_logging_functions() {
let mut engine = Engine::new();
add_custom_logging_functions(&mut engine, "test_actor");
// Test custom logging functions
engine.eval::<()>(r#"
log_info("Info message");
log_debug("Debug message");
log_warn("Warning message");
log_error("Error message");
"#).unwrap();
}
#[test]
fn test_create_logging_enabled_engine() {
let engine = create_logging_enabled_engine("test_actor", true);
// Verify engine was created successfully
// In a real test, you would verify the logging configuration
assert!(engine.eval::<i64>("1 + 1").unwrap() == 2);
}
#[test]
fn test_rhai_logging_config() {
let config = RhaiLoggingConfig::new("test")
.print_level("error")
.debug_level("warn")
.include_source_info(false)
.message_prefix("TEST");
assert_eq!(config.target, "test");
assert_eq!(config.print_level, "error");
assert_eq!(config.debug_level, "warn");
assert!(!config.include_source_info);
assert_eq!(config.message_prefix, Some("TEST".to_string()));
}
}

View File

@ -1,170 +0,0 @@
//! System Logger Implementation
//!
//! This module implements the system-wide logging functionality that captures
//! all non-job-specific logs from every component with target-based filtering.
use crate::{LoggerError, Result, custom_formatter::HeroFormatter};
use std::path::{Path, PathBuf};
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
fmt,
layer::SubscriberExt,
util::SubscriberInitExt,
Layer,
};
use tracing_appender::{non_blocking::WorkerGuard, rolling};
/// Initialize the system logger with component-based filtering
///
/// This creates multiple file appenders, each filtered by a specific tracing target:
/// - `tracing::info!(target: "supervisor", ...)` -> `logs/supervisor/`
/// - `tracing::info!(target: "osis_actor", ...)` -> `logs/actor/osis/`
/// - etc.
pub fn init_system_logger<P: AsRef<Path>>(
logs_root: P,
components: &[String],
) -> Result<Vec<WorkerGuard>> {
let logs_root = logs_root.as_ref();
// Ensure log directories exist
crate::utils::ensure_log_directories(logs_root, components)?;
let mut guards = Vec::new();
let mut layers = Vec::new();
// Create a layer for each component
for component in components {
let (layer, guard) = create_component_layer(logs_root, component)?;
layers.push(layer);
guards.push(guard);
}
// Create the registry with all layers
let registry = tracing_subscriber::registry();
// Add all component layers to the registry
let collected_layers = layers.into_iter().collect::<Vec<_>>();
let registry = registry.with(collected_layers);
// Add console output for development
let console_layer = fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.with_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into()));
// Set as global default
registry.with(console_layer).init();
tracing::info!(target: "hero_logger", "System logger initialized with {} components", components.len());
Ok(guards)
}
/// Create a filtered layer for a specific component
fn create_component_layer<P: AsRef<Path>>(
logs_root: P,
component: &str,
) -> Result<(Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync>, WorkerGuard)> {
let logs_root = logs_root.as_ref();
// Determine the log directory based on component type
let log_dir = if component == "supervisor" {
logs_root.join("supervisor")
} else {
// Extract actor type from component name (e.g., "osis_actor" -> "osis")
let actor_type = component.strip_suffix("_actor").unwrap_or(component);
logs_root.join("actor").join(actor_type)
};
// Create hourly rolling file appender
let file_appender = rolling::hourly(&log_dir, "log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
// Create a formatted layer with custom Hero formatter and target filtering
let layer = fmt::layer()
.with_writer(non_blocking)
.event_format(HeroFormatter::new())
.with_ansi(false) // No ANSI colors in log files
.with_filter(
EnvFilter::new(format!("{}=trace", component))
.add_directive(LevelFilter::INFO.into())
);
Ok((layer.boxed(), guard))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tracing::{info, warn};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
async fn test_system_logger_initialization() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let components = vec![
"supervisor".to_string(),
"osis_actor".to_string(),
"sal_actor".to_string(),
];
let _guards = init_system_logger(logs_root, &components).unwrap();
// Test logging to different targets
info!(target: "supervisor", "Supervisor started");
info!(target: "osis_actor", "OSIS actor ready");
info!(target: "sal_actor", "SAL actor ready");
// Give some time for async writing
sleep(Duration::from_millis(100)).await;
// Verify directories were created
assert!(logs_root.join("supervisor").exists());
assert!(logs_root.join("actor/osis").exists());
assert!(logs_root.join("actor/sal").exists());
}
#[tokio::test]
async fn test_component_layer_creation() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
// Create supervisor layer
let (supervisor_layer, _guard1) = create_component_layer(logs_root, "supervisor").unwrap();
assert!(logs_root.join("supervisor").exists());
// Create actor layer
let (actor_layer, _guard2) = create_component_layer(logs_root, "osis_actor").unwrap();
assert!(logs_root.join("actor/osis").exists());
}
#[tokio::test]
async fn test_multiple_components() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let components = vec![
"supervisor".to_string(),
"osis_actor".to_string(),
"sal_actor".to_string(),
"v_actor".to_string(),
"python_actor".to_string(),
];
let guards = init_system_logger(logs_root, &components).unwrap();
assert_eq!(guards.len(), components.len());
// Test that all directories were created
assert!(logs_root.join("supervisor").exists());
assert!(logs_root.join("actor/osis").exists());
assert!(logs_root.join("actor/sal").exists());
assert!(logs_root.join("actor/v").exists());
assert!(logs_root.join("actor/python").exists());
}
}

View File

@ -1,468 +0,0 @@
//! Utility functions for the Hero Logger
//!
//! This module provides common utility functions used throughout the logging system.
use crate::{LoggerError, Result};
use std::path::{Path, PathBuf};
/// Ensure the log directory structure exists
///
/// Creates the necessary directory structure for the logging system:
/// - `logs/supervisor/`
/// - `logs/actor/osis/`
/// - `logs/actor/sal/`
/// - etc.
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `components` - List of component names
pub fn ensure_log_directories<P: AsRef<Path>>(
logs_root: P,
components: &[String],
) -> Result<()> {
let logs_root = logs_root.as_ref();
// Create the root logs directory
std::fs::create_dir_all(logs_root)
.map_err(|e| LoggerError::DirectoryCreation(
format!("Failed to create logs root directory {}: {}", logs_root.display(), e)
))?;
// Create directories for each component
for component in components {
let component_dir = get_component_log_directory(logs_root, component);
std::fs::create_dir_all(&component_dir)
.map_err(|e| LoggerError::DirectoryCreation(
format!("Failed to create component directory {}: {}", component_dir.display(), e)
))?;
tracing::debug!(
target: "hero_logger",
"Created log directory for component '{}': {}",
component,
component_dir.display()
);
}
tracing::info!(
target: "hero_logger",
"Log directory structure created at: {}",
logs_root.display()
);
Ok(())
}
/// Get the log directory path for a specific component
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `component` - Component name (e.g., "supervisor", "osis_actor")
///
/// # Returns
///
/// Returns the appropriate directory path:
/// - "supervisor" -> `logs/supervisor/`
/// - "osis_actor" -> `logs/actor/osis/`
/// - etc.
pub fn get_component_log_directory<P: AsRef<Path>>(
logs_root: P,
component: &str,
) -> PathBuf {
let logs_root = logs_root.as_ref();
if component == "supervisor" {
logs_root.join("supervisor")
} else {
// Extract actor type from component name (e.g., "osis_actor" -> "osis")
let actor_type = component.strip_suffix("_actor").unwrap_or(component);
logs_root.join("actor").join(actor_type)
}
}
/// Get the job log directory path for a specific job
///
/// # Arguments
///
/// * `logs_root` - Root directory for all log files
/// * `actor_type` - Type of actor (e.g., "osis", "sal")
/// * `job_id` - Unique job identifier
///
/// # Returns
///
/// Returns the job-specific directory path: `logs/actor/<type>/job-<job_id>/`
pub fn get_job_log_directory<P: AsRef<Path>>(
logs_root: P,
actor_type: &str,
job_id: &str,
) -> PathBuf {
logs_root
.as_ref()
.join("actor")
.join(actor_type)
.join(format!("job-{}", job_id))
}
/// Extract actor type from component name
///
/// # Arguments
///
/// * `component` - Component name (e.g., "osis_actor_1", "sal_actor")
///
/// # Returns
///
/// Returns the actor type (e.g., "osis", "sal")
pub fn extract_actor_type(component: &str) -> &str {
// Handle patterns like "osis_actor_1" -> "osis"
if let Some(actor_part) = component.strip_suffix("_actor") {
return actor_part;
}
// Handle patterns like "osis_actor_1" -> "osis"
if component.contains("_actor_") {
if let Some(pos) = component.find("_actor_") {
return &component[..pos];
}
}
// Handle patterns like "osis_actor" -> "osis"
component.strip_suffix("_actor").unwrap_or(component)
}
/// Generate a timestamp string for log file naming
///
/// # Arguments
///
/// * `format` - Timestamp format ("hourly", "daily", or custom format string)
///
/// # Returns
///
/// Returns a formatted timestamp string
pub fn generate_timestamp(format: &str) -> String {
let now = chrono::Utc::now();
match format {
"hourly" => now.format("%Y-%m-%d-%H").to_string(),
"daily" => now.format("%Y-%m-%d").to_string(),
custom => now.format(custom).to_string(),
}
}
/// Clean up old log files in a directory
///
/// # Arguments
///
/// * `directory` - Directory to clean up
/// * `file_pattern` - Pattern to match files (e.g., "*.log")
/// * `max_age_days` - Maximum age in days for files to keep
pub fn cleanup_old_logs<P: AsRef<Path>>(
directory: P,
file_pattern: &str,
max_age_days: u64,
) -> Result<usize> {
let directory = directory.as_ref();
if !directory.exists() {
return Ok(0);
}
let cutoff_time = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(max_age_days * 24 * 60 * 60))
.ok_or_else(|| LoggerError::Config("Invalid max_age_days value".to_string()))?;
let mut removed_count = 0;
let entries = std::fs::read_dir(directory)
.map_err(|e| LoggerError::Io(e))?;
for entry in entries {
let entry = entry.map_err(|e| LoggerError::Io(e))?;
let path = entry.path();
if path.is_file() {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
// Simple pattern matching (could be enhanced with regex)
let matches_pattern = if file_pattern == "*" {
true
} else if file_pattern.starts_with("*.") {
let extension = &file_pattern[2..];
file_name.ends_with(extension)
} else {
file_name.contains(file_pattern)
};
if matches_pattern {
if let Ok(metadata) = entry.metadata() {
if let Ok(modified) = metadata.modified() {
if modified < cutoff_time {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(
target: "hero_logger",
"Failed to remove old log file {}: {}",
path.display(),
e
);
} else {
tracing::debug!(
target: "hero_logger",
"Removed old log file: {}",
path.display()
);
removed_count += 1;
}
}
}
}
}
}
}
}
if removed_count > 0 {
tracing::info!(
target: "hero_logger",
"Cleaned up {} old log files from {}",
removed_count,
directory.display()
);
}
Ok(removed_count)
}
/// Get disk usage information for the logs directory
pub fn get_logs_disk_usage<P: AsRef<Path>>(logs_root: P) -> Result<LogsDiskUsage> {
let logs_root = logs_root.as_ref();
if !logs_root.exists() {
return Ok(LogsDiskUsage {
total_size_bytes: 0,
file_count: 0,
directories: Vec::new(),
});
}
let mut total_size = 0u64;
let mut file_count = 0usize;
let mut directories = Vec::new();
fn scan_directory(
dir: &Path,
total_size: &mut u64,
file_count: &mut usize,
) -> Result<DirectoryUsage> {
let mut dir_size = 0u64;
let mut dir_file_count = 0usize;
let entries = std::fs::read_dir(dir)
.map_err(|e| LoggerError::Io(e))?;
for entry in entries {
let entry = entry.map_err(|e| LoggerError::Io(e))?;
let path = entry.path();
if path.is_file() {
if let Ok(metadata) = entry.metadata() {
let size = metadata.len();
dir_size += size;
*total_size += size;
dir_file_count += 1;
*file_count += 1;
}
} else if path.is_dir() {
let sub_usage = scan_directory(&path, total_size, file_count)?;
dir_size += sub_usage.size_bytes;
dir_file_count += sub_usage.file_count;
}
}
Ok(DirectoryUsage {
path: dir.to_path_buf(),
size_bytes: dir_size,
file_count: dir_file_count,
})
}
let root_usage = scan_directory(logs_root, &mut total_size, &mut file_count)?;
directories.push(root_usage);
Ok(LogsDiskUsage {
total_size_bytes: total_size,
file_count,
directories,
})
}
/// Information about disk usage of logs
#[derive(Debug, Clone)]
pub struct LogsDiskUsage {
pub total_size_bytes: u64,
pub file_count: usize,
pub directories: Vec<DirectoryUsage>,
}
/// Information about disk usage of a specific directory
#[derive(Debug, Clone)]
pub struct DirectoryUsage {
pub path: PathBuf,
pub size_bytes: u64,
pub file_count: usize,
}
impl LogsDiskUsage {
/// Get total size in human-readable format
pub fn total_size_human(&self) -> String {
format_bytes(self.total_size_bytes)
}
}
impl DirectoryUsage {
/// Get size in human-readable format
pub fn size_human(&self) -> String {
format_bytes(self.size_bytes)
}
}
/// Format bytes in human-readable format
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", bytes, UNITS[unit_index])
} else {
format!("{:.2} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::time::Duration;
#[test]
fn test_ensure_log_directories() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
let components = vec![
"supervisor".to_string(),
"osis_actor".to_string(),
"sal_actor".to_string(),
];
ensure_log_directories(logs_root, &components).unwrap();
assert!(logs_root.join("supervisor").exists());
assert!(logs_root.join("actor/osis").exists());
assert!(logs_root.join("actor/sal").exists());
}
#[test]
fn test_get_component_log_directory() {
let logs_root = Path::new("/logs");
assert_eq!(
get_component_log_directory(logs_root, "supervisor"),
logs_root.join("supervisor")
);
assert_eq!(
get_component_log_directory(logs_root, "osis_actor"),
logs_root.join("actor/osis")
);
assert_eq!(
get_component_log_directory(logs_root, "sal_actor_1"),
logs_root.join("actor/sal_actor_1")
);
}
#[test]
fn test_get_job_log_directory() {
let logs_root = Path::new("/logs");
assert_eq!(
get_job_log_directory(logs_root, "osis", "job-123"),
logs_root.join("actor/osis/job-job-123")
);
}
#[test]
fn test_extract_actor_type() {
assert_eq!(extract_actor_type("osis_actor"), "osis");
assert_eq!(extract_actor_type("sal_actor_1"), "sal");
assert_eq!(extract_actor_type("python_actor"), "python");
assert_eq!(extract_actor_type("supervisor"), "supervisor");
assert_eq!(extract_actor_type("custom"), "custom");
}
#[test]
fn test_generate_timestamp() {
let hourly = generate_timestamp("hourly");
let daily = generate_timestamp("daily");
// Basic format validation
assert!(hourly.len() >= 13); // YYYY-MM-DD-HH
assert!(daily.len() >= 10); // YYYY-MM-DD
// Custom format
let custom = generate_timestamp("%Y%m%d");
assert!(custom.len() == 8); // YYYYMMDD
}
#[test]
fn test_cleanup_old_logs() {
let temp_dir = TempDir::new().unwrap();
let logs_dir = temp_dir.path();
// Create some test log files
for i in 0..5 {
let file_path = logs_dir.join(format!("test{}.log", i));
std::fs::write(&file_path, "test content").unwrap();
}
// Create a non-log file
std::fs::write(logs_dir.join("not_a_log.txt"), "content").unwrap();
// Cleanup with 0 days (should remove all files)
let removed = cleanup_old_logs(logs_dir, "*.log", 0).unwrap();
assert_eq!(removed, 5);
// Verify non-log file still exists
assert!(logs_dir.join("not_a_log.txt").exists());
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(1023), "1023 B");
assert_eq!(format_bytes(1024), "1.00 KB");
assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
}
#[test]
fn test_get_logs_disk_usage() {
let temp_dir = TempDir::new().unwrap();
let logs_root = temp_dir.path();
// Create some test files
std::fs::create_dir_all(logs_root.join("supervisor")).unwrap();
std::fs::write(logs_root.join("supervisor/test.log"), "test content").unwrap();
let usage = get_logs_disk_usage(logs_root).unwrap();
assert!(usage.total_size_bytes > 0);
assert!(usage.file_count > 0);
assert!(!usage.directories.is_empty());
}
}

View File

@ -3,15 +3,24 @@ name = "hero_supervisor"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]]
name = "supervisor-cli"
path = "cmd/supervisor_cli.rs"
[[bin]]
name = "supervisor-tui"
path = "cmd/supervisor_tui.rs"
[dependencies] [dependencies]
clap = { version = "4.4", features = ["derive"] } clap = { version = "4.4", features = ["derive"] }
env_logger = "0.10"
redis = { version = "0.25.0", features = ["tokio-comp"] } redis = { version = "0.25.0", features = ["tokio-comp"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
toml = "0.8" toml = "0.8"
uuid = { version = "1.6", features = ["v4", "serde"] } uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1" log = "0.4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # For async main in examples, and general async tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # For async main in examples, and general async
colored = "2.0" colored = "2.0"
hero_job = { path = "../job" } hero_job = { path = "../job" }
@ -21,5 +30,5 @@ crossterm = "0.28"
anyhow = "1.0" anyhow = "1.0"
[dev-dependencies] # For examples later [dev-dependencies] # For examples later
tracing-subscriber = { version = "0.3", features = ["env-filter"] } env_logger = "0.10"
rhai = "1.18.0" # For examples that might need to show engine setup rhai = "1.18.0" # For examples that might need to show engine setup

View File

@ -0,0 +1,117 @@
# Supervisor CLI
Interactive command-line interface for the Hero Supervisor that allows you to dispatch jobs to actors and manage the job lifecycle.
## Features
- **Interactive Menu**: Easy-to-use menu system for all supervisor operations
- **Job Management**: Create, run, monitor, and manage jobs
- **OSIS Actor Integration**: Dispatch Rhai scripts to the OSIS actor
- **Real-time Results**: Get immediate feedback from job execution
- **Colorized Output**: Clear visual feedback with colored status indicators
## Usage
### 1. Build the OSIS Actor
First, ensure the OSIS actor is built:
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis
cargo build
```
### 2. Configure the Supervisor
Create or use the example configuration file at `examples/cli_config.toml`:
```toml
[global]
redis_url = "redis://127.0.0.1/"
[actors]
osis_actor = "/Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis/target/debug/actor_osis"
```
### 3. Run the CLI
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/baobab/core/supervisor
cargo run --bin supervisor-cli -- --config examples/cli_config.toml
```
Or with verbose logging:
```bash
cargo run --bin supervisor-cli -- --config examples/cli_config.toml --verbose
```
## Available Commands
1. **list_jobs** - List all jobs in the system
2. **run_job** - Create and run a new job interactively
3. **get_job_status** - Get status of a specific job
4. **get_job_output** - Get output of a completed job
5. **get_job_logs** - Get logs for a specific job
6. **stop_job** - Stop a running job
7. **delete_job** - Delete a specific job
8. **clear_all_jobs** - Clear all jobs from the system
9. **quit** - Exit the CLI
## Example Workflow
1. Start the CLI with your configuration
2. Select option `2` (run_job)
3. Enter job details:
- **Caller**: Your name or identifier
- **Context**: Description of what the job does
- **Script**: Rhai script to execute (end with empty line)
4. The job is automatically dispatched to the OSIS actor
5. View the real-time result
### Example Rhai Script
```rhai
// Simple calculation
let result = 10 + 20 * 3;
print("Calculation result: " + result);
result
```
```rhai
// Working with strings
let message = "Hello from OSIS Actor!";
print(message);
message.to_upper()
```
## Job Status Colors
- **Created** - Cyan
- **Dispatched** - Blue
- **Started** - Yellow
- **Finished** - Green
- **Error** - Red
## Prerequisites
- Redis server running on localhost:6379 (or configured URL)
- OSIS actor binary built and accessible
- Proper permissions to start/stop processes via Zinit
## Troubleshooting
### Actor Not Starting
- Verify the OSIS actor binary path in the TOML config
- Check that the binary exists and is executable
- Ensure Redis is running and accessible
### Connection Issues
- Verify Redis URL in configuration
- Check network connectivity to Redis server
- Ensure no firewall blocking connections
### Job Execution Failures
- Check job logs using `get_job_logs` command
- Verify Rhai script syntax
- Check actor logs for detailed error information

View File

@ -0,0 +1,178 @@
# Supervisor Terminal UI (TUI)
A modern, interactive Terminal User Interface for the Hero Supervisor that provides intuitive job management with real-time updates and visual navigation.
## Features
### 🎯 **Intuitive Interface**
- **Split-pane Layout**: Job list on the left, details on the right
- **Real-time Updates**: Auto-refreshes every 2 seconds
- **Color-coded Status**: Visual job status indicators
- **Keyboard Navigation**: Vim-style and arrow key support
### 📋 **Job Management**
- **Create Jobs**: Interactive form with tab navigation
- **Monitor Jobs**: Real-time status updates with color coding
- **View Details**: Detailed job information and output
- **View Logs**: Access job execution logs
- **Stop/Delete**: Job lifecycle management
- **Bulk Operations**: Clear all jobs with confirmation
### 🎨 **Visual Design**
- **Status Colors**:
- 🔵 **Blue**: Dispatched
- 🟡 **Yellow**: Started
- 🟢 **Green**: Finished
- 🔴 **Red**: Error
- 🟣 **Magenta**: Waiting for Prerequisites
- **Highlighted Selection**: Clear visual feedback
- **Popup Messages**: Status and error notifications
- **Confirmation Dialogs**: Safe bulk operations
## Usage
### 1. Start the TUI
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/baobab/core/supervisor
cargo run --bin supervisor-tui -- --config examples/cli_config.toml
```
### 2. Navigation
#### Main View
- **↑/↓ or j/k**: Navigate job list
- **Enter/Space**: View job details
- **n/c**: Create new job
- **r**: Manual refresh
- **d**: Delete selected job (with confirmation)
- **s**: Stop selected job
- **C**: Clear all jobs (with confirmation)
- **q**: Quit application
#### Job Creation Form
- **Tab**: Next field
- **Shift+Tab**: Previous field
- **Enter**: Next field (or newline in script field)
- **F5**: Submit job
- **Esc**: Cancel and return to main view
#### Job Details/Logs View
- **Esc/q**: Return to main view
- **l**: Switch to logs view
- **d**: Switch to details view
## Interface Layout
```
┌─────────────────────────────────────────────────────────────┐
│ Hero Supervisor TUI - Job Management │
├─────────────────────┬───────────────────────────────────────┤
│ Jobs │ Job Details │
│ │ │
│ >> 1a2b3c4d - ✅ Fi │ Job ID: 1a2b3c4d5e6f7g8h │
│ 2b3c4d5e - 🟡 St │ Status: Finished │
│ 3c4d5e6f - 🔴 Er │ │
│ 4d5e6f7g - 🔵 Di │ Output: │
│ │ Calculation result: 70 │
│ │ 70 │
├─────────────────────┴───────────────────────────────────────┤
│ q: Quit | n: New Job | ↑↓: Navigate | Enter: Details │
└─────────────────────────────────────────────────────────────┘
```
## Job Creation Workflow
1. **Press 'n'** to create a new job
2. **Fill in the form**:
- **Caller**: Your name or identifier
- **Context**: Job description
- **Script**: Rhai script (supports multi-line)
3. **Press F5** to submit
4. **Watch real-time execution** in the main view
### Example Rhai Scripts
```rhai
// Simple calculation
let result = 10 + 20 * 3;
print("Calculation result: " + result);
result
```
```rhai
// String manipulation
let message = "Hello from OSIS Actor!";
print(message);
message.to_upper()
```
```rhai
// Loop example
let sum = 0;
for i in 1..=10 {
sum += i;
}
print("Sum of 1-10: " + sum);
sum
```
## Key Improvements over CLI
### ✅ **Better UX**
- **Visual Navigation**: No need to remember numbers
- **Real-time Updates**: See job progress immediately
- **Split-pane Design**: View list and details simultaneously
- **Form Validation**: Clear error messages
### ✅ **Enhanced Productivity**
- **Auto-refresh**: Always up-to-date information
- **Keyboard Shortcuts**: Fast navigation and actions
- **Confirmation Dialogs**: Prevent accidental operations
- **Multi-line Script Input**: Better script editing
### ✅ **Professional Interface**
- **Color-coded Status**: Quick visual assessment
- **Consistent Layout**: Predictable interface elements
- **Popup Notifications**: Non-intrusive feedback
- **Graceful Error Handling**: User-friendly error messages
## Prerequisites
- Redis server running (default: localhost:6379)
- OSIS actor binary built and configured
- Terminal with color support
- Minimum terminal size: 80x24
## Troubleshooting
### Display Issues
- Ensure terminal supports colors and Unicode
- Resize terminal if layout appears broken
- Use a modern terminal emulator (iTerm2, Alacritty, etc.)
### Performance
- TUI auto-refreshes every 2 seconds
- Large job lists may impact performance
- Use 'r' for manual refresh if needed
### Navigation Issues
- Use arrow keys if vim keys (j/k) don't work
- Ensure terminal is in focus
- Try Esc to reset state if stuck
## Advanced Features
### Bulk Operations
- **Clear All Jobs**: Press 'C' with confirmation
- **Safe Deletion**: Confirmation required for destructive operations
### Real-time Monitoring
- **Auto-refresh**: Updates every 2 seconds
- **Status Tracking**: Watch job progression
- **Immediate Feedback**: See results as they complete
### Multi-line Scripts
- **Rich Text Input**: Full script editing in TUI
- **Syntax Awareness**: Better than single-line CLI input
- **Preview**: See script before submission

View File

@ -0,0 +1,398 @@
use clap::Parser;
use colored::*;
use hero_supervisor::{Supervisor, SupervisorBuilder, SupervisorError, Job, JobStatus, ScriptType};
use log::{error, info};
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
#[derive(Parser)]
#[command(name = "supervisor-cli")]
#[command(about = "Interactive CLI for Hero Supervisor - Dispatch jobs to actors")]
struct Args {
/// Path to TOML configuration file
#[arg(short, long)]
config: PathBuf,
/// Enable verbose logging
#[arg(short, long)]
verbose: bool,
}
#[derive(Debug, Clone)]
enum CliCommand {
ListJobs,
RunJob,
GetJobStatus,
GetJobOutput,
GetJobLogs,
StopJob,
DeleteJob,
ClearAllJobs,
Quit,
}
impl CliCommand {
fn all_commands() -> Vec<(CliCommand, &'static str, &'static str)> {
vec![
(CliCommand::ListJobs, "list_jobs", "List all jobs in the system"),
(CliCommand::RunJob, "run_job", "Create and run a new job"),
(CliCommand::GetJobStatus, "get_job_status", "Get status of a specific job"),
(CliCommand::GetJobOutput, "get_job_output", "Get output of a completed job"),
(CliCommand::GetJobLogs, "get_job_logs", "Get logs for a specific job"),
(CliCommand::StopJob, "stop_job", "Stop a running job"),
(CliCommand::DeleteJob, "delete_job", "Delete a specific job"),
(CliCommand::ClearAllJobs, "clear_all_jobs", "Clear all jobs from the system"),
(CliCommand::Quit, "quit", "Exit the CLI"),
]
}
fn from_index(index: usize) -> Option<CliCommand> {
Self::all_commands().get(index).map(|(cmd, _, _)| cmd.clone())
}
}
struct SupervisorCli {
supervisor: Arc<Supervisor>,
}
impl SupervisorCli {
fn new(supervisor: Arc<Supervisor>) -> Self {
Self { supervisor }
}
async fn run(&self) -> Result<(), SupervisorError> {
println!("{}", "=== Hero Supervisor CLI ===".bright_blue().bold());
println!("{}", "Interactive job management interface".cyan());
println!();
loop {
self.display_menu();
match self.get_user_choice().await {
Some(command) => {
match command {
CliCommand::Quit => {
println!("{}", "Goodbye!".bright_green());
break;
}
_ => {
if let Err(e) = self.execute_command(command).await {
eprintln!("{} {}", "Error:".bright_red(), e);
}
}
}
}
None => {
println!("{}", "Invalid selection. Please try again.".yellow());
}
}
println!();
}
Ok(())
}
fn display_menu(&self) {
println!("{}", "Available Commands:".bright_yellow().bold());
for (index, (_, name, description)) in CliCommand::all_commands().iter().enumerate() {
println!(" {}. {} - {}",
(index + 1).to_string().bright_white().bold(),
name.bright_cyan(),
description
);
}
print!("\n{} ", "Select a command (1-9):".bright_white());
io::stdout().flush().unwrap();
}
async fn get_user_choice(&self) -> Option<CliCommand> {
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
if let Ok(choice) = input.trim().parse::<usize>() {
if choice > 0 {
return CliCommand::from_index(choice - 1);
}
}
}
None
}
async fn execute_command(&self, command: CliCommand) -> Result<(), SupervisorError> {
match command {
CliCommand::ListJobs => self.list_jobs().await,
CliCommand::RunJob => self.run_job().await,
CliCommand::GetJobStatus => self.get_job_status().await,
CliCommand::GetJobOutput => self.get_job_output().await,
CliCommand::GetJobLogs => self.get_job_logs().await,
CliCommand::StopJob => self.stop_job().await,
CliCommand::DeleteJob => self.delete_job().await,
CliCommand::ClearAllJobs => self.clear_all_jobs().await,
CliCommand::Quit => Ok(()),
}
}
async fn list_jobs(&self) -> Result<(), SupervisorError> {
println!("{}", "Listing all jobs...".bright_blue());
let jobs = self.supervisor.list_jobs().await?;
if jobs.is_empty() {
println!("{}", "No jobs found.".yellow());
} else {
println!("{} jobs found:", jobs.len().to_string().bright_white().bold());
for job_id in jobs {
let status = self.supervisor.get_job_status(&job_id).await?;
let status_color = match status {
JobStatus::Dispatched => "blue",
JobStatus::Started => "yellow",
JobStatus::Finished => "green",
JobStatus::Error => "red",
JobStatus::WaitingForPrerequisites => "magenta",
};
println!(" {} - {}",
job_id.bright_white(),
format!("{:?}", status).color(status_color)
);
}
}
Ok(())
}
async fn run_job(&self) -> Result<(), SupervisorError> {
println!("{}", "Creating a new job...".bright_blue());
// Get caller
print!("Enter caller name: ");
io::stdout().flush().unwrap();
let mut caller = String::new();
io::stdin().read_line(&mut caller).unwrap();
let caller = caller.trim().to_string();
// Get context
print!("Enter job context: ");
io::stdout().flush().unwrap();
let mut context = String::new();
io::stdin().read_line(&mut context).unwrap();
let context = context.trim().to_string();
// Get script
println!("Enter Rhai script (end with empty line):");
let mut script_lines = Vec::new();
loop {
let mut line = String::new();
io::stdin().read_line(&mut line).unwrap();
let line = line.trim_end_matches('\n');
if line.is_empty() {
break;
}
script_lines.push(line.to_string());
}
let script = script_lines.join("\n");
if script.is_empty() {
println!("{}", "Script cannot be empty!".bright_red());
return Ok(());
}
// For now, default to OSIS actor (ScriptType::OSIS)
let script_type = ScriptType::OSIS;
// Create the job
let job = Job::new(caller, context, script, script_type);
println!("{} Job ID: {}",
"Created job with".bright_green(),
job.id.bright_white().bold()
);
// Run the job and await result
println!("{}", "Dispatching job and waiting for result...".bright_blue());
match self.supervisor.run_job_and_await_result(&job).await {
Ok(result) => {
println!("{}", "Job completed successfully!".bright_green().bold());
println!("{} {}", "Result:".bright_yellow(), result.bright_white());
}
Err(e) => {
println!("{} {}", "Job failed:".bright_red().bold(), e);
}
}
Ok(())
}
async fn get_job_status(&self) -> Result<(), SupervisorError> {
let job_id = self.prompt_for_job_id("Enter job ID to check status: ")?;
let status = self.supervisor.get_job_status(&job_id).await?;
let status_color = match status {
JobStatus::Dispatched => "blue",
JobStatus::Started => "yellow",
JobStatus::Finished => "green",
JobStatus::Error => "red",
JobStatus::WaitingForPrerequisites => "magenta",
};
println!("{} {} - {}",
"Job".bright_white(),
job_id.bright_white().bold(),
format!("{:?}", status).color(status_color).bold()
);
Ok(())
}
async fn get_job_output(&self) -> Result<(), SupervisorError> {
let job_id = self.prompt_for_job_id("Enter job ID to get output: ")?;
match self.supervisor.get_job_output(&job_id).await? {
Some(output) => {
println!("{}", "Job Output:".bright_yellow().bold());
println!("{}", output.bright_white());
}
None => {
println!("{}", "No output available for this job.".yellow());
}
}
Ok(())
}
async fn get_job_logs(&self) -> Result<(), SupervisorError> {
let job_id = self.prompt_for_job_id("Enter job ID to get logs: ")?;
match self.supervisor.get_job_logs(&job_id).await? {
Some(logs) => {
println!("{}", "Job Logs:".bright_yellow().bold());
println!("{}", logs.bright_white());
}
None => {
println!("{}", "No logs available for this job.".yellow());
}
}
Ok(())
}
async fn stop_job(&self) -> Result<(), SupervisorError> {
let job_id = self.prompt_for_job_id("Enter job ID to stop: ")?;
self.supervisor.stop_job(&job_id).await?;
println!("{} {}",
"Stop signal sent for job".bright_green(),
job_id.bright_white().bold()
);
Ok(())
}
async fn delete_job(&self) -> Result<(), SupervisorError> {
let job_id = self.prompt_for_job_id("Enter job ID to delete: ")?;
self.supervisor.delete_job(&job_id).await?;
println!("{} {}",
"Deleted job".bright_green(),
job_id.bright_white().bold()
);
Ok(())
}
async fn clear_all_jobs(&self) -> Result<(), SupervisorError> {
print!("Are you sure you want to clear ALL jobs? (y/N): ");
io::stdout().flush().unwrap();
let mut confirmation = String::new();
io::stdin().read_line(&mut confirmation).unwrap();
if confirmation.trim().to_lowercase() == "y" {
let count = self.supervisor.clear_all_jobs().await?;
println!("{} {} jobs",
"Cleared".bright_green().bold(),
count.to_string().bright_white().bold()
);
} else {
println!("{}", "Operation cancelled.".yellow());
}
Ok(())
}
fn prompt_for_job_id(&self, prompt: &str) -> Result<String, SupervisorError> {
print!("{}", prompt);
io::stdout().flush().unwrap();
let mut job_id = String::new();
io::stdin().read_line(&mut job_id).unwrap();
let job_id = job_id.trim().to_string();
if job_id.is_empty() {
return Err(SupervisorError::ConfigError("Job ID cannot be empty".to_string()));
}
Ok(job_id)
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
// Setup logging
if args.verbose {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.init();
} else {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
}
info!("Starting Supervisor CLI with config: {:?}", args.config);
// Build supervisor from TOML config
let supervisor = Arc::new(
SupervisorBuilder::from_toml(&args.config)?
.build().await?
);
println!("{}", "Starting actors...".bright_blue());
// Start the actors
supervisor.start_actors().await?;
// Give actors time to start up
sleep(Duration::from_secs(2)).await;
println!("{}", "Actors started successfully!".bright_green());
println!();
// Create and run the CLI
let cli = SupervisorCli::new(supervisor.clone());
// Setup cleanup on exit
let supervisor_cleanup = supervisor.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for ctrl+c");
println!("\n{}", "Shutting down...".bright_yellow());
if let Err(e) = supervisor_cleanup.cleanup_and_shutdown().await {
eprintln!("Error during cleanup: {}", e);
}
std::process::exit(0);
});
// Run the interactive CLI
cli.run().await?;
// Cleanup on normal exit
supervisor.cleanup_and_shutdown().await?;
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@ -45,13 +45,27 @@ Jobs can have dependencies on other jobs, which are stored in the `dependencies`
### Work Queues ### Work Queues
Jobs are queued for execution using Redis lists: Jobs are queued for execution using Redis lists with the following naming convention:
``` ```
hero:work_queue:{actor_id} hero:job:actor_queue:{script_type_suffix}
``` ```
Where `{script_type_suffix}` corresponds to the script type:
- `osis` for OSIS actors (Rhai/HeroScript execution)
- `sal` for SAL actors (System Abstraction Layer)
- `v` for V actors (V language execution)
- `python` for Python actors
**Examples:**
- OSIS actor queue: `hero:job:actor_queue:osis`
- SAL actor queue: `hero:job:actor_queue:sal`
- V actor queue: `hero:job:actor_queue:v`
- Python actor queue: `hero:job:actor_queue:python`
Actors listen on their specific queue using `BLPOP` for job IDs to process. Actors listen on their specific queue using `BLPOP` for job IDs to process.
**Important:** Actors must use the same queue naming convention in their `actor_id()` method to ensure proper job dispatch. The actor should return `"actor_queue:{script_type_suffix}"` as its actor ID.
### Stop Queues ### Stop Queues
Job stop requests are sent through dedicated stop queues: Job stop requests are sent through dedicated stop queues:
@ -63,12 +77,26 @@ Actors monitor these queues to receive stop requests for running jobs.
### Reply Queues ### Reply Queues
For synchronous job execution, dedicated reply queues are used: Reply queues are used for responses to specific requests:
```
hero:reply:{job_id}
```
Actors send results to these queues when jobs complete. - `hero:reply:{request_id}`: Response to a specific request
### Result and Error Queues
When actors process jobs, they store results and errors in two places:
1. **Job Hash Storage**: Results are stored in the job hash fields:
- `hero:job:{job_id}` hash with `output` field for results
- `hero:job:{job_id}` hash with `error` field for errors
2. **Dedicated Queues**: Results and errors are also pushed to dedicated queues for asynchronous retrieval:
- `hero:job:{job_id}:result`: Queue containing job result (use `LPOP` to retrieve)
- `hero:job:{job_id}:error`: Queue containing job error (use `LPOP` to retrieve)
This dual storage approach allows clients to:
- Access results/errors directly from job hash for immediate retrieval
- Listen on result/error queues for asynchronous notification of job completion
- Use `BLPOP` on result/error queues for blocking waits on job completion
## Job Lifecycle ## Job Lifecycle

View File

@ -0,0 +1,20 @@
# Hero Supervisor CLI Configuration
# This configuration sets up the supervisor with an OSIS actor for job processing
[global]
redis_url = "redis://127.0.0.1/"
[actors]
# OSIS Actor configuration - handles Object Storage and Indexing System jobs
osis_actor = "/Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis/target/debug/actor_osis"
# Optional: Other actors can be configured here
# sal_actor = "/path/to/sal_actor"
# v_actor = "/path/to/v_actor"
# python_actor = "/path/to/python_actor"
# Optional: WebSocket server configuration for remote API access
# [websocket]
# host = "127.0.0.1"
# port = 8443
# redis_url = "redis://127.0.0.1/"

View File

@ -0,0 +1,52 @@
use hero_supervisor::{SupervisorBuilder, ScriptType};
use hero_job::JobBuilder as CoreJobBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1) Build a Supervisor
let supervisor = SupervisorBuilder::new()
.redis_url("redis://127.0.0.1/")
.build()
.await?;
// 2) Build a Job (using core job builder to set caller_id, context_id)
let job = CoreJobBuilder::new()
.caller_id("02abc...caller") // required
.context_id("02def...context") // required
.script_type(ScriptType::OSIS) // select the OSIS actor (matches configured osis_actor_1)
.script("40 + 3") // simple Rhai script
.timeout(std::time::Duration::from_secs(10))
.build()?; // returns hero_job::Job
let job_id = job.id.clone();
// 3a) Store the job in Redis
supervisor.create_job(&job).await?;
// 3b) Start the job (pushes ID to the actors Redis queue)
supervisor.start_job(&job_id).await?;
// 3c) Wait until finished, then fetch output
use tokio::time::sleep;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let status = supervisor.get_job_status(&job_id).await?;
if status == hero_supervisor::JobStatus::Finished {
break;
}
if std::time::Instant::now() >= deadline {
println!("Job {} timed out waiting for completion (status: {:?})", job_id, status);
break;
}
sleep(std::time::Duration::from_millis(250)).await;
}
if let Some(output) = supervisor.get_job_output(&job_id).await? {
println!("Job {} output: {}", job_id, output);
} else {
println!("Job {} completed with no output field set", job_id);
}
Ok(())
}

View File

@ -1,4 +1,4 @@
use tracing::{debug, error, info, warn}; use log::{debug, error, info, warn};
use redis::AsyncCommands; use redis::AsyncCommands;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@ -408,7 +408,8 @@ impl Supervisor {
/// Get the hardcoded actor queue key for the script type /// Get the hardcoded actor queue key for the script type
fn get_actor_queue_key(&self, script_type: &ScriptType) -> String { fn get_actor_queue_key(&self, script_type: &ScriptType) -> String {
format!("{}actor_queue:{}", NAMESPACE_PREFIX, script_type.actor_queue_suffix()) // Canonical type queue
hero_job::keys::work_type(script_type)
} }
pub fn new_job(&self) -> JobBuilder { pub fn new_job(&self) -> JobBuilder {
@ -586,14 +587,9 @@ impl Supervisor {
job_id: String, job_id: String,
script_type: &ScriptType script_type: &ScriptType
) -> Result<(), SupervisorError> { ) -> Result<(), SupervisorError> {
let actor_queue_key = self.get_actor_queue_key(script_type); // Canonical dispatch to type queue
let actor_queue_key = hero_job::keys::work_type(script_type);
// lpush also infers its types, RV is typically i64 (length of list) or () depending on exact command variant let _: redis::RedisResult<i64> = conn.lpush(&actor_queue_key, job_id.clone()).await;
// For `redis::AsyncCommands::lpush`, it's `RedisResult<R>` where R: FromRedisValue
// Often this is the length of the list. Let's allow inference or specify if needed.
let _: redis::RedisResult<i64> =
conn.lpush(&actor_queue_key, job_id.clone()).await;
Ok(()) Ok(())
} }
@ -675,7 +671,8 @@ impl Supervisor {
) -> Result<String, SupervisorError> { ) -> Result<String, SupervisorError> {
let mut conn = self.redis_client.get_multiplexed_async_connection().await?; let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
let reply_queue_key = format!("{}:reply:{}", NAMESPACE_PREFIX, job.id); // Derived from the passed job_id // Canonical reply queue
let reply_queue_key = hero_job::keys::reply(&job.id);
self.create_job_using_connection( self.create_job_using_connection(
&mut conn, &mut conn,
@ -692,13 +689,48 @@ impl Supervisor {
job.timeout job.timeout
); );
self.await_response_from_connection( // Some actors update the job hash directly and do not use reply queues.
&mut conn, // Poll the job hash for output until timeout to support both models.
&job.id, let start_time = std::time::Instant::now();
&reply_queue_key,
job.timeout, loop {
) // If output is present in the job hash, return it immediately
.await match self.get_job_output(&job.id).await {
Ok(Some(output)) => {
// Optional: cleanup reply queue in case it was created
let _: redis::RedisResult<i32> = conn.del(&reply_queue_key).await;
return Ok(output);
}
Ok(None) => {
// Check for error state
match self.get_job_status(&job.id).await {
Ok(JobStatus::Error) => {
// Try to read the error field for context
let mut conn2 = self.redis_client.get_multiplexed_async_connection().await?;
let job_key = format!("{}{}", NAMESPACE_PREFIX, job.id);
let err: Option<String> = conn2.hget(&job_key, "error").await.ok();
return Err(SupervisorError::InvalidInput(
err.unwrap_or_else(|| "Job failed".to_string())
));
}
_ => {
// keep polling
}
}
}
Err(_) => {
// Ignore transient read errors and continue polling
}
}
if start_time.elapsed() >= job.timeout {
// On timeout, ensure any reply queue is cleaned up and return a Timeout error
let _: redis::RedisResult<i32> = conn.del(&reply_queue_key).await;
return Err(SupervisorError::Timeout(job.id.clone()));
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
} }
// Method to get job status // Method to get job status
@ -772,7 +804,7 @@ impl Supervisor {
let mut conn = self.redis_client.get_multiplexed_async_connection().await?; let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
// Get job details to determine script type and appropriate actor // Get job details to determine script type and appropriate actor
let job_key = format!("{}job:{}", NAMESPACE_PREFIX, job_id); let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
let job_data: std::collections::HashMap<String, String> = conn.hgetall(&job_key).await?; let job_data: std::collections::HashMap<String, String> = conn.hgetall(&job_key).await?;
if job_data.is_empty() { if job_data.is_empty() {
@ -787,7 +819,8 @@ impl Supervisor {
.map_err(|e| SupervisorError::InvalidInput(format!("Invalid script type: {}", e)))?; .map_err(|e| SupervisorError::InvalidInput(format!("Invalid script type: {}", e)))?;
// Use hardcoded stop queue key for this script type // Use hardcoded stop queue key for this script type
let stop_queue_key = format!("{}stop_queue:{}", NAMESPACE_PREFIX, script_type.actor_queue_suffix()); // Stop queue per protocol: hero:stop_queue:{suffix}
let stop_queue_key = format!("hero:stop_queue:{}", script_type.actor_queue_suffix());
// Push job ID to the stop queue // Push job ID to the stop queue
conn.lpush::<_, _, ()>(&stop_queue_key, job_id).await?; conn.lpush::<_, _, ()>(&stop_queue_key, job_id).await?;
@ -799,7 +832,7 @@ impl Supervisor {
/// Get logs for a job by reading from its log file /// Get logs for a job by reading from its log file
pub async fn get_job_logs(&self, job_id: &str) -> Result<Option<String>, SupervisorError> { pub async fn get_job_logs(&self, job_id: &str) -> Result<Option<String>, SupervisorError> {
let mut conn = self.redis_client.get_multiplexed_async_connection().await?; let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
let job_key = format!("{}job:{}", NAMESPACE_PREFIX, job_id); let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
// Get the job data to find the log path // Get the job data to find the log path
let result_map: Option<std::collections::HashMap<String, String>> = let result_map: Option<std::collections::HashMap<String, String>> =
@ -922,7 +955,7 @@ impl Supervisor {
for job_id in ready_job_ids { for job_id in ready_job_ids {
// Get job data to determine script type and select actor // Get job data to determine script type and select actor
let mut conn = self.redis_client.get_multiplexed_async_connection().await?; let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
let job_key = format!("{}job:{}", NAMESPACE_PREFIX, job_id); let job_key = format!("{}{}", NAMESPACE_PREFIX, job_id);
let job_data: std::collections::HashMap<String, String> = conn.hgetall(&job_key).await?; let job_data: std::collections::HashMap<String, String> = conn.hgetall(&job_key).await?;
if let Some(script_type_str) = job_data.get("script_type") { if let Some(script_type_str) = job_data.get("script_type") {

View File

@ -3,7 +3,7 @@
//! This module provides actor process lifecycle management using Zinit as the process manager. //! This module provides actor process lifecycle management using Zinit as the process manager.
//! All functionality is implemented as methods on the Supervisor struct for a clean API. //! All functionality is implemented as methods on the Supervisor struct for a clean API.
use tracing::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;

209
docs/JOBS_QUICKSTART.md Normal file
View File

@ -0,0 +1,209 @@
# Jobs Quickstart: Create and Send a Simple Job to the Supervisor
This guide shows how a new (simple) job looks, how to construct it, and how to submit it to the Supervisor. It covers:
- The minimal fields a job needs
- Picking an actor via script type
- Submitting a job using the Rust API
- Submitting a job via the OpenRPC server over Unix IPC (and WS)
Key references:
- [rust.ScriptType](core/job/src/lib.rs:16) determines the target actor queue
- [rust.Job](core/job/src/lib.rs:87) is the canonical job payload stored in Redis
- [rust.JobBuilder::new()](core/job/src/builder.rs:47), [rust.JobBuilder::caller_id()](core/job/src/builder.rs:79), [rust.JobBuilder::context_id()](core/job/src/builder.rs:74), [rust.JobBuilder::script_type()](core/job/src/builder.rs:69), [rust.JobBuilder::script()](core/job/src/builder.rs:84), [rust.JobBuilder::timeout()](core/job/src/builder.rs:94), [rust.JobBuilder::build()](core/job/src/builder.rs:158)
- [rust.SupervisorBuilder::new()](core/supervisor/src/lib.rs:124), [rust.SupervisorBuilder::build()](core/supervisor/src/lib.rs:267)
- [rust.Supervisor::create_job()](core/supervisor/src/lib.rs:642), [rust.Supervisor::start_job()](core/supervisor/src/lib.rs:658), [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:672), [rust.Supervisor::get_job_output()](core/supervisor/src/lib.rs:740)
- Redis key namespace: [rust.NAMESPACE_PREFIX](core/job/src/lib.rs:13)
## 1) What is a “simple job”?
A simple job is the minimal unit of work that an actor can execute. At minimum, you must provide:
- caller_id: String (identifier of the requester; often a public key)
- context_id: String (the “circle” or execution context)
- script: String (the code to run; Rhai for OSIS/SAL; HeroScript for V/Python)
- script_type: ScriptType (OSIS | SAL | V | Python)
- timeout: Duration (optional; default used if not set)
The jobs script_type selects the actor and thus the queue. See [rust.ScriptType::actor_queue_suffix()](core/job/src/lib.rs:29) for mapping.
## 2) Choosing the actor by ScriptType
- OSIS: Rhai script, sequential non-blocking
- SAL: Rhai script, blocking async, concurrent
- V: HeroScript via V engine
- Python: HeroScript via Python engine
Pick the script_type that matches your script/runtime requirements. See design summary in [core/docs/architecture.md](core/docs/architecture.md).
## 3) Build and submit a job using the Rust API
This is the most direct, strongly-typed integration. You will:
1) Build a Supervisor
2) Construct a Job (using the “core” job builder for explicit caller_id/context_id)
3) Submit it with either:
- create_job + start_job (two-step)
- run_job_and_await_result (one-shot request-reply)
Note: We deliberately use the core job builder (hero_job) so we can set caller_id explicitly via [rust.JobBuilder::caller_id()](core/job/src/builder.rs:79).
Example Rhai script (returns 42):
```rhai
40 + 2
```
Rust example (two-step create + start + poll output):
```rust
use hero_supervisor::{SupervisorBuilder, ScriptType};
use hero_job::JobBuilder as CoreJobBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1) Build a Supervisor
let supervisor = SupervisorBuilder::new()
.redis_url("redis://127.0.0.1/")
.build()
.await?;
// 2) Build a Job (using core job builder to set caller_id, context_id)
let job = CoreJobBuilder::new()
.caller_id("02abc...caller") // required
.context_id("02def...context") // required
.script_type(ScriptType::SAL) // select the SAL actor
.script("40 + 2") // simple Rhai script
.timeout(std::time::Duration::from_secs(10))
.build()?; // returns hero_job::Job
let job_id = job.id.clone();
// 3a) Store the job in Redis
supervisor.create_job(&job).await?;
// 3b) Start the job (pushes ID to the actors Redis queue)
supervisor.start_job(&job_id).await?;
// 3c) Fetch output when finished (or poll status via get_job_status)
if let Some(output) = supervisor.get_job_output(&job_id).await? {
println!("Job {} output: {}", job_id, output);
} else {
println!("Job {} has no output yet", job_id);
}
Ok(())
}
```
Rust example (one-shot request-reply):
```rust
use hero_supervisor::{SupervisorBuilder, ScriptType};
use hero_job::JobBuilder as CoreJobBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let supervisor = SupervisorBuilder::new()
.redis_url("redis://127.0.0.1/")
.build()
.await?;
let job = CoreJobBuilder::new()
.caller_id("02abc...caller")
.context_id("02def...context")
.script_type(ScriptType::SAL)
.script("40 + 2")
.timeout(std::time::Duration::from_secs(10))
.build()?;
// Creates the job, dispatches it to the correct actor queue,
// and waits for a reply on the dedicated reply queue.
let output = supervisor.run_job_and_await_result(&job).await?;
println!("Synchronous output: {}", output);
Ok(())
}
```
References used in this flow:
- [rust.SupervisorBuilder::new()](core/supervisor/src/lib.rs:124), [rust.SupervisorBuilder::build()](core/supervisor/src/lib.rs:267)
- [rust.JobBuilder::caller_id()](core/job/src/builder.rs:79), [rust.JobBuilder::context_id()](core/job/src/builder.rs:74), [rust.JobBuilder::script_type()](core/job/src/builder.rs:69), [rust.JobBuilder::script()](core/job/src/builder.rs:84), [rust.JobBuilder::timeout()](core/job/src/builder.rs:94), [rust.JobBuilder::build()](core/job/src/builder.rs:158)
- [rust.Supervisor::create_job()](core/supervisor/src/lib.rs:642), [rust.Supervisor::start_job()](core/supervisor/src/lib.rs:658), [rust.Supervisor::get_job_output()](core/supervisor/src/lib.rs:740)
- [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:672)
## 4) Submit a job via the OpenRPC server (Unix IPC or WebSocket)
The OpenRPC server exposes JSON-RPC 2.0 methods which proxy to the Supervisor:
- Types: [rust.JobParams](interfaces/openrpc/server/src/types.rs:6)
- Methods registered in [interfaces/openrpc/server/src/lib.rs](interfaces/openrpc/server/src/lib.rs:117)
Unix IPC launcher and client:
- Server: [interfaces/unix/server/src/main.rs](interfaces/unix/server/src/main.rs)
- Client: [interfaces/unix/client/src/main.rs](interfaces/unix/client/src/main.rs)
Start the IPC server:
```bash
cargo run -p hero-unix-server -- \
--socket /tmp/baobab.ipc \
--db-path ./db
```
Create a job (JSON-RPC, IPC):
```bash
cargo run -p hero-unix-client -- \
--socket /tmp/baobab.ipc \
--method create_job \
--params '{
"script": "40 + 2",
"script_type": "SAL",
"caller_id": "02abc...caller",
"context_id": "02def...context",
"timeout": 10
}'
```
This returns the job_id. Then start the job:
```bash
cargo run -p hero-unix-client -- \
--socket /tmp/baobab.ipc \
--method start_job \
--params '["<job_id_from_create>"]'
```
Fetch output (optional):
```bash
cargo run -p hero-unix-client -- \
--socket /tmp/baobab.ipc \
--method get_job_output \
--params '["<job_id_from_create>"]'
```
Notes:
- The “run_job” JSON-RPC method is present but not fully wired to the full request-reply flow; prefer create_job + start_job + get_job_output for now.
- JobParams fields are defined in [rust.JobParams](interfaces/openrpc/server/src/types.rs:6).
## 5) What happens under the hood
- The job is serialized to Redis under the namespace [rust.NAMESPACE_PREFIX](core/job/src/lib.rs:13)
- The Supervisor picks the actor queue from [rust.ScriptType::actor_queue_suffix()](core/job/src/lib.rs:29) and LPUSHes your job ID
- The actor BLPOPs its queue, loads the job, executes your script, and stores the result back into the Redis job hash
- For synchronous flows, Supervisor waits on a dedicated reply queue until the result arrives via [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:672)
## 6) Minimal scripts by actor type
- OSIS/SAL (Rhai):
- "40 + 2"
- "let x = 21; x * 2"
- You can access injected context variables such as CALLER_ID, CONTEXT_ID (see architecture doc in [core/docs/architecture.md](core/docs/architecture.md)).
- V/Python (HeroScript):
- Provide a valid HeroScript snippet appropriate for the selected engine and your deployment.
## 7) Troubleshooting
- Ensure Redis is running and reachable at the configured URL
- SAL vs OSIS: pick SAL if your script is blocking/IO-heavy and needs concurrency; otherwise OSIS is fine for sequential non-blocking tasks
- If using OpenRPC IPC, ensure the socket path matches between server and client
- For lifecycle of actors (starting/restarting/health checks), see [core/supervisor/README.md](core/supervisor/README.md)

216
docs/PROJECT_OVERVIEW.md Normal file
View File

@ -0,0 +1,216 @@
# Baobab Project Overview
This document explains the system architecture and execution model: what a supervisor is, what an actor is (including each actor type and how they are used), how jobs flow through Redis, and how the various interfaces expose functionality over WebSocket and Unix IPC.
References point directly into the codebase for quick lookup.
## 1. Core Concepts
- Supervisor
- A long-lived orchestrator that:
- Supervises actor lifecycles (start/restart/stop/health checks),
- Dispatches jobs to actors via Redis queues,
- Exposes a high-level API for creating, starting, running, and inspecting jobs.
- Key types and entry points:
- [Supervisor](core/supervisor/src/lib.rs:23)
- [SupervisorBuilder](core/supervisor/src/lib.rs:29)
- [SupervisorBuilder::from_toml()](core/supervisor/src/lib.rs:137)
- [Supervisor::start_actors()](core/supervisor/src/lib.rs:299)
- [Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:672)
- [Supervisor::start_job()](core/supervisor/src/lib.rs:658)
- [Supervisor::get_job_status()](core/supervisor/src/lib.rs:705)
- [Supervisor::get_job_output()](core/supervisor/src/lib.rs:740)
- [Supervisor::list_jobs()](core/supervisor/src/lib.rs:761)
- [Supervisor::stop_job()](core/supervisor/src/lib.rs:771)
- [Supervisor::delete_job()](core/supervisor/src/lib.rs:831)
- [Supervisor::clear_all_jobs()](core/supervisor/src/lib.rs:844)
- Actor
- A worker service that pulls jobs from a Redis queue and executes the jobs script with the appropriate engine/runtime for its type.
- Trait and common loop:
- [Actor](core/actor/src/actor_trait.rs:80)
- [ActorConfig](core/actor/src/actor_trait.rs:41)
- [Actor::spawn() (common loop)](core/actor/src/actor_trait.rs:119)
- [spawn_actor()](core/actor/src/actor_trait.rs:250)
- Job and Redis schema
- A job encapsulates a unit of work: script, script type (which selects the actor queue), caller/context IDs, timeout, etc.
- Canonical data and status types are re-exported by the supervisor:
- [Job](core/supervisor/src/lib.rs:21)
- [JobStatus](core/supervisor/src/lib.rs:21)
- [ScriptType](core/supervisor/src/lib.rs:21)
- Redis schema used by the supervisor for job supervision is documented in:
- [core/supervisor/README.md](core/supervisor/README.md)
- Keys overview (jobs, actor work queues, reply queues): see lines 95100 in that file.
## 2. Actors and Script Execution
The system defines four actor types. Each actor has its own queue and executes scripts differently, with standardized context variables injected into script execution (e.g., CALLER_ID, CONTEXT_ID).
- Design summary:
- [core/docs/architecture.md](core/docs/architecture.md:3)
- [core/docs/architecture.md](core/docs/architecture.md:5)
Actor types and behavior:
- OSIS (Rhai, non-blocking, sequential)
- Executes Rhai scripts one after another on a single thread using the Rhai engine.
- Intended for non-blocking tasks.
- SAL (Rhai, blocking async, concurrent)
- Executes blocking asynchronous Rhai scripts concurrently by spawning a new thread per evaluation.
- Intended for IO-bound or blocking tasks requiring concurrency.
- V (HeroScript via V engine) and Python (HeroScript via Python engine)
- Execute HeroScript scripts in their respective engines.
Execution context:
- Both CALLER_ID and CONTEXT_ID are injected in scope for scripts. See description at:
- [core/docs/architecture.md](core/docs/architecture.md:3)
Actor implementation surface:
- Actors implement [Actor](core/actor/src/actor_trait.rs:80) and plug into the provided [Actor::spawn()](core/actor/src/actor_trait.rs:119) loop.
- The common loop:
- Connects to Redis (per-actor id),
- Blocks on the actors queue with BLPOP,
- Handles a special “ping” script inline (health check),
- Delegates other jobs to Actor::process_job().
## 3. Supervisor Responsibilities and Guarantees
- Lifecycle management
- Starts/zinit-registers actors, monitors health, restarts if unhealthy or unresponsive, and cleans up services on shutdown.
- Health checking includes a ping job if idle (actor must respond “pong” immediately).
- Key entry points:
- [Supervisor::start_actors()](core/supervisor/src/lib.rs:299)
- Background lifecycle manager (health loop):
- [Supervisor::spawn_lifecycle_manager()](core/supervisor/src/lib.rs:466)
- Per-actor health handling and restart:
- [Supervisor::check_and_restart_actor()](core/supervisor/src/lib.rs:506)
- Uses zinit as the process manager; see the supervisor readme:
- [core/supervisor/README.md](core/supervisor/README.md)
- Job supervision
- Create, start, run-and-await, inspect, stop, delete jobs; dispatch based on script type using hardcoded per-type queues:
- [Supervisor::get_actor_queue_key()](core/supervisor/src/lib.rs:410)
- [Supervisor::create_job()](core/supervisor/src/lib.rs:642)
- [Supervisor::start_job()](core/supervisor/src/lib.rs:658)
- [Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:672)
- [Supervisor::get_job_status()](core/supervisor/src/lib.rs:705)
- [Supervisor::get_job_output()](core/supervisor/src/lib.rs:740)
- [Supervisor::list_jobs()](core/supervisor/src/lib.rs:761)
- [Supervisor::stop_job()](core/supervisor/src/lib.rs:771)
- [Supervisor::delete_job()](core/supervisor/src/lib.rs:831)
- [Supervisor::clear_all_jobs()](core/supervisor/src/lib.rs:844)
- Job dependency utilities
- Check prerequisites and update dependents upon completion:
- [Supervisor::check_prerequisites_completed()](core/supervisor/src/lib.rs:862)
- [Supervisor::update_job_status_and_check_dependents()](core/supervisor/src/lib.rs:884)
- [Supervisor::dispatch_ready_jobs()](core/supervisor/src/lib.rs:920)
- Redis naming and keys (namespace “hero:”)
- See “Redis Schema” section:
- [core/supervisor/README.md](core/supervisor/README.md)
## 4. Interfaces (APIs and Transports)
The project exposes two complementary ways to interact with the supervisor and job system.
A. OpenRPC Server (JSON-RPC 2.0 over WebSocket or Unix IPC)
- Core types:
- [Transport](interfaces/openrpc/server/src/lib.rs:21)
- [OpenRpcServer](interfaces/openrpc/server/src/lib.rs:37)
- [OpenRpcApi](interfaces/openrpc/server/src/lib.rs:45)
- Server lifecycle:
- [OpenRpcServer::new()](interfaces/openrpc/server/src/lib.rs:98)
- [OpenRpcServer::start()](interfaces/openrpc/server/src/lib.rs:117)
- Methods exposed (selected):
- Authentication: fetch_nonce, authenticate, whoami
- Script execution: play
- Job management: create_job, start_job, run_job, get_job_status, get_job_output, get_job_logs, list_jobs, stop_job, delete_job, clear_all_jobs
- All are registered inside [OpenRpcServer::start()](interfaces/openrpc/server/src/lib.rs:117) using jsonrpsee.
- Transports:
- WebSocket server binding is provided via jsonrpsee when using [Transport::WebSocket](interfaces/openrpc/server/src/lib.rs:21).
- Unix Domain Socket (IPC) is implemented using reth-ipc when using [Transport::Unix](interfaces/openrpc/server/src/lib.rs:21).
- Launchers:
- IPC server binary:
- [interfaces/unix/server/src/main.rs](interfaces/unix/server/src/main.rs)
- IPC client (manual testing tool):
- [interfaces/unix/client/src/main.rs](interfaces/unix/client/src/main.rs)
B. WebSocket Server (Actix)
- A dedicated Actix-based WebSocket server that runs a multi-circle endpoint: each connected circle uses its path “/{circle_pk}”. Each connection is handled by a dedicated Actix actor.
- Server runtime and session actor:
- [Server](interfaces/websocket/server/src/lib.rs:197)
- Starts HTTP/WS server, binds routes, and spawns the WS actor per connection:
- [Server::spawn_circle_server()](interfaces/websocket/server/src/lib.rs:229)
- per-connection handler:
- [ws_handler()](interfaces/websocket/server/src/lib.rs:688)
- Auth and flow:
- Signature-based auth and session lifecycle are documented in:
- [interfaces/websocket/server/docs/ARCHITECTURE.md](interfaces/websocket/server/docs/ARCHITECTURE.md)
- Nonce issuing, signature verification, and circle membership checks gate protected actions (e.g., play).
- Integration with supervisor:
- The WS server issues job requests via the supervisor (e.g., a “play” call builds and runs a job through [Supervisor](core/supervisor/src/lib.rs:23)).
## 5. End-to-End Job Flow
- Creating and starting a job via the OpenRPC server
- Client calls OpenRPC “create_job”, which builds a [Job](core/supervisor/src/lib.rs:21) and stores it in Redis via [Supervisor::create_job()](core/supervisor/src/lib.rs:642).
- Client then calls “start_job”, which reads the job to determine its [ScriptType](core/supervisor/src/lib.rs:21), computes the actor queue via [Supervisor::get_actor_queue_key()](core/supervisor/src/lib.rs:410), and pushes the job ID to the actors Redis list via [Supervisor::start_job()](core/supervisor/src/lib.rs:658).
- Running-and-awaiting a job in one step
- Client calls “run_job” or equivalent flow; the server uses [Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:672):
- Stores the job,
- Pushes to the appropriate actor queue,
- Waits for the result on a dedicated reply queue “hero::reply:{job_id}”.
- Actor processing loop
- The actor BLPOPs its queue (timeout), receives a job ID, loads the job, handles “ping” inline, otherwise calls [Actor::process_job()](core/actor/src/actor_trait.rs:80) for execution, and writes status/output back to Redis.
- The common loop is provided by [Actor::spawn()](core/actor/src/actor_trait.rs:119).
- Health checks
- The supervisor periodically checks zinit state and may issue ping jobs if idle; failure to respond leads to restart. See lifecycle logic:
- [Supervisor::spawn_lifecycle_manager()](core/supervisor/src/lib.rs:466)
- [Supervisor::check_and_restart_actor()](core/supervisor/src/lib.rs:506)
- Redis schema pointers (namespace hero:)
- See section “Redis Schema for Job Supervision”:
- [core/supervisor/README.md](core/supervisor/README.md)
## 6. How the Interfaces Fit Together
- The OpenRPC server provides a JSON-RPC 2.0 façade for programmatic control (automation, services).
- Choose between WebSocket and Unix IPC transports via [Transport](interfaces/openrpc/server/src/lib.rs:21).
- It wraps the [Supervisor](core/supervisor/src/lib.rs:23), delegating all job and lifecycle supervision calls.
- The WebSocket (Actix) server provides a multi-circle, session-based, interactive API well-suited for browser or persistent WS clients.
- It authenticates users per-circle, then issues supervisor-backed job calls within the authenticated context.
- Session isolation is per WS actor instance; see:
- [interfaces/websocket/server/docs/ARCHITECTURE.md](interfaces/websocket/server/docs/ARCHITECTURE.md)
Both interfaces ultimately converge on the same core abstraction: the [Supervisor](core/supervisor/src/lib.rs:23) orchestrating jobs and actors over Redis with zinit-backed lifecycle guarantees.
## 7. Additional References
- Architecture summary for actor types and scripting:
- [core/docs/architecture.md](core/docs/architecture.md)
- Supervisor documentation and prerequisites (Redis, zinit):
- [core/supervisor/README.md](core/supervisor/README.md)
- TUI/CLI examples and lifecycle demos:
- [core/supervisor/examples](core/supervisor/examples)
- Actor README (queue consumption, Rhai execution, context variables):
- [core/actor/README.md](core/actor/README.md)

199
docs/REDIS_QUEUES_GUIDE.md Normal file
View File

@ -0,0 +1,199 @@
# Redis Queues Guide: Who Pushes Where, When, and How to Inspect
This guide documents the canonical queues used in the project, explains which component pushes to which queue at each step, and provides redis-cli commands to inspect state during development.
Canonical keys
- Job hash (immutable key shape):
- hero:job:{job_id}
- Builder: [rust.keys::job_hash()](core/job/src/lib.rs:396)
- Work queues (push here to dispatch work):
- Type queue: hero:q:work:type:{script_type}
- Builders:
- [rust.keys::work_type()](core/job/src/lib.rs:405)
- [rust.keys::work_group()](core/job/src/lib.rs:411)
- [rust.keys::work_instance()](core/job/src/lib.rs:420)
- Reply queue (optional, for actors that send explicit replies):
- hero:q:reply:{job_id}
- Builder: [rust.keys::reply()](core/job/src/lib.rs:401)
- Control queue (optional stop/control per-type):
- hero:q:ctl:type:{script_type}
- Builder: [rust.keys::stop_type()](core/job/src/lib.rs:429)
1) Who pushes where
A. Supervisor: creating, starting, and running jobs
- Create job (stores job hash):
- [rust.Supervisor::create_job()](core/supervisor/src/lib.rs:660)
- Persists hero:job:{job_id} via [rust.Job::store_in_redis()](core/job/src/lib.rs:147)
- Start job (dispatch to worker queue):
- [rust.Supervisor::start_job()](core/supervisor/src/lib.rs:675) → [rust.Supervisor::start_job_using_connection()](core/supervisor/src/lib.rs:599)
- LPUSH hero:q:work:type:{script_type} using [rust.keys::work_type()](core/job/src/lib.rs:405)
- Run-and-wait (one-shot):
- [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
- Stores hero:job:{job_id}, LPUSH hero:q:work:type:{script_type} (same as start)
- Waits on hero:q:reply:{job_id} (via [rust.keys::reply()](core/job/src/lib.rs:401)) and also polls hero:job:{job_id} for output to support hash-only actors
B. Terminal UI: quick dispatch from the actor TUI
- Stores job using Job::store_in_redis, then pushes to type queue:
- Dispatch code: [core/actor/src/terminal_ui.rs](core/actor/src/terminal_ui.rs:460)
- LPUSH hero:q:work:type:{script_type} using [rust.keys::work_type()](core/job/src/lib.rs:405)
C. Actors: consuming and completing work
- Consume jobs:
- Standalone Rhai actor: [rust.spawn_rhai_actor()](core/actor/src/lib.rs:211)
- BLPOP hero:q:work:type:{script_type} (queue selection computed via [rust.derive_script_type_from_actor_id()](core/actor/src/lib.rs:262), then [rust.keys::work_type()](core/job/src/lib.rs:405))
- Trait-based actor loop: [rust.Actor::spawn()](core/actor/src/actor_trait.rs:119)
- BLPOP hero:q:work:type:{script_type} using [rust.keys::work_type()](core/job/src/lib.rs:405)
- Write results:
- Hash-only (current default): [rust.Job::set_result()](core/job/src/lib.rs:322) updates hero:job:{job_id} with output and status=finished
- Optional reply queue model: actor may LPUSH hero:q:reply:{job_id} (if implemented)
2) End-to-end flows and the queues involved
Flow A: Two-step (create + start) with Supervisor
- Code path:
- [rust.Supervisor::create_job()](core/supervisor/src/lib.rs:660)
- [rust.Supervisor::start_job()](core/supervisor/src/lib.rs:675)
- Keys touched:
- hero:job:{job_id} (created)
- hero:q:work:type:{script_type} (LPUSH job_id)
- Expected actor behavior:
- BLPOP hero:q:work:type:{script_type}
- Execute script, then [rust.Job::set_result()](core/job/src/lib.rs:322)
- How to inspect with redis-cli:
- FLUSHALL (fresh dev) then run create and start
- Verify job hash:
- HGETALL hero:job:{job_id}
- Verify queue length before consumption:
- LLEN hero:q:work:type:osis
- See pending items:
- LRANGE hero:q:work:type:osis 0 -1
- After actor runs, verify result in job hash:
- HGET hero:job:{job_id} status
- HGET hero:job:{job_id} output
Flow B: One-shot (run and await result) with Supervisor
- Code path:
- [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
- Uses [rust.keys::reply()](core/job/src/lib.rs:401) and polls the hash for output
- Keys touched:
- hero:job:{job_id}
- hero:q:work:type:{script_type}
- hero:q:reply:{job_id} (only if an actor uses reply queues)
- How to inspect with redis-cli:
- While waiting:
- LLEN hero:q:work:type:osis
- HGET hero:job:{job_id} status
- If an actor uses reply queues (optional):
- LLEN hero:q:reply:{job_id}
- LRANGE hero:q:reply:{job_id} 0 -1
- After completion:
- HGET hero:job:{job_id} output
Flow C: Dispatch from the Actor TUI (manual testing)
- Code path:
- [core/actor/src/terminal_ui.rs](core/actor/src/terminal_ui.rs:460) stores job and LPUSH to [rust.keys::work_type()](core/job/src/lib.rs:405)
- Keys touched:
- hero:job:{job_id}
- hero:q:work:type:{script_type}
- How to inspect with redis-cli:
- List all work queues:
- KEYS hero:q:work:type:*
- Show items in a specific type queue:
- LRANGE hero:q:work:type:osis 0 -1
- Read one pending job:
- HGETALL hero:job:{job_id}
- After actor runs:
- HGET hero:job:{job_id} status
- HGET hero:job:{job_id} output
3) Example redis-cli sequences
A. Basic OSIS job lifecycle (two-step)
- Prepare
- FLUSHALL
- Create and start (via code or supervisor-cli)
- Inspect queue and job
- KEYS hero:q:work:type:*
- LLEN hero:q:work:type:osis
- LRANGE hero:q:work:type:osis 0 -1
- HGETALL hero:job:{job_id}
- After actor consumes the job:
- HGET hero:job:{job_id} status → finished
- HGET hero:job:{job_id} output → script result
- LLEN hero:q:work:type:osis → likely 0 if all consumed
B. One-shot run-and-wait (hash-only actor)
- Prepare
- FLUSHALL
- Submit via run_job_and_await_result()
- While supervisor waits:
- HGET hero:job:{job_id} status → started/finished
- (Optional) LLEN hero:q:reply:{job_id} → typically 0 if actor doesnt use reply queues
- When done:
- HGET hero:job:{job_id} output → result
C. Listing and cleanup helpers
- List jobs
- KEYS hero:job:*
- Show a specific job
- HGETALL hero:job:{job_id}
- Clear all keys (dev only)
- FLUSHALL
4) Where the queue names are computed in code
- Builders for canonical keys:
- [rust.keys::job_hash()](core/job/src/lib.rs:396)
- [rust.keys::reply()](core/job/src/lib.rs:401)
- [rust.keys::work_type()](core/job/src/lib.rs:405)
- [rust.keys::work_group()](core/job/src/lib.rs:411)
- [rust.keys::work_instance()](core/job/src/lib.rs:420)
- Supervisor routing and waiting:
- Type queue selection: [rust.Supervisor::get_actor_queue_key()](core/supervisor/src/lib.rs:410)
- LPUSH to type queue: [rust.Supervisor::start_job_using_connection()](core/supervisor/src/lib.rs:599)
- One-shot run and wait: [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
- Actor consumption:
- Standalone Rhai actor: [rust.spawn_rhai_actor()](core/actor/src/lib.rs:211)
- Type queue computed via [rust.derive_script_type_from_actor_id()](core/actor/src/lib.rs:262) + [rust.keys::work_type()](core/job/src/lib.rs:405)
- Trait-based actor loop: [rust.Actor::spawn()](core/actor/src/actor_trait.rs:119)
- BLPOP type queue via [rust.keys::work_type()](core/job/src/lib.rs:405)
5) Quick checklist for debugging
- Nothing consumes from the type queue
- Is at least one actor process running that BLPOPs hero:q:work:type:{script_type}?
- LLEN hero:q:work:type:{script_type} shows > 0 means unconsumed backlog
- Job “Dispatched” but never “Finished”
- HGET hero:job:{job_id} status
- Actor logs: check for script errors and verify it is connected to the same Redis
- “run-and-wait” timeout
- Hash-only actors dont push to reply queues; the supervisor will still return once it sees hero:job:{job_id}.output set by [rust.Job::set_result()](core/job/src/lib.rs:322)
- Mixed types:
- Verify you targeted the correct type queue (e.g., osis vs sal): LLEN hero:q:work:type:osis, hero:q:work:type:sal
6) Canonical patterns to remember
- To dispatch a job:
- LPUSH hero:q:work:type:{script_type} {job_id}
- To read job data:
- HGETALL hero:job:{job_id}
- To wait for output (optional reply model):
- BLPOP hero:q:reply:{job_id} {timeout_secs}
- To verify system state:
- KEYS hero:q:*
- KEYS hero:job:*
This guide reflects the canonical scheme implemented in:
- [rust.Supervisor](core/supervisor/src/lib.rs:1)
- [rust.keys](core/job/src/lib.rs:392)
- [core/actor/src/lib.rs](core/actor/src/lib.rs:1)
- [core/actor/src/actor_trait.rs](core/actor/src/actor_trait.rs:1)
- [core/actor/src/terminal_ui.rs](core/actor/src/terminal_ui.rs:1)

View File

@ -0,0 +1,231 @@
# Redis Queue Naming Proposal (Multi-Actor, Multi-Type, Scalable)
Goal
- Define a consistent, future-proof Redis naming scheme that:
- Supports multiple actor types (OSIS, SAL, V, Python)
- Supports multiple pools/groups and instances per type
- Enables fair load-balancing and targeted dispatch
- Works with both “hash-output” actors and “reply-queue” actors
- Keeps migration straightforward from the current keys
Motivation
- Today, multiple non-unified patterns exist:
- Per-actor keys like "hero:job:{actor_id}" consumed by in-crate Rhai actor
- Per-type keys like "hero:job:actor_queue:{suffix}" used by other components
- Protocol docs that reference "hero:work_queue:{actor_id}" and "hero:reply:{job_id}"
- This fragmentation causes stuck “Dispatched” jobs when the LPUSH target doesnt match the BLPOP listener. We need one canonical scheme, with well-defined fallbacks.
## 1) Canonical Key Names
Prefix conventions
- Namespace prefix: hero:
- All queues collected under hero:q:* to separate from job hashes hero:job:*
- All metadata under hero:meta:* for discoverability
Job and result keys
- Job hash (unchanged): hero:job:{job_id}
- Reply queue: hero:q:reply:{job_id}
Work queues (new canonical)
- Type queue (shared): hero:q:work:type:{script_type}
- Examples:
- hero:q:work:type:osis
- hero:q:work:type:sal
- hero:q:work:type:v
- hero:q:work:type:python
- Group queue (optional, shared within a group): hero:q:work:type:{script_type}:group:{group}
- Examples:
- hero:q:work:type:osis:group:default
- hero:q:work:type:sal:group:io
- Instance queue (most specific, used for targeted dispatch): hero:q:work:type:{script_type}:group:{group}:inst:{instance}
- Examples:
- hero:q:work:type:osis:group:default:inst:1
- hero:q:work:type:sal:group:io:inst:3
Control queues (optional, future)
- Stop/control per-type: hero:q:ctl:type:{script_type}
- Stop/control per-instance: hero:q:ctl:type:{script_type}:group:{group}:inst:{instance}
Actor presence and metadata
- Instance presence (ephemeral, with TTL refresh): hero:meta:actor:inst:{script_type}:{group}:{instance}
- Value: JSON { pid, hostname, started_at, version, capabilities, last_heartbeat }
- Used by the supervisor to discover live consumers and to select targeted queueing
## 2) Dispatch Strategy
- Default: Push to the Type queue hero:q:work:type:{script_type}
- Allows N instances to BLPOP the same shared queue (standard fan-out).
- Targeted: If user or scheduler specifies a group and/or instance, push to the most specific queue
- Instance queue (highest specificity):
- hero:q:work:type:{script_type}:group:{group}:inst:{instance}
- Else Group queue:
- hero:q:work:type:{script_type}:group:{group}
- Else Type queue (fallback):
- hero:q:work:type:{script_type}
- Priority queues (optional extension):
- Append :prio:{level} to any of the above
- Actors BLPOP a list of queues in priority order
Example routing
- No group/instance specified:
- LPUSH hero:q:work:type:osis {job_id}
- Group specified ("default"), no instance:
- LPUSH hero:q:work:type:osis:group:default {job_id}
- Specific instance:
- LPUSH hero:q:work:type:osis:group:default:inst:2 {job_id}
## 3) Actor Consumption Strategy
- Actor identifies itself with:
- script_type (osis/sal/v/python)
- group (defaults to "default")
- instance number (unique within group)
- Actor registers presence:
- SET hero:meta:actor:inst:{script_type}:{group}:{instance} {...} EX 15
- Periodically refresh to act as heartbeat
- Actor BLPOP order:
1) Instance queue (most specific)
2) Group queue
3) Type queue
- This ensures targeted jobs are taken first (if any), otherwise fall back to group or shared type queue.
- Actors that implement reply-queue semantics will also LPUSH to hero:q:reply:{job_id} on completion. Others just update hero:job:{job_id} with status+output.
## 4) Backward Compatibility And Migration
- During transition, Supervisor can LPUSH to both:
- New canonical queues (hero:q:work:type:...)
- Selected legacy queues (hero:job:actor_queue:{suffix}, hero:job:{actor_id}, hero:work_queue:...)
- Actors:
- Update actors to BLPOP the canonical queues first, then legacy fallback
- Phased plan:
1) Introduce canonical queues alongside legacy; Supervisor pushes to both (compat mode)
2) Switch actors to consume canonical first
3) Deprecate legacy queues and remove dual-push
- No change to job hashes hero:job:{job_id}
## 5) Required Code Changes (by file)
Supervisor (routing and reply queue)
- Replace queue computation with canonical builder:
- [rust.Supervisor::get_actor_queue_key()](core/supervisor/src/lib.rs:410)
- Change to build canonical keys given script_type (+ optional group/instance from Job or policy)
- Update start logic to LPUSH to canonical queue(s):
- [rust.Supervisor::start_job_using_connection()](core/supervisor/src/lib.rs:599)
- Use only canonical queue(s). In migration phase, also LPUSH legacy queues.
- Standardize reply queue name:
- [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
- Use hero:q:reply:{job_id}
- Keep “poll job hash” fallback for actors that dont use reply queues
- Stop queue naming:
- [rust.Supervisor::stop_job()](core/supervisor/src/lib.rs:789)
- Use hero:q:ctl:type:{script_type} in canonical mode
Actor (consumption and presence)
- In-crate Rhai actor:
- Queue key construction and BLPOP list:
- [rust.spawn_rhai_actor()](core/actor/src/lib.rs:211)
- Current queue_key at [core/actor/src/lib.rs:220]
- Replace single-queue BLPOP with multi-key BLPOP in priority order:
1) hero:q:work:type:{script_type}:group:{group}:inst:{instance}
2) hero:q:work:type:{script_type}:group:{group}
3) hero:q:work:type:{script_type}
- For migration, optionally include legacy queues last.
- Presence registration (periodic SET with TTL):
- Add at actor startup and refresh on loop tick
- For actors that implement reply queues:
- After finishing job, LPUSH hero:q:reply:{job_id} {result}
- For hash-only actors, continue to call [rust.Job::set_result()](core/job/src/lib.rs:322)
Shared constants (avoid string drift)
- Introduce constants and helpers in a central crate (hero_job) to build keys consistently:
- fn job_hash_key(job_id) -> "hero:job:{job_id}"
- fn reply_queue_key(job_id) -> "hero:q:reply:{job_id}"
- fn work_queue_type(script_type) -> "hero:q:work:type:{type}"
- fn work_queue_group(script_type, group) -> "hero:q:work:type:{type}:group:{group}"
- fn work_queue_instance(script_type, group, inst) -> "hero:q:work:type:{type}:group:{group}:inst:{inst}"
- Replace open-coded strings in:
- [rust.Supervisor](core/supervisor/src/lib.rs:1)
- [rust.Actor code](core/actor/src/lib.rs:1)
- Any CLI/TUI or interface components that reference queues
Interfaces
- OpenRPC/WebSocket servers do not need to know queue names; they call Supervisor API. No changes except to follow the Supervisors behavior for “run-and-wait” vs “create+start+get_output” flows.
## 6) Example Scenarios
Scenario A: Single OSIS pool with two instances
- Actors:
- osis group=default inst=1
- osis group=default inst=2
- Incoming job (no targeting):
- LPUSH hero:q:work:type:osis {job_id}
- Actors BLPOP order:
- inst queue
- group queue
- type queue (this one will supply)
- Effective result: classic round-robin-like behavior, two workers share load.
Scenario B: SAL pool “io” with instance 3; targeted dispatch
- Job sets target group=io and instance=3
- Supervisor LPUSH hero:q:work:type:sal:group:io:inst:3 {job_id}
- Only that instance consumes it, enabling pinning to a specific worker.
Scenario C: Mixed old and new actors (migration window)
- Supervisor pushes to canonical queue(s) and to a legacy queue hero:job:actor_queue:osis
- New actors consume canonical queues
- Legacy actors consume legacy queue
- No job is stuck; both ecosystems coexist until the legacy path is removed.
## 7) Phased Migration Plan
Phase 0 (Docs + helpers)
- Add helpers in hero_job to compute keys (see “Shared constants”)
- Document the new scheme and consumption order (this file)
Phase 1 (Supervisor)
- Update [rust.Supervisor::get_actor_queue_key()](core/supervisor/src/lib.rs:410) and [rust.Supervisor::start_job_using_connection()](core/supervisor/src/lib.rs:599) to use canonical queues
- Keep dual-push to legacy queues behind a feature flag or config for rollout
- Standardize reply queue to hero:q:reply:{job_id} in [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
Phase 2 (Actors)
- Update [rust.spawn_rhai_actor()](core/actor/src/lib.rs:211) to BLPOP from canonical queues in priority order and to register presence keys
- Optionally emit reply to hero:q:reply:{job_id} in addition to hash-based result (feature flag)
Phase 3 (Cleanup)
- After all actors and Supervisor deployments are updated and stable, remove the legacy dual-push and fallback consume paths
## 8) Optional Enhancements
- Priority queues:
- Suffix queues with :prio:{0|1|2}; actors BLPOP [inst prio0, group prio0, type prio0, inst prio1, group prio1, type prio1, ...]
- Rate limiting/back-pressure:
- Use metadata to signal busy state or reported in-flight jobs; Supervisor can target instance queues accordingly.
- Resilience:
- Move to Redis Streams for job event logs; lists remain fine for simple FIFO processing.
- Observability:
- hero:meta:actor:* and hero:meta:queue:stats:* to keep simple metrics for dashboards.
## 9) Summary
- Canonicalize to hero:q:work:type:{...} (+ group, + instance), and hero:q:reply:{job_id}
- Actors consume instance → group → type
- Supervisor pushes to most specific queue available, defaulting to type
- Provide helpers to build keys and remove ad-hoc string formatting
- Migrate with a dual-push (canonical + legacy) phase to avoid downtime
Proposed touchpoints to implement (clickable references)
- [rust.Supervisor::get_actor_queue_key()](core/supervisor/src/lib.rs:410)
- [rust.Supervisor::start_job_using_connection()](core/supervisor/src/lib.rs:599)
- [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
- [rust.spawn_rhai_actor()](core/actor/src/lib.rs:211)
- [core/actor/src/lib.rs](core/actor/src/lib.rs:220)
- [rust.Job::set_result()](core/job/src/lib.rs:322)

124
docs/RPC_IMPLEMENTATION.md Normal file
View File

@ -0,0 +1,124 @@
# RPC Implementation (jsonrpsee) for Supervisor
Objective
- Provide an HTTP/WS JSON-RPC server with jsonrpsee that exposes all Supervisor job operations.
- Use the current Supervisor and job model directly; methods should map 1:1 to Supervisor APIs.
- Keep the implementation simple: a single transport (jsonrpsee::server::Server on SocketAddr).
Canonical model
- Jobs are stored and updated in Redis under hero:job:{job_id}.
- Work is dispatched to type queues hero:q:work:type:{script_type}.
- Actors consume by script type and update the job hash status/output.
- Server-side types and queues are already aligned in code (see keys in [rust.keys](core/job/src/lib.rs:392)).
What exists today (summary)
- Server state and registry
- [rust.OpenRpcServer](interfaces/openrpc/server/src/lib.rs:37) holds a Supervisor inside RwLock.
- Methods are registered manually with jsonrpsee::RpcModule in [rust.OpenRpcServer::start()](interfaces/openrpc/server/src/lib.rs:117).
- Methods wired vs. stubbed
- Wired: create_job, start_job, get_job_status, get_job_output, stop_job, delete_job, clear_all_jobs.
- Stubbed or partial: run_job (returns a formatted string), play (returns canned output), get_job_logs (mocked), list_jobs (returns fabricated Job objects from IDs).
- Transports
- start() supports a Unix transport through reth-ipc and a WebSocket SocketAddr. We only need HTTP/WS via jsonrpsee::server::Server::builder().build(addr).
Target surface (final)
- Methods
- fetch_nonce(pubkey: String) -> String [optional now]
- authenticate(pubkey: String, signature: String, nonce: String) -> bool [optional now]
- whoami() -> String [optional now]
- play(script: String) -> PlayResult { output: String } [maps to run_job with a chosen default ScriptType]
- create_job(job: JobParams) -> String (job_id)
- start_job(job_id: String) -> { success: bool }
- run_job(script: String, script_type: ScriptType, prerequisites?: Vec<String>) -> String (output)
- get_job_status(job_id: String) -> JobStatus
- get_job_output(job_id: String) -> String
- get_job_logs(job_id: String) -> JobLogsResult { logs: String | null }
- list_jobs() -> Vec<String>
- stop_job(job_id: String) -> null
- delete_job(job_id: String) -> null
- clear_all_jobs() -> null
- Types
- ScriptType = OSIS | SAL | V | Python ([rust.ScriptType](core/job/src/lib.rs:16))
- JobParams: script, script_type, caller_id, context_id, timeout?, prerequisites?
- JobStatus: Dispatched | WaitingForPrerequisites | Started | Error | Finished
- DTOs in [rust.interfaces/openrpc/server/src/types.rs](interfaces/openrpc/server/src/types.rs:1)
Required changes
1) Transport: simplify to HTTP/WS on SocketAddr
- Remove Unix transport: in [rust.OpenRpcServer::start()](interfaces/openrpc/server/src/lib.rs:247), delete Transport::Unix and reth-ipc usage.
- Use jsonrpsee::server::Server::builder().build(addr) and server.start(module), per upstream examples:
- [rust.http](reference_jsonrpsee_crate_examples/http.rs:53)
- [rust.ws](reference_jsonrpsee_crate_examples/ws.rs:55)
2) ScriptType consistency end-to-end
- Ensure ScriptType is hero_job::ScriptType (OSIS | SAL | V | Python) in request/response types (already used in [rust.JobParams](interfaces/openrpc/server/src/types.rs:6)). If openrpc.json is used to generate docs or clients, update its enum to match.
3) Implement run_job (one-shot)
- In [rust.OpenRpcApiServer::run_job](interfaces/openrpc/server/src/lib.rs:366):
- Build a hero_job::JobBuilder with caller_id/context_id placeholders (or accept them as parameters later).
- Set script, script_type, optional prerequisites, timeout default.
- Call supervisor.run_job_and_await_result(&job) and return the output string.
4) Implement play as a thin wrapper
- In [rust.OpenRpcApiServer::play](interfaces/openrpc/server/src/lib.rs:304):
- Choose a default ScriptType (recommendation: SAL), then delegate to run_job(script, SAL, None).
- Return PlayResult { output }.
5) Implement get_job_logs via Supervisor
- Replace the mocked return in [rust.get_job_logs](interfaces/openrpc/server/src/lib.rs:400) with a call to:
- supervisor.get_job_logs(&job_id) -> Option<String> and wrap into JobLogsResult { logs }.
6) list_jobs should return Vec<String> (IDs only)
- Replace placeholder construction in [rust.list_jobs](interfaces/openrpc/server/src/lib.rs:407) with:
- supervisor.list_jobs() returning Vec<String> directly.
- Optionally add get_job(job_id) later if needed.
7) Error handling
- Map SupervisorError to jsonrpsee error codes:
- Invalid input → ErrorCode::InvalidParams
- Timeout → a custom code or InvalidParams; optionally use -32002 as a custom timeout code.
- Internal IO/Redis errors → ErrorCode::InternalError
- Keep server logs descriptive; return minimal error messages to clients.
8) Server lifecycle
- Keep OpenRpcServer::new() to build with TOML or builder defaults (see [rust.OpenRpcServer::new()](interfaces/openrpc/server/src/lib.rs:98)).
- Expose a “start_on(addr)” function that returns a ServerHandle (just like upstream examples).
- Optional: expose Supervisor::start_rpc_server(host, port) to own lifecycle from Supervisor; or leave it in interfaces/openrpc with a thin cmd binary to start it.
Non-goals (for this phase)
- Unix IPC transport (reth-ipc).
- Advanced middleware (CORS, host filters, rate-limiting).
- RPC auth flows (fetch_nonce/authenticate/whoami) beyond placeholders.
- Pub/Sub over RPC.
Reference mapping (clickable)
- Server core and methods:
- [rust.OpenRpcServer](interfaces/openrpc/server/src/lib.rs:37)
- [rust.OpenRpcApi](interfaces/openrpc/server/src/lib.rs:45)
- [rust.OpenRpcServer::start()](interfaces/openrpc/server/src/lib.rs:117)
- [rust.JobParams](interfaces/openrpc/server/src/types.rs:6)
- [rust.StartJobResult](interfaces/openrpc/server/src/types.rs:23)
- [rust.JobLogsResult](interfaces/openrpc/server/src/types.rs:29)
- Supervisor backend:
- [rust.Supervisor::create_job()](core/supervisor/src/lib.rs:660)
- [rust.Supervisor::start_job()](core/supervisor/src/lib.rs:675)
- [rust.Supervisor::run_job_and_await_result()](core/supervisor/src/lib.rs:689)
- [rust.Supervisor::get_job_status()](core/supervisor/src/lib.rs:723)
- [rust.Supervisor::get_job_output()](core/supervisor/src/lib.rs:758)
- [rust.Supervisor::get_job_logs()](core/supervisor/src/lib.rs:817)
- [rust.Supervisor::list_jobs()](core/supervisor/src/lib.rs:780)
- [rust.Supervisor::stop_job()](core/supervisor/src/lib.rs:789)
- [rust.Supervisor::delete_job()](core/supervisor/src/lib.rs:850)
- [rust.Supervisor::clear_all_jobs()](core/supervisor/src/lib.rs:862)
- jsonrpsee examples to replicate transport and registration patterns:
- HTTP: [rust.http example](reference_jsonrpsee_crate_examples/http.rs:53)
- WS: [rust.ws example](reference_jsonrpsee_crate_examples/ws.rs:55)
Acceptance checklist
- Server starts on a host:port using jsonrpsee::server::Server.
- All Supervisor operations callable over RPC, 1:1 mapping, returning correct DTOs.
- ScriptType uses OSIS|SAL|V|Python.
- list_jobs returns Vec<String> and no fake job objects.
- run_job and play perform real execution and return actual outputs.
- No Unix IPC code path remains in start().

View File

@ -265,11 +265,11 @@
"params": [], "params": [],
"result": { "result": {
"name": "jobList", "name": "jobList",
"description": "List of all jobs.", "description": "List of all job IDs.",
"schema": { "schema": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/Job" "type": "string"
} }
} }
} }
@ -343,7 +343,7 @@
}, },
"ScriptType": { "ScriptType": {
"type": "string", "type": "string",
"enum": ["HeroScript", "RhaiSAL", "RhaiDSL"], "enum": ["OSIS", "SAL", "V", "Python"],
"description": "The type of script to execute." "description": "The type of script to execute."
}, },
"JobStatus": { "JobStatus": {

View File

@ -0,0 +1,42 @@
[package]
name = "hero-openrpc-client"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "hero-openrpc-client"
path = "cmd/main.rs"
[dependencies]
# Core dependencies
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4.0", features = ["derive"] }
# JSON-RPC dependencies
jsonrpsee = { version = "0.21", features = [
"client",
"macros"
] }
async-trait = "0.1"
# Hero dependencies
hero_job = { path = "../../../core/job" }
# Authentication and crypto
secp256k1 = { version = "0.28", features = ["rand", "recovery"] }
hex = "0.4"
sha2 = "0.10"
rand = "0.8"
# CLI utilities
dialoguer = "0.11"
colored = "2.0"
# Async utilities
futures = "0.3"

View File

@ -0,0 +1,489 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::*;
use dialoguer::{Input, Select, Confirm, MultiSelect};
use hero_job::ScriptType;
use hero_openrpc_client::{
AuthHelper, ClientTransport, HeroOpenRpcClient, JobParams,
};
use std::path::PathBuf;
use tracing::{error, info, Level};
use tracing_subscriber;
#[derive(Parser)]
#[command(name = "hero-openrpc-client")]
#[command(about = "Hero OpenRPC Client - Interactive JSON-RPC client")]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Private key for authentication (hex format)
#[arg(long)]
private_key: Option<String>,
/// Generate a new private key and exit
#[arg(long)]
generate_key: bool,
/// Log level
#[arg(long, default_value = "info")]
log_level: String,
}
#[derive(Subcommand)]
enum Commands {
/// Connect to WebSocket server
Websocket {
/// Server URL
#[arg(long, default_value = "ws://127.0.0.1:9944")]
url: String,
},
}
/// Available RPC methods with descriptions
#[derive(Debug, Clone)]
struct RpcMethod {
name: &'static str,
description: &'static str,
requires_auth: bool,
}
const RPC_METHODS: &[RpcMethod] = &[
RpcMethod {
name: "fetch_nonce",
description: "Fetch a nonce for authentication",
requires_auth: false,
},
RpcMethod {
name: "authenticate",
description: "Authenticate with public key and signature",
requires_auth: false,
},
RpcMethod {
name: "whoami",
description: "Get authentication status and user information",
requires_auth: true,
},
RpcMethod {
name: "play",
description: "Execute a Rhai script immediately",
requires_auth: true,
},
RpcMethod {
name: "create_job",
description: "Create a new job without starting it",
requires_auth: true,
},
RpcMethod {
name: "start_job",
description: "Start a previously created job",
requires_auth: true,
},
RpcMethod {
name: "run_job",
description: "Create and run a job, returning result when complete",
requires_auth: true,
},
RpcMethod {
name: "get_job_status",
description: "Get the current status of a job",
requires_auth: true,
},
RpcMethod {
name: "get_job_output",
description: "Get the output of a completed job",
requires_auth: true,
},
RpcMethod {
name: "get_job_logs",
description: "Get the logs of a job",
requires_auth: true,
},
RpcMethod {
name: "list_jobs",
description: "List all jobs in the system",
requires_auth: true,
},
RpcMethod {
name: "stop_job",
description: "Stop a running job",
requires_auth: true,
},
RpcMethod {
name: "delete_job",
description: "Delete a job from the system",
requires_auth: true,
},
RpcMethod {
name: "clear_all_jobs",
description: "Clear all jobs from the system",
requires_auth: true,
},
];
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize tracing
let log_level = match cli.log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
};
tracing_subscriber::fmt()
.with_max_level(log_level)
.init();
// Handle key generation
if cli.generate_key {
let auth_helper = AuthHelper::generate()?;
println!("{}", "Generated new private key:".green().bold());
println!("Private Key: {}", auth_helper.private_key_hex().yellow());
println!("Public Key: {}", auth_helper.public_key_hex().cyan());
println!();
println!("{}", "Save the private key securely and use it with --private-key".bright_yellow());
return Ok(());
}
let transport = match cli.command {
Commands::Websocket { url } => {
println!("{} {}", "Connecting to WebSocket server:".green(), url.cyan());
ClientTransport::WebSocket(url)
}
};
// Connect to the server
let client = HeroOpenRpcClient::connect(transport).await?;
println!("{}", "Connected successfully!".green().bold());
// Handle authentication if private key is provided
let mut authenticated = false;
if let Some(private_key) = cli.private_key {
println!("{}", "Authenticating...".yellow());
match client.authenticate_with_key(&private_key).await {
Ok(true) => {
println!("{}", "Authentication successful!".green().bold());
authenticated = true;
}
Ok(false) => {
println!("{}", "Authentication failed!".red().bold());
}
Err(e) => {
error!("Authentication error: {}", e);
println!("{} {}", "Authentication error:".red().bold(), e);
}
}
} else {
println!("{}", "No private key provided. Some methods will require authentication.".yellow());
println!("{}", "Use --generate-key to create a new key or --private-key to use an existing one.".bright_yellow());
}
println!();
// Interactive loop
loop {
// Filter methods based on authentication status
let available_methods: Vec<&RpcMethod> = RPC_METHODS
.iter()
.filter(|method| !method.requires_auth || authenticated)
.collect();
if available_methods.is_empty() {
println!("{}", "No methods available. Please authenticate first.".red());
break;
}
// Display method selection
let method_names: Vec<String> = available_methods
.iter()
.map(|method| {
if method.requires_auth && !authenticated {
format!("{} {} (requires auth)", method.name.red(), method.description)
} else {
format!("{} {}", method.name.green(), method.description)
}
})
.collect();
let selection = Select::new()
.with_prompt("Select an RPC method to call")
.items(&method_names)
.default(0)
.interact_opt()?;
let Some(selection) = selection else {
println!("{}", "Goodbye!".cyan());
break;
};
let selected_method = available_methods[selection];
println!();
println!("{} {}", "Selected method:".bold(), selected_method.name.green());
// Handle method-specific parameter collection and execution
match execute_method(&client, selected_method.name).await {
Ok(_) => {}
Err(e) => {
error!("Method execution failed: {}", e);
println!("{} {}", "Error:".red().bold(), e);
}
}
println!();
// Ask if user wants to continue
if !Confirm::new()
.with_prompt("Do you want to call another method?")
.default(true)
.interact()?
{
break;
}
println!();
}
println!("{}", "Goodbye!".cyan().bold());
Ok(())
}
async fn execute_method(client: &HeroOpenRpcClient, method_name: &str) -> Result<()> {
match method_name {
"fetch_nonce" => {
let pubkey: String = Input::new()
.with_prompt("Public key (hex)")
.interact_text()?;
let result = client.fetch_nonce(pubkey).await?;
println!("{} {}", "Nonce:".green().bold(), result.yellow());
}
"authenticate" => {
let pubkey: String = Input::new()
.with_prompt("Public key (hex)")
.interact_text()?;
let signature: String = Input::new()
.with_prompt("Signature (hex)")
.interact_text()?;
let nonce: String = Input::new()
.with_prompt("Nonce (hex) - fetch via fetch_nonce first")
.interact_text()?;
let result = client.authenticate(pubkey, signature, nonce).await?;
println!("{} {}", "Authentication result:".green().bold(),
if result { "Success".green() } else { "Failed".red() });
}
"whoami" => {
let result = client.whoami().await?;
println!("{} {}", "User info:".green().bold(), result.cyan());
}
"play" => {
let script: String = Input::new()
.with_prompt("Rhai script to execute")
.interact_text()?;
let result = client.play(script).await?;
println!("{} {}", "Script output:".green().bold(), result.output.cyan());
}
"create_job" => {
let script: String = Input::new()
.with_prompt("Script content")
.interact_text()?;
let script_types = ["OSIS", "SAL", "V", "Python"];
let script_type_selection = Select::new()
.with_prompt("Script type")
.items(&script_types)
.default(0)
.interact()?;
let script_type = match script_type_selection {
0 => ScriptType::OSIS,
1 => ScriptType::SAL,
2 => ScriptType::V,
_ => ScriptType::Python,
};
let add_prerequisites = Confirm::new()
.with_prompt("Add prerequisites?")
.default(false)
.interact()?;
let prerequisites = if add_prerequisites {
let prereq_input: String = Input::new()
.with_prompt("Prerequisites (comma-separated job IDs)")
.interact_text()?;
Some(prereq_input.split(',').map(|s| s.trim().to_string()).collect())
} else {
None
};
let caller_id: String = Input::new()
.with_prompt("Caller ID")
.interact_text()?;
let context_id: String = Input::new()
.with_prompt("Context ID")
.interact_text()?;
let specify_timeout = Confirm::new()
.with_prompt("Specify timeout (seconds)?")
.default(false)
.interact()?;
let timeout = if specify_timeout {
let t: u64 = Input::new()
.with_prompt("Timeout (seconds)")
.interact_text()?;
Some(t)
} else {
None
};
let job_params = JobParams {
script,
script_type,
caller_id,
context_id,
timeout,
prerequisites,
};
let result = client.create_job(job_params).await?;
println!("{} {}", "Created job ID:".green().bold(), result.yellow());
}
"start_job" => {
let job_id: String = Input::new()
.with_prompt("Job ID to start")
.interact_text()?;
let result = client.start_job(job_id).await?;
println!("{} {}", "Start result:".green().bold(),
if result.success { "Success".green() } else { "Failed".red() });
}
"run_job" => {
let script: String = Input::new()
.with_prompt("Script content")
.interact_text()?;
let script_types = ["OSIS", "SAL", "V", "Python"];
let script_type_selection = Select::new()
.with_prompt("Script type")
.items(&script_types)
.default(0)
.interact()?;
let script_type = match script_type_selection {
0 => ScriptType::OSIS,
1 => ScriptType::SAL,
2 => ScriptType::V,
_ => ScriptType::Python,
};
let add_prerequisites = Confirm::new()
.with_prompt("Add prerequisites?")
.default(false)
.interact()?;
let prerequisites = if add_prerequisites {
let prereq_input: String = Input::new()
.with_prompt("Prerequisites (comma-separated job IDs)")
.interact_text()?;
Some(prereq_input.split(',').map(|s| s.trim().to_string()).collect())
} else {
None
};
let result = client.run_job(script, script_type, prerequisites).await?;
println!("{} {}", "Job result:".green().bold(), result.cyan());
}
"get_job_status" => {
let job_id: String = Input::new()
.with_prompt("Job ID")
.interact_text()?;
let result = client.get_job_status(job_id).await?;
println!("{} {:?}", "Job status:".green().bold(), result);
}
"get_job_output" => {
let job_id: String = Input::new()
.with_prompt("Job ID")
.interact_text()?;
let result = client.get_job_output(job_id).await?;
println!("{} {}", "Job output:".green().bold(), result.cyan());
}
"get_job_logs" => {
let job_id: String = Input::new()
.with_prompt("Job ID")
.interact_text()?;
let result = client.get_job_logs(job_id).await?;
match result.logs {
Some(logs) => println!("{} {}", "Job logs:".green().bold(), logs.cyan()),
None => println!("{} {}", "Job logs:".green().bold(), "(no logs)".yellow()),
}
}
"list_jobs" => {
let result = client.list_jobs().await?;
println!("{}", "Job IDs:".green().bold());
for id in result {
println!(" {}", id.yellow());
}
}
"stop_job" => {
let job_id: String = Input::new()
.with_prompt("Job ID to stop")
.interact_text()?;
client.stop_job(job_id.clone()).await?;
println!("{} {}", "Stopped job:".green().bold(), job_id.yellow());
}
"delete_job" => {
let job_id: String = Input::new()
.with_prompt("Job ID to delete")
.interact_text()?;
client.delete_job(job_id.clone()).await?;
println!("{} {}", "Deleted job:".green().bold(), job_id.yellow());
}
"clear_all_jobs" => {
let confirm = Confirm::new()
.with_prompt("Are you sure you want to clear ALL jobs?")
.default(false)
.interact()?;
if confirm {
client.clear_all_jobs().await?;
println!("{}", "Cleared all jobs".green().bold());
} else {
println!("{}", "Operation cancelled".yellow());
}
}
_ => {
println!("{} {}", "Unknown method:".red().bold(), method_name);
}
}
Ok(())
}

View File

@ -0,0 +1,81 @@
use anyhow::{anyhow, Result};
use secp256k1::{Message, PublicKey, ecdsa::Signature, Secp256k1, SecretKey};
use sha2::{Digest, Sha256};
/// Helper for authentication operations
pub struct AuthHelper {
secret_key: SecretKey,
public_key: PublicKey,
secp: Secp256k1<secp256k1::All>,
}
impl AuthHelper {
/// Create a new auth helper from a private key hex string
pub fn new(private_key_hex: &str) -> Result<Self> {
let secp = Secp256k1::new();
let secret_key_bytes = hex::decode(private_key_hex)
.map_err(|_| anyhow!("Invalid private key hex format"))?;
let secret_key = SecretKey::from_slice(&secret_key_bytes)
.map_err(|_| anyhow!("Invalid private key"))?;
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
Ok(Self {
secret_key,
public_key,
secp,
})
}
/// Generate a new random private key
pub fn generate() -> Result<Self> {
let secp = Secp256k1::new();
let (secret_key, public_key) = secp.generate_keypair(&mut rand::thread_rng());
Ok(Self {
secret_key,
public_key,
secp,
})
}
/// Get the public key as a hex string
pub fn public_key_hex(&self) -> String {
hex::encode(self.public_key.serialize())
}
/// Get the private key as a hex string
pub fn private_key_hex(&self) -> String {
hex::encode(self.secret_key.secret_bytes())
}
/// Sign a message and return the signature as hex
pub fn sign_message(&self, message: &str) -> Result<String> {
let message_hash = Sha256::digest(message.as_bytes());
let message = Message::from_slice(&message_hash)
.map_err(|_| anyhow!("Failed to create message from hash"))?;
let signature = self.secp.sign_ecdsa(&message, &self.secret_key);
Ok(hex::encode(signature.serialize_compact()))
}
/// Verify a signature against a message
pub fn verify_signature(&self, message: &str, signature_hex: &str) -> Result<bool> {
let message_hash = Sha256::digest(message.as_bytes());
let message = Message::from_slice(&message_hash)
.map_err(|_| anyhow!("Failed to create message from hash"))?;
let signature_bytes = hex::decode(signature_hex)
.map_err(|_| anyhow!("Invalid signature hex format"))?;
let signature = Signature::from_compact(&signature_bytes)
.map_err(|_| anyhow!("Invalid signature format"))?;
match self.secp.verify_ecdsa(&message, &signature, &self.public_key) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
}

View File

@ -0,0 +1,212 @@
use anyhow::Result;
use async_trait::async_trait;
use hero_job::{JobStatus, ScriptType};
use jsonrpsee::core::client::ClientT;
use jsonrpsee::core::ClientError;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::rpc_params;
use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
use std::path::PathBuf;
use tracing::{error, info};
mod auth;
mod types;
pub use auth::*;
pub use types::*;
/// Transport configuration for the client
#[derive(Debug, Clone)]
pub enum ClientTransport {
WebSocket(String),
}
/// OpenRPC client trait defining all available methods
#[rpc(client)]
pub trait OpenRpcClient {
// Authentication methods
#[method(name = "fetch_nonce")]
async fn fetch_nonce(&self, pubkey: String) -> Result<String, ClientError>;
#[method(name = "authenticate")]
async fn authenticate(
&self,
pubkey: String,
signature: String,
nonce: String,
) -> Result<bool, ClientError>;
#[method(name = "whoami")]
async fn whoami(&self) -> Result<String, ClientError>;
// Script execution
#[method(name = "play")]
async fn play(&self, script: String) -> Result<PlayResult, ClientError>;
// Job management
#[method(name = "create_job")]
async fn create_job(&self, job: JobParams) -> Result<String, ClientError>;
#[method(name = "start_job")]
async fn start_job(&self, job_id: String) -> Result<StartJobResult, ClientError>;
#[method(name = "run_job")]
async fn run_job(
&self,
script: String,
script_type: ScriptType,
prerequisites: Option<Vec<String>>,
) -> Result<String, ClientError>;
#[method(name = "get_job_status")]
async fn get_job_status(&self, job_id: String) -> Result<JobStatus, ClientError>;
#[method(name = "get_job_output")]
async fn get_job_output(&self, job_id: String) -> Result<String, ClientError>;
#[method(name = "get_job_logs")]
async fn get_job_logs(&self, job_id: String) -> Result<JobLogsResult, ClientError>;
#[method(name = "list_jobs")]
async fn list_jobs(&self) -> Result<Vec<String>, ClientError>;
#[method(name = "stop_job")]
async fn stop_job(&self, job_id: String) -> Result<(), ClientError>;
#[method(name = "delete_job")]
async fn delete_job(&self, job_id: String) -> Result<(), ClientError>;
#[method(name = "clear_all_jobs")]
async fn clear_all_jobs(&self) -> Result<(), ClientError>;
}
/// Wrapper client that can use WebSocket transport
pub struct HeroOpenRpcClient {
client: WsClient,
}
impl HeroOpenRpcClient {
/// Connect to the OpenRPC server using the specified transport
pub async fn connect(transport: ClientTransport) -> Result<Self> {
match transport {
ClientTransport::WebSocket(url) => {
info!("Connecting to WebSocket server at {}", url);
let client = WsClientBuilder::default()
.build(&url)
.await?;
Ok(Self { client })
}
}
}
/// Get the underlying client for making RPC calls
pub fn client(&self) -> &WsClient {
&self.client
}
/// Authenticate with the server using a private key
pub async fn authenticate_with_key(&self, private_key: &str) -> Result<bool> {
let auth_helper = AuthHelper::new(private_key)?;
// Get nonce
let pubkey = auth_helper.public_key_hex();
let nonce: String = self.client.fetch_nonce(pubkey.clone()).await?;
// Sign nonce
let signature = auth_helper.sign_message(&nonce)?;
// Authenticate
let result = self.client.authenticate(pubkey, signature, nonce).await?;
if result {
info!("Authentication successful");
} else {
error!("Authentication failed");
}
Ok(result)
}
}
// Implement delegation methods on HeroOpenRpcClient to use the generated trait methods
impl HeroOpenRpcClient {
/// Delegate to fetch_nonce on the underlying client
pub async fn fetch_nonce(&self, pubkey: String) -> Result<String, ClientError> {
self.client.fetch_nonce(pubkey).await
}
/// Delegate to authenticate on the underlying client
pub async fn authenticate(
&self,
pubkey: String,
signature: String,
nonce: String,
) -> Result<bool, ClientError> {
self.client.authenticate(pubkey, signature, nonce).await
}
/// Delegate to whoami on the underlying client
pub async fn whoami(&self) -> Result<String, ClientError> {
self.client.whoami().await
}
/// Delegate to play on the underlying client
pub async fn play(&self, script: String) -> Result<PlayResult, ClientError> {
self.client.play(script).await
}
/// Delegate to create_job on the underlying client
pub async fn create_job(&self, job: JobParams) -> Result<String, ClientError> {
self.client.create_job(job).await
}
/// Delegate to start_job on the underlying client
pub async fn start_job(&self, job_id: String) -> Result<StartJobResult, ClientError> {
self.client.start_job(job_id).await
}
/// Delegate to run_job on the underlying client
pub async fn run_job(
&self,
script: String,
script_type: ScriptType,
prerequisites: Option<Vec<String>>,
) -> Result<String, ClientError> {
self.client.run_job(script, script_type, prerequisites).await
}
/// Delegate to get_job_status on the underlying client
pub async fn get_job_status(&self, job_id: String) -> Result<JobStatus, ClientError> {
self.client.get_job_status(job_id).await
}
/// Delegate to get_job_output on the underlying client
pub async fn get_job_output(&self, job_id: String) -> Result<String, ClientError> {
self.client.get_job_output(job_id).await
}
/// Delegate to get_job_logs on the underlying client
pub async fn get_job_logs(&self, job_id: String) -> Result<JobLogsResult, ClientError> {
self.client.get_job_logs(job_id).await
}
/// Delegate to list_jobs on the underlying client
pub async fn list_jobs(&self) -> Result<Vec<String>, ClientError> {
self.client.list_jobs().await
}
/// Delegate to stop_job on the underlying client
pub async fn stop_job(&self, job_id: String) -> Result<(), ClientError> {
self.client.stop_job(job_id).await
}
/// Delegate to delete_job on the underlying client
pub async fn delete_job(&self, job_id: String) -> Result<(), ClientError> {
self.client.delete_job(job_id).await
}
/// Delegate to clear_all_jobs on the underlying client
pub async fn clear_all_jobs(&self) -> Result<(), ClientError> {
self.client.clear_all_jobs().await
}
}

View File

@ -0,0 +1,31 @@
use hero_job::ScriptType;
use serde::{Deserialize, Serialize};
/** Parameters for creating a job (must mirror server DTO) */
#[derive(Debug, Serialize, Deserialize)]
pub struct JobParams {
pub script: String,
pub script_type: ScriptType,
pub caller_id: String,
pub context_id: String,
pub timeout: Option<u64>, // seconds
pub prerequisites: Option<Vec<String>>,
}
/// Result of script execution
#[derive(Debug, Serialize, Deserialize)]
pub struct PlayResult {
pub output: String,
}
/// Result of starting a job
#[derive(Debug, Serialize, Deserialize)]
pub struct StartJobResult {
pub success: bool,
}
/** Result of getting job logs */
#[derive(Debug, Serialize, Deserialize)]
pub struct JobLogsResult {
pub logs: Option<String>,
}

View File

@ -0,0 +1,44 @@
[package]
name = "hero-openrpc-server"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "hero-openrpc-server"
path = "cmd/main.rs"
[dependencies]
# Core dependencies
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4.0", features = ["derive"] }
# JSON-RPC dependencies
jsonrpsee = { version = "0.21", features = ["server", "macros"] }
jsonrpsee-types = "0.21"
uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Hero dependencies
hero_supervisor = { path = "../../../core/supervisor" }
hero_job = { path = "../../../core/job" }
# Authentication and crypto
secp256k1 = { version = "0.28", features = ["rand", "recovery"] }
hex = "0.4"
sha2 = "0.10"
rand = "0.8"
# Async utilities
futures = "0.3"
# Test dependencies
[dev-dependencies]
tokio-test = "0.4"
uuid = { version = "1.6", features = ["v4"] }

View File

@ -0,0 +1,81 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use hero_openrpc_server::{OpenRpcServer, OpenRpcServerConfig, Transport};
use std::net::SocketAddr;
use std::path::PathBuf;
use tracing::{info, Level};
use tracing_subscriber;
#[derive(Parser)]
#[command(name = "hero-openrpc-server")]
#[command(about = "Hero OpenRPC Server - JSON-RPC over HTTP/WS")]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Path to supervisor configuration file
#[arg(long)]
supervisor_config: Option<PathBuf>,
/// Database path for supervisor
#[arg(long, default_value = "./supervisor.db")]
db_path: PathBuf,
/// Log level
#[arg(long, default_value = "info")]
log_level: String,
}
#[derive(Subcommand)]
enum Commands {
/// Start WebSocket server
Websocket {
/// Address to bind to
#[arg(long, default_value = "127.0.0.1:9944")]
addr: SocketAddr,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize tracing
let log_level = match cli.log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
};
tracing_subscriber::fmt()
.with_max_level(log_level)
.init();
let transport = match cli.command {
Commands::Websocket { addr } => {
info!("Starting WebSocket server on {}", addr);
Transport::WebSocket(addr)
}
};
let config = OpenRpcServerConfig {
transport: transport.clone(),
supervisor_config_path: cli.supervisor_config,
db_path: cli.db_path,
};
// Create and start the server
let server = OpenRpcServer::new(config.clone()).await?;
let handle = server.start(config).await?;
info!("Server started successfully");
// Wait for the server to finish
handle.stopped().await;
info!("Server stopped");
Ok(())
}

View File

@ -0,0 +1,131 @@
use anyhow::{anyhow, Result};
use secp256k1::{Message, PublicKey, Secp256k1, ecdsa::Signature};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
/// Nonce response structure
#[derive(Debug, Serialize, Deserialize)]
pub struct NonceResponse {
pub nonce: String,
pub timestamp: u64,
}
/// Authentication manager for handling nonces and signature verification
#[derive(Debug)]
pub struct AuthManager {
nonces: HashMap<String, NonceResponse>,
authenticated_keys: HashMap<String, u64>, // pubkey -> timestamp
}
impl AuthManager {
/// Create a new authentication manager
pub fn new() -> Self {
Self {
nonces: HashMap::new(),
authenticated_keys: HashMap::new(),
}
}
/// Generate a nonce for a given public key
pub fn generate_nonce(&mut self, pubkey: &str) -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let nonce = format!("{}:{}", pubkey, timestamp);
let nonce_hash = format!("{:x}", Sha256::digest(nonce.as_bytes()));
self.nonces.insert(
pubkey.to_string(),
NonceResponse {
nonce: nonce_hash.clone(),
timestamp,
},
);
nonce_hash
}
/// Verify a signature against a stored nonce
pub fn verify_signature(&mut self, pubkey: &str, signature: &str) -> Result<bool> {
// Get the nonce for this public key
let nonce_response = self
.nonces
.get(pubkey)
.ok_or_else(|| anyhow!("No nonce found for public key"))?;
// Check if nonce is not too old (5 minutes)
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if current_time - nonce_response.timestamp > 300 {
return Err(anyhow!("Nonce expired"));
}
// Parse the public key
let pubkey_bytes = hex::decode(pubkey)
.map_err(|_| anyhow!("Invalid public key format"))?;
let secp = Secp256k1::new();
let public_key = PublicKey::from_slice(&pubkey_bytes)
.map_err(|_| anyhow!("Invalid public key"))?;
// Parse the signature
let signature_bytes = hex::decode(signature)
.map_err(|_| anyhow!("Invalid signature format"))?;
let signature = Signature::from_compact(&signature_bytes)
.map_err(|_| anyhow!("Invalid signature"))?;
// Create message hash from nonce
let message_hash = Sha256::digest(nonce_response.nonce.as_bytes());
let message = Message::from_slice(&message_hash)
.map_err(|_| anyhow!("Failed to create message"))?;
// Verify the signature
match secp.verify_ecdsa(&message, &signature, &public_key) {
Ok(_) => {
// Mark this key as authenticated
self.authenticated_keys.insert(pubkey.to_string(), current_time);
// Remove the used nonce
self.nonces.remove(pubkey);
Ok(true)
}
Err(_) => Ok(false),
}
}
/// Check if a public key is currently authenticated
pub fn is_authenticated(&self, pubkey: &str) -> bool {
if let Some(&timestamp) = self.authenticated_keys.get(pubkey) {
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Authentication is valid for 1 hour
current_time - timestamp < 3600
} else {
false
}
}
/// Remove expired authentications
pub fn cleanup_expired(&mut self) {
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Remove expired nonces (older than 5 minutes)
self.nonces.retain(|_, nonce| current_time - nonce.timestamp <= 300);
// Remove expired authentications (older than 1 hour)
self.authenticated_keys.retain(|_, &mut timestamp| current_time - timestamp <= 3600);
}
}

View File

@ -0,0 +1,479 @@
use anyhow::Result;
use hero_job::{Job, JobBuilder, JobStatus, ScriptType};
use hero_supervisor::{Supervisor, SupervisorBuilder, SupervisorError};
use jsonrpsee::core::async_trait;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::{ServerBuilder, ServerHandle};
use jsonrpsee::RpcModule;
use jsonrpsee_types::error::ErrorCode;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::error;
fn map_sup_error_to_rpc(e: &SupervisorError) -> ErrorCode {
match e {
SupervisorError::InvalidInput(_) | SupervisorError::JobError(_) => ErrorCode::InvalidParams,
SupervisorError::Timeout(_) => ErrorCode::ServerError(-32002),
_ => ErrorCode::InternalError,
}
}
mod auth;
pub mod types;
pub use auth::*;
pub use types::*;
/** Transport type for the OpenRPC server */
#[derive(Debug, Clone)]
pub enum Transport {
WebSocket(SocketAddr),
}
/// OpenRPC server configuration
#[derive(Debug, Clone)]
pub struct OpenRpcServerConfig {
pub transport: Transport,
pub supervisor_config_path: Option<PathBuf>,
pub db_path: PathBuf,
}
/// Main OpenRPC server state
#[derive(Clone)]
pub struct OpenRpcServer {
supervisor: Arc<RwLock<Supervisor>>,
auth_manager: Arc<RwLock<AuthManager>>,
}
/// OpenRPC trait defining all available methods
#[rpc(server)]
pub trait OpenRpcApi {
// Authentication methods
#[method(name = "fetch_nonce")]
async fn fetch_nonce(&self, public_key: String) -> Result<String, ErrorCode>;
#[method(name = "authenticate")]
async fn authenticate(&self, public_key: String, signature: String, nonce: String) -> Result<bool, ErrorCode>;
#[method(name = "whoami")]
async fn whoami(&self) -> Result<String, ErrorCode>;
// Script execution
#[method(name = "play")]
async fn play(&self, script: String) -> Result<PlayResult, ErrorCode>;
// Job management
#[method(name = "create_job")]
async fn create_job(&self, job_params: JobParams) -> Result<String, ErrorCode>;
#[method(name = "start_job")]
async fn start_job(&self, job_id: String) -> Result<StartJobResult, ErrorCode>;
#[method(name = "run_job")]
async fn run_job(
&self,
script: String,
script_type: ScriptType,
prerequisites: Option<Vec<String>>,
) -> Result<String, ErrorCode>;
#[method(name = "get_job_status")]
async fn get_job_status(&self, job_id: String) -> Result<JobStatus, ErrorCode>;
#[method(name = "get_job_output")]
async fn get_job_output(&self, job_id: String) -> Result<String, ErrorCode>;
#[method(name = "get_job_logs")]
async fn get_job_logs(&self, job_id: String) -> Result<JobLogsResult, ErrorCode>;
#[method(name = "list_jobs")]
async fn list_jobs(&self) -> Result<Vec<String>, ErrorCode>;
#[method(name = "stop_job")]
async fn stop_job(&self, job_id: String) -> Result<(), ErrorCode>;
#[method(name = "delete_job")]
async fn delete_job(&self, job_id: String) -> Result<(), ErrorCode>;
#[method(name = "clear_all_jobs")]
async fn clear_all_jobs(&self) -> Result<(), ErrorCode>;
}
impl OpenRpcServer {
/// Create a new OpenRPC server instance
pub async fn new(config: OpenRpcServerConfig) -> Result<Self> {
let supervisor = if let Some(config_path) = config.supervisor_config_path {
// Load supervisor from config file
SupervisorBuilder::from_toml(&config_path)?
.build().await?
} else {
// Create default supervisor with Redis URL
SupervisorBuilder::new()
.redis_url("redis://localhost:6379")
.build().await?
};
Ok(Self {
supervisor: Arc::new(RwLock::new(supervisor)),
auth_manager: Arc::new(RwLock::new(AuthManager::new())),
})
}
/// Start the OpenRPC server on the given SocketAddr (HTTP/WS only)
pub async fn start_on(self, addr: SocketAddr) -> Result<ServerHandle> {
let mut module = RpcModule::new(());
// Register all the RPC methods
let server_clone = self.clone();
module.register_async_method("fetch_nonce", move |params, _| {
let server = server_clone.clone();
async move {
let public_key: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.fetch_nonce(public_key).await
}
})?;
let server_clone = self.clone();
module.register_async_method("authenticate", move |params, _| {
let server = server_clone.clone();
async move {
let (public_key, signature, nonce): (String, String, String) = params.parse().map_err(|_| ErrorCode::InvalidParams)?;
server.authenticate(public_key, signature, nonce).await
}
})?;
let server_clone = self.clone();
module.register_async_method("whoami", move |_params, _| {
let server = server_clone.clone();
async move {
server.whoami().await
}
})?;
let server_clone = self.clone();
module.register_async_method("play", move |params, _| {
let server = server_clone.clone();
async move {
let script: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.play(script).await
}
})?;
let server_clone = self.clone();
module.register_async_method("create_job", move |params, _| {
let server = server_clone.clone();
async move {
let job: JobParams = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.create_job(job).await
}
})?;
let server_clone = self.clone();
module.register_async_method("start_job", move |params, _| {
let server = server_clone.clone();
async move {
let job_id: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.start_job(job_id).await
}
})?;
let server_clone = self.clone();
module.register_async_method("run_job", move |params, _| {
let server = server_clone.clone();
async move {
let (script, script_type, prerequisites): (String, ScriptType, Option<Vec<String>>) = params.parse().map_err(|_| ErrorCode::InvalidParams)?;
server.run_job(script, script_type, prerequisites).await
}
})?;
let server_clone = self.clone();
module.register_async_method("get_job_status", move |params, _| {
let server = server_clone.clone();
async move {
let job_id: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.get_job_status(job_id).await
}
})?;
let server_clone = self.clone();
module.register_async_method("get_job_output", move |params, _| {
let server = server_clone.clone();
async move {
let job_id: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.get_job_output(job_id).await
}
})?;
let server_clone = self.clone();
module.register_async_method("get_job_logs", move |params, _| {
let server = server_clone.clone();
async move {
let job_id: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.get_job_logs(job_id).await
}
})?;
let server_clone = self.clone();
module.register_async_method("list_jobs", move |_params, _| {
let server = server_clone.clone();
async move {
// No parameters expected; ignore any provided params for robustness
server.list_jobs().await
}
})?;
let server_clone = self.clone();
module.register_async_method("stop_job", move |params, _| {
let server = server_clone.clone();
async move {
let job_id: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.stop_job(job_id).await
}
})?;
let server_clone = self.clone();
module.register_async_method("delete_job", move |params, _| {
let server = server_clone.clone();
async move {
let job_id: String = params.one().map_err(|_| ErrorCode::InvalidParams)?;
server.delete_job(job_id).await
}
})?;
let server_clone = self.clone();
module.register_async_method("clear_all_jobs", move |params, _| {
let server = server_clone.clone();
async move {
let _: () = params.parse().map_err(|_| ErrorCode::InvalidParams)?;
server.clear_all_jobs().await
}
})?;
let server = ServerBuilder::default()
.build(addr)
.await?;
let handle = server.start(module);
Ok(handle)
}
/// Start the OpenRPC server (config wrapper)
pub async fn start(self, config: OpenRpcServerConfig) -> Result<ServerHandle> {
match config.transport {
Transport::WebSocket(addr) => self.start_on(addr).await,
}
}
}
#[async_trait]
impl OpenRpcApiServer for OpenRpcServer {
async fn fetch_nonce(&self, public_key: String) -> Result<String, ErrorCode> {
let mut auth_manager = self.auth_manager.write().await;
let nonce = auth_manager.generate_nonce(&public_key);
Ok(nonce)
}
async fn authenticate(
&self,
public_key: String,
signature: String,
_nonce: String,
) -> Result<bool, ErrorCode> {
let mut auth_manager = self.auth_manager.write().await;
match auth_manager.verify_signature(&public_key, &signature) {
Ok(is_valid) => Ok(is_valid),
Err(e) => {
error!("Authentication error: {}", e);
Ok(false)
}
}
}
async fn whoami(&self) -> Result<String, ErrorCode> {
let _auth_manager = self.auth_manager.read().await;
// For now, return basic info - in a real implementation,
// you'd track authenticated sessions
Ok(serde_json::json!({
"authenticated": true,
"user_id": "anonymous"
}).to_string())
}
async fn play(&self, script: String) -> Result<PlayResult, ErrorCode> {
let output = self.run_job(script, ScriptType::SAL, None).await?;
Ok(PlayResult { output })
}
async fn create_job(&self, job_params: JobParams) -> Result<String, ErrorCode> {
let supervisor = self.supervisor.read().await;
// Use JobBuilder to create a Job instance
let mut builder = hero_job::JobBuilder::new()
.caller_id(&job_params.caller_id)
.context_id(&job_params.context_id)
.script(&job_params.script)
.script_type(job_params.script_type);
// Set timeout if provided
if let Some(timeout_secs) = job_params.timeout {
builder = builder.timeout(std::time::Duration::from_secs(timeout_secs));
}
// Set prerequisites if provided
if let Some(prerequisites) = job_params.prerequisites {
builder = builder.prerequisites(prerequisites);
}
// Build the job
let job = match builder.build() {
Ok(job) => job,
Err(e) => {
error!("Failed to build job: {}", e);
return Err(ErrorCode::InvalidParams);
}
};
let job_id = job.id.clone();
// Create the job using the supervisor
match supervisor.create_job(&job).await {
Ok(_) => Ok(job_id),
Err(e) => {
error!("Failed to create job: {}", e);
Err(ErrorCode::InternalError)
}
}
}
async fn start_job(&self, job_id: String) -> Result<StartJobResult, ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.start_job(&job_id).await {
Ok(_) => Ok(StartJobResult { success: true }),
Err(e) => {
error!("Failed to start job {}: {}", job_id, e);
Ok(StartJobResult { success: false })
}
}
}
async fn run_job(
&self,
script: String,
script_type: ScriptType,
prerequisites: Option<Vec<String>>,
) -> Result<String, ErrorCode> {
let supervisor = self.supervisor.read().await;
// Build job with defaults and optional prerequisites
let mut builder = JobBuilder::new()
.caller_id("rpc-caller")
.context_id("rpc-context")
.script(&script)
.script_type(script_type)
.timeout(std::time::Duration::from_secs(30));
if let Some(prs) = prerequisites {
builder = builder.prerequisites(prs);
}
let job = match builder.build() {
Ok(j) => j,
Err(e) => {
error!("Failed to build job in run_job: {}", e);
return Err(ErrorCode::InvalidParams);
}
};
match supervisor.run_job_and_await_result(&job).await {
Ok(output) => Ok(output),
Err(e) => {
error!("run_job failed: {}", e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn get_job_status(&self, job_id: String) -> Result<JobStatus, ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.get_job_status(&job_id).await {
Ok(status) => Ok(status),
Err(e) => {
error!("Failed to get job status for {}: {}", job_id, e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn get_job_output(&self, job_id: String) -> Result<String, ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.get_job_output(&job_id).await {
Ok(output) => Ok(output.unwrap_or_else(|| "No output available".to_string())),
Err(e) => {
error!("Failed to get job output for {}: {}", job_id, e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn get_job_logs(&self, job_id: String) -> Result<JobLogsResult, ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.get_job_logs(&job_id).await {
Ok(logs_opt) => Ok(JobLogsResult { logs: logs_opt }),
Err(e) => {
error!("Failed to get job logs for {}: {}", job_id, e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn list_jobs(&self) -> Result<Vec<String>, ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.list_jobs().await {
Ok(job_ids) => Ok(job_ids),
Err(e) => {
error!("Failed to list jobs: {}", e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn stop_job(&self, job_id: String) -> Result<(), ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.stop_job(&job_id).await {
Ok(_) => Ok(()),
Err(e) => {
error!("Failed to stop job {}: {}", job_id, e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn delete_job(&self, job_id: String) -> Result<(), ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.delete_job(&job_id).await {
Ok(_) => Ok(()),
Err(e) => {
error!("Failed to delete job {}: {}", job_id, e);
Err(map_sup_error_to_rpc(&e))
}
}
}
async fn clear_all_jobs(&self) -> Result<(), ErrorCode> {
let supervisor = self.supervisor.read().await;
match supervisor.clear_all_jobs().await {
Ok(_) => Ok(()),
Err(e) => {
error!("Failed to clear all jobs: {}", e);
Err(map_sup_error_to_rpc(&e))
}
}
}
}

View File

@ -0,0 +1,31 @@
use hero_job::ScriptType;
use serde::{Deserialize, Serialize};
/// Parameters for creating a job
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobParams {
pub script: String,
pub script_type: ScriptType,
pub caller_id: String,
pub context_id: String,
pub timeout: Option<u64>, // timeout in seconds
pub prerequisites: Option<Vec<String>>,
}
/// Result of script execution
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayResult {
pub output: String,
}
/// Result of starting a job
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StartJobResult {
pub success: bool,
}
/** Result of getting job logs */
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobLogsResult {
pub logs: Option<String>,
}

View File

@ -0,0 +1,412 @@
use hero_openrpc_server::{OpenRpcServer, OpenRpcServerConfig, OpenRpcApiServer, Transport, types::*};
use hero_supervisor::{Supervisor, SupervisorBuilder};
use hero_job::{JobBuilder, JobStatus, ScriptType};
use jsonrpsee_types::error::ErrorCode;
use std::sync::Arc;
use tokio::sync::RwLock;
use std::time::Duration;
/// Helper function to create a test supervisor
async fn create_test_supervisor() -> Arc<RwLock<Supervisor>> {
let supervisor = SupervisorBuilder::new()
.redis_url("redis://localhost:6379")
.build()
.await
.expect("Failed to create test supervisor");
Arc::new(RwLock::new(supervisor))
}
/// Helper function to create a test OpenRPC server
async fn create_test_server() -> OpenRpcServer {
use std::net::SocketAddr;
use std::path::PathBuf;
let config = OpenRpcServerConfig {
transport: Transport::WebSocket("127.0.0.1:0".parse::<SocketAddr>().unwrap()),
supervisor_config_path: None,
db_path: PathBuf::from("/tmp/test_openrpc.db"),
};
OpenRpcServer::new(config).await.expect("Failed to create OpenRPC server")
}
#[tokio::test]
async fn test_fetch_nonce() {
let server = create_test_server().await;
let public_key = "test_public_key".to_string();
let result = server.fetch_nonce(public_key).await;
assert!(result.is_ok());
let nonce = result.unwrap();
assert!(!nonce.is_empty());
assert_eq!(nonce.len(), 64); // Should be a 32-byte hex string
}
#[tokio::test]
async fn test_create_job_success() {
let server = create_test_server().await;
let job_params = JobParams {
script: "print('Hello, World!');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let result = server.create_job(job_params).await;
assert!(result.is_ok());
let job_id = result.unwrap();
assert!(!job_id.is_empty());
// Job ID should be a valid UUID format
assert!(uuid::Uuid::parse_str(&job_id).is_ok());
}
#[tokio::test]
async fn test_create_job_with_prerequisites() {
let server = create_test_server().await;
let job_params = JobParams {
script: "print('Job with prerequisites');".to_string(),
script_type: ScriptType::SAL,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(120),
prerequisites: Some(vec!["prereq_job_1".to_string(), "prereq_job_2".to_string()]),
};
let result = server.create_job(job_params).await;
assert!(result.is_ok());
let job_id = result.unwrap();
assert!(!job_id.is_empty());
}
#[tokio::test]
async fn test_create_job_invalid_params() {
let server = create_test_server().await;
// Test with empty caller_id (should fail JobBuilder validation)
let job_params = JobParams {
script: "print('Test');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "".to_string(), // Empty caller_id should fail
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let result = server.create_job(job_params).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ErrorCode::InvalidParams);
}
#[tokio::test]
async fn test_start_job() {
let server = create_test_server().await;
// First create a job
let job_params = JobParams {
script: "print('Test job');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let job_id = server.create_job(job_params).await.unwrap();
// Then start the job
let result = server.start_job(job_id).await;
assert!(result.is_ok());
let start_result = result.unwrap();
assert!(start_result.success);
}
#[tokio::test]
async fn test_get_job_status() {
let server = create_test_server().await;
// First create a job
let job_params = JobParams {
script: "print('Status test');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let job_id = server.create_job(job_params).await.unwrap();
// Get job status
let result = server.get_job_status(job_id).await;
assert!(result.is_ok());
let status = result.unwrap();
// Status should be one of the valid JobStatus variants
match status {
JobStatus::Dispatched | JobStatus::WaitingForPrerequisites |
JobStatus::Started | JobStatus::Error | JobStatus::Finished => {
// Valid status
}
}
}
#[tokio::test]
async fn test_get_job_output() {
let server = create_test_server().await;
// First create a job
let job_params = JobParams {
script: "print('Output test');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let job_id = server.create_job(job_params).await.unwrap();
// Get job output
let result = server.get_job_output(job_id).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.is_empty());
}
#[tokio::test]
async fn test_list_jobs() {
let server = create_test_server().await;
// Create a few jobs first
for i in 0..3 {
let job_params = JobParams {
script: format!("print('Job {}');", i),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let _ = server.create_job(job_params).await.unwrap();
}
// List all jobs
let result = server.list_jobs().await;
assert!(result.is_ok());
let job_ids = result.unwrap();
assert!(job_ids.len() >= 3); // Should have at least the 3 jobs we created
// Verify job IDs are valid UUIDs
for id in job_ids {
assert!(!id.is_empty());
assert!(uuid::Uuid::parse_str(&id).is_ok());
}
}
#[tokio::test]
async fn test_stop_job() {
let server = create_test_server().await;
// First create and start a job
let job_params = JobParams {
script: "print('Stop test');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let job_id = server.create_job(job_params).await.unwrap();
let _ = server.start_job(job_id.clone()).await.unwrap();
// Stop the job
let result = server.stop_job(job_id).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_delete_job() {
let server = create_test_server().await;
// First create a job
let job_params = JobParams {
script: "print('Delete test');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let job_id = server.create_job(job_params).await.unwrap();
// Delete the job
let result = server.delete_job(job_id).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_clear_all_jobs() {
let server = create_test_server().await;
// Create a few jobs first
for i in 0..3 {
let job_params = JobParams {
script: format!("print('Clear test {}');", i),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let _ = server.create_job(job_params).await.unwrap();
}
// Clear all jobs
let result = server.clear_all_jobs().await;
assert!(result.is_ok());
// Verify jobs are cleared
let jobs = server.list_jobs().await.unwrap();
assert_eq!(jobs.len(), 0);
}
#[tokio::test]
async fn test_run_job() {
let server = create_test_server().await;
let script = "print('Run job test');".to_string();
let script_type = ScriptType::OSIS;
let prerequisites = None;
let result = server.run_job(script, script_type, prerequisites).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.is_empty());
assert!(output.contains("Run job test"));
}
#[tokio::test]
async fn test_play_script() {
let server = create_test_server().await;
let script = "print('Play script test');".to_string();
let result = server.play(script.clone()).await;
assert!(result.is_ok());
let play_result = result.unwrap();
assert!(!play_result.output.is_empty());
assert!(play_result.output.contains(&script));
}
#[tokio::test]
async fn test_get_job_logs() {
let server = create_test_server().await;
// First create a job
let job_params = JobParams {
script: "print('Logs test');".to_string(),
script_type: ScriptType::OSIS,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(60),
prerequisites: None,
};
let job_id = server.create_job(job_params).await.unwrap();
// Get job logs
let result = server.get_job_logs(job_id).await;
assert!(result.is_ok());
let logs_result = result.unwrap();
match logs_result.logs {
Some(ref logs) => assert!(!logs.is_empty()),
None => {} // acceptable when no logs are available
}
}
#[tokio::test]
async fn test_job_builder_integration() {
// Test that JobBuilder is working correctly with all the fields
let job_params = JobParams {
script: "print('JobBuilder test');".to_string(),
script_type: ScriptType::V,
caller_id: "test_caller".to_string(),
context_id: "test_context".to_string(),
timeout: Some(300),
prerequisites: Some(vec!["prereq1".to_string(), "prereq2".to_string()]),
};
// Build job using JobBuilder (similar to what the server does)
let mut builder = JobBuilder::new()
.caller_id(&job_params.caller_id)
.context_id(&job_params.context_id)
.script(&job_params.script)
.script_type(job_params.script_type);
if let Some(timeout_secs) = job_params.timeout {
builder = builder.timeout(Duration::from_secs(timeout_secs));
}
if let Some(prerequisites) = job_params.prerequisites {
builder = builder.prerequisites(prerequisites);
}
let job = builder.build();
assert!(job.is_ok());
let job = job.unwrap();
assert_eq!(job.caller_id, "test_caller");
assert_eq!(job.context_id, "test_context");
assert_eq!(job.script, "print('JobBuilder test');");
assert_eq!(job.script_type, ScriptType::V);
assert_eq!(job.timeout, Duration::from_secs(300));
assert_eq!(job.prerequisites, vec!["prereq1".to_string(), "prereq2".to_string()]);
}
#[tokio::test]
async fn test_error_handling() {
let server = create_test_server().await;
// Test getting status for non-existent job
let result = server.get_job_status("non_existent_job".to_string()).await;
// Should return an error or handle gracefully
match result {
Ok(_) => {
// Some implementations might return a default status
},
Err(error_code) => {
assert_eq!(error_code, ErrorCode::InvalidParams);
}
}
// Test getting output for non-existent job
let result = server.get_job_output("non_existent_job".to_string()).await;
match result {
Ok(output) => {
// Should return "No output available" or similar
assert!(output.contains("No output available") || output.is_empty());
},
Err(error_code) => {
assert_eq!(error_code, ErrorCode::InvalidParams);
}
}
}

View File

@ -1,6 +1,18 @@
[package] [package]
name = "hero-client-unix" name = "hero-client-unix"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
# JSON-RPC async client and params types
jsonrpsee = { version = "0.21", features = ["macros", "async-client"] }
jsonrpsee-types = "0.21"
# IPC transport
reth-ipc = { git = "https://github.com/paradigmxyz/reth", package = "reth-ipc" }

View File

@ -1,3 +1,124 @@
fn main() { use std::path::PathBuf;
println!("Hello, world!");
use anyhow::Result;
use clap::Parser;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::rpc_params;
use reth_ipc::client::IpcClientBuilder;
use serde_json::Value;
use tracing_subscriber::EnvFilter;
/// Simple IPC (Unix socket) JSON-RPC client for manual testing.
///
/// Examples:
/// - Call method without params:
/// hero-client-unix --socket /tmp/baobab.ipc --method whoami
///
/// - Call method with positional params (as JSON array):
/// hero-client-unix --socket /tmp/baobab.ipc --method authenticate --params '["pubkey","signature","nonce"]'
///
/// - Call method with single object param:
/// hero-client-unix --socket /tmp/baobab.ipc --method create_job --params '{"job_id":"abc"}'
#[derive(Parser, Debug)]
#[command(name = "hero-client-unix", version, about = "IPC JSON-RPC client")]
struct Args {
/// Filesystem path to the Unix domain socket
#[arg(long, default_value = "/tmp/baobab.ipc", env = "HERO_IPC_SOCKET")]
socket: PathBuf,
/// JSON-RPC method name to call
#[arg(long)]
method: String,
/// JSON string for params. Either an array for positional params or an object for named params.
/// Defaults to [] (no params).
#[arg(long, default_value = "[]")]
params: String,
/// Log filter (e.g., info, debug, trace)
#[arg(long, default_value = "info", env = "RUST_LOG")]
log: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(EnvFilter::new(args.log.clone()))
.try_init()
.expect("setting default subscriber failed");
let socket_str = args.socket.to_string_lossy().to_string();
let client = IpcClientBuilder::default().build(&socket_str).await?;
let params_value: Value = serde_json::from_str(&args.params)?;
// We deserialize responses to serde_json::Value for generality.
// You can set a concrete type instead if needed.
let result: Value = match params_value {
Value::Array(arr) => match arr.len() {
0 => client.request(&args.method, rpc_params![]).await?,
1 => client.request(&args.method, rpc_params![arr[0].clone()]).await?,
2 => client.request(&args.method, rpc_params![arr[0].clone(), arr[1].clone()]).await?,
3 => client
.request(&args.method, rpc_params![arr[0].clone(), arr[1].clone(), arr[2].clone()])
.await?,
4 => client
.request(
&args.method,
rpc_params![arr[0].clone(), arr[1].clone(), arr[2].clone(), arr[3].clone()],
)
.await?,
5 => client
.request(
&args.method,
rpc_params![
arr[0].clone(),
arr[1].clone(),
arr[2].clone(),
arr[3].clone(),
arr[4].clone()
],
)
.await?,
6 => client
.request(
&args.method,
rpc_params![
arr[0].clone(),
arr[1].clone(),
arr[2].clone(),
arr[3].clone(),
arr[4].clone(),
arr[5].clone()
],
)
.await?,
7 => client
.request(
&args.method,
rpc_params![
arr[0].clone(),
arr[1].clone(),
arr[2].clone(),
arr[3].clone(),
arr[4].clone(),
arr[5].clone(),
arr[6].clone()
],
)
.await?,
_ => {
// Fallback: send entire array as a single param to avoid combinatorial explosion.
// Adjust if your server expects strictly positional expansion beyond 7 items.
client.request(&args.method, rpc_params![Value::Array(arr)]).await?
}
},
// Single non-array param (object, string, number, etc.)
other => client.request(&args.method, rpc_params![other]).await?,
};
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
} }

View File

@ -1,6 +1,14 @@
[package] [package]
name = "hero-server-unix" name = "hero-server-unix"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
# Reuse the OpenRPC server crate that registers all methods and now supports IPC
hero-openrpc-server = { path = "../../openrpc/server" }

View File

@ -1,3 +1,64 @@
fn main() { use std::path::PathBuf;
println!("Hello, world!");
use clap::Parser;
use tracing_subscriber::EnvFilter;
use hero_openrpc_server::{OpenRpcServer, OpenRpcServerConfig, Transport};
/// IPC (Unix socket) JSON-RPC server launcher.
///
/// This binary starts the OpenRPC server over a Unix domain socket using the reth-ipc transport.
#[derive(Parser, Debug)]
#[command(name = "hero-server-unix", version, about = "Start the JSON-RPC IPC server")]
struct Args {
/// Filesystem path to the Unix domain socket
#[arg(long, default_value = "/tmp/baobab.ipc", env = "HERO_IPC_SOCKET")]
socket_path: PathBuf,
/// Optional path to a supervisor configuration file
#[arg(long)]
supervisor_config: Option<PathBuf>,
/// Database path (reserved for future use)
#[arg(long, default_value = "./db", env = "HERO_DB_PATH")]
db_path: PathBuf,
/// Log filter (e.g., info, debug, trace)
#[arg(long, default_value = "info", env = "RUST_LOG")]
log: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
// Initialize tracing with provided log filter
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(EnvFilter::new(args.log.clone()))
.try_init()
.expect("setting default subscriber failed");
let cfg = OpenRpcServerConfig {
transport: Transport::Unix(args.socket_path.clone()),
supervisor_config_path: args.supervisor_config.clone(),
db_path: args.db_path.clone(),
};
// Build server state
let server = OpenRpcServer::new(cfg.clone()).await?;
// Start IPC server
let handle = server.start(cfg).await?;
tracing::info!(
"IPC server started on {} (press Ctrl+C to stop)",
args.socket_path.display()
);
// Run until stopped
tokio::spawn(handle.stopped());
tokio::signal::ctrl_c().await?;
tracing::info!("Shutting down IPC server");
Ok(())
} }

View File

@ -150,7 +150,6 @@ async fn main() -> std::io::Result<()> {
} }
println!(" Authentication: {}", if config.auth { "ENABLED" } else { "DISABLED" }); println!(" Authentication: {}", if config.auth { "ENABLED" } else { "DISABLED" });
println!(" TLS/WSS: {}", if config.tls { "ENABLED" } else { "DISABLED" }); println!(" TLS/WSS: {}", if config.tls { "ENABLED" } else { "DISABLED" });
println!(" Webhooks: {}", if config.webhooks { "ENABLED" } else { "DISABLED" });
println!(" Circles configured: {}", config.circles.len()); println!(" Circles configured: {}", config.circles.len());
if config.tls { if config.tls {
@ -160,12 +159,6 @@ async fn main() -> std::io::Result<()> {
} }
} }
if config.webhooks {
println!(" Webhook secrets loaded from environment variables:");
println!(" - STRIPE_WEBHOOK_SECRET");
println!(" - IDENFY_WEBHOOK_SECRET");
}
if config.auth && !config.circles.is_empty() { if config.auth && !config.circles.is_empty() {
println!(" Configured circles:"); println!(" Configured circles:");
for (circle_name, members) in &config.circles { for (circle_name, members) in &config.circles {

View File

@ -0,0 +1,127 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use std::time::Duration;
use futures::{Stream, StreamExt};
use jsonrpsee::core::DeserializeOwned;
use jsonrpsee::core::client::{Subscription, SubscriptionClientT};
use jsonrpsee::rpc_params;
use jsonrpsee::server::{RpcModule, Server};
use jsonrpsee::ws_client::WsClientBuilder;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let client = WsClientBuilder::default().build(&url).await?;
let sub: Subscription<i32> = client.subscribe("subscribe_hello", rpc_params![], "unsubscribe_hello").await?;
// drop oldest messages from subscription:
let mut sub = drop_oldest_when_lagging(sub, 10);
// Simulate that polling takes a long time.
tokio::time::sleep(Duration::from_secs(1)).await;
// The subscription starts from zero but you can
// notice that many items have been replaced
// because the subscription wasn't polled.
for _ in 0..10 {
match sub.next().await.unwrap() {
Ok(n) => {
tracing::info!("recv={n}");
}
Err(e) => {
tracing::info!("{e}");
}
};
}
Ok(())
}
fn drop_oldest_when_lagging<T: Clone + DeserializeOwned + Send + Sync + 'static>(
mut sub: Subscription<T>,
buffer_size: usize,
) -> impl Stream<Item = Result<T, BroadcastStreamRecvError>> {
let (tx, rx) = tokio::sync::broadcast::channel(buffer_size);
tokio::spawn(async move {
// Poll the subscription which ignores errors.
while let Some(n) = sub.next().await {
let msg = match n {
Ok(msg) => msg,
Err(e) => {
tracing::error!("Failed to decode the subscription message: {e}");
continue;
}
};
if tx.send(msg).is_err() {
return;
}
}
});
BroadcastStream::new(rx)
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module
.register_subscription("subscribe_hello", "s_hello", "unsubscribe_hello", |_, pending, _, _| async move {
let sub = pending.accept().await.unwrap();
for i in 0..usize::MAX {
let json = serde_json::value::to_raw_value(&i).unwrap();
sub.send(json).await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
}
Ok(())
})
.unwrap();
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,65 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::client_transport::ws::{Url, WsTransportClientBuilder};
use jsonrpsee::core::client::{ClientBuilder, ClientT};
use jsonrpsee::rpc_params;
use jsonrpsee::server::{RpcModule, Server};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let uri = Url::parse(&format!("ws://{}", addr))?;
let (tx, rx) = WsTransportClientBuilder::default().build(uri).await?;
let client = ClientBuilder::default().build_with_tokio(tx, rx);
let response: String = client.request("say_hello", rpc_params![]).await?;
tracing::info!("response: {:?}", response);
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,104 @@
// Copyright 2019-2022 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! This example adds upstream CORS layers to the RPC service,
//! with access control allowing requests from all hosts.
use hyper::Method;
use jsonrpsee::server::{RpcModule, Server};
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
// Start up a JSON-RPC server that allows cross origin requests.
let server_addr = run_server().await?;
// Print instructions for testing CORS from a browser.
println!("Run the following snippet in the developer console in any Website.");
println!(
r#"
fetch("http://{}", {{
method: 'POST',
mode: 'cors',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{
jsonrpc: '2.0',
method: 'say_hello',
id: 1
}})
}}).then(res => {{
console.log("Response:", res);
return res.text()
}}).then(body => {{
console.log("Response Body:", body)
}});
"#,
server_addr
);
futures::future::pending().await
}
async fn run_server() -> anyhow::Result<SocketAddr> {
// Add a CORS middleware for handling HTTP requests.
// This middleware does affect the response, including appropriate
// headers to satisfy CORS. Because any origins are allowed, the
// "Access-Control-Allow-Origin: *" header is appended to the response.
let cors = CorsLayer::new()
// Allow `POST` when accessing the resource
.allow_methods([Method::POST])
// Allow requests from any origin
.allow_origin(Any)
.allow_headers([hyper::header::CONTENT_TYPE]);
let middleware = tower::ServiceBuilder::new().layer(cors);
// The RPC exposes the access control for filtering and the middleware for
// modifying requests / responses. These features are independent of one another
// and can also be used separately.
// In this example, we use both features.
let server = Server::builder().set_http_middleware(middleware).build("127.0.0.1:0".parse::<SocketAddr>()?).await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| {
println!("say_hello method called!");
"Hello there!!"
})?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,83 @@
// Copyright 2019-2022 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! This example shows how to configure `host filtering` by tower middleware on the jsonrpsee server.
//!
//! The server whitelist's only `example.com` and any call from localhost will be
//! rejected both by HTTP and WebSocket transports.
use std::net::SocketAddr;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::http_client::HttpClient;
use jsonrpsee::rpc_params;
use jsonrpsee::server::middleware::http::HostFilterLayer;
use jsonrpsee::server::{RpcModule, Server};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("http://{}", addr);
// Use RPC client to get the response of `say_hello` method.
let client = HttpClient::builder().build(&url)?;
// This call will be denied because only `example.com` URIs/hosts are allowed by the host filter.
let response = client.request::<String, _>("say_hello", rpc_params![]).await.unwrap_err();
println!("[main]: response: {}", response);
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
// Custom tower service to handle the RPC requests
let service_builder = tower::ServiceBuilder::new()
// For this example we only want to permit requests from `example.com`
// all other request are denied.
//
// `HostFilerLayer::new` only fails on invalid URIs..
.layer(HostFilterLayer::new(["example.com"]).unwrap());
let server =
Server::builder().set_http_middleware(service_builder).build("127.0.0.1:0".parse::<SocketAddr>()?).await?;
let addr = server.local_addr()?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo").unwrap();
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,65 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::http_client::HttpClient;
use jsonrpsee::rpc_params;
use jsonrpsee::server::{RpcModule, Server};
use tracing_subscriber::util::SubscriberInitExt;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()?
.add_directive("jsonrpsee[method_call{name = \"say_hello\"}]=trace".parse()?);
tracing_subscriber::FmtSubscriber::builder().with_env_filter(filter).finish().try_init()?;
let server_addr = run_server().await?;
let url = format!("http://{}", server_addr);
let client = HttpClient::builder().build(url)?;
let params = rpc_params![1_u64, 2, 3];
let response: Result<String, _> = client.request("say_hello", params).await;
tracing::info!("r: {:?}", response);
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0".parse::<SocketAddr>()?).await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,129 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! jsonrpsee supports two kinds of middlewares `http_middleware` and `rpc_middleware`.
//!
//! This example demonstrates how to use the `http_middleware` which applies for each
//! HTTP request.
//!
//! A typical use-case for this it to apply a specific CORS policy which applies both
//! for HTTP and WebSocket.
//!
use hyper::Method;
use hyper::body::Bytes;
use hyper::http::HeaderValue;
use jsonrpsee::rpc_params;
use std::iter::once;
use std::net::SocketAddr;
use std::time::Duration;
use tower_http::LatencyUnit;
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
use tower_http::sensitive_headers::SetSensitiveRequestHeadersLayer;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use jsonrpsee::core::client::ClientT;
use jsonrpsee::http_client::HttpClient;
use jsonrpsee::server::{RpcModule, Server};
use jsonrpsee::ws_client::WsClientBuilder;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
// WebSocket.
{
let client = WsClientBuilder::default().build(format!("ws://{}", addr)).await?;
let response: String = client.request("say_hello", rpc_params![]).await?;
println!("[main]: ws response: {:?}", response);
let _response: Result<String, _> = client.request("unknown_method", rpc_params![]).await;
let _ = client.request::<String, _>("say_hello", rpc_params![]).await?;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// HTTP.
{
let client = HttpClient::builder().build(format!("http://{}", addr))?;
let response: String = client.request("say_hello", rpc_params![]).await?;
println!("[main]: http response: {:?}", response);
let _response: Result<String, _> = client.request("unknown_method", rpc_params![]).await;
let _ = client.request::<String, _>("say_hello", rpc_params![]).await?;
}
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let cors = CorsLayer::new()
// Allow `POST` when accessing the resource
.allow_methods([Method::POST])
// Allow requests from any origin
.allow_origin(HeaderValue::from_str("http://example.com").unwrap())
.allow_headers([hyper::header::CONTENT_TYPE]);
// Custom tower service to handle the RPC requests
let service_builder = tower::ServiceBuilder::new()
// Add high level tracing/logging to all requests
.layer(
TraceLayer::new_for_http()
.on_request(
|request: &hyper::Request<_>, _span: &tracing::Span| tracing::info!(request = ?request, "on_request"),
)
.on_body_chunk(|chunk: &Bytes, latency: Duration, _: &tracing::Span| {
tracing::info!(size_bytes = chunk.len(), latency = ?latency, "sending body chunk")
})
.make_span_with(DefaultMakeSpan::new().include_headers(true))
.on_response(DefaultOnResponse::new().include_headers(true).latency_unit(LatencyUnit::Micros)),
)
// Mark the `Authorization` request header as sensitive so it doesn't show in logs
.layer(SetSensitiveRequestHeadersLayer::new(once(hyper::header::AUTHORIZATION)))
.layer(cors)
.layer(CompressionLayer::new())
.timeout(Duration::from_secs(2));
let server =
Server::builder().set_http_middleware(service_builder).build("127.0.0.1:0".parse::<SocketAddr>()?).await?;
let addr = server.local_addr()?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo").unwrap();
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,109 @@
// Copyright 2019-2022 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! This example utilizes the `ProxyRequest` layer for redirecting
//! `GET /path` requests to internal RPC methods.
//!
//! The RPC server registers a method named `system_health` which
//! returns `serde_json::Value`. Redirect any `GET /health`
//! requests to the internal method, and return only the method's
//! response in the body (ie, without any jsonRPC 2.0 overhead).
//!
//! # Note
//!
//! This functionality is useful for services which would
//! like to query a certain `URI` path for statistics.
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use std::net::SocketAddr;
use std::time::Duration;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::http_client::HttpClient;
use jsonrpsee::rpc_params;
use jsonrpsee::server::middleware::http::ProxyGetRequestLayer;
use jsonrpsee::server::{RpcModule, Server};
type EmptyBody = http_body_util::Empty<hyper::body::Bytes>;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("http://{}", addr);
// Use RPC client to get the response of `say_hello` method.
let client = HttpClient::builder().build(&url)?;
let response: String = client.request("say_hello", rpc_params![]).await?;
println!("[main]: response: {:?}", response);
// Use hyper client to manually submit a `GET /health` request.
let http_client = Client::builder(TokioExecutor::new()).build_http();
let uri = format!("http://{}/health", addr);
let req = hyper::Request::builder().method("GET").uri(&uri).body(EmptyBody::new())?;
println!("[main]: Submit proxy request: {:?}", req);
let res = http_client.request(req).await?;
println!("[main]: Received proxy response: {:?}", res);
// Interpret the response as String.
let collected = http_body_util::BodyExt::collect(res.into_body()).await?;
let out = String::from_utf8(collected.to_bytes().to_vec()).unwrap();
println!("[main]: Interpret proxy response: {:?}", out);
assert_eq!(out.as_str(), "{\"health\":true}");
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
// Custom tower service to handle the RPC requests
let service_builder = tower::ServiceBuilder::new()
// Proxy `GET /health` requests to internal `system_health` method.
.layer(ProxyGetRequestLayer::new([("/health", "system_health")])?)
.timeout(Duration::from_secs(2));
let server =
Server::builder().set_http_middleware(service_builder).build("127.0.0.1:0".parse::<SocketAddr>()?).await?;
let addr = server.local_addr()?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo").unwrap();
module.register_method("system_health", |_, _, _| serde_json::json!({ "health": true })).unwrap();
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,380 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! This example shows how to use the `jsonrpsee::server` as
//! a tower service such that it's possible to get access
//! HTTP related things by launching a `hyper::service_fn`.
//!
//! The typical use-case for this is when one wants to have
//! access to HTTP related things.
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::FutureExt;
use hyper::HeaderMap;
use hyper::header::AUTHORIZATION;
use jsonrpsee::core::async_trait;
use jsonrpsee::core::middleware::{Batch, BatchEntry, BatchEntryErr, Notification, RpcServiceBuilder, RpcServiceT};
use jsonrpsee::http_client::HttpClient;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::middleware::http::{HostFilterLayer, ProxyGetRequestLayer};
use jsonrpsee::server::{
ServerConfig, ServerHandle, StopHandle, TowerServiceBuilder, serve_with_graceful_shutdown, stop_channel,
};
use jsonrpsee::types::{ErrorObject, ErrorObjectOwned, Request};
use jsonrpsee::ws_client::{HeaderValue, WsClientBuilder};
use jsonrpsee::{MethodResponse, Methods};
use tokio::net::TcpListener;
use tower::Service;
use tower_http::cors::CorsLayer;
use tracing_subscriber::util::SubscriberInitExt;
#[derive(Clone)]
struct IdentityLayer;
impl<S> tower::Layer<S> for IdentityLayer
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type Service = Identity<S>;
fn layer(&self, inner: S) -> Self::Service {
Identity(inner)
}
}
#[derive(Clone)]
struct Identity<S>(S);
impl<S> RpcServiceT for Identity<S>
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type BatchResponse = S::BatchResponse;
type NotificationResponse = S::NotificationResponse;
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
self.0.batch(batch)
}
fn call<'a>(&self, request: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
self.0.call(request)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.0.notification(n)
}
}
#[derive(Default, Clone, Debug)]
struct Metrics {
opened_ws_connections: Arc<AtomicUsize>,
closed_ws_connections: Arc<AtomicUsize>,
http_calls: Arc<AtomicUsize>,
success_http_calls: Arc<AtomicUsize>,
}
fn auth_reject_error() -> ErrorObjectOwned {
ErrorObject::owned(-32999, "HTTP Authorization header is missing", None::<()>)
}
#[derive(Clone)]
struct AuthorizationMiddleware<S> {
headers: HeaderMap,
inner: S,
#[allow(unused)]
transport_label: &'static str,
}
impl<S> AuthorizationMiddleware<S> {
/// Authorize the request by checking the `Authorization` header.
///
///
/// In this example for simplicity, the authorization value is not checked
// and used because it's just a toy example.
fn auth_method_call(&self, req: &Request<'_>) -> bool {
if req.method_name() == "trusted_call" {
let Some(Ok(_)) = self.headers.get(AUTHORIZATION).map(|auth| auth.to_str()) else { return false };
}
true
}
/// Authorize the notification by checking the `Authorization` header.
///
/// Because notifications are not expected to return a response, we
/// return a `MethodResponse` by injecting an error into the extensions
/// which could be read by other middleware or the server.
fn auth_notif(&self, notif: &Notification<'_>) -> bool {
if notif.method_name() == "trusted_call" {
let Some(Ok(_)) = self.headers.get(AUTHORIZATION).map(|auth| auth.to_str()) else { return false };
}
true
}
}
impl<S> RpcServiceT for AuthorizationMiddleware<S>
where
// We need to specify the concrete types here because otherwise we return an error or specific response
// in the middleware implementation.
S: RpcServiceT<MethodResponse = MethodResponse, BatchResponse = MethodResponse> + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type BatchResponse = S::BatchResponse;
type NotificationResponse = S::NotificationResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
let this = self.clone();
let auth_ok = this.auth_method_call(&req);
async move {
// If the authorization header is missing, it's recommended to
// to return the response as MethodResponse::error instead of
// returning an error from the service.
//
// This way the error is returned as a JSON-RPC error
if !auth_ok {
return MethodResponse::error(req.id, auth_reject_error());
}
this.inner.call(req).await
}
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
// Check the authorization header for each entry in the batch.
let entries: Vec<_> = batch
.into_iter()
.filter_map(|entry| match entry {
Ok(BatchEntry::Call(req)) => {
if self.auth_method_call(&req) {
Some(Ok(BatchEntry::Call(req)))
} else {
// If the authorization header is missing, we return
// a JSON-RPC error instead of an error from the service.
Some(Err(BatchEntryErr::new(req.id, auth_reject_error())))
}
}
Ok(BatchEntry::Notification(notif)) => {
if self.auth_notif(&notif) {
Some(Ok(BatchEntry::Notification(notif)))
} else {
// Just filter out the notification if the auth fails
// because notifications are not expected to return a response.
None
}
}
// Errors which could happen such as invalid JSON-RPC call
// or invalid JSON are just passed through.
Err(err) => Some(Err(err)),
})
.collect();
self.inner.batch(Batch::from(entries))
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.inner.notification(n)
}
}
#[rpc(server, client)]
pub trait Rpc {
#[method(name = "trusted_call")]
async fn trusted_call(&self) -> Result<String, ErrorObjectOwned>;
}
#[async_trait]
impl RpcServer for () {
async fn trusted_call(&self) -> Result<String, ErrorObjectOwned> {
Ok("mysecret".to_string())
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()?;
tracing_subscriber::FmtSubscriber::builder().with_env_filter(filter).finish().try_init()?;
let metrics = Metrics::default();
let handle = run_server(metrics.clone()).await?;
tokio::spawn(handle.stopped());
{
let client = HttpClient::builder().build("http://127.0.0.1:9944").unwrap();
// Fails because the authorization header is missing.
let x = client.trusted_call().await.unwrap_err();
tracing::info!("response: {x}");
}
{
let client = WsClientBuilder::default().build("ws://127.0.0.1:9944").await.unwrap();
// Fails because the authorization header is missing.
let x = client.trusted_call().await.unwrap_err();
tracing::info!("response: {x}");
}
{
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("don't care in this example"));
let client = HttpClient::builder().set_headers(headers).build("http://127.0.0.1:9944").unwrap();
let x = client.trusted_call().await.unwrap();
tracing::info!("response: {x}");
}
tracing::info!("{:?}", metrics);
Ok(())
}
async fn run_server(metrics: Metrics) -> anyhow::Result<ServerHandle> {
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 9944))).await?;
// This state is cloned for every connection
// all these types based on Arcs and it should
// be relatively cheap to clone them.
//
// Make sure that nothing expensive is cloned here
// when doing this or use an `Arc`.
#[derive(Clone)]
struct PerConnection<RpcMiddleware, HttpMiddleware> {
methods: Methods,
stop_handle: StopHandle,
metrics: Metrics,
svc_builder: TowerServiceBuilder<RpcMiddleware, HttpMiddleware>,
}
// Each RPC call/connection get its own `stop_handle`
// to able to determine whether the server has been stopped or not.
//
// To keep the server running the `server_handle`
// must be kept and it can also be used to stop the server.
let (stop_handle, server_handle) = stop_channel();
let per_conn = PerConnection {
methods: ().into_rpc().into(),
stop_handle: stop_handle.clone(),
metrics,
svc_builder: jsonrpsee::server::Server::builder()
.set_config(ServerConfig::builder().max_connections(33).build())
.set_http_middleware(
tower::ServiceBuilder::new()
.layer(CorsLayer::permissive())
.layer(ProxyGetRequestLayer::new(vec![("trusted_call", "foo")]).unwrap())
.layer(HostFilterLayer::new(["example.com"]).unwrap()),
)
.to_service_builder(),
};
tokio::spawn(async move {
loop {
// The `tokio::select!` macro is used to wait for either of the
// listeners to accept a new connection or for the server to be
// stopped.
let sock = tokio::select! {
res = listener.accept() => {
match res {
Ok((stream, _remote_addr)) => stream,
Err(e) => {
tracing::error!("failed to accept v4 connection: {:?}", e);
continue;
}
}
}
_ = per_conn.stop_handle.clone().shutdown() => break,
};
let per_conn2 = per_conn.clone();
let svc = tower::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let is_websocket = jsonrpsee::server::ws::is_upgrade_request(&req);
let transport_label = if is_websocket { "ws" } else { "http" };
let PerConnection { methods, stop_handle, metrics, svc_builder } = per_conn2.clone();
// NOTE, the rpc middleware must be initialized here to be able to created once per connection
// with data from the connection such as the headers in this example
let headers = req.headers().clone();
let rpc_middleware = RpcServiceBuilder::new()
.rpc_logger(1024)
.layer_fn(move |service| AuthorizationMiddleware {
inner: service,
headers: headers.clone(),
transport_label,
})
.option_layer(Some(IdentityLayer));
let mut svc = svc_builder.set_rpc_middleware(rpc_middleware).build(methods, stop_handle);
if is_websocket {
// Utilize the session close future to know when the actual WebSocket
// session was closed.
let session_close = svc.on_session_closed();
// A little bit weird API but the response to HTTP request must be returned below
// and we spawn a task to register when the session is closed.
tokio::spawn(async move {
session_close.await;
tracing::info!("Closed WebSocket connection");
metrics.closed_ws_connections.fetch_add(1, Ordering::Relaxed);
});
async move {
tracing::info!("Opened WebSocket connection");
metrics.opened_ws_connections.fetch_add(1, Ordering::Relaxed);
svc.call(req).await
}
.boxed()
} else {
// HTTP.
async move {
tracing::info!("Opened HTTP connection");
metrics.http_calls.fetch_add(1, Ordering::Relaxed);
let rp = svc.call(req).await;
if rp.is_ok() {
metrics.success_http_calls.fetch_add(1, Ordering::Relaxed);
}
tracing::info!("Closed HTTP connection");
rp
}
.boxed()
}
});
tokio::spawn(serve_with_graceful_shutdown(sock, svc, stop_handle.clone().shutdown()));
}
});
Ok(server_handle)
}

View File

@ -0,0 +1,222 @@
// Copyright 2024 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! This example shows how to use the low-level server API
//! in jsonrpsee and inject a `mpsc::Sender<()>` into the
//! request extensions to be able to close the connection from
//! a rpc handler (method call or subscription).
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use futures::FutureExt;
use jsonrpsee::core::middleware::RpcServiceBuilder;
use jsonrpsee::core::{SubscriptionResult, async_trait};
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::{
ConnectionGuard, ConnectionState, HttpRequest, ServerConfig, ServerHandle, StopHandle, http,
serve_with_graceful_shutdown, stop_channel, ws,
};
use jsonrpsee::types::ErrorObjectOwned;
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{Extensions, Methods, PendingSubscriptionSink};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tracing_subscriber::util::SubscriberInitExt;
#[rpc(server, client)]
pub trait Rpc {
#[method(name = "closeConn", with_extensions)]
async fn close_conn(&self) -> Result<(), ErrorObjectOwned>;
#[subscription(name = "subscribeCloseConn", item = String, with_extensions)]
async fn close_conn_from_sub(&self) -> SubscriptionResult;
}
#[async_trait]
impl RpcServer for () {
async fn close_conn(&self, ext: &Extensions) -> Result<(), ErrorObjectOwned> {
let tx = ext.get::<mpsc::Sender<()>>().unwrap();
tx.send(()).await.unwrap();
Ok(())
}
async fn close_conn_from_sub(&self, _pending: PendingSubscriptionSink, ext: &Extensions) -> SubscriptionResult {
let tx = ext.get::<mpsc::Sender<()>>().unwrap();
tx.send(()).await.unwrap();
Ok(())
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()?;
tracing_subscriber::FmtSubscriber::builder().with_env_filter(filter).finish().try_init()?;
let handle = run_server().await?;
{
let client = WsClientBuilder::default().build("ws://127.0.0.1:9944").await?;
let _ = client.close_conn().await;
client.on_disconnect().await;
eprintln!("Connection closed from RPC call");
}
{
let client = WsClientBuilder::default().build("ws://127.0.0.1:9944").await?;
let _ = client.close_conn_from_sub().await;
client.on_disconnect().await;
eprintln!("Connection closed from RPC subscription");
}
let _ = handle.stop();
handle.stopped().await;
Ok(())
}
async fn run_server() -> anyhow::Result<ServerHandle> {
// Construct our SocketAddr to listen on...
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 9944))).await?;
// Each RPC call/connection get its own `stop_handle`
// to able to determine whether the server has been stopped or not.
//
// To keep the server running the `server_handle`
// must be kept and it can also be used to stop the server.
let (stop_handle, server_handle) = stop_channel();
// This state is cloned for every connection
// all these types based on Arcs and it should
// be relatively cheap to clone them.
//
// Make sure that nothing expensive is cloned here
// when doing this or use an `Arc`.
#[derive(Clone)]
struct PerConnection {
methods: Methods,
stop_handle: StopHandle,
conn_id: Arc<AtomicU32>,
conn_guard: ConnectionGuard,
}
let per_conn = PerConnection {
methods: ().into_rpc().into(),
stop_handle: stop_handle.clone(),
conn_id: Default::default(),
conn_guard: ConnectionGuard::new(100),
};
tokio::spawn(async move {
loop {
// The `tokio::select!` macro is used to wait for either of the
// listeners to accept a new connection or for the server to be
// stopped.
let (sock, _) = tokio::select! {
res = listener.accept() => {
match res {
Ok(sock) => sock,
Err(e) => {
tracing::error!("failed to accept v4 connection: {:?}", e);
continue;
}
}
}
_ = per_conn.stop_handle.clone().shutdown() => break,
};
let per_conn = per_conn.clone();
// Create a service handler.
let stop_handle2 = per_conn.stop_handle.clone();
let per_conn = per_conn.clone();
let svc = tower::service_fn(move |mut req: HttpRequest<hyper::body::Incoming>| {
let PerConnection { methods, stop_handle, conn_guard, conn_id } = per_conn.clone();
let (tx, mut disconnect) = mpsc::channel::<()>(1);
// Insert the `tx` into the request extensions to be able to close the connection
// from method or subscription handlers.
req.extensions_mut().insert(tx.clone());
// jsonrpsee expects a `conn permit` for each connection.
//
// This may be omitted if don't want to limit the number of connections
// to the server.
let Some(conn_permit) = conn_guard.try_acquire() else {
return async { Ok::<_, Infallible>(http::response::too_many_requests()) }.boxed();
};
let conn = ConnectionState::new(stop_handle, conn_id.fetch_add(1, Ordering::Relaxed), conn_permit);
if ws::is_upgrade_request(&req) {
let rpc_service = RpcServiceBuilder::new();
// Establishes the websocket connection
async move {
match ws::connect(req, ServerConfig::default(), methods, conn, rpc_service).await {
Ok((rp, conn_fut)) => {
tokio::spawn(async move {
tokio::select! {
_ = conn_fut => (),
_ = disconnect.recv() => {
eprintln!("Server closed connection");
},
}
});
Ok(rp)
}
Err(rp) => Ok(rp),
}
}
.boxed()
} else if !ws::is_upgrade_request(&req) {
// There is another API for making call with just a service as well.
//
// See [`jsonrpsee::server::http::call_with_service`]
async move {
tokio::select! {
// RPC call finished successfully.
res = http::call_with_service_builder(req, ServerConfig::default(), conn, methods, RpcServiceBuilder::new()) => Ok(res),
// The connection was closed by a RPC handler
_ = disconnect.recv() => Ok(http::response::denied()),
}
}
.boxed()
} else {
async { Ok(http::response::denied()) }.boxed()
}
});
// Upgrade the connection to a HTTP service.
tokio::spawn(serve_with_graceful_shutdown(sock, svc, stop_handle2.shutdown()));
}
});
Ok(server_handle)
}

View File

@ -0,0 +1,349 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! This example shows how to use the low-level server API
//! in jsonrpsee.
//!
//! The particular example disconnects peers that
//! makes more than ten RPC calls and bans the IP addr.
//!
//! NOTE:
//!
//! Enabling tower middleware in this example doesn't work,
//! to do so then the low-level API in hyper must be used.
//!
//! See <https://docs.rs/hyper/latest/hyper/server/conn/index.html>
//! for further information regarding the "low-level API" in hyper.
use std::collections::HashSet;
use std::convert::Infallible;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use futures::FutureExt;
use jsonrpsee::core::async_trait;
use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceBuilder, RpcServiceT};
use jsonrpsee::http_client::HttpClient;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::{
ConnectionGuard, ConnectionState, ServerConfig, ServerHandle, StopHandle, http, serve_with_graceful_shutdown,
stop_channel, ws,
};
use jsonrpsee::types::{ErrorObject, ErrorObjectOwned, Id, Request};
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{MethodResponse, Methods};
use tokio::net::TcpListener;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::mpsc;
use tracing_subscriber::util::SubscriberInitExt;
/// This is just a counter to limit
/// the number of calls per connection.
/// Once the limit has been exceeded
/// all future calls are rejected.
#[derive(Clone)]
struct CallLimit<S> {
service: S,
count: Arc<AsyncMutex<usize>>,
state: mpsc::Sender<()>,
}
impl<S> RpcServiceT for CallLimit<S>
where
S: RpcServiceT<
MethodResponse = MethodResponse,
BatchResponse = MethodResponse,
NotificationResponse = MethodResponse,
> + Send
+ Sync
+ Clone
+ 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
let count = self.count.clone();
let state = self.state.clone();
let service = self.service.clone();
async move {
let mut lock = count.lock().await;
if *lock >= 10 {
let _ = state.try_send(());
MethodResponse::error(req.id, ErrorObject::borrowed(-32000, "RPC rate limit", None))
} else {
let rp = service.call(req).await;
*lock += 1;
rp
}
}
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
let count = self.count.clone();
let state = self.state.clone();
let service = self.service.clone();
async move {
let mut lock = count.lock().await;
let batch_len = batch.len();
if *lock >= 10 + batch_len {
let _ = state.try_send(());
MethodResponse::error(Id::Null, ErrorObject::borrowed(-32000, "RPC rate limit", None))
} else {
let rp = service.batch(batch).await;
*lock += batch_len;
rp
}
}
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
let count = self.count.clone();
let service = self.service.clone();
// A notification is not expected to return a response so the result here doesn't matter
// rather than other middlewares may not be invoked.
async move { if *count.lock().await >= 10 { MethodResponse::notification() } else { service.notification(n).await } }
}
}
#[rpc(server, client)]
pub trait Rpc {
#[method(name = "say_hello")]
async fn say_hello(&self) -> Result<String, ErrorObjectOwned>;
}
#[async_trait]
impl RpcServer for () {
async fn say_hello(&self) -> Result<String, ErrorObjectOwned> {
Ok("lo".to_string())
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()?;
tracing_subscriber::FmtSubscriber::builder().with_env_filter(filter).finish().try_init()?;
// Make a bunch of WebSocket calls to be blacklisted by server.
{
let mut i = 0;
let handle = run_server().await?;
let client = WsClientBuilder::default().build("ws://127.0.0.1:9944").await.unwrap();
while client.is_connected() {
let rp: Result<String, _> = client.say_hello().await;
if rp.is_ok() {
i += 1;
}
}
// After the server has blacklisted the IP address, the connection is denied.
assert!(WsClientBuilder::default().build("ws://127.0.0.1:9944").await.is_err());
tracing::info!("WS client made {i} successful calls before getting blacklisted");
handle.stop().unwrap();
handle.stopped().await;
}
// Make a bunch of HTTP calls to be blacklisted by server.
{
let mut i = 0;
let handle = run_server().await?;
let client = HttpClient::builder().build("http://127.0.0.1:9944").unwrap();
while client.say_hello().await.is_ok() {
i += 1;
}
tracing::info!("HTTP client made {i} successful calls before getting blacklisted");
handle.stop().unwrap();
handle.stopped().await;
}
Ok(())
}
async fn run_server() -> anyhow::Result<ServerHandle> {
// Construct our SocketAddr to listen on...
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 9944))).await?;
// Each RPC call/connection get its own `stop_handle`
// to able to determine whether the server has been stopped or not.
//
// To keep the server running the `server_handle`
// must be kept and it can also be used to stop the server.
let (stop_handle, server_handle) = stop_channel();
// This state is cloned for every connection
// all these types based on Arcs and it should
// be relatively cheap to clone them.
//
// Make sure that nothing expensive is cloned here
// when doing this or use an `Arc`.
#[derive(Clone)]
struct PerConnection {
methods: Methods,
stop_handle: StopHandle,
conn_id: Arc<AtomicU32>,
conn_guard: ConnectionGuard,
blacklisted_peers: Arc<Mutex<HashSet<IpAddr>>>,
// HTTP rate limit that is shared by all connections.
//
// This is just a toy-example and one not should "limit" HTTP connections
// like this because the actual IP addr of each request is not checked.
//
// Because it's possible to blacklist a peer which has only made one or
// a few calls.
global_http_rate_limit: Arc<AsyncMutex<usize>>,
}
let per_conn = PerConnection {
methods: ().into_rpc().into(),
stop_handle: stop_handle.clone(),
conn_id: Default::default(),
conn_guard: ConnectionGuard::new(100),
blacklisted_peers: Default::default(),
global_http_rate_limit: Default::default(),
};
tokio::spawn(async move {
loop {
// The `tokio::select!` macro is used to wait for either of the
// listeners to accept a new connection or for the server to be
// stopped.
let (sock, remote_addr) = tokio::select! {
res = listener.accept() => {
match res {
Ok(sock) => sock,
Err(e) => {
tracing::error!("failed to accept v4 connection: {:?}", e);
continue;
}
}
}
_ = per_conn.stop_handle.clone().shutdown() => break,
};
let per_conn = per_conn.clone();
// Create a service handler.
let stop_handle2 = per_conn.stop_handle.clone();
let per_conn = per_conn.clone();
let svc = tower::service_fn(move |req| {
let PerConnection {
methods,
stop_handle,
conn_guard,
conn_id,
blacklisted_peers,
global_http_rate_limit,
} = per_conn.clone();
// jsonrpsee expects a `conn permit` for each connection.
//
// This may be omitted if don't want to limit the number of connections
// to the server.
let Some(conn_permit) = conn_guard.try_acquire() else {
return async { Ok::<_, Infallible>(http::response::too_many_requests()) }.boxed();
};
// The IP addr was blacklisted.
if blacklisted_peers.lock().unwrap().get(&remote_addr.ip()).is_some() {
return async { Ok(http::response::denied()) }.boxed();
}
if ws::is_upgrade_request(&req) {
let (tx, mut disconnect) = mpsc::channel(1);
let rpc_service = RpcServiceBuilder::new().layer_fn(move |service| CallLimit {
service,
count: Default::default(),
state: tx.clone(),
});
let conn = ConnectionState::new(stop_handle, conn_id.fetch_add(1, Ordering::Relaxed), conn_permit);
// Establishes the websocket connection
// and if the `CallLimit` middleware triggers the hard limit
// then the connection is closed i.e, the `conn_fut` is dropped.
async move {
match ws::connect(req, ServerConfig::default(), methods, conn, rpc_service).await {
Ok((rp, conn_fut)) => {
tokio::spawn(async move {
tokio::select! {
_ = conn_fut => (),
_ = disconnect.recv() => {
blacklisted_peers.lock().unwrap().insert(remote_addr.ip());
},
}
});
Ok(rp)
}
Err(rp) => Ok(rp),
}
}
.boxed()
} else if !ws::is_upgrade_request(&req) {
let (tx, mut disconnect) = mpsc::channel(1);
let rpc_service = RpcServiceBuilder::new().layer_fn(move |service| CallLimit {
service,
count: global_http_rate_limit.clone(),
state: tx.clone(),
});
let server_cfg = ServerConfig::default();
let conn = ConnectionState::new(stop_handle, conn_id.fetch_add(1, Ordering::Relaxed), conn_permit);
// There is another API for making call with just a service as well.
//
// See [`jsonrpsee::server::http::call_with_service`]
async move {
tokio::select! {
// Rpc call finished successfully.
res = http::call_with_service_builder(req, server_cfg, conn, methods, rpc_service) => Ok(res),
// Deny the call if the call limit is exceeded.
_ = disconnect.recv() => Ok(http::response::denied()),
}
}
.boxed()
} else {
async { Ok(http::response::denied()) }.boxed()
}
});
// Upgrade the connection to a HTTP service.
tokio::spawn(serve_with_graceful_shutdown(sock, svc, stop_handle2.shutdown()));
}
});
Ok(server_handle)
}

View File

@ -0,0 +1,123 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::core::{SubscriptionResult, async_trait, client::Subscription};
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::{PendingSubscriptionSink, Server};
use jsonrpsee::types::ErrorObjectOwned;
use jsonrpsee::ws_client::WsClientBuilder;
type ExampleHash = [u8; 32];
type ExampleStorageKey = Vec<u8>;
#[rpc(server, client, namespace = "state")]
pub trait Rpc<Hash, StorageKey>
where
Hash: std::fmt::Debug,
{
/// Async method call example.
#[method(name = "getKeys")]
async fn storage_keys(
&self,
storage_key: StorageKey,
hash: Option<Hash>,
) -> Result<Vec<StorageKey>, ErrorObjectOwned>;
/// Subscription that takes a `StorageKey` as input and produces a `Vec<Hash>`.
#[subscription(name = "subscribeStorage" => "override", item = Vec<Hash>)]
async fn subscribe_storage(&self, keys: Option<Vec<StorageKey>>) -> SubscriptionResult;
#[subscription(name = "subscribeSync" => "sync", item = Vec<Hash>)]
fn s(&self, keys: Option<Vec<StorageKey>>);
}
pub struct RpcServerImpl;
#[async_trait]
impl RpcServer<ExampleHash, ExampleStorageKey> for RpcServerImpl {
async fn storage_keys(
&self,
storage_key: ExampleStorageKey,
_hash: Option<ExampleHash>,
) -> Result<Vec<ExampleStorageKey>, ErrorObjectOwned> {
Ok(vec![storage_key])
}
async fn subscribe_storage(
&self,
pending: PendingSubscriptionSink,
_keys: Option<Vec<ExampleStorageKey>>,
) -> SubscriptionResult {
let sink = pending.accept().await?;
let json = serde_json::value::to_raw_value(&vec![[0; 32]])?;
sink.send(json).await?;
Ok(())
}
fn s(&self, pending: PendingSubscriptionSink, _keys: Option<Vec<ExampleStorageKey>>) {
tokio::spawn(async move {
let sink = pending.accept().await.unwrap();
let json = serde_json::value::to_raw_value(&vec![[0; 32]]).unwrap();
sink.send(json).await.unwrap();
});
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let server_addr = run_server().await?;
let url = format!("ws://{}", server_addr);
let client = WsClientBuilder::default().build(&url).await?;
assert_eq!(client.storage_keys(vec![1, 2, 3, 4], None::<ExampleHash>).await.unwrap(), vec![vec![1, 2, 3, 4]]);
let mut sub: Subscription<Vec<ExampleHash>> =
RpcClient::<ExampleHash, ExampleStorageKey>::subscribe_storage(&client, None).await.unwrap();
assert_eq!(Some(vec![[0; 32]]), sub.next().await.transpose().unwrap());
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0").await?;
let addr = server.local_addr()?;
let handle = server.start(RpcServerImpl.into_rpc());
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,95 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::core::async_trait;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::Server;
use jsonrpsee::types::ErrorObjectOwned;
use jsonrpsee::ws_client::WsClientBuilder;
type ExampleHash = [u8; 32];
pub trait Config {
type Hash: Send + Sync + 'static;
}
impl Config for ExampleHash {
type Hash = Self;
}
/// The RPC macro requires `DeserializeOwned` for output types for the client implementation, while the
/// server implementation requires output types to be bounded by `Serialize`.
///
/// In this example, we don't want the `Conf` to be bounded by default to
/// `Conf : Send + Sync + 'static + jsonrpsee::core::DeserializeOwned` for client implementation and
/// `Conf : Send + Sync + 'static + jsonrpsee::core::Serialize` for server implementation.
///
/// Explicitly, specify client and server bounds to handle the `Serialize` and `DeserializeOwned` cases
/// just for the `Conf::hash` part.
#[rpc(server, client, namespace = "foo", client_bounds(T::Hash: jsonrpsee::core::DeserializeOwned), server_bounds(T::Hash: jsonrpsee::core::Serialize + Clone))]
pub trait Rpc<T: Config> {
#[method(name = "bar")]
fn method(&self) -> Result<T::Hash, ErrorObjectOwned>;
}
pub struct RpcServerImpl;
#[async_trait]
impl RpcServer<ExampleHash> for RpcServerImpl {
fn method(&self) -> Result<<ExampleHash as Config>::Hash, ErrorObjectOwned> {
Ok([0u8; 32])
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let server_addr = run_server().await?;
let url = format!("ws://{}", server_addr);
let client = WsClientBuilder::default().build(&url).await?;
assert_eq!(RpcClient::<ExampleHash>::method(&client).await.unwrap(), [0u8; 32]);
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0").await?;
let addr = server.local_addr()?;
let handle = server.start(RpcServerImpl.into_rpc());
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,84 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::Server;
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{ResponsePayload, rpc_params};
#[rpc(client, server, namespace = "state")]
pub trait Rpc {
/// Async method call example.
#[method(name = "getKeys")]
fn storage_keys(&self) -> ResponsePayload<'static, String>;
}
pub struct RpcServerImpl;
impl RpcServer for RpcServerImpl {
fn storage_keys(&self) -> ResponsePayload<'static, String> {
let (rp, rp_future) = ResponsePayload::success("ehheeheh".to_string()).notify_on_completion();
tokio::spawn(async move {
rp_future.await.unwrap();
println!("Method response to `state_getKeys` finished");
});
rp
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let server_addr = run_server().await?;
let url = format!("ws://{}", server_addr);
let client = WsClientBuilder::default().build(&url).await?;
assert_eq!("ehheeheh".to_string(), client.request::<String, _>("state_getKeys", rpc_params![]).await.unwrap());
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0").await?;
let addr = server.local_addr()?;
let handle = server.start(RpcServerImpl.into_rpc());
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,265 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! jsonrpsee supports two kinds of middlewares `http_middleware` and `rpc_middleware`.
//!
//! This example demonstrates how to use the `rpc_middleware` which applies for each
//! JSON-RPC method call and batch requests may call the middleware more than once.
//!
//! A typical use-case for this is to implement rate-limiting based on the actual
//! number of JSON-RPC methods calls and a request could potentially be made
//! by HTTP or WebSocket which this middleware is agnostic to.
//!
//! Contrary the HTTP middleware does only apply per HTTP request and
//! may be handy in some scenarios such CORS but if you want to access
//! to the actual JSON-RPC details this is the middleware to use.
//!
//! This example enables the same middleware for both the server and client which
//! can be confusing when one runs this but it is just to demonstrate the API.
//!
//! That the middleware is applied to the server and client in the same way.
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use jsonrpsee::core::client::ClientT;
use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceBuilder, RpcServiceT};
use jsonrpsee::rpc_params;
use jsonrpsee::server::{RpcModule, Server};
use jsonrpsee::types::Request;
use jsonrpsee::ws_client::WsClientBuilder;
#[derive(Clone)]
struct IdentityLayer;
impl<S> tower::Layer<S> for IdentityLayer
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type Service = Identity<S>;
fn layer(&self, inner: S) -> Self::Service {
Identity(inner)
}
}
#[derive(Clone)]
struct Identity<S>(S);
impl<S> RpcServiceT for Identity<S>
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
self.0.batch(batch)
}
fn call<'a>(&self, request: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
self.0.call(request)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.0.notification(n)
}
}
// It's possible to access the connection ID
// by using the low-level API.
#[derive(Clone)]
pub struct CallsPerConn<S> {
service: S,
count: Arc<AtomicUsize>,
role: &'static str,
}
impl<S> RpcServiceT for CallsPerConn<S>
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
let count = self.count.clone();
let service = self.service.clone();
let role = self.role;
async move {
let rp = service.call(req).await;
count.fetch_add(1, Ordering::SeqCst);
println!("{role} processed calls={} on the connection", count.load(Ordering::SeqCst));
rp
}
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
let len = batch.len();
self.count.fetch_add(len, Ordering::SeqCst);
println!("{} processed calls={} on the connection", self.role, self.count.load(Ordering::SeqCst));
self.service.batch(batch)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.service.notification(n)
}
}
#[derive(Clone)]
pub struct GlobalCalls<S> {
service: S,
count: Arc<AtomicUsize>,
role: &'static str,
}
impl<S> RpcServiceT for GlobalCalls<S>
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
let count = self.count.clone();
let service = self.service.clone();
let role = self.role;
async move {
let rp = service.call(req).await;
count.fetch_add(1, Ordering::SeqCst);
println!("{role} processed calls={} in total", count.load(Ordering::SeqCst));
rp
}
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
let len = batch.len();
self.count.fetch_add(len, Ordering::SeqCst);
println!("{}, processed calls={} in total", self.role, self.count.load(Ordering::SeqCst));
self.service.batch(batch)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.service.notification(n)
}
}
#[derive(Clone)]
pub struct Logger<S> {
service: S,
role: &'static str,
}
impl<S> RpcServiceT for Logger<S>
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
println!("{} logger middleware: method `{}`", self.role, req.method);
self.service.call(req)
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
println!("{} logger middleware: batch {batch}", self.role);
self.service.batch(batch)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.service.notification(n)
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
for _ in 0..2 {
let global_cnt = Arc::new(AtomicUsize::new(0));
let rpc_middleware = RpcServiceBuilder::new()
.layer_fn(|service| Logger { service, role: "client" })
// This state is created per connection.
.layer_fn(|service| CallsPerConn { service, count: Default::default(), role: "client" })
// This state is shared by all connections.
.layer_fn(move |service| GlobalCalls { service, count: global_cnt.clone(), role: "client" });
let client = WsClientBuilder::new().set_rpc_middleware(rpc_middleware).build(&url).await?;
let response: String = client.request("say_hello", rpc_params![]).await?;
println!("response: {:?}", response);
let _response: Result<String, _> = client.request("unknown_method", rpc_params![]).await;
let _: String = client.request("say_hello", rpc_params![]).await?;
let _: String = client.request("thready", rpc_params![4]).await?;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let global_cnt = Arc::new(AtomicUsize::new(0));
let rpc_middleware = RpcServiceBuilder::new()
.layer_fn(|service| Logger { service, role: "server" })
// This state is created per connection.
.layer_fn(|service| CallsPerConn { service, count: Default::default(), role: "server" })
// This state is shared by all connections.
.layer_fn(move |service| GlobalCalls { service, count: global_cnt.clone(), role: "server" })
// Optional layer that does nothing, just an example to be useful if one has an optional layer.
.option_layer(Some(IdentityLayer));
let server = Server::builder().set_rpc_middleware(rpc_middleware).build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
module.register_method("thready", |params, _, _| {
let thread_count: usize = params.one().unwrap();
for _ in 0..thread_count {
std::thread::spawn(|| std::thread::sleep(std::time::Duration::from_secs(1)));
}
""
})?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,173 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! jsonrpsee supports two kinds of middlewares `http_middleware` and `rpc_middleware`.
//!
//! This example demonstrates how to use the `rpc_middleware` which applies for each
//! JSON-RPC method calls, notifications and batch requests.
//!
//! This example demonstrates how to use the `rpc_middleware` for the client
//! and you may benefit specifying the response type to `core::client::MethodResponse`
//! to actually inspect the response instead of using the serialized JSON-RPC response.
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use jsonrpsee::core::client::{ClientT, MiddlewareMethodResponse, error::Error};
use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceBuilder, RpcServiceT};
use jsonrpsee::rpc_params;
use jsonrpsee::server::{RpcModule, Server};
use jsonrpsee::types::{ErrorCode, ErrorObject, Request};
use jsonrpsee::ws_client::WsClientBuilder;
#[derive(Default)]
struct InnerMetrics {
method_calls_success: usize,
method_calls_failure: usize,
notifications: usize,
batch_calls: usize,
}
#[derive(Clone)]
pub struct Metrics<S> {
service: S,
metrics: Arc<Mutex<InnerMetrics>>,
}
impl std::fmt::Debug for InnerMetrics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InnerMetrics")
.field("method_calls_success", &self.method_calls_success)
.field("method_calls_failure", &self.method_calls_failure)
.field("notifications", &self.notifications)
.field("batch_calls", &self.batch_calls)
.finish()
}
}
impl<S> Metrics<S> {
pub fn new(service: S) -> Self {
Self { service, metrics: Arc::new(Mutex::new(InnerMetrics::default())) }
}
}
// NOTE: We are using MethodResponse as the response type here to be able to inspect the response
// and not just the serialized JSON-RPC response. This is not necessary if you only care about
// the serialized JSON-RPC response.
impl<S> RpcServiceT for Metrics<S>
where
S: RpcServiceT<MethodResponse = Result<MiddlewareMethodResponse, Error>> + Send + Sync + Clone + 'static,
{
type MethodResponse = Result<MiddlewareMethodResponse, Error>;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
let m = self.metrics.clone();
let service = self.service.clone();
async move {
let rp = service.call(req).await;
// Access to inner response via the deref implementation.
match &rp {
Ok(rp) => {
if rp.is_success() {
m.lock().unwrap().method_calls_success += 1;
} else {
m.lock().unwrap().method_calls_failure += 1;
}
}
Err(e) => {
m.lock().unwrap().method_calls_failure += 1;
tracing::error!("Error: {:?}", e);
}
}
rp
}
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
self.metrics.lock().unwrap().batch_calls += 1;
self.service.batch(batch)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
self.metrics.lock().unwrap().notifications += 1;
self.service.notification(n)
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let metrics = Arc::new(Mutex::new(InnerMetrics::default()));
for _ in 0..2 {
let metrics = metrics.clone();
let rpc_middleware =
RpcServiceBuilder::new().layer_fn(move |s| Metrics { service: s, metrics: metrics.clone() });
let client = WsClientBuilder::new().set_rpc_middleware(rpc_middleware).build(&url).await?;
let _: Result<String, _> = client.request("say_hello", rpc_params![]).await;
let _: Result<String, _> = client.request("unknown_method", rpc_params![]).await;
let _: Result<String, _> = client.request("thready", rpc_params![4]).await;
let _: Result<String, _> = client.request("mul", rpc_params![4]).await;
let _: Result<String, _> = client.request("err", rpc_params![4]).await;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
println!("Metrics: {:?}", metrics.lock().unwrap());
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
module.register_method("mul", |params, _, _| {
let count: usize = params.one().unwrap();
count * 2
})?;
module.register_method("error", |_, _, _| ErrorObject::from(ErrorCode::InternalError))?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,139 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use jsonrpsee::core::client::ClientT;
use jsonrpsee::core::middleware::{Batch, BatchEntry, Notification, RpcServiceBuilder, RpcServiceT};
use jsonrpsee::server::Server;
use jsonrpsee::types::Request;
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{RpcModule, rpc_params};
use std::borrow::Cow as StdCow;
use std::net::SocketAddr;
fn modify_method_call(req: &mut Request<'_>) {
// Example how to modify the params in the call.
if req.method == "say_hello" {
// It's a bit awkward to create new params in the request
// but this shows how to do it.
let raw_value = serde_json::value::to_raw_value("myparams").unwrap();
req.params = Some(StdCow::Owned(raw_value));
}
// Re-direct all calls that isn't `say_hello` to `say_goodbye`
else if req.method != "say_hello" {
req.method = "say_goodbye".into();
}
}
fn modify_notif(n: &mut Notification<'_>) {
// Example how to modify the params in the notification.
if n.method == "say_hello" {
// It's a bit awkward to create new params in the request
// but this shows how to do it.
let raw_value = serde_json::value::to_raw_value("myparams").unwrap();
n.params = Some(StdCow::Owned(raw_value));
}
// Re-direct all notifs that isn't `say_hello` to `say_goodbye`
else if n.method != "say_hello" {
n.method = "say_goodbye".into();
}
}
#[derive(Clone)]
pub struct ModifyRequestIf<S>(S);
impl<S> RpcServiceT for ModifyRequestIf<S>
where
S: RpcServiceT + Send + Sync + Clone + 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, mut req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
modify_method_call(&mut req);
self.0.call(req)
}
fn batch<'a>(&self, mut batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
for call in batch.iter_mut() {
match call {
Ok(BatchEntry::Call(call)) => {
modify_method_call(call);
}
Ok(BatchEntry::Notification(n)) => {
modify_notif(n);
}
// Invalid request, we don't care about it.
Err(_err) => {}
}
}
self.0.batch(batch)
}
fn notification<'a>(
&self,
mut n: Notification<'a>,
) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
modify_notif(&mut n);
self.0.notification(n)
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let client = WsClientBuilder::default().build(&url).await?;
let _response: String = client.request("say_hello", rpc_params![]).await?;
let _response: Result<String, _> = client.request("unknown_method", rpc_params![]).await;
let _: String = client.request("say_hello", rpc_params![]).await?;
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let rpc_middleware = RpcServiceBuilder::new().layer_fn(ModifyRequestIf);
let server = Server::builder().set_rpc_middleware(rpc_middleware).build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
module.register_method("say_goodbye", |_, _, _| "goodbye")?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,218 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! Example middleware to rate limit based on the number
//! JSON-RPC calls.
//!
//! As demonstrated in this example any state must be
//! stored in something to provide interior mutability
//! such as `Arc<Mutex>`
use jsonrpsee::core::client::ClientT;
use jsonrpsee::core::middleware::{
Batch, BatchEntry, BatchEntryErr, Notification, ResponseFuture, RpcServiceBuilder, RpcServiceT,
};
use jsonrpsee::server::Server;
use jsonrpsee::types::{ErrorObject, Request};
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{MethodResponse, RpcModule, rpc_params};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Copy, Clone)]
struct Rate {
num: u64,
period: Duration,
}
#[derive(Debug, Copy, Clone)]
enum State {
Deny { until: Instant },
Allow { until: Instant, rem: u64 },
}
/// Depending on how the rate limit is instantiated
/// it's possible to select whether the rate limit
/// is be applied per connection or shared by
/// all connections.
///
/// Have a look at `async fn run_server` below which
/// shows how do it.
#[derive(Clone)]
pub struct RateLimit<S> {
service: S,
state: Arc<Mutex<State>>,
rate: Rate,
}
impl<S> RateLimit<S> {
fn new(service: S, rate: Rate) -> Self {
let period = rate.period;
let num = rate.num;
Self {
service,
rate,
state: Arc::new(Mutex::new(State::Allow { until: Instant::now() + period, rem: num + 1 })),
}
}
fn rate_limit_deny(&self) -> bool {
let now = Instant::now();
let mut lock = self.state.lock().unwrap();
let next_state = match *lock {
State::Deny { until } => {
if now > until {
State::Allow { until: now + self.rate.period, rem: self.rate.num - 1 }
} else {
State::Deny { until }
}
}
State::Allow { until, rem } => {
if now > until {
State::Allow { until: now + self.rate.period, rem: self.rate.num - 1 }
} else {
let n = rem - 1;
if n > 0 { State::Allow { until: now + self.rate.period, rem: n } } else { State::Deny { until } }
}
}
};
*lock = next_state;
matches!(next_state, State::Deny { .. })
}
}
impl<S> RpcServiceT for RateLimit<S>
where
S: RpcServiceT<
MethodResponse = MethodResponse,
BatchResponse = MethodResponse,
NotificationResponse = MethodResponse,
> + Send
+ Sync
+ Clone
+ 'static,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, req: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
if self.rate_limit_deny() {
ResponseFuture::ready(MethodResponse::error(req.id, ErrorObject::borrowed(-32000, "RPC rate limit", None)))
} else {
ResponseFuture::future(self.service.call(req))
}
}
fn batch<'a>(&self, mut batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
// If the rate limit is reached then we modify each entry
// in the batch to be a request with an error.
//
// This makes sure that the client will receive an error
// for each request in the batch.
if self.rate_limit_deny() {
for entry in batch.iter_mut() {
let id = match entry {
Ok(BatchEntry::Call(req)) => req.id.clone(),
Ok(BatchEntry::Notification(_)) => continue,
Err(_) => continue,
};
// This will create a new error response for batch and replace the method call
*entry = Err(BatchEntryErr::new(id, ErrorObject::borrowed(-32000, "RPC rate limit", None)));
}
}
self.service.batch(batch)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
if self.rate_limit_deny() {
// Notifications are not expected to return a response so just ignore
// if the rate limit is reached.
ResponseFuture::ready(MethodResponse::notification())
} else {
ResponseFuture::future(self.service.notification(n))
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let client1 = WsClientBuilder::default().build(&url).await?;
let _response: String = client1.request("say_hello", rpc_params![]).await?;
// The rate limit should trigger an error here.
let _response = client1.request::<String, _>("unknown_method", rpc_params![]).await.unwrap_err();
// Make a new connection and the server will allow it because our `RateLimit`
// applies per connection and not globally on the server.
let client2 = WsClientBuilder::default().build(&url).await?;
let _response: String = client2.request("say_hello", rpc_params![]).await?;
// The first connection should allow a call now again.
tokio::time::sleep(Duration::from_secs(2)).await;
let _response: String = client1.request("say_hello", rpc_params![]).await?;
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
// This will create a new `RateLimit` per connection.
//
// In this particular example the server will only
// allow one RPC call per second.
//
// Have a look at the `rpc_middleware example` if you want see an example
// how to share state of the "middleware" for all connections on the server.
let rpc_middleware = RpcServiceBuilder::new()
.layer_fn(|service| RateLimit::new(service, Rate { num: 1, period: Duration::from_secs(1) }));
let server = Server::builder().set_rpc_middleware(rpc_middleware).build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
module.register_method("say_goodbye", |_, _, _| "goodbye")?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,156 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::core::middleware::{Batch, Notification, Request, RpcServiceT};
use jsonrpsee::core::{SubscriptionResult, async_trait};
use jsonrpsee::proc_macros::rpc;
use jsonrpsee::server::PendingSubscriptionSink;
use jsonrpsee::types::{ErrorObject, ErrorObjectOwned};
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{ConnectionId, Extensions};
#[rpc(server, client)]
pub trait Rpc {
/// method with connection ID.
#[method(name = "connectionIdMethod", with_extensions)]
async fn method(&self) -> Result<usize, ErrorObjectOwned>;
#[subscription(name = "subscribeConnectionId", item = usize, with_extensions)]
async fn sub(&self) -> SubscriptionResult;
#[subscription(name = "subscribeSyncConnectionId", item = usize, with_extensions)]
fn sub2(&self) -> SubscriptionResult;
}
struct LoggingMiddleware<S>(S);
impl<S> RpcServiceT for LoggingMiddleware<S>
where
S: RpcServiceT,
{
type MethodResponse = S::MethodResponse;
type NotificationResponse = S::NotificationResponse;
type BatchResponse = S::BatchResponse;
fn call<'a>(&self, request: Request<'a>) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
tracing::info!("Received request: {:?}", request);
assert!(request.extensions().get::<ConnectionId>().is_some());
self.0.call(request)
}
fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
tracing::info!("Received batch: {:?}", batch);
self.0.batch(batch)
}
fn notification<'a>(&self, n: Notification<'a>) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
tracing::info!("Received notif: {:?}", n);
self.0.notification(n)
}
}
pub struct RpcServerImpl;
#[async_trait]
impl RpcServer for RpcServerImpl {
async fn method(&self, ext: &Extensions) -> Result<usize, ErrorObjectOwned> {
let conn_id = ext
.get::<ConnectionId>()
.cloned()
.ok_or_else(|| ErrorObject::owned(0, "No connection details found", None::<()>))?;
Ok(conn_id.0)
}
async fn sub(&self, pending: PendingSubscriptionSink, ext: &Extensions) -> SubscriptionResult {
let sink = pending.accept().await?;
let conn_id = ext
.get::<ConnectionId>()
.cloned()
.ok_or_else(|| ErrorObject::owned(0, "No connection details found", None::<()>))?;
let json = serde_json::value::to_raw_value(&conn_id)
.map_err(|e| ErrorObject::owned(0, format!("Failed to serialize connection ID: {e}"), None::<()>))?;
sink.send(json).await?;
Ok(())
}
fn sub2(&self, pending: PendingSubscriptionSink, ext: &Extensions) -> SubscriptionResult {
let conn_id = ext.get::<ConnectionId>().cloned().unwrap();
tokio::spawn(async move {
let sink = pending.accept().await.unwrap();
let json = serde_json::value::to_raw_value(&conn_id).unwrap();
sink.send(json).await.unwrap();
});
Ok(())
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let server_addr = run_server().await?;
let url = format!("ws://{}", server_addr);
let client = WsClientBuilder::default().build(&url).await?;
let connection_id_first = client.method().await.unwrap();
// Second call from the same connection ID.
assert_eq!(client.method().await.unwrap(), connection_id_first);
// Second client will increment the connection ID.
let client2 = WsClientBuilder::default().build(&url).await?;
let connection_id_second = client2.method().await.unwrap();
assert_ne!(connection_id_first, connection_id_second);
let mut sub = client.sub().await.unwrap();
assert_eq!(connection_id_first, sub.next().await.transpose().unwrap().unwrap());
let mut sub = client2.sub().await.unwrap();
assert_eq!(connection_id_second, sub.next().await.transpose().unwrap().unwrap());
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let rpc_middleware = jsonrpsee::server::middleware::rpc::RpcServiceBuilder::new().layer_fn(LoggingMiddleware);
let server = jsonrpsee::server::Server::builder().set_rpc_middleware(rpc_middleware).build("127.0.0.1:0").await?;
let addr = server.local_addr()?;
let handle = server.start(RpcServerImpl.into_rpc());
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,69 @@
// Copyright 2022 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! Example how to use `tokio-console` to debug async tasks `jsonrpsee`.
//! For further information see https://docs.rs/console-subscriber.
//!
//! To run it:
//! `$ cargo install --locked tokio-console`
//! `$ RUSTFLAGS="--cfg tokio_unstable" cargo run --example tokio_console`
//! `$ tokio-console`
//!
//! It will start a server on http://127.0.0.1:6669 for `tokio-console` to connect to.
use std::net::SocketAddr;
use jsonrpsee::RpcModule;
use jsonrpsee::server::Server;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
console_subscriber::init();
let _ = run_server().await?;
futures::future::pending().await
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let server = Server::builder().build("127.0.0.1:9944").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
module.register_method("memory_call", |_, _, _| "A".repeat(1024 * 1024))?;
module.register_async_method("sleep", |_, _, _| async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
"lo"
})?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing a stopping the server so let it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,67 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use jsonrpsee::core::client::ClientT;
use jsonrpsee::core::middleware::RpcServiceBuilder;
use jsonrpsee::server::Server;
use jsonrpsee::ws_client::{WsClient, WsClientBuilder};
use jsonrpsee::{RpcModule, rpc_params};
use tracing_subscriber::util::SubscriberInitExt;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()?
.add_directive("jsonrpsee[method_call{name = \"say_hello\"}]=trace".parse()?);
tracing_subscriber::FmtSubscriber::builder().with_env_filter(filter).finish().try_init()?;
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let client: WsClient = WsClientBuilder::new().build(&url).await?;
let response: String = client.request("say_hello", rpc_params![]).await?;
tracing::info!("response: {:?}", response);
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
let rpc_middleware = RpcServiceBuilder::new().rpc_logger(1024);
let server = Server::builder().set_rpc_middleware(rpc_middleware).build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}

View File

@ -0,0 +1,122 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use jsonrpsee::core::client::ClientT;
use jsonrpsee::server::{ServerHandle, serve_with_graceful_shutdown, stop_channel};
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{RpcModule, rpc_params};
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tracing_subscriber::util::SubscriberInitExt;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()?
.add_directive("jsonrpsee[method_call{name = \"say_hello\"}]=trace".parse()?)
.add_directive("jsonrpsee-client=trace".parse()?);
tracing_subscriber::FmtSubscriber::builder().with_env_filter(filter).finish().try_init()?;
let (_server_hdl, addrs) = run_server().await?;
let url_v4 = format!("ws://{}", addrs.v4);
let url_v6 = format!("ws://{}", addrs.v6);
let client_v4 = WsClientBuilder::default().build(&url_v4).await?;
let client_v6 = WsClientBuilder::default().build(&url_v6).await?;
let response_v4: String = client_v4.request("say_hello", rpc_params![]).await?;
let response_v6: String = client_v6.request("say_hello", rpc_params![]).await?;
tracing::info!("response V4: {:?}", response_v4);
tracing::info!("response V6: {:?}", response_v6);
Ok(())
}
async fn run_server() -> anyhow::Result<(ServerHandle, Addrs)> {
let port = 9944;
// V4 address
let v4_addr = SocketAddr::from(([127, 0, 0, 1], port));
// V6 address
let v6_addr = SocketAddr::new("::1".parse().unwrap(), port);
let mut module = RpcModule::new(());
module.register_method("say_hello", |_, _, _| "lo")?;
// Bind to both IPv4 and IPv6 addresses.
let listener_v4 = TcpListener::bind(&v4_addr).await?;
let listener_v6 = TcpListener::bind(&v6_addr).await?;
// Each RPC call/connection get its own `stop_handle`
// to able to determine whether the server has been stopped or not.
//
// To keep the server running the `server_handle`
// must be kept and it can also be used to stop the server.
let (stop_hdl, server_hdl) = stop_channel();
// Create and finalize a server configuration from a TowerServiceBuilder
// given an RpcModule and the stop handle.
let svc = jsonrpsee::server::Server::builder().to_service_builder().build(module, stop_hdl.clone());
tokio::spawn(async move {
loop {
// The `tokio::select!` macro is used to wait for either of the
// listeners to accept a new connection or for the server to be
// stopped.
let stream = tokio::select! {
res = listener_v4.accept() => {
match res {
Ok((stream, _remote_addr)) => stream,
Err(e) => {
tracing::error!("failed to accept v4 connection: {:?}", e);
continue;
}
}
}
res = listener_v6.accept() => {
match res {
Ok((stream, _remote_addr)) => stream,
Err(e) => {
tracing::error!("failed to accept v6 connection: {:?}", e);
continue;
}
}
}
_ = stop_hdl.clone().shutdown() => break,
};
// Spawn a new task to serve each respective (Hyper) connection.
tokio::spawn(serve_with_graceful_shutdown(stream, svc.clone(), stop_hdl.clone().shutdown()));
}
});
Ok((server_hdl, Addrs { v4: v4_addr, v6: v6_addr }))
}
struct Addrs {
v4: SocketAddr,
v6: SocketAddr,
}

View File

@ -0,0 +1,149 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! Example that shows how to broadcast to all active subscriptions using `tokio::sync::broadcast`.
use std::net::SocketAddr;
use futures::StreamExt;
use futures::future::{self, Either};
use jsonrpsee::PendingSubscriptionSink;
use jsonrpsee::core::client::{Subscription, SubscriptionClientT};
use jsonrpsee::core::middleware::RpcServiceBuilder;
use jsonrpsee::rpc_params;
use jsonrpsee::server::{RpcModule, Server, ServerConfig};
use jsonrpsee::ws_client::WsClientBuilder;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
const NUM_SUBSCRIPTION_RESPONSES: usize = 5;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let client1 =
WsClientBuilder::default().set_rpc_middleware(RpcServiceBuilder::new().rpc_logger(1024)).build(&url).await?;
let client2 =
WsClientBuilder::default().set_rpc_middleware(RpcServiceBuilder::new().rpc_logger(1024)).build(&url).await?;
let sub1: Subscription<i32> = client1.subscribe("subscribe_hello", rpc_params![], "unsubscribe_hello").await?;
let sub2: Subscription<i32> = client2.subscribe("subscribe_hello", rpc_params![], "unsubscribe_hello").await?;
let fut1 = sub1.take(NUM_SUBSCRIPTION_RESPONSES).for_each(|r| async move { tracing::info!("sub1 rx: {:?}", r) });
let fut2 = sub2.take(NUM_SUBSCRIPTION_RESPONSES).for_each(|r| async move { tracing::info!("sub2 rx: {:?}", r) });
future::join(fut1, fut2).await;
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
// let's configure the server only hold 5 messages in memory.
let config = ServerConfig::builder().set_message_buffer_capacity(5).build();
let server = Server::builder()
.set_config(config)
.set_rpc_middleware(RpcServiceBuilder::new().rpc_logger(1024))
.build("127.0.0.1:0")
.await?;
let (tx, _rx) = broadcast::channel::<usize>(16);
let mut module = RpcModule::new(tx.clone());
std::thread::spawn(move || produce_items(tx));
module
.register_subscription("subscribe_hello", "s_hello", "unsubscribe_hello", |_, pending, tx, _| async move {
let rx = tx.subscribe();
let stream = BroadcastStream::new(rx);
pipe_from_stream_with_bounded_buffer(pending, stream).await?;
Ok(())
})
.unwrap();
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}
async fn pipe_from_stream_with_bounded_buffer(
pending: PendingSubscriptionSink,
stream: BroadcastStream<usize>,
) -> Result<(), anyhow::Error> {
let sink = pending.accept().await?;
let closed = sink.closed();
futures::pin_mut!(closed, stream);
loop {
match future::select(closed, stream.next()).await {
// subscription closed.
Either::Left((_, _)) => break Ok(()),
// received new item from the stream.
Either::Right((Some(Ok(item)), c)) => {
let msg = serde_json::value::to_raw_value(&item)?;
// NOTE: this will block until there a spot in the queue
// and you might want to do something smarter if it's
// critical that "the most recent item" must be sent when it is produced.
if sink.send(msg).await.is_err() {
break Ok(());
}
closed = c;
}
// Send back back the error.
Either::Right((Some(Err(e)), _)) => break Err(e.into()),
// Stream is closed.
Either::Right((None, _)) => break Ok(()),
}
}
}
// Naive example that broadcasts the produced values to all active subscribers.
fn produce_items(tx: broadcast::Sender<usize>) {
for c in 1..=100 {
std::thread::sleep(std::time::Duration::from_millis(1));
// This might fail if no receivers are alive, could occur if no subscriptions are active...
// Also be aware that this will succeed when at least one receiver is alive
// Thus, clients connecting at different point in time will not receive
// the items sent before the subscription got established.
let _ = tx.send(c);
}
}

View File

@ -0,0 +1,138 @@
// Copyright 2019-2021 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::net::SocketAddr;
use std::time::Duration;
use futures::{Stream, StreamExt};
use jsonrpsee::core::Serialize;
use jsonrpsee::core::client::{Subscription, SubscriptionClientT};
use jsonrpsee::server::{RpcModule, Server, ServerConfig, TrySendError};
use jsonrpsee::ws_client::WsClientBuilder;
use jsonrpsee::{PendingSubscriptionSink, rpc_params};
use tokio::time::interval;
use tokio_stream::wrappers::IntervalStream;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init()
.expect("setting default subscriber failed");
let addr = run_server().await?;
let url = format!("ws://{}", addr);
let client = WsClientBuilder::default().build(&url).await?;
// Subscription with a single parameter
let mut sub_params_one: Subscription<Option<char>> =
client.subscribe("sub_one_param", rpc_params![3], "unsub_one_param").await?;
tracing::info!("subscription with one param: {:?}", sub_params_one.next().await);
// Subscription with multiple parameters
let mut sub_params_two: Subscription<String> =
client.subscribe("sub_params_two", rpc_params![2, 5], "unsub_params_two").await?;
tracing::info!("subscription with two params: {:?}", sub_params_two.next().await);
Ok(())
}
async fn run_server() -> anyhow::Result<SocketAddr> {
const LETTERS: &str = "abcdefghijklmnopqrstuvxyz";
let config = ServerConfig::builder().set_message_buffer_capacity(10).build();
let server = Server::builder().set_config(config).build("127.0.0.1:0").await?;
let mut module = RpcModule::new(());
module
.register_subscription(
"sub_one_param",
"sub_one_param",
"unsub_one_param",
|params, pending, _, _| async move {
// we are doing this verbose way to get a customized reject error on the subscription.
let idx = match params.one::<usize>() {
Ok(p) => p,
Err(e) => {
let _ = pending.reject(e).await;
return Ok(());
}
};
let item = LETTERS.chars().nth(idx);
let interval = interval(Duration::from_millis(200));
let stream = IntervalStream::new(interval).map(move |_| item);
pipe_from_stream_and_drop(pending, stream).await.map_err(Into::into)
},
)
.unwrap();
module
.register_subscription("sub_params_two", "params_two", "unsub_params_two", |params, pending, _, _| async move {
let (one, two) = params.parse::<(usize, usize)>()?;
let item = &LETTERS[one..two];
let interval = interval(Duration::from_millis(200));
let stream = IntervalStream::new(interval).map(move |_| item);
pipe_from_stream_and_drop(pending, stream).await.map_err(Into::into)
})
.unwrap();
let addr = server.local_addr()?;
let handle = server.start(module);
// In this example we don't care about doing shutdown so let's it run forever.
// You may use the `ServerHandle` to shut it down or manage it yourself.
tokio::spawn(handle.stopped());
Ok(addr)
}
pub async fn pipe_from_stream_and_drop<T: Serialize>(
pending: PendingSubscriptionSink,
mut stream: impl Stream<Item = T> + Unpin,
) -> Result<(), anyhow::Error> {
let mut sink = pending.accept().await?;
loop {
tokio::select! {
_ = sink.closed() => break Err(anyhow::anyhow!("Subscription was closed")),
maybe_item = stream.next() => {
let item = match maybe_item {
Some(item) => item,
None => break Err(anyhow::anyhow!("Subscription was closed")),
};
let msg = serde_json::value::to_raw_value(&item)?;
match sink.try_send(msg) {
Ok(_) => (),
Err(TrySendError::Closed(_)) => break Err(anyhow::anyhow!("Subscription was closed")),
// channel is full, let's be naive an just drop the message.
Err(TrySendError::Full(_)) => (),
}
}
}
}
}

View File

@ -0,0 +1,54 @@
[package]
name = "actor_osis"
version = "0.1.0"
edition = "2024"
[lib]
name = "actor_osis" # Can be different from package name, or same
path = "src/lib.rs"
[[bin]]
name = "actor_osis"
path = "cmd/actor_osis.rs"
[[example]]
name = "engine"
path = "examples/engine.rs"
[[example]]
name = "actor"
path = "examples/actor.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
redis = { version = "0.25.0", features = ["tokio-comp"] }
rhai = { version = "1.21.0", features = ["std", "sync", "decimal", "internals"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
log = "0.4"
env_logger = "0.10"
clap = { version = "4.4", features = ["derive"] }
uuid = { version = "1.6", features = ["v4", "serde"] } # Though task_id is string, uuid might be useful
chrono = { version = "0.4", features = ["serde"] }
toml = "0.8"
thiserror = "1.0"
async-trait = "0.1"
hero_job = { git = "https://git.ourworld.tf/herocode/baobab.git"}
baobab_actor = { git = "https://git.ourworld.tf/herocode/baobab.git"}
heromodels = { git = "https://git.ourworld.tf/herocode/db.git" }
heromodels_core = { git = "https://git.ourworld.tf/herocode/db.git" }
heromodels-derive = { git = "https://git.ourworld.tf/herocode/db.git" }
rhailib_dsl = { git = "https://git.ourworld.tf/herocode/rhailib.git" }
hero_logger = { git = "https://git.ourworld.tf/herocode/baobab.git", branch = "logger" }
tracing = "0.1.41"
[features]
default = ["calendar", "finance"]
calendar = []
finance = []
flow = []
legal = []
projects = []
biz = []

View File

@ -0,0 +1,79 @@
# Object Storage and Indexing System (OSIS) Actor
The OSIS Actor is responsible for storing and indexing objects in the system. It implements the actor interface to process jobs in a **blocking, synchronized manner**.
## Job Processing Behavior
The OSISActor processes jobs sequentially with the following characteristics:
- **Blocking Processing**: Each job is processed completely before the next job begins
- **Synchronized Execution**: Jobs are executed one at a time in the order they are received
- **No Concurrency**: Unlike async actors, OSIS ensures no parallel job execution
- **Deterministic Order**: Job completion follows the exact order of job submission
This design ensures data consistency and prevents race conditions when performing storage and indexing operations.
## Usage
```rust
use actor_osis::{OSISActor, spawn_osis_actor};
// Create an OSIS actor with builder pattern
let actor = OSISActor::builder()
.db_path("/path/to/database")
.redis_url("redis://localhost:6379")
.build()
.expect("Failed to build OSISActor");
// Or spawn directly with convenience function
let handle = spawn_osis_actor(
"/path/to/database".to_string(),
"redis://localhost:6379".to_string(),
shutdown_rx,
);
```
## Actor Properties
- **Actor ID**: `"osis"` (constant)
- **Actor Type**: `"OSIS"`
- **Processing Model**: Sequential, blocking
- **Script Engine**: Rhai with OSIS-specific DSL extensions
## Canonical Redis queues and verification
The project uses canonical dispatch queues per script type. For OSIS, the work queue is:
- hero:q:work:type:osis
Consumer behavior:
- The in-repo actor derives ScriptType=OSIS from its actor_id containing "osis" and BLPOPs hero:q:work:type:osis.
- This repos OSIS actor has been updated so its actor_id is "osis", ensuring it consumes the canonical queue.
Quick verification (redis-cli):
- List work queues:
- KEYS hero:q:work:type:*
- Check OSIS queue length:
- LLEN hero:q:work:type:osis
- Inspect a specific job (replace {job_id} with the printed id):
- HGET hero:job:{job_id} status
- HGET hero:job:{job_id} output
Run options:
- Option A: Run the example which spawns the OSIS actor and dispatches jobs to the canonical queue.
1) Start Redis (if not already): redis-server
2) In this repo:
- cargo run --example actor
3) Observe the console: job IDs will be printed as they are created and dispatched.
4) In a separate terminal, verify with redis-cli:
- LLEN hero:q:work:type:osis (will briefly increment, then return to 0 as the actor consumes)
- HGET hero:job:{job_id} status (should transition to started then finished)
- HGET hero:job:{job_id} output (should contain the script result)
- Option B: Run the standalone actor binary and dispatch from another process that pushes to the canonical type queue.
1) Start the actor:
- cargo run --bin actor_osis
2) From any producer, LPUSH hero:q:work:type:osis {job_id} after persisting the job hash hero:job:{job_id}.
3) Use the same redis-cli checks above to confirm consumption and completion.
Notes:
- Hash-only result model is the default. The job result is written to hero:job:{job_id}.output and status=finished.
- Reply queues (hero:q:reply:{job_id}) are optional and not required for OSIS to function.

View File

@ -0,0 +1,60 @@
use actor_osis::OSISActor;
use clap::Parser;
use log::info;
use std::sync::Arc;
use tokio::sync::mpsc;
#[derive(Parser, Debug)]
#[command(name = "actor_osis")]
#[command(about = "OSIS Actor - Synchronous job processing actor")]
struct Args {
/// Database path
#[arg(short, long, default_value = "/tmp/osis_db")]
db_path: String,
/// Redis URL
#[arg(short, long, default_value = "redis://localhost:6379")]
redis_url: String,
/// Preserve completed tasks in Redis
#[arg(short, long)]
preserve_tasks: bool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
env_logger::init();
let args = Args::parse();
info!("Starting OSIS Actor");
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
// Setup signal handler for graceful shutdown
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
info!("Received Ctrl+C, initiating shutdown...");
let _ = shutdown_tx_clone.send(()).await;
});
// Create and start the actor
let actor = Arc::new(
OSISActor::builder()
.db_path(args.db_path)
.redis_url(args.redis_url)
.build()?
);
let handle = baobab_actor::spawn_actor(actor, shutdown_rx);
info!("OSIS Actor started, waiting for jobs...");
// Wait for the actor to complete
handle.await??;
info!("OSIS Actor shutdown complete");
Ok(())
}

View File

@ -0,0 +1,179 @@
//! # Rhailib Domain-Specific Language (DSL) Engine
//!
//! This module provides a comprehensive Domain-Specific Language implementation for the Rhai
//! scripting engine, exposing business domain models and operations through a fluent,
//! chainable API.
//!
//! ## Overview
//!
//! The DSL is organized into business domain modules, each providing Rhai-compatible
//! functions for creating, manipulating, and persisting domain entities. All operations
//! include proper authorization checks and type safety.
//!
//! ## Available Domains
//!
//! - **Business Operations** (`biz`): Companies, products, sales, shareholders
//! - **Financial Models** (`finance`): Accounts, assets, marketplace operations
//! - **Content Management** (`library`): Collections, images, PDFs, books, slideshows
//! - **Workflow Management** (`flow`): Flows, steps, signature requirements
//! - **Community Management** (`circle`): Circles, themes, membership
//! - **Contact Management** (`contact`): Contact information and relationships
//! - **Access Control** (`access`): Security and permissions
//! - **Time Management** (`calendar`): Calendar and scheduling
//! - **Core Utilities** (`core`): Comments and fundamental operations
//! - **Generic Objects** (`object`): Generic object manipulation
//!
//! ## Usage Example
//!
//! ```rust
//! use rhai::Engine;
//! use crate::engine::register_dsl_modules;
//!
//! let mut engine = Engine::new();
//! register_dsl_modules(&mut engine);
//!
//! // Now the engine can execute scripts like:
//! // let company = new_company().name("Acme Corp").email("contact@acme.com");
//! // let saved = save_company(company);
//! ```
use rhai::Engine;
use rhailib_dsl;
use std::sync::{Arc, OnceLock};
/// Engine factory for creating and sharing Rhai engines.
pub struct EngineFactory {
engine: Arc<Engine>,
}
impl EngineFactory {
/// Create a new engine factory with a configured Rhai engine.
pub fn new() -> Self {
let mut engine = Engine::new();
register_dsl_modules(&mut engine);
// Logger
hero_logger::rhai_integration::configure_rhai_logging(&mut engine, "osis_actor");
Self {
engine: Arc::new(engine),
}
}
/// Get a shared reference to the engine.
pub fn get_engine(&self) -> Arc<Engine> {
Arc::clone(&self.engine)
}
/// Get the global singleton engine factory.
pub fn global() -> &'static EngineFactory {
static FACTORY: OnceLock<EngineFactory> = OnceLock::new();
FACTORY.get_or_init(|| EngineFactory::new())
}
}
/// Register basic object functions directly in the engine.
/// This provides object functionality without relying on the problematic rhailib_dsl object module.
fn register_object_functions(engine: &mut Engine) {
use heromodels::models::object::Object;
// Register the Object type
engine.register_type_with_name::<Object>("Object");
// Register constructor function
engine.register_fn("new_object", || Object::new());
// Register setter functions
engine.register_fn("object_title", |obj: &mut Object, title: String| {
obj.title = title;
obj.clone()
});
engine.register_fn(
"object_description",
|obj: &mut Object, description: String| {
obj.description = description;
obj.clone()
},
);
// Register getter functions
engine.register_fn("get_object_id", |obj: &mut Object| obj.id() as i64);
engine.register_fn("get_object_title", |obj: &mut Object| obj.title.clone());
engine.register_fn("get_object_description", |obj: &mut Object| {
obj.description.clone()
});
}
/// Registers all DSL modules with the provided Rhai engine.
///
/// This function is the main entry point for integrating the rhailib DSL with a Rhai engine.
/// It registers all business domain modules, making their functions available to Rhai scripts.
///
/// # Arguments
///
/// * `engine` - A mutable reference to the Rhai engine to register modules with
///
/// # Example
///
/// ```rust
/// use rhai::Engine;
/// use crate::engine::register_dsl_modules;
///
/// let mut engine = Engine::new();
/// register_dsl_modules(&mut engine);
///
/// // Engine now has access to all DSL functions
/// let result = engine.eval::<String>(r#"
/// let company = new_company().name("Test Corp");
/// company.name
/// "#).unwrap();
/// assert_eq!(result, "Test Corp");
/// ```
///
/// # Registered Modules
///
/// This function registers the following domain modules:
/// - Access control functions
/// - Business operation functions (companies, products, sales, shareholders)
/// - Calendar and scheduling functions
/// - Circle and community management functions
/// - Company management functions
/// - Contact management functions
/// - Core utility functions
/// - Financial operation functions (accounts, assets, marketplace)
/// - Workflow management functions (flows, steps, signatures)
/// - Library and content management functions
/// - Generic object manipulation functions (custom implementation)
pub fn register_dsl_modules(engine: &mut Engine) {
rhailib_dsl::access::register_access_rhai_module(engine);
rhailib_dsl::biz::register_biz_rhai_module(engine);
rhailib_dsl::calendar::register_calendar_rhai_module(engine);
rhailib_dsl::circle::register_circle_rhai_module(engine);
rhailib_dsl::company::register_company_rhai_module(engine);
rhailib_dsl::contact::register_contact_rhai_module(engine);
rhailib_dsl::core::register_core_rhai_module(engine);
rhailib_dsl::finance::register_finance_rhai_modules(engine);
// rhailib_dsl::flow::register_flow_rhai_modules(engine);
rhailib_dsl::library::register_library_rhai_module(engine);
// Skip problematic object module for now - can be implemented separately if needed
// rhailib_dsl::object::register_object_fns(engine);
rhailib_dsl::payment::register_payment_rhai_module(engine);
// Register basic object functionality directly
register_object_functions(engine);
println!("Rhailib Domain Specific Language modules registered successfully.");
}
/// Create a shared heromodels engine using the factory.
pub fn create_osis_engine() -> Arc<Engine> {
EngineFactory::global().get_engine()
}
/// Evaluate a Rhai script string.
pub fn eval_script(
engine: &Engine,
script: &str,
) -> Result<rhai::Dynamic, Box<rhai::EvalAltResult>> {
engine.eval(script)
}

View File

@ -0,0 +1,332 @@
mod engine;
use async_trait::async_trait;
use baobab_actor::execute_job_with_engine;
use hero_job::{Job, JobStatus, ScriptType};
use hero_logger::{create_job_logger, create_job_logger_with_guard};
use log::{error, info};
use redis::AsyncCommands;
use rhai::Engine;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::subscriber::with_default;
use baobab_actor::{actor_trait::Actor, spawn_actor};
/// Constant actor ID for OSIS actor
const OSIS: &str = "osis";
/// Builder for OSISActor
#[derive(Debug)]
pub struct OSISActorBuilder {
engine: Option<Arc<Engine>>,
db_path: Option<String>,
redis_url: Option<String>,
}
impl Default for OSISActorBuilder {
fn default() -> Self {
Self {
engine: None,
db_path: None,
redis_url: Some("redis://localhost:6379".to_string()),
}
}
}
impl OSISActorBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn engine(mut self, engine: Engine) -> Self {
self.engine = Some(Arc::new(engine));
self
}
pub fn shared_engine(mut self, engine: Arc<Engine>) -> Self {
self.engine = Some(engine);
self
}
pub fn db_path<S: Into<String>>(mut self, db_path: S) -> Self {
self.db_path = Some(db_path.into());
self
}
pub fn redis_url<S: Into<String>>(mut self, redis_url: S) -> Self {
self.redis_url = Some(redis_url.into());
self
}
pub fn build(self) -> Result<OSISActor, String> {
let engine = self
.engine
.unwrap_or_else(|| crate::engine::create_osis_engine());
Ok(OSISActor {
engine,
db_path: self.db_path.ok_or("db_path is required")?,
redis_url: self
.redis_url
.unwrap_or("redis://localhost:6379".to_string()),
})
}
}
/// OSIS actor that processes jobs in a blocking, synchronized manner
#[derive(Debug, Clone)]
pub struct OSISActor {
pub engine: Arc<Engine>,
pub db_path: String,
pub redis_url: String,
}
impl OSISActor {
/// Create a new OSISActorBuilder
pub fn builder() -> OSISActorBuilder {
OSISActorBuilder::new()
}
}
impl Default for OSISActor {
fn default() -> Self {
Self {
engine: crate::engine::create_osis_engine(),
db_path: "/tmp".to_string(),
redis_url: "redis://localhost:6379".to_string(),
}
}
}
#[async_trait]
impl Actor for OSISActor {
async fn process_job(&self, job: Job, redis_conn: &mut redis::aio::MultiplexedConnection) {
let job_id = &job.id;
let _db_path = &self.db_path;
// Debug: Log job details
info!(
"OSIS Actor '{}', Job {}: Processing job with context_id: {}, script length: {}",
OSIS, job_id, job.context_id, job.script.len()
);
// Create job-specific logger
let (job_logger, guard) = match create_job_logger_with_guard("logs", "osis", job_id) {
Ok((logger, guard)) => {
info!(
"OSIS Actor '{}', Job {}: Job logger created successfully",
OSIS, job_id
);
(logger, guard)
},
Err(e) => {
error!(
"OSIS Actor '{}', Job {}: Failed to create job logger: {}",
OSIS, job_id, e
);
return;
}
};
info!(
"OSIS Actor '{}', Job {}: Starting sequential processing",
OSIS, job_id
);
// Update job status to Started
if let Err(e) = Job::update_status(redis_conn, job_id, JobStatus::Started).await {
error!(
"OSIS Actor '{}', Job {}: Failed to update status to Started: {}",
OSIS, job_id, e
);
return;
}
// Execute ALL job processing within logging context
let job_result = with_default(job_logger, || {
tracing::info!(target: "osis_actor", "Job {} started", job_id);
// Move the Rhai script execution inside this scope
// IMPORTANT: Create a new engine and configure Rhai logging for this job context
let mut job_engine = Engine::new();
register_dsl_modules(&mut job_engine);
// Configure Rhai logging integration for this engine instance
hero_logger::rhai_integration::configure_rhai_logging(&mut job_engine, "osis_actor");
// Execute the script within the job logger context
let script_result = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
execute_job_with_engine(&mut job_engine, &job, &self.db_path).await
})
});
tracing::info!(target: "osis_actor", "Job {} completed", job_id);
script_result // Return the result
});
// Handle the result outside the logging context
match job_result {
Ok(result) => {
let result_str = format!("{:?}", result);
info!(
"OSIS Actor '{}', Job {}: Script executed successfully. Result: {}",
OSIS, job_id, result_str
);
// Update job with success result (stores in job hash output field)
if let Err(e) = Job::set_result(redis_conn, job_id, &result_str).await {
error!(
"OSIS Actor '{}', Job {}: Failed to set result: {}",
OSIS, job_id, e
);
return;
}
// Also push result to result queue for retrieval
let result_queue_key = format!("hero:job:{}:result", job_id);
if let Err(e) = redis_conn
.lpush::<_, _, ()>(&result_queue_key, &result_str)
.await
{
error!(
"OSIS Actor '{}', Job {}: Failed to push result to queue {}: {}",
OSIS, job_id, result_queue_key, e
);
} else {
info!(
"OSIS Actor '{}', Job {}: Result pushed to queue: {}",
OSIS, job_id, result_queue_key
);
}
if let Err(e) = Job::update_status(redis_conn, job_id, JobStatus::Finished).await {
error!(
"OSIS Actor '{}', Job {}: Failed to update status to Finished: {}",
OSIS, job_id, e
);
}
}
Err(e) => {
let error_msg = format!("Script execution error: {}", e);
error!("OSIS Actor '{}', Job {}: {}", OSIS, job_id, error_msg);
// Update job with error (stores in job hash error field)
if let Err(e) = Job::set_error(redis_conn, job_id, &error_msg).await {
error!(
"OSIS Actor '{}', Job {}: Failed to set error: {}",
OSIS, job_id, e
);
}
// Also push error to error queue for retrieval
let error_queue_key = format!("hero:job:{}:error", job_id);
if let Err(e) = redis_conn
.lpush::<_, _, ()>(&error_queue_key, &error_msg)
.await
{
error!(
"OSIS Actor '{}', Job {}: Failed to push error to queue {}: {}",
OSIS, job_id, error_queue_key, e
);
} else {
info!(
"OSIS Actor '{}', Job {}: Error pushed to queue: {}",
OSIS, job_id, error_queue_key
);
}
if let Err(e) = Job::update_status(redis_conn, job_id, JobStatus::Error).await {
error!(
"OSIS Actor '{}', Job {}: Failed to update status to Error: {}",
OSIS, job_id, e
);
}
}
}
// Force flush logs before dropping guard
std::thread::sleep(std::time::Duration::from_millis(100));
// Keep the guard alive until after processing
drop(guard);
info!(
"OSIS Actor '{}', Job {}: Sequential processing completed",
OSIS, job_id
);
}
fn actor_type(&self) -> &'static str {
"OSIS"
}
fn actor_id(&self) -> &str {
// Actor ID contains "osis" so the runtime derives ScriptType=OSIS and consumes the canonical type queue.
"osis"
}
fn redis_url(&self) -> &str {
&self.redis_url
}
}
/// Convenience function to spawn an OSIS actor using the trait interface
///
/// This function provides backward compatibility with the original actor API
/// while using the new trait-based implementation.
pub fn spawn_osis_actor(
db_path: String,
redis_url: String,
shutdown_rx: mpsc::Receiver<()>,
) -> JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>> {
let actor = Arc::new(
OSISActor::builder()
.db_path(db_path)
.redis_url(redis_url)
.build()
.expect("Failed to build OSISActor"),
);
spawn_actor(actor, shutdown_rx)
}
// Re-export engine functions for examples and external use
pub use crate::engine::{create_osis_engine, register_dsl_modules};
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_osis_actor_creation() {
let actor = OSISActor::builder().build().unwrap();
assert_eq!(actor.actor_type(), "OSIS");
}
#[tokio::test]
async fn test_osis_actor_default() {
let actor = OSISActor::default();
assert_eq!(actor.actor_type(), "OSIS");
}
#[tokio::test]
async fn test_osis_actor_process_job_interface() {
let actor = OSISActor::default();
// Create a simple test job
let _job = Job::new(
"test_caller".to_string(),
"test_context".to_string(),
r#"print("Hello from sync actor test!"); 42"#.to_string(),
ScriptType::OSIS,
);
// Note: This test doesn't actually connect to Redis, it just tests the interface
// In a real test environment, you'd need a Redis instance or mock
// For now, just verify the actor was created successfully
assert_eq!(actor.actor_type(), "OSIS");
}
}

109
tools/gen_auth.py Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Generate secp256k1 keypair and sign a nonce in the exact format the server expects.
Install dependencies once:
python3 -m pip install -r tools/requirements.txt
Usage examples:
# Generate a new keypair and sign a nonce (prints PRIVATE_HEX, PUBLIC_HEX, SIGNATURE_HEX)
python tools/gen_auth.py --nonce "PASTE_NONCE_FROM_fetch_nonce"
# Sign with an existing private key (64 hex chars)
python tools/gen_auth.py --nonce "PASTE_NONCE" --priv "YOUR_PRIVATE_KEY_HEX"
# Output JSON instead of key=value lines
python tools/gen_auth.py --nonce "PASTE_NONCE" --json
Notes:
- Public key is compressed (33 bytes) hex, starting with 02/03 (66 hex chars total).
- Signature is compact ECDSA (r||s) 64 bytes (128 hex chars).
- The nonce should be the exact ASCII string returned by fetch_nonce().
- The message signed is sha256(nonce_ascii) to match client/server behavior:
- [rust.AuthHelper::sign_message()](interfaces/openrpc/client/src/auth.rs:55)
- [rust.AuthManager::verify_signature()](interfaces/openrpc/server/src/auth.rs:85)
"""
import argparse
import hashlib
import json
import sys
from typing import Dict, Tuple, Optional
try:
from ecdsa import SigningKey, VerifyingKey, SECP256k1, util
except Exception as e:
print("Missing dependency 'ecdsa'. Install with:", file=sys.stderr)
print(" python3 -m pip install -r tools/requirements.txt", file=sys.stderr)
raise
def sha256_ascii(s: str) -> bytes:
return hashlib.sha256(s.encode()).digest()
def to_compact_signature_hex(sk: SigningKey, nonce_ascii: str) -> str:
digest = sha256_ascii(nonce_ascii)
sig = sk.sign_digest(digest, sigencode=util.sigencode_string) # 64 bytes r||s
return sig.hex()
def compressed_pubkey_hex(vk: VerifyingKey) -> str:
# Prefer compressed output if library supports it directly (ecdsa>=0.18)
try:
return vk.to_string("compressed").hex()
except TypeError:
# Manual compression (02/03 + X)
p = vk.pubkey.point
x = p.x()
y = p.y()
prefix = b"\x02" if (y % 2 == 0) else b"\x03"
return (prefix + x.to_bytes(32, "big")).hex()
def generate_or_load_sk(priv_hex: Optional[str]) -> Tuple[SigningKey, bool]:
if priv_hex:
if len(priv_hex) != 64:
raise ValueError("Provided --priv must be 64 hex chars (32 bytes).")
return SigningKey.from_string(bytes.fromhex(priv_hex), curve=SECP256k1), False
return SigningKey.generate(curve=SECP256k1), True
def run(nonce: str, priv_hex: Optional[str], as_json: bool) -> int:
sk, generated = generate_or_load_sk(priv_hex)
vk = sk.get_verifying_key()
out: Dict[str, str] = {
"PUBLIC_HEX": compressed_pubkey_hex(vk),
"NONCE": nonce,
"SIGNATURE_HEX": to_compact_signature_hex(sk, nonce),
}
# Always print the private key for convenience (either generated or provided)
out["PRIVATE_HEX"] = sk.to_string().hex()
if as_json:
print(json.dumps(out, separators=(",", ":")))
else:
# key=value form for easy copy/paste
print(f"PRIVATE_HEX={out['PRIVATE_HEX']}")
print(f"PUBLIC_HEX={out['PUBLIC_HEX']}")
print(f"NONCE={out['NONCE']}")
print(f"SIGNATURE_HEX={out['SIGNATURE_HEX']}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Generate secp256k1 auth material and signature for a nonce.")
parser.add_argument("--nonce", required=True, help="Nonce string returned by fetch_nonce (paste as-is)")
parser.add_argument("--priv", help="Existing private key hex (64 hex chars). If omitted, a new keypair is generated.")
parser.add_argument("--json", action="store_true", help="Output JSON instead of key=value lines.")
args = parser.parse_args()
try:
return run(args.nonce, args.priv, args.json)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

124
tools/gen_auth.sh Executable file
View File

@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
gen_auth.sh --nonce "<nonce_string>" [--priv <private_key_hex>] [--json]
Options:
--nonce The nonce string returned by fetch_nonce (paste as-is).
--priv Optional private key hex (64 hex chars). If omitted, a new key is generated.
--json Output JSON instead of plain KEY=VALUE lines.
Outputs:
PRIVATE_HEX Private key hex (only when generated, or echoed back if provided)
PUBLIC_HEX Compressed secp256k1 public key hex (33 bytes, 66 hex chars)
NONCE The nonce string you passed in
SIGNATURE_HEX Compact ECDSA signature hex (64 bytes, 128 hex chars)
Notes:
- The signature is produced by signing sha256(nonce_ascii) and encoded as compact r||s (64 bytes),
which matches the server/client behavior ([interfaces/openrpc/client/src/auth.rs](interfaces/openrpc/client/src/auth.rs:55), [interfaces/openrpc/server/src/auth.rs](interfaces/openrpc/server/src/auth.rs:85)).
USAGE
}
NONCE=""
PRIV_HEX=""
OUT_JSON=0
while [[ $# -gt 0 ]]; do
case "$1" in
--nonce)
NONCE="${2:-}"; shift 2 ;;
--priv)
PRIV_HEX="${2:-}"; shift 2 ;;
--json)
OUT_JSON=1; shift ;;
-h|--help)
usage; exit 0 ;;
*)
echo "Unknown arg: $1" >&2; usage; exit 1 ;;
esac
done
if [[ -z "$NONCE" ]]; then
echo "Error: --nonce is required" >&2
usage
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 not found. Install Python 3 (e.g., sudo pacman -S python) and retry." >&2
exit 1
fi
# Ensure 'ecdsa' module is available; install to user site if missing.
if ! python3 - <<'PY' >/dev/null 2>&1
import importlib; importlib.import_module("ecdsa")
PY
then
echo "Installing Python 'ecdsa' package in user site..." >&2
if ! python3 -m pip install --user --quiet ecdsa; then
echo "Error: failed to install 'ecdsa'. Install manually: python3 -m pip install --user ecdsa" >&2
exit 1
fi
fi
# Now run Python to generate/derive keys and sign the nonce (ASCII) with compact ECDSA.
python3 - "$NONCE" "$PRIV_HEX" "$OUT_JSON" <<'PY'
import sys, json, hashlib
from ecdsa import SigningKey, VerifyingKey, SECP256k1, util
NONCE = sys.argv[1]
PRIV_HEX = sys.argv[2]
OUT_JSON = int(sys.argv[3]) == 1
def to_compact_signature(sk: SigningKey, msg_ascii: str) -> bytes:
digest = hashlib.sha256(msg_ascii.encode()).digest()
return sk.sign_digest(digest, sigencode=util.sigencode_string) # 64 bytes r||s
def compressed_pubkey(vk: VerifyingKey) -> bytes:
try:
return vk.to_string("compressed")
except TypeError:
p = vk.pubkey.point
x = p.x()
y = vk.pubkey.point.y()
prefix = b'\x02' if (y % 2 == 0) else b'\x03'
return prefix + x.to_bytes(32, "big")
generated = False
if PRIV_HEX:
if len(PRIV_HEX) != 64:
print("ERROR: Provided --priv must be 64 hex chars", file=sys.stderr)
sys.exit(1)
sk = SigningKey.from_string(bytes.fromhex(PRIV_HEX), curve=SECP256k1)
else:
sk = SigningKey.generate(curve=SECP256k1)
generated = True
vk = sk.get_verifying_key()
pub_hex = compressed_pubkey(vk).hex()
sig_hex = to_compact_signature(sk, NONCE).hex()
priv_hex = sk.to_string().hex()
out = {
"PUBLIC_HEX": pub_hex,
"NONCE": NONCE,
"SIGNATURE_HEX": sig_hex,
}
if generated or PRIV_HEX:
out["PRIVATE_HEX"] = priv_hex
if OUT_JSON:
print(json.dumps(out, separators=(",", ":")))
else:
if "PRIVATE_HEX" in out:
print(f"PRIVATE_HEX={out['PRIVATE_HEX']}")
print(f"PUBLIC_HEX={out['PUBLIC_HEX']}")
print(f"NONCE={out['NONCE']}")
print(f"SIGNATURE_HEX={out['SIGNATURE_HEX']}")
PY
# End

2
tools/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
ecdsa==0.18.0
requests==2.32.3

204
tools/rpc_smoke_test.py Normal file
View File

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Non-destructive JSON-RPC smoke tests against the OpenRPC server.
Installs:
python3 -m pip install -r tools/requirements.txt
Usage:
# Default URL http://127.0.0.1:9944
python tools/rpc_smoke_test.py
# Specify a different URL
python tools/rpc_smoke_test.py --url http://127.0.0.1:9944
# Provide a specific pubkey for fetch_nonce (compressed 33-byte hex)
python tools/rpc_smoke_test.py --pubkey 02deadbeef...
# Lookup details for first N jobs returned by list_jobs
python tools/rpc_smoke_test.py --limit 5
What it tests (non-destructive):
- fetch_nonce(pubkey) -> returns a nonce string from the server auth manager
- whoami() -> returns a JSON string with basic server info
- list_jobs() -> returns job IDs only (no mutation)
- get_job_status(id) -> reads status (for up to --limit items)
- get_job_output(id) -> reads output (for up to --limit items)
- get_job_logs(id) -> reads logs (for up to --limit items)
Notes:
- If you don't pass --pubkey, this script will generate a random secp256k1 keypair
and derive a compressed public key (no persistence, just for testing fetch_nonce).
"""
import argparse
import json
import os
import random
import sys
import time
from typing import Any, Dict, List, Optional
try:
import requests
except Exception:
print("Missing dependency 'requests'. Install with:\n python3 -m pip install -r tools/requirements.txt", file=sys.stderr)
raise
try:
from ecdsa import SigningKey, SECP256k1
except Exception:
# ecdsa is optional here; only used to generate a test pubkey if --pubkey is absent
SigningKey = None # type: ignore
def ensure_http_url(url: str) -> str:
if url.startswith("http://") or url.startswith("https://"):
return url
# Accept ws:// scheme too; convert to http for JSON-RPC over HTTP
if url.startswith("ws://"):
return "http://" + url[len("ws://") :]
if url.startswith("wss://"):
return "https://" + url[len("wss://") :]
# Default to http if no scheme
return "http://" + url
class JsonRpcClient:
def __init__(self, url: str):
self.url = ensure_http_url(url)
self._id = int(time.time() * 1000)
def call(self, method: str, params: Any) -> Any:
self._id += 1
payload = {
"jsonrpc": "2.0",
"id": self._id,
"method": method,
"params": params,
}
resp = requests.post(self.url, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
if "error" in data and data["error"] is not None:
raise RuntimeError(f"RPC error for {method}: {data['error']}")
return data.get("result")
def random_compressed_pubkey_hex() -> str:
"""
Generate a random secp256k1 keypair and return compressed public key hex.
Requires 'ecdsa'. If unavailable, raise an informative error.
"""
if SigningKey is None:
raise RuntimeError(
"ecdsa not installed; either install with:\n"
" python3 -m pip install -r tools/requirements.txt\n"
"or pass --pubkey explicitly."
)
sk = SigningKey.generate(curve=SECP256k1)
vk = sk.get_verifying_key()
try:
comp = vk.to_string("compressed")
except TypeError:
# Manual compression
p = vk.pubkey.point
x = p.x()
y = p.y()
prefix = b"\x02" if (y % 2 == 0) else b"\x03"
comp = prefix + x.to_bytes(32, "big")
return comp.hex()
def main() -> int:
parser = argparse.ArgumentParser(description="Non-destructive RPC smoke tests")
parser.add_argument("--url", default=os.environ.get("RPC_URL", "http://127.0.0.1:9944"),
help="RPC server URL (http[s]://host:port or ws[s]://host:port)")
parser.add_argument("--pubkey", help="Compressed secp256k1 public key hex (33 bytes, 66 hex chars)")
parser.add_argument("--limit", type=int, default=3, help="Number of job IDs to detail from list_jobs()")
args = parser.parse_args()
client = JsonRpcClient(args.url)
print(f"[rpc] URL: {client.url}")
# 1) fetch_nonce
pubkey = args.pubkey or random_compressed_pubkey_hex()
print(f"[rpc] fetch_nonce(pubkey={pubkey[:10]}...):", end=" ")
try:
nonce = client.call("fetch_nonce", [pubkey])
print("OK")
print(f" nonce: {nonce}")
except Exception as e:
print(f"ERROR: {e}")
return 1
# 2) whoami
print("[rpc] whoami():", end=" ")
try:
who = client.call("whoami", [])
print("OK")
print(f" whoami: {who}")
except Exception as e:
print(f"ERROR: {e}")
return 1
# 3) list_jobs
print("[rpc] list_jobs():", end=" ")
try:
job_ids: List[str] = client.call("list_jobs", [])
print("OK")
print(f" total: {len(job_ids)}")
for i, jid in enumerate(job_ids[: max(0, args.limit)]):
print(f" [{i}] {jid}")
except Exception as e:
print(f"ERROR: {e}")
return 1
# 4) For a few jobs, query status/output/logs
detail_count = 0
for jid in job_ids[: max(0, args.limit)] if 'job_ids' in locals() else []:
print(f"[rpc] get_job_status({jid}):", end=" ")
try:
st = client.call("get_job_status", [jid])
print("OK")
print(f" status: {st}")
except Exception as e:
print(f"ERROR: {e}")
print(f"[rpc] get_job_output({jid}):", end=" ")
try:
out = client.call("get_job_output", [jid])
print("OK")
snippet = (out if isinstance(out, str) else json.dumps(out))[:120]
print(f" output: {snippet}{'...' if len(snippet)==120 else ''}")
except Exception as e:
print(f"ERROR: {e}")
print(f"[rpc] get_job_logs({jid}):", end=" ")
try:
logs_obj = client.call("get_job_logs", [jid]) # { logs: String | null }
print("OK")
logs = logs_obj.get("logs") if isinstance(logs_obj, dict) else None
if logs is None:
print(" logs: (no logs)")
else:
snippet = logs[:120]
print(f" logs: {snippet}{'...' if len(snippet)==120 else ''}")
except Exception as e:
print(f"ERROR: {e}")
detail_count += 1
print("\nSmoke tests complete.")
print("Summary:")
print(f" whoami tested")
print(f" fetch_nonce tested (pubkey provided/generated)")
print(f" list_jobs tested (count printed)")
print(f" detailed queries for up to {detail_count} job(s) (status/output/logs)")
return 0
if __name__ == "__main__":
sys.exit(main())