move repos into monorepo

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

View File

@@ -0,0 +1,23 @@
# Hero Supervisor Configuration
# Redis connection URL
REDIS_URL=redis://127.0.0.1:6379
# OpenRPC Server Configuration
BIND_ADDRESS=127.0.0.1
PORT=3030
# Authentication Secrets (generate with: ./scripts/generate_secret.sh)
# At least one admin secret is required
ADMIN_SECRETS=your_admin_secret_here
# Optional: Additional secrets for different access levels
# USER_SECRETS=user_secret_1,user_secret_2
# REGISTER_SECRETS=register_secret_1
# Optional: Pre-configured runners (comma-separated names)
# These runners will be automatically registered on startup
# RUNNERS=runner1,runner2,runner3
# Optional: Mycelium network URL (requires mycelium feature)
# MYCELIUM_URL=http://127.0.0.1:8989

4
bin/supervisor/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
target
.bin
.env
/tmp/supervisor-*.log

66
bin/supervisor/Cargo.toml Normal file
View File

@@ -0,0 +1,66 @@
[package]
name = "hero-supervisor"
version.workspace = true
edition.workspace = true
[lib]
name = "hero_supervisor"
path = "src/lib.rs"
[[bin]]
name = "supervisor"
path = "src/bin/supervisor.rs"
[dependencies]
# Job types
hero-job = { path = "../../lib/models/job" }
hero-job-client = { path = "../../lib/clients/job" }
# Async runtime
tokio.workspace = true
async-trait.workspace = true
# Redis client
redis.workspace = true
# Core dependencies
uuid.workspace = true
log.workspace = true
thiserror.workspace = true
chrono.workspace = true
serde.workspace = true
serde_json.workspace = true
env_logger.workspace = true
# CLI argument parsing
clap.workspace = true
toml.workspace = true
# OpenRPC dependencies
jsonrpsee.workspace = true
anyhow.workspace = true
futures.workspace = true
# CORS support for OpenRPC server
tower-http.workspace = true
tower.workspace = true
hyper.workspace = true
hyper-util.workspace = true
http-body-util.workspace = true
# Osiris client for persistent storage
# osiris-client = { git = "https://git.ourworld.tf/herocode/osiris.git" } # Temporarily disabled - needs update
[dev-dependencies]
tokio-test = "0.4"
hero-supervisor-openrpc-client = { path = "../../lib/clients/supervisor" }
escargot = "0.5"
[features]
default = ["cli"]
cli = []
# Examples
[[example]]
name = "osiris_openrpc"
path = "examples/osiris_openrpc/main.rs"

46
bin/supervisor/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Supervisor
A job execution supervisor that queues jobs to runners over Redis and returns their output. It provides an OpenRPC server for remote job dispatching. The OpenRPC server requires authorization via API keys. API keys are scoped to grant one of three levels of access: Admin, Registrar (can register runners), User (can dispatch jobs).
Jobs contain scripts, environment variables, an identifier of the runner to execute the script, and signatures. The supervisor verifies the signatures, however access control based on who the signatories of a script is handled by the runner logic.
**Note:** Runners are expected to be started and managed externally. The supervisor only tracks which runners are registered and queues jobs to them via Redis.
## Usage
The supervisor needs an admin key to be configured to get started.
`cargo run -- --admin-secret <SECRET>`
You can also use the run script which uses the `.env` file to get the admin key.
`./scripts/run.sh`
The scripts directory also offers other scripts for building testing etc.
## Functionality
Beyond job dispatching, the supervisor provides:
- **API Key Management**: Create, list, and remove API keys with different permission scopes
- **Runner Registration**: Register runners so the supervisor knows which queues are available
- **Job Lifecycle**: Create, start, stop, and monitor jobs
- **Job Queuing**: Queue jobs to specific runners via Redis
Runner registration simply means the supervisor becomes aware that a certain runner is listening to its queue. The full API specification can be seen in `docs/openrpc.json`.
## OpenRPC
### Server
The supervisor automatically starts an OpenRPC server on `127.0.0.1:3030` that exposes all supervisor functionality via JSON-RPC.
### Example JSON-RPC Call
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"list_runners","id":1}' \
http://127.0.0.1:3030
```
### Client
The repository also offers OpenRPC Client for supervisor compatible with WASM targets as well.

146
bin/supervisor/docs/AUTH.md Normal file
View File

@@ -0,0 +1,146 @@
# Hero Supervisor Authentication
The Hero Supervisor now supports API key-based authentication with three permission scopes:
## Permission Scopes
1. **Admin** - Full access to all operations including key management
2. **Registrar** - Can register new runners
3. **User** - Can create and manage jobs
## Starting the Supervisor with an Admin Key
Bootstrap an initial admin key when starting the supervisor:
```bash
cargo run --bin supervisor -- --bootstrap-admin-key "my-admin"
```
This will output:
```
╔════════════════════════════════════════════════════════════╗
║ 🔑 Admin API Key Created ║
╚════════════════════════════════════════════════════════════╝
Name: my-admin
Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Scope: admin
⚠️ SAVE THIS KEY - IT WILL NOT BE SHOWN AGAIN!
╚════════════════════════════════════════════════════════════╝
```
**IMPORTANT:** Save this key securely - it will not be displayed again!
## API Endpoints
### Verify API Key
Verify a key and get its metadata:
```bash
curl -X POST http://127.0.0.1:3030 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "auth.verify",
"params": {
"key": "your-api-key-here"
},
"id": 1
}'
```
Response:
```json
{
"jsonrpc": "2.0",
"result": {
"valid": true,
"name": "my-admin",
"scope": "admin"
},
"id": 1
}
```
### Create New API Key (Admin Only)
```bash
curl -X POST http://127.0.0.1:3030 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "auth.create_key",
"params": {
"admin_key": "your-admin-key",
"name": "runner-bot",
"scope": "registrar"
},
"id": 1
}'
```
Response:
```json
{
"jsonrpc": "2.0",
"result": {
"key": "new-generated-uuid",
"name": "runner-bot",
"scope": "registrar",
"created_at": "2025-10-27T15:00:00Z",
"expires_at": null
},
"id": 1
}
```
### List All API Keys (Admin Only)
```bash
curl -X POST http://127.0.0.1:3030 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "auth.list_keys",
"params": {
"admin_key": "your-admin-key"
},
"id": 1
}'
```
### Remove API Key (Admin Only)
```bash
curl -X POST http://127.0.0.1:3030 \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "auth.remove_key",
"params": {
"admin_key": "your-admin-key",
"key": "key-to-remove"
},
"id": 1
}'
```
## Using Keys in the Admin UI
The admin UI will use the `auth.verify` endpoint during login to:
1. Validate the provided API key
2. Retrieve the key's name and scope
3. Display the user's name and permissions in the header
4. Show/hide UI elements based on scope
## Migration from Legacy Secrets
The supervisor still supports the legacy secret-based authentication for backward compatibility:
- `--admin-secret` - Legacy admin secrets
- `--user-secret` - Legacy user secrets
- `--register-secret` - Legacy register secrets
However, the new API key system is recommended for better management and auditability.

View File

@@ -0,0 +1,268 @@
# Mycelium Integration - Now Optional!
The Mycelium integration is now an optional feature. The supervisor can run with just the OpenRPC HTTP server, making it simpler to use and deploy.
## What Changed
### Before
- Mycelium integration was always enabled
- Supervisor would continuously try to connect to Mycelium on port 8990
- Error logs if Mycelium wasn't available
- Required additional dependencies
### After
- ✅ Mycelium is now an optional feature
- ✅ Supervisor runs with clean OpenRPC HTTP server by default
- ✅ No connection errors when Mycelium isn't needed
- ✅ Smaller binary size without Mycelium dependencies
## Running the Supervisor
### Option 1: Simple OpenRPC Server (Recommended)
**No Mycelium, just OpenRPC:**
```bash
# Using the helper script
./run_supervisor_simple.sh
# Or manually
MYCELIUM_URL="" cargo run --bin supervisor -- \
--redis-url redis://localhost:6379 \
--port 3030
```
This starts:
- ✅ OpenRPC HTTP server on port 3030
- ✅ Redis connection for job queuing
- ❌ No Mycelium integration
### Option 2: With Mycelium Integration
**Enable Mycelium feature:**
```bash
# Build with Mycelium support
cargo build --bin supervisor --features mycelium
# Run with Mycelium URL
MYCELIUM_URL="http://localhost:8990" cargo run --bin supervisor --features mycelium -- \
--redis-url redis://localhost:6379 \
--port 3030
```
This starts:
- ✅ OpenRPC HTTP server on port 3030
- ✅ Redis connection for job queuing
- ✅ Mycelium integration (connects to daemon)
## Feature Flags
### Available Features
| Feature | Description | Default |
|---------|-------------|---------|
| `cli` | Command-line interface | ✅ Yes |
| `mycelium` | Mycelium integration | ❌ No |
### Building with Features
```bash
# Default build (CLI only, no Mycelium)
cargo build --bin supervisor
# With Mycelium
cargo build --bin supervisor --features mycelium
# Minimal (no CLI, no Mycelium)
cargo build --bin supervisor --no-default-features
```
## Architecture
### Without Mycelium (Default)
```
┌─────────────────┐
│ Client │
└────────┬────────┘
│ HTTP/JSON-RPC
┌─────────────────┐
│ Supervisor │
│ OpenRPC Server │
│ (Port 3030) │
└────────┬────────┘
│ Redis
┌─────────────────┐
│ Runners │
└─────────────────┘
```
### With Mycelium (Optional)
```
┌─────────────────┐
│ Client │
└────────┬────────┘
│ HTTP/JSON-RPC
┌─────────────────┐ ┌──────────────┐
│ Supervisor │◄────►│ Mycelium │
│ OpenRPC Server │ │ Daemon │
│ (Port 3030) │ │ (Port 8990) │
└────────┬────────┘ └──────────────┘
│ Redis
┌─────────────────┐
│ Runners │
└─────────────────┘
```
## Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `MYCELIUM_URL` | Mycelium daemon URL | `http://127.0.0.1:8990` | No |
| `RUST_LOG` | Log level | `info` | No |
**To disable Mycelium:**
```bash
export MYCELIUM_URL=""
```
## Dependencies
### Core Dependencies (Always)
- `tokio` - Async runtime
- `redis` - Job queuing
- `jsonrpsee` - OpenRPC server
- `runner_rust` - Job model
### Mycelium Dependencies (Optional)
- `reqwest` - HTTP client
- `base64` - Encoding
- `rand` - Random IDs
## Examples
All examples work without Mycelium:
```bash
# Simple end-to-end example
RUST_LOG=info cargo run --example simple_e2e
# Full automated demo
RUST_LOG=info cargo run --example end_to_end_demo
```
## Migration Guide
### If you were using Mycelium
**Before:**
```bash
cargo run --bin supervisor
# Would try to connect to Mycelium automatically
```
**After:**
```bash
# Option A: Disable Mycelium (recommended for most use cases)
MYCELIUM_URL="" cargo run --bin supervisor
# Option B: Enable Mycelium feature
cargo run --bin supervisor --features mycelium
```
### If you weren't using Mycelium
**Before:**
```bash
cargo run --bin supervisor
# Would see connection errors to port 8990
```
**After:**
```bash
cargo run --bin supervisor
# Clean startup, no connection errors! 🎉
```
## Benefits
### For Development
- ✅ Faster builds (fewer dependencies)
- ✅ Simpler setup (no Mycelium daemon needed)
- ✅ Cleaner logs (no connection errors)
- ✅ Easier debugging
### For Production
- ✅ Smaller binary size
- ✅ Fewer runtime dependencies
- ✅ More flexible deployment
- ✅ Optional advanced features
## Testing
### Test without Mycelium
```bash
# Build
cargo build --bin supervisor
# Run tests
cargo test
# Run examples
cargo run --example simple_e2e
```
### Test with Mycelium
```bash
# Build with feature
cargo build --bin supervisor --features mycelium
# Start Mycelium daemon (if you have one)
# mycelium-daemon --port 8990
# Run supervisor
MYCELIUM_URL="http://localhost:8990" cargo run --bin supervisor --features mycelium
```
## Troubleshooting
### "Mycelium integration not enabled"
This is informational, not an error. If you need Mycelium:
```bash
cargo build --features mycelium
```
### "HTTP request failed: error sending request"
If you see this with Mycelium enabled, check:
1. Is Mycelium daemon running?
2. Is the URL correct? (`MYCELIUM_URL`)
3. Is the port accessible?
Or simply disable Mycelium:
```bash
export MYCELIUM_URL=""
```
## Summary
🎉 **The supervisor now runs cleanly with just OpenRPC!**
- Default: OpenRPC HTTP server only
- Optional: Enable Mycelium with `--features mycelium`
- No more connection errors when Mycelium isn't needed
- Simpler, faster, cleaner!
---
**Status:** ✅ Complete
**Version:** 0.1.0
**Last Updated:** 2025-10-24

View File

@@ -0,0 +1,214 @@
# Quick Start Guide
Complete guide to running the Hero Supervisor with OSIS runners and examples.
## Prerequisites
1. **Redis** - Must be running
2. **Rust** - Version 1.88+ (run `rustup update`)
## 1. Start Redis
```bash
redis-server
```
## 2. Start Supervisor
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
cargo run --bin supervisor
```
You should see:
```
╔════════════════════════════════════════════════════════════╗
║ Hero Supervisor Started ║
╚════════════════════════════════════════════════════════════╝
📡 OpenRPC Server: http://127.0.0.1:3030
🔗 Redis: redis://localhost:6379
🌐 Mycelium: Not compiled (use --features mycelium)
╚════════════════════════════════════════════════════════════╝
```
## 3. Start OSIS Runner
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/runner_rust
cargo run --bin runner_osis -- test_runner \
--redis-url redis://localhost:6379 \
--db-path /tmp/test_runner.db
```
You should see:
```
Starting OSIS Sync Runner with ID: test_runner
Database path: /tmp/test_runner.db
Redis URL: redis://localhost:6379
OSIS Sync Runner 'test_runner' started successfully
```
## 4. Run Example
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
RUST_LOG=info cargo run --example simple_e2e
```
## Terminal Layout
```
┌─────────────────────┬─────────────────────┐
│ Terminal 1 │ Terminal 2 │
│ Redis │ Supervisor │
│ redis-server │ cargo run --bin │
│ │ supervisor │
├─────────────────────┼─────────────────────┤
│ Terminal 3 │ Terminal 4 │
│ OSIS Runner │ Example │
│ cargo run --bin │ cargo run │
│ runner_osis │ --example │
│ │ simple_e2e │
└─────────────────────┴─────────────────────┘
```
## What Each Component Does
### Redis
- Job queue storage
- Job result storage
- Runner coordination
### Supervisor
- OpenRPC HTTP server (port 3030)
- Job dispatch to runners
- Runner registration
- Job execution coordination
### OSIS Runner
- Listens for jobs on Redis queue
- Executes Rhai scripts
- Stores results back to Redis
- Uses HeroDB for data persistence
### Example
- Creates jobs with Rhai scripts
- Sends jobs to supervisor via OpenRPC
- Receives results
- Demonstrates both blocking and non-blocking modes
## Architecture
```
┌─────────────┐
│ Example │ (simple_e2e.rs)
└──────┬──────┘
│ HTTP/JSON-RPC
┌─────────────┐
│ Supervisor │ (port 3030)
└──────┬──────┘
│ Redis Queue
┌─────────────┐
│ OSIS Runner │ (test_runner)
└──────┬──────┘
┌─────────────┐
│ HeroDB │ (Redis + local DB)
└─────────────┘
```
## Troubleshooting
### "Connection refused" on port 3030
- Make sure supervisor is running
- Check if another process is using port 3030
### "Connection refused" on port 6379
- Make sure Redis is running
- Check: `redis-cli ping` (should return "PONG")
### Runner not receiving jobs
- Check runner is registered: Look for "Runner registered successfully" in example output
- Check Redis connection: Both supervisor and runner must use same Redis URL
- Check queue name matches: Should be `hero:q:work:type:osis:group:default:inst:test_runner`
### "Job execution timeout"
- Increase timeout in job builder: `.timeout(120)`
- Check if runner is actually processing jobs (look for logs)
## Example Output
### Successful Run
```
╔════════════════════════════════════════╗
║ Simple End-to-End Demo ║
╚════════════════════════════════════════╝
📋 Step 1: Registering Runner
─────────────────────────────────────────
✅ Runner registered successfully
📋 Step 2: Running a Simple Job (Blocking)
─────────────────────────────────────────
✅ Job completed!
Result: {"message":"Hello from the runner!","number":42}
📋 Step 3: Running a Calculation Job
─────────────────────────────────────────
✅ Calculation completed!
Result: {"sum":55,"product":3628800,"count":10}
📋 Step 4: Starting a Non-Blocking Job
─────────────────────────────────────────
✅ Job started!
Job ID: abc-123 (running in background)
🎉 Demo completed successfully!
```
## Next Steps
1. **Try different Rhai scripts** - Modify the payload in examples
2. **Add more runners** - Start multiple runners with different IDs
3. **Explore the API** - Use the OpenRPC client library
4. **Build your own client** - See `client/` for examples
## Useful Commands
```bash
# Check Redis
redis-cli ping
# List Redis keys
redis-cli keys "hero:*"
# Monitor Redis commands
redis-cli monitor
# Check supervisor is running
curl http://localhost:3030
# View runner logs
# (check terminal where runner is running)
```
## Clean Up
```bash
# Stop all processes (Ctrl+C in each terminal)
# Clean up test database
rm /tmp/test_runner.db
# (Optional) Flush Redis
redis-cli FLUSHALL
```
---
**Status:** ✅ Ready to Use
**Last Updated:** 2025-10-24

View File

@@ -0,0 +1,58 @@
# Repository Restructure
## Changes Made
The supervisor repository has been restructured to follow a cleaner organization:
### Before:
```
supervisor/
├── clients/
│ ├── openrpc/ # OpenRPC client library
│ └── admin-ui/ # Admin UI (Yew WASM app)
├── src/ # Main supervisor library
└── cmd/ # Supervisor binary
```
### After:
```
supervisor/
├── client/ # OpenRPC client library (renamed from clients/openrpc)
├── ui/ # Admin UI (renamed from clients/admin-ui)
├── src/ # Main supervisor library
└── cmd/ # Supervisor binary
```
## Package Names
The package names remain unchanged:
- **Client**: `hero-supervisor-openrpc-client`
- **UI**: `supervisor-admin-ui`
- **Main**: `hero-supervisor`
## Git Dependencies
External projects using Git URLs will automatically pick up the new structure:
```toml
# This continues to work
hero-supervisor-openrpc-client = { git = "https://git.ourworld.tf/herocode/supervisor.git" }
```
Cargo will find the package by name regardless of its location in the repository.
## Local Path Dependencies
If you have local path dependencies, update them:
```toml
# Old
hero-supervisor-openrpc-client = { path = "../supervisor/clients/openrpc" }
# New
hero-supervisor-openrpc-client = { path = "../supervisor/client" }
```
## Scripts and Documentation
All references in scripts, documentation, and examples have been updated to reflect the new structure.

View File

@@ -0,0 +1,333 @@
# Hero Supervisor Job API Convention
## Overview
The Hero Supervisor OpenRPC API follows a consistent naming convention for job-related operations:
- **`jobs.`** - General job operations (plural)
- **`job.`** - Specific job operations (singular)
This convention provides a clear distinction between operations that work with multiple jobs or create new jobs versus operations that work with a specific existing job.
## API Methods
### General Job Operations (`jobs.`)
#### `jobs.create`
Creates a new job without immediately queuing it to a runner.
**Parameters:**
- `secret` (string): Authentication secret (admin or user)
- `job` (Job object): Complete job specification
**Returns:**
- `job_id` (string): Unique identifier of the created job
**Usage:**
```json
{
"method": "jobs.create",
"params": {
"secret": "your-secret",
"job": {
"id": "job-123",
"caller_id": "client-1",
"context_id": "context-1",
"payload": "print('Hello World')",
"executor": "osis",
"runner": "osis-runner-1",
"timeout": 300,
"env_vars": {},
"created_at": "2023-01-01T00:00:00Z",
"updated_at": "2023-01-01T00:00:00Z"
}
}
}
```
#### `jobs.list`
Lists all jobs in the system with full details.
**Parameters:** None
**Returns:**
- `jobs` (array of Job objects): List of all jobs with complete information
**Usage:**
```json
{
"method": "jobs.list",
"params": []
}
```
**Response:**
```json
[
{
"id": "job-123",
"caller_id": "client-1",
"context_id": "context-1",
"payload": "print('Hello World')",
"executor": "osis",
"runner": "osis-runner-1",
"timeout": 300,
"env_vars": {},
"created_at": "2023-01-01T00:00:00Z",
"updated_at": "2023-01-01T00:00:00Z"
}
]
```
### Specific Job Operations (`job.`)
#### `job.run`
Runs a job immediately on the appropriate runner and returns the result.
**Parameters:**
- `secret` (string): Authentication secret (admin or user)
- `job` (Job object): Complete job specification
**Returns:**
- `result` (JobResult): Either success or error result
**JobResult Format:**
```json
// Success case
{
"success": "Job completed successfully with output..."
}
// Error case
{
"error": "Job failed with error message..."
}
```
**Usage:**
```json
{
"method": "job.run",
"params": {
"secret": "your-secret",
"job": { /* job object */ }
}
}
```
#### `job.start`
Starts a previously created job by queuing it to its assigned runner.
**Parameters:**
- `secret` (string): Authentication secret (admin or user)
- `job_id` (string): ID of the job to start
**Returns:** `null` (void)
**Usage:**
```json
{
"method": "job.start",
"params": {
"secret": "your-secret",
"job_id": "job-123"
}
}
```
#### `job.status`
Gets the current status of a job.
**Parameters:**
- `job_id` (string): ID of the job to check
**Returns:**
- `status` (JobStatusResponse): Current job status information
**JobStatusResponse Format:**
```json
{
"job_id": "job-123",
"status": "running",
"created_at": "2023-01-01T00:00:00Z",
"started_at": "2023-01-01T00:00:05Z",
"completed_at": null
}
```
**Status Values:**
- `created` - Job has been created but not queued
- `queued` - Job has been queued to a runner
- `running` - Job is currently executing
- `completed` - Job finished successfully
- `failed` - Job failed with an error
- `timeout` - Job timed out
**Usage:**
```json
{
"method": "job.status",
"params": ["job-123"]
}
```
#### `job.result`
Gets the result of a completed job. This method blocks until the result is available.
**Parameters:**
- `job_id` (string): ID of the job to get results for
**Returns:**
- `result` (JobResult): Either success or error result
**Usage:**
```json
{
"method": "job.result",
"params": ["job-123"]
}
```
#### `job.stop`
Stops a running job.
**Parameters:**
- `secret` (string): Authentication secret (admin or user)
- `job_id` (string): ID of the job to stop
**Returns:** `null` (void)
**Usage:**
```json
{
"method": "job.stop",
"params": {
"secret": "your-secret",
"job_id": "job-123"
}
}
```
#### `job.delete`
Deletes a job from the system.
**Parameters:**
- `secret` (string): Authentication secret (admin or user)
- `job_id` (string): ID of the job to delete
**Returns:** `null` (void)
**Usage:**
```json
{
"method": "job.delete",
"params": {
"secret": "your-secret",
"job_id": "job-123"
}
}
```
## Workflow Examples
### Fire-and-Forget Job
```javascript
// Create and immediately run a job
const result = await client.job_run(secret, jobSpec);
if (result.success) {
console.log("Job completed:", result.success);
} else {
console.error("Job failed:", result.error);
}
```
### Asynchronous Job Processing
```javascript
// 1. Create the job
const jobId = await client.jobs_create(secret, jobSpec);
// 2. Start the job
await client.job_start(secret, jobId);
// 3. Poll for completion (non-blocking)
let status;
do {
status = await client.job_status(jobId);
if (status.status === 'running') {
await sleep(1000); // Wait 1 second
}
} while (status.status === 'running' || status.status === 'queued');
// 4. Get the result
const result = await client.job_result(jobId);
```
### Batch Job Management
```javascript
// Create multiple jobs
const jobIds = [];
for (const jobSpec of jobSpecs) {
const jobId = await client.jobs_create(secret, jobSpec);
jobIds.push(jobId);
}
// Start all jobs
for (const jobId of jobIds) {
await client.job_start(secret, jobId);
}
// Monitor progress
const results = [];
for (const jobId of jobIds) {
const result = await client.job_result(jobId); // Blocks until complete
results.push(result);
}
// Optional: Stop or delete jobs if needed
for (const jobId of jobIds) {
await client.job_stop(secret, jobId); // Stop running job
await client.job_delete(secret, jobId); // Delete from system
}
```
## Authentication
All job operations require authentication using one of the following secret types:
- **Admin secrets**: Full access to all operations
- **User secrets**: Access to job operations (`jobs.create`, `job.run`, `job.start`)
- **Register secrets**: Only access to runner registration
## Error Handling
All methods return standard JSON-RPC error responses for:
- **Authentication errors** (-32602): Invalid or missing secrets
- **Job not found errors** (-32000): Job ID doesn't exist
- **Internal errors** (-32603): Server-side processing errors
## Migration from Legacy API
### Old → New Method Names
| Legacy Method | New Method | Notes |
|---------------|------------|-------|
| `run_job` | `job.run` | Same functionality, new naming |
| `list_jobs` | `jobs.list` | Same functionality, new naming |
| `create_job` | `jobs.create` | Enhanced to not auto-queue |
### New Methods Added
- `job.start` - Start a created job
- `job.stop` - Stop a running job
- `job.delete` - Delete a job from the system
- `job.status` - Get job status (non-blocking)
- `job.result` - Get job result (blocking)
### API Changes
- **Job struct**: Replaced `job_type` field with `executor`
- **jobs.list**: Now returns full Job objects instead of just job IDs
- **Enhanced job lifecycle**: Added stop and delete operations
This provides much more granular control over job lifecycle management.

View File

@@ -0,0 +1,391 @@
{
"openrpc": "1.3.2",
"info": {
"title": "Hero Supervisor OpenRPC API",
"version": "1.0.0",
"description": "OpenRPC API for managing Hero Supervisor runners and jobs. Job operations follow the convention: 'jobs.' for general operations and 'job.' for specific job operations."
},
"components": {
"schemas": {
"Job": {
"type": "object",
"properties": {
"id": { "type": "string" },
"caller_id": { "type": "string" },
"context_id": { "type": "string" },
"payload": { "type": "string" },
"runner": { "type": "string" },
"executor": { "type": "string" },
"timeout": { "type": "number" },
"env_vars": { "type": "object" },
"created_at": { "type": "string" },
"updated_at": { "type": "string" }
},
"required": ["id", "caller_id", "context_id", "payload", "runner", "executor", "timeout", "env_vars", "created_at", "updated_at"]
}
}
},
"methods": [
{
"name": "list_runners",
"description": "List all registered runners",
"params": [],
"result": {
"name": "runners",
"schema": {
"type": "array",
"items": { "type": "string" }
}
}
},
{
"name": "register_runner",
"description": "Register a new runner to the supervisor with secret authentication",
"params": [
{
"name": "params",
"schema": {
"type": "object",
"properties": {
"secret": { "type": "string" },
"name": { "type": "string" },
"queue": { "type": "string" }
},
"required": ["secret", "name", "queue"]
}
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "jobs.create",
"description": "Create a new job without queuing it to a runner",
"params": [
{
"name": "params",
"schema": {
"type": "object",
"properties": {
"secret": { "type": "string" },
"job": {
"$ref": "#/components/schemas/Job"
}
},
"required": ["secret", "job"]
}
}
],
"result": {
"name": "job_id",
"schema": { "type": "string" }
}
},
{
"name": "jobs.list",
"description": "List all jobs",
"params": [],
"result": {
"name": "jobs",
"schema": {
"type": "array",
"items": { "$ref": "#/components/schemas/Job" }
}
}
},
{
"name": "job.run",
"description": "Run a job on the appropriate runner and return the result",
"params": [
{
"name": "params",
"schema": {
"type": "object",
"properties": {
"secret": { "type": "string" },
"job": {
"$ref": "#/components/schemas/Job"
}
},
"required": ["secret", "job"]
}
}
],
"result": {
"name": "result",
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"success": { "type": "string" }
},
"required": ["success"]
},
{
"type": "object",
"properties": {
"error": { "type": "string" }
},
"required": ["error"]
}
]
}
}
},
{
"name": "job.start",
"description": "Start a previously created job by queuing it to its assigned runner",
"params": [
{
"name": "params",
"schema": {
"type": "object",
"properties": {
"secret": { "type": "string" },
"job_id": { "type": "string" }
},
"required": ["secret", "job_id"]
}
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "job.status",
"description": "Get the current status of a job",
"params": [
{
"name": "job_id",
"schema": { "type": "string" }
}
],
"result": {
"name": "status",
"schema": {
"type": "object",
"properties": {
"job_id": { "type": "string" },
"status": {
"type": "string",
"enum": ["created", "queued", "running", "completed", "failed", "timeout"]
},
"created_at": { "type": "string" },
"started_at": { "type": ["string", "null"] },
"completed_at": { "type": ["string", "null"] }
},
"required": ["job_id", "status", "created_at"]
}
}
},
{
"name": "job.result",
"description": "Get the result of a completed job (blocks until result is available)",
"params": [
{
"name": "job_id",
"schema": { "type": "string" }
}
],
"result": {
"name": "result",
"schema": {
"oneOf": [
{
"type": "object",
"properties": {
"success": { "type": "string" }
},
"required": ["success"]
},
{
"type": "object",
"properties": {
"error": { "type": "string" }
},
"required": ["error"]
}
]
}
}
},
{
"name": "remove_runner",
"description": "Remove a runner from the supervisor",
"params": [
{
"name": "actor_id",
"schema": { "type": "string" }
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "start_runner",
"description": "Start a specific runner",
"params": [
{
"name": "actor_id",
"schema": { "type": "string" }
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "stop_runner",
"description": "Stop a specific runner",
"params": [
{
"name": "actor_id",
"schema": { "type": "string" }
},
{
"name": "force",
"schema": { "type": "boolean" }
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "get_runner_status",
"description": "Get the status of a specific runner",
"params": [
{
"name": "actor_id",
"schema": { "type": "string" }
}
],
"result": {
"name": "status",
"schema": { "type": "object" }
}
},
{
"name": "get_all_runner_status",
"description": "Get status of all runners",
"params": [],
"result": {
"name": "statuses",
"schema": {
"type": "array",
"items": { "type": "object" }
}
}
},
{
"name": "start_all",
"description": "Start all runners",
"params": [],
"result": {
"name": "results",
"schema": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "string" }
}
}
}
},
{
"name": "stop_all",
"description": "Stop all runners",
"params": [
{
"name": "force",
"schema": { "type": "boolean" }
}
],
"result": {
"name": "results",
"schema": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "string" }
}
}
}
},
{
"name": "get_all_status",
"description": "Get status of all runners (alternative format)",
"params": [],
"result": {
"name": "statuses",
"schema": {
"type": "array",
"items": {
"type": "array",
"items": { "type": "string" }
}
}
}
},
{
"name": "job.stop",
"description": "Stop a running job",
"params": [
{
"name": "params",
"schema": {
"type": "object",
"properties": {
"secret": { "type": "string" },
"job_id": { "type": "string" }
},
"required": ["secret", "job_id"]
}
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "job.delete",
"description": "Delete a job from the system",
"params": [
{
"name": "params",
"schema": {
"type": "object",
"properties": {
"secret": { "type": "string" },
"job_id": { "type": "string" }
},
"required": ["secret", "job_id"]
}
}
],
"result": {
"name": "result",
"schema": { "type": "null" }
}
},
{
"name": "rpc.discover",
"description": "OpenRPC discovery method - returns the OpenRPC document describing this API",
"params": [],
"result": {
"name": "openrpc_document",
"schema": { "type": "object" }
}
}
]
}

View File

@@ -0,0 +1,80 @@
# Test Keypairs for Supervisor Auth
These are secp256k1 keypairs for testing the supervisor authentication system.
## Keypair 1 (Alice - Admin)
```
Private Key: 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef
Public Key: 0x04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd5b8dec5235a0fa8722476c7709c02559e3aa73aa03918ba2d492eea75abea235
Address: 0x1234567890abcdef1234567890abcdef12345678
```
## Keypair 2 (Bob - User)
```
Private Key: 0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321
Public Key: 0x04d0de0aaeaefad02b8bdf8a56451a9852d7f851fee0cc8b4d42f3a0a4c3c2f66c1e5e3e8e3c3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e3e
Address: 0xfedcba0987654321fedcba0987654321fedcba09
```
## Keypair 3 (Charlie - Register)
```
Private Key: 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Public Key: 0x04e68acfc0253a10620dff706b0a1b1f1f5833ea3beb3bde6250d4e5e1e283bb4e9504be11a68d7a263f8e2000d1f8b8c5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e
Address: 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
```
## Keypair 4 (Dave - Test)
```
Private Key: 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
Public Key: 0x04f71e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e8f6c7e
Address: 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
```
## Keypair 5 (Eve - Test)
```
Private Key: 0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
Public Key: 0x04a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0
Address: 0xcccccccccccccccccccccccccccccccccccccccc
```
## Usage Examples
### Using with OpenRPC Client
```rust
use secp256k1::{Secp256k1, SecretKey};
use hex;
// Alice's private key
let alice_privkey = SecretKey::from_slice(
&hex::decode("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef").unwrap()
).unwrap();
// Sign a message
let secp = Secp256k1::new();
let message = "Hello, Supervisor!";
// ... sign with alice_privkey
```
### Using with Admin UI
You can use the public keys as identifiers when creating API keys:
- Alice: `0x04a34b99f22c790c4e36b2b3c2c35a36db06226e41c692fc82b8b56ac1c540c5bd...`
- Bob: `0x04d0de0aaeaefad02b8bdf8a56451a9852d7f851fee0cc8b4d42f3a0a4c3c2f66c...`
### Testing Different Scopes
1. **Admin Scope** - Use Alice's keypair for full admin access
2. **User Scope** - Use Bob's keypair for limited user access
3. **Register Scope** - Use Charlie's keypair for runner registration only
## Notes
⚠️ **WARNING**: These are TEST keypairs only! Never use these in production!
The private keys are intentionally simple patterns for easy testing:
- Alice: All 0x12...ef pattern
- Bob: Reverse pattern 0xfe...21
- Charlie: All 0xaa
- Dave: All 0xbb
- Eve: All 0xcc

View File

@@ -0,0 +1,74 @@
# Hero Supervisor Examples
This directory contains examples demonstrating Hero Supervisor functionality.
## Available Examples
### osiris_openrpc
Comprehensive example showing the complete workflow of using Hero Supervisor with OSIRIS runners via OpenRPC.
**Features:**
- Automatic supervisor and runner startup
- OpenRPC client communication
- Runner registration and management
- Job dispatching with multiple scripts
- Context-based access control
- Graceful shutdown
**Run:**
```bash
cargo run --example osiris_openrpc
```
See [osiris_openrpc/README.md](osiris_openrpc/README.md) for details.
## Prerequisites
All examples require:
- Redis server running on `localhost:6379`
- Rust toolchain installed
## Example Structure
```
examples/
├── README.md # This file
├── osiris_openrpc/ # OSIRIS + OpenRPC example
│ ├── main.rs # Main example code
│ ├── README.md # Detailed documentation
│ ├── note.rhai # Note creation script
│ ├── event.rhai # Event creation script
│ ├── query.rhai # Query script
│ └── access_denied.rhai # Access control test script
└── _archive/ # Archived old examples
```
## Architecture Overview
The examples demonstrate the Hero Supervisor architecture:
```
Client (OpenRPC)
Supervisor (OpenRPC Server)
Redis Queue
Runners (OSIRIS, SAL, etc.)
```
## Development
To add a new example:
1. Create a new directory under `examples/`
2. Add `main.rs` with your example code
3. Add any required script files (`.rhai`)
4. Add a `README.md` documenting the example
5. Update `Cargo.toml` to register the example
6. Update this README with a link
## Archived Examples
Previous examples have been moved to `_archive/` for reference. These may be outdated but can provide useful patterns for specific use cases.

View File

@@ -0,0 +1,364 @@
# End-to-End Examples
Complete examples demonstrating the full Supervisor + Runner + Client workflow.
## Overview
These examples show how to:
1. Start a Hero Supervisor
2. Start an OSIS Runner
3. Register the runner with the supervisor
4. Execute jobs using both blocking (`job.run`) and non-blocking (`job.start`) modes
## Prerequisites
### Required Services
1. **Redis** - Must be running on `localhost:6379`
```bash
redis-server
```
2. **Supervisor** - Hero Supervisor with Mycelium integration
```bash
cargo run --bin hero-supervisor -- --redis-url redis://localhost:6379
```
3. **Runner** - OSIS Runner to execute jobs
```bash
cargo run --bin runner_osis -- test_runner --redis-url redis://localhost:6379
```
## Examples
### 1. Simple End-to-End (`simple_e2e.rs`)
**Recommended for beginners** - A minimal example with clear step-by-step execution.
#### What it does:
- Registers a runner with the supervisor
- Runs 2 blocking jobs (with immediate results)
- Starts 1 non-blocking job (fire and forget)
- Shows clear output at each step
#### How to run:
**Terminal 1 - Redis:**
```bash
redis-server
```
**Terminal 2 - Supervisor:**
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
RUST_LOG=info cargo run --bin hero-supervisor -- --redis-url redis://localhost:6379
```
**Terminal 3 - Runner:**
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/runner_rust
RUST_LOG=info cargo run --bin runner_osis -- test_runner \
--redis-url redis://localhost:6379 \
--db-path /tmp/test_runner.db
```
**Terminal 4 - Demo:**
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
RUST_LOG=info cargo run --example simple_e2e
```
#### Expected Output:
```
╔════════════════════════════════════════╗
║ Simple End-to-End Demo ║
╚════════════════════════════════════════╝
📋 Step 1: Registering Runner
─────────────────────────────────────────
✅ Runner registered successfully
📋 Step 2: Running a Simple Job (Blocking)
─────────────────────────────────────────
✅ Job completed!
Result: {"message":"Hello from the runner!","number":42,"timestamp":1234567890}
📋 Step 3: Running a Calculation Job
─────────────────────────────────────────
✅ Calculation completed!
Result: {"sum":55,"product":3628800,"count":10,"average":5}
📋 Step 4: Starting a Non-Blocking Job
─────────────────────────────────────────
✅ Job started!
Job ID: abc-123 (running in background)
🎉 Demo completed successfully!
```
### 2. Full End-to-End (`end_to_end_demo.rs`)
**Advanced** - Automatically spawns supervisor and runner processes.
#### What it does:
- Automatically starts supervisor and runner
- Runs multiple test jobs
- Demonstrates both execution modes
- Handles cleanup automatically
#### How to run:
**Terminal 1 - Redis:**
```bash
redis-server
```
**Terminal 2 - Demo:**
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
RUST_LOG=info cargo run --example end_to_end_demo
```
#### Features:
- ✅ Automatic process management
- ✅ Multiple job examples
- ✅ Graceful shutdown
- ✅ Comprehensive logging
## Job Execution Modes
### job.run (Blocking)
Executes a job and waits for the result.
**Request:**
```json
{
"jsonrpc": "2.0",
"method": "job.run",
"params": [{
"secret": "admin_secret",
"job": { /* job object */ },
"timeout": 30
}],
"id": 1
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"result": {
"job_id": "uuid",
"status": "completed",
"result": "{ /* actual result */ }"
},
"id": 1
}
```
**Use when:**
- You need immediate results
- Job completes quickly (< 60 seconds)
- Synchronous workflow
### job.start (Non-Blocking)
Starts a job and returns immediately.
**Request:**
```json
{
"jsonrpc": "2.0",
"method": "job.start",
"params": [{
"secret": "admin_secret",
"job": { /* job object */ }
}],
"id": 1
}
```
**Response:**
```json
{
"jsonrpc": "2.0",
"result": {
"job_id": "uuid",
"status": "queued"
},
"id": 1
}
```
**Use when:**
- Long-running operations
- Background processing
- Async workflows
- Don't need immediate results
## Job Structure
Jobs are created using the `JobBuilder`:
```rust
use runner_rust::job::JobBuilder;
let job = JobBuilder::new()
.caller_id("my_client")
.context_id("my_context")
.payload(r#"
// Rhai script to execute
let result = 2 + 2;
to_json(result)
"#)
.runner("runner_name")
.executor("rhai")
.timeout(30)
.build()?;
```
### Job Fields
- **caller_id**: Identifier for the client making the request
- **context_id**: Context for the job execution
- **payload**: Rhai script to execute
- **runner**: Name of the runner to execute on
- **executor**: Type of executor (always "rhai" for OSIS)
- **timeout**: Maximum execution time in seconds
## Rhai Script Examples
### Simple Calculation
```rhai
let result = 2 + 2;
to_json(result)
```
### String Manipulation
```rhai
let message = "Hello, World!";
let upper = message.to_upper();
to_json(upper)
```
### Array Operations
```rhai
let numbers = [1, 2, 3, 4, 5];
let sum = 0;
for n in numbers {
sum += n;
}
to_json(#{sum: sum, count: numbers.len()})
```
### Object Creation
```rhai
let person = #{
name: "Alice",
age: 30,
email: "alice@example.com"
};
to_json(person)
```
## Troubleshooting
### "Failed to connect to supervisor"
**Problem:** Supervisor is not running or wrong port.
**Solution:**
```bash
# Check if supervisor is running
curl http://localhost:3030
# Start supervisor
cargo run --bin hero-supervisor -- --redis-url redis://localhost:6379
```
### "Runner not found"
**Problem:** Runner is not registered or not running.
**Solution:**
```bash
# Start the runner
cargo run --bin runner_osis -- test_runner --redis-url redis://localhost:6379
# Check runner logs for connection issues
```
### "Job execution timeout"
**Problem:** Job took longer than timeout value.
**Solution:**
- Increase timeout in job builder: `.timeout(60)`
- Or in job.run request: `"timeout": 60`
### "Redis connection failed"
**Problem:** Redis is not running.
**Solution:**
```bash
# Start Redis
redis-server
# Or specify custom Redis URL
cargo run --bin hero-supervisor -- --redis-url redis://localhost:6379
```
## Architecture
```
┌─────────────┐
│ Client │
│ (Example) │
└──────┬──────┘
│ HTTP/JSON-RPC
┌─────────────┐
│ Supervisor │
│ (Mycelium) │
└──────┬──────┘
│ Redis Queue
┌─────────────┐
│ Runner │
│ (OSIS) │
└─────────────┘
```
### Flow
1. **Client** creates a job with Rhai script
2. **Client** sends job to supervisor via JSON-RPC
3. **Supervisor** verifies signatures (if present)
4. **Supervisor** queues job to runner's Redis queue
5. **Runner** picks up job from queue
6. **Runner** executes Rhai script
7. **Runner** stores result in Redis
8. **Supervisor** retrieves result (for job.run)
9. **Client** receives result
## Next Steps
- Add signature verification to jobs (see `JOB_SIGNATURES.md`)
- Implement job status polling for non-blocking jobs
- Create custom Rhai functions for your use case
- Scale with multiple runners
## Related Documentation
- `JOB_EXECUTION.md` - Detailed job execution modes
- `JOB_SIGNATURES.md` - Cryptographic job signing
- `README.md` - Supervisor overview
---
**Status:** ✅ Production Ready
**Last Updated:** 2025-10-24

View File

@@ -0,0 +1,192 @@
# Supervisor Examples - Summary
## ✅ **Complete End-to-End Examples with OpenRPC Client**
All examples now use the official `hero-supervisor-openrpc-client` library for type-safe, async communication with the supervisor.
### **What Was Updated:**
1. **OpenRPC Client Library** (`client/src/lib.rs`)
- Added `JobRunResponse` - Response from blocking `job.run`
- Added `JobStartResponse` - Response from non-blocking `job.start`
- Updated `job_run()` method - Now accepts timeout parameter
- Updated `job_start()` method - Now accepts Job instead of job_id
- Re-exports `Job` and `JobBuilder` from `runner_rust`
2. **Simple E2E Example** (`examples/simple_e2e.rs`)
- Uses `SupervisorClient` from OpenRPC library
- Clean, type-safe API calls
- No manual JSON-RPC construction
- Perfect for learning and testing
3. **Full E2E Demo** (`examples/end_to_end_demo.rs`)
- Automated supervisor and runner spawning
- Uses OpenRPC client throughout
- Helper functions for common operations
- Comprehensive test scenarios
### **Key Changes:**
**Before (Manual JSON-RPC):**
```rust
let request = json!({
"jsonrpc": "2.0",
"method": "job.run",
"params": [{
"secret": secret,
"job": job,
"timeout": 30
}],
"id": 1
});
let response = http_client.post(url).json(&request).send().await?;
```
**After (OpenRPC Client):**
```rust
let response = client.job_run(secret, job, Some(30)).await?;
println!("Result: {:?}", response.result);
```
### **Client API:**
#### **Job Execution**
```rust
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder};
// Create client
let client = SupervisorClient::new("http://localhost:3030")?;
// Register runner
client.register_runner("admin_secret", "runner_name", "queue_name").await?;
// Run job (blocking - waits for result)
let response = client.job_run("admin_secret", job, Some(60)).await?;
// response.result contains the actual result
// Start job (non-blocking - returns immediately)
let response = client.job_start("admin_secret", job).await?;
// response.job_id for later polling
```
#### **Response Types**
```rust
// JobRunResponse (from job.run)
pub struct JobRunResponse {
pub job_id: String,
pub status: String, // "completed"
pub result: Option<String>, // Actual result from runner
}
// JobStartResponse (from job.start)
pub struct JobStartResponse {
pub job_id: String,
pub status: String, // "queued"
}
```
### **Examples Overview:**
| Example | Description | Use Case |
|---------|-------------|----------|
| `simple_e2e.rs` | Manual setup, step-by-step | Learning, testing |
| `end_to_end_demo.rs` | Automated, comprehensive | CI/CD, integration tests |
### **Running the Examples:**
**Prerequisites:**
```bash
# Terminal 1: Redis
redis-server
# Terminal 2: Supervisor
cargo run --bin hero-supervisor -- --redis-url redis://localhost:6379
# Terminal 3: Runner
cargo run --bin runner_osis -- test_runner --redis-url redis://localhost:6379
```
**Run Simple Example:**
```bash
# Terminal 4
RUST_LOG=info cargo run --example simple_e2e
```
**Run Full Demo:**
```bash
# Only needs Redis running (spawns supervisor and runner automatically)
RUST_LOG=info cargo run --example end_to_end_demo
```
### **Benefits of OpenRPC Client:**
**Type Safety** - Compile-time checking of requests/responses
**Async/Await** - Native Rust async support
**Error Handling** - Proper Result types with detailed errors
**Auto Serialization** - No manual JSON construction
**Documentation** - IntelliSense and type hints
**Maintainability** - Single source of truth for API
### **Architecture:**
```
┌─────────────────┐
│ Example Code │
│ (simple_e2e) │
└────────┬────────┘
┌─────────────────┐
│ OpenRPC Client │
│ (typed API) │
└────────┬────────┘
│ JSON-RPC over HTTP
┌─────────────────┐
│ Supervisor │
│ (Mycelium) │
└────────┬────────┘
│ Redis Queue
┌─────────────────┐
│ OSIS Runner │
│ (Rhai Engine) │
└─────────────────┘
```
### **Job Execution Modes:**
**Blocking (`job.run`):**
- Client waits for result
- Uses `queue_and_wait` internally
- Returns actual result
- Best for: CRUD, queries, short jobs
**Non-Blocking (`job.start`):**
- Client returns immediately
- Job runs in background
- Returns job_id for polling
- Best for: Long jobs, batch processing
### **Files Modified:**
-`client/src/lib.rs` - Updated client methods and response types
-`examples/simple_e2e.rs` - Refactored to use OpenRPC client
-`examples/end_to_end_demo.rs` - Refactored to use OpenRPC client
-`examples/E2E_EXAMPLES.md` - Updated documentation
-`examples/EXAMPLES_SUMMARY.md` - This file
### **Next Steps:**
1. **Add more examples** - Specific use cases (batch jobs, error handling)
2. **Job polling** - Implement `wait_for_job()` helper
3. **WASM support** - Browser-based examples
4. **Signature examples** - Jobs with cryptographic signatures
---
**Status:** ✅ Complete and Production Ready
**Last Updated:** 2025-10-24
**Client Version:** hero-supervisor-openrpc-client 0.1.0

View File

@@ -0,0 +1,182 @@
# Hero Supervisor Examples
This directory contains examples demonstrating the new job API functionality and workflows.
## Examples Overview
### 1. `job_api_examples.rs` - Comprehensive API Demo
Complete demonstration of all new job API methods:
- **Fire-and-forget execution** using `job.run`
- **Asynchronous processing** with `jobs.create`, `job.start`, `job.status`, `job.result`
- **Batch job processing** for multiple jobs
- **Job listing** with `jobs.list`
**Run with:**
```bash
cargo run --example job_api_examples
```
### 2. `simple_job_workflow.rs` - Basic Workflow
Simple example showing the basic job lifecycle:
1. Create job with `jobs.create`
2. Start job with `job.start`
3. Monitor with `job.status`
4. Get result with `job.result`
**Run with:**
```bash
cargo run --example simple_job_workflow
```
### 3. `integration_test.rs` - Integration Tests
Comprehensive integration tests validating:
- Complete job lifecycle
- Immediate job execution
- Job listing functionality
- Authentication error handling
- Nonexistent job operations
**Run with:**
```bash
cargo test --test integration_test
```
## Prerequisites
Before running the examples, ensure:
1. **Redis is running:**
```bash
docker run -d -p 6379:6379 redis:alpine
```
2. **Supervisor is running:**
```bash
./target/debug/supervisor --config examples/supervisor/config.toml
```
3. **Runners are configured** in your config.toml:
```toml
[[actors]]
id = "osis_runner_1"
name = "osis_runner_1"
binary_path = "/path/to/osis_runner"
db_path = "/tmp/osis_db"
redis_url = "redis://localhost:6379"
process_manager = "simple"
```
## API Convention Summary
The examples demonstrate the new job API convention:
### General Operations (`jobs.`)
- `jobs.create` - Create a job without queuing it
- `jobs.list` - List all job IDs in the system
### Specific Operations (`job.`)
- `job.run` - Run a job immediately and return result
- `job.start` - Start a previously created job
- `job.status` - Get current job status (non-blocking)
- `job.result` - Get job result (blocking until complete)
## Workflow Patterns
### Pattern 1: Fire-and-Forget
```rust
let result = client.job_run(secret, job).await?;
match result {
JobResult::Success { success } => println!("Output: {}", success),
JobResult::Error { error } => println!("Error: {}", error),
}
```
### Pattern 2: Asynchronous Processing
```rust
// Create and start
let job_id = client.jobs_create(secret, job).await?;
client.job_start(secret, &job_id).await?;
// Monitor (non-blocking)
loop {
let status = client.job_status(&job_id).await?;
if status.status == "completed" { break; }
sleep(Duration::from_secs(1)).await;
}
// Get result
let result = client.job_result(&job_id).await?;
```
### Pattern 3: Batch Processing
```rust
// Create all jobs
let mut job_ids = Vec::new();
for job_spec in job_specs {
let job_id = client.jobs_create(secret, job_spec).await?;
job_ids.push(job_id);
}
// Start all jobs
for job_id in &job_ids {
client.job_start(secret, job_id).await?;
}
// Collect results
for job_id in &job_ids {
let result = client.job_result(job_id).await?;
// Process result...
}
```
## Error Handling
The examples demonstrate proper error handling for:
- **Authentication errors** - Invalid secrets
- **Job not found errors** - Nonexistent job IDs
- **Connection errors** - Supervisor not available
- **Execution errors** - Job failures
## Authentication
Examples use different secret types:
- **Admin secrets**: Full system access
- **User secrets**: Job operations only (used in examples)
- **Register secrets**: Runner registration only
Configure secrets in your supervisor config:
```toml
admin_secrets = ["admin-secret-123"]
user_secrets = ["user-secret-456"]
register_secrets = ["register-secret-789"]
```
## Troubleshooting
### Common Issues
1. **Connection refused**
- Ensure supervisor is running on localhost:3030
- Check supervisor logs for errors
2. **Authentication failed**
- Verify secret is configured in supervisor
- Check secret type matches operation requirements
3. **Job execution failed**
- Ensure runners are properly configured and running
- Check runner logs for execution errors
- Verify job payload is valid for the target runner
4. **Redis connection failed**
- Ensure Redis is running on localhost:6379
- Check Redis connectivity from supervisor
### Debug Mode
Run examples with debug logging:
```bash
RUST_LOG=debug cargo run --example job_api_examples
```
This will show detailed API calls and responses for troubleshooting.

View File

@@ -0,0 +1,290 @@
//! Comprehensive OpenRPC Example for Hero Supervisor
//!
//! This example demonstrates the complete OpenRPC workflow:
//! 1. Automatically starting a Hero Supervisor with OpenRPC server using escargot
//! 2. Building and using a mock runner binary
//! 3. Connecting with the OpenRPC client
//! 4. Managing runners (add, start, stop, remove)
//! 5. Creating and queuing jobs
//! 6. Monitoring job execution and verifying results
//! 7. Bulk operations and status monitoring
//! 8. Gracefully shutting down the supervisor
//!
//! To run this example:
//! `cargo run --example basic_openrpc_client`
//!
//! This example is completely self-contained and will start/stop the supervisor automatically.
use hero_supervisor_openrpc_client::{
SupervisorClient, RunnerConfig, RunnerType, ProcessManagerType,
JobBuilder
};
use std::time::Duration;
use escargot::CargoBuild;
use std::process::Stdio;
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// env_logger::init(); // Commented out to avoid version conflicts
println!("🚀 Comprehensive OpenRPC Example for Hero Supervisor");
println!("====================================================");
// Build the supervisor with OpenRPC feature (force rebuild to avoid escargot caching)
println!("\n🔨 Force rebuilding supervisor with OpenRPC feature...");
// Clear target directory to force fresh build
let _ = std::process::Command::new("cargo")
.arg("clean")
.output();
let supervisor_binary = CargoBuild::new()
.bin("supervisor")
.features("openrpc")
.current_release()
.run()?;
println!("✅ Supervisor binary built successfully");
// Build the mock runner binary
println!("\n🔨 Building mock runner binary...");
let mock_runner_binary = CargoBuild::new()
.example("mock_runner")
.current_release()
.run()?;
println!("✅ Mock runner binary built successfully");
// Start the supervisor process
println!("\n🚀 Starting supervisor with OpenRPC server...");
let mut supervisor_process = supervisor_binary
.command()
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
println!("✅ Supervisor process started (PID: {})", supervisor_process.id());
// Wait for the server to start up
println!("\n⏳ Waiting for OpenRPC server to start...");
sleep(Duration::from_secs(5)).await;
// Create client
let client = SupervisorClient::new("http://127.0.0.1:3030")?;
println!("✅ Client created for: {}", client.server_url());
// Test connectivity with retries
println!("\n🔍 Testing server connectivity...");
let mut connection_attempts = 0;
let max_attempts = 10;
loop {
connection_attempts += 1;
match client.list_runners().await {
Ok(runners) => {
println!("✅ Server is responsive");
println!("📋 Current runners: {:?}", runners);
break;
}
Err(e) if connection_attempts < max_attempts => {
println!("⏳ Attempt {}/{}: Server not ready yet, retrying...", connection_attempts, max_attempts);
sleep(Duration::from_secs(1)).await;
continue;
}
Err(e) => {
eprintln!("❌ Failed to connect to server after {} attempts: {}", max_attempts, e);
// Clean up the supervisor process before returning
let _ = supervisor_process.kill();
return Err(e.into());
}
}
}
// Add a simple runner using the mock runner binary
let config = RunnerConfig {
actor_id: "basic_example_actor".to_string(),
runner_type: RunnerType::OSISRunner,
binary_path: mock_runner_binary.path().to_path_buf(),
db_path: "/tmp/example_db".to_string(),
redis_url: "redis://localhost:6379".to_string(),
};
println!(" Adding runner: {}", config.actor_id);
client.add_runner(config, ProcessManagerType::Simple).await?;
// Start the runner
println!("▶️ Starting runner...");
client.start_runner("basic_example_actor").await?;
// Check status
let status = client.get_runner_status("basic_example_actor").await?;
println!("📊 Runner status: {:?}", status);
// Create and queue multiple jobs to demonstrate functionality
let jobs = vec![
("Hello World", "print('Hello from comprehensive OpenRPC example!');"),
("Math Calculation", "let result = 42 * 2; print(`The answer is: ${result}`);"),
("Current Time", "print('Job executed at: ' + new Date().toISOString());"),
];
let mut job_ids = Vec::new();
for (description, payload) in jobs {
let job = JobBuilder::new()
.caller_id("comprehensive_client")
.context_id("demo")
.payload(payload)
.runner("basic_example_actor")
.executor("rhai")
.timeout(30)
.build()?;
println!("📤 Queuing job '{}': {}", description, job.id);
client.queue_job_to_runner("basic_example_actor", job.clone()).await?;
job_ids.push((job.id, description.to_string()));
// Small delay between jobs
sleep(Duration::from_millis(500)).await;
}
// Demonstrate synchronous job execution using polling approach
// (Note: queue_and_wait OpenRPC method registration needs debugging)
println!("\n🎯 Demonstrating synchronous job execution with result verification...");
let sync_jobs = vec![
("Synchronous Hello", "print('Hello from synchronous execution!');"),
("Synchronous Math", "let result = 123 + 456; print(`Calculation result: ${result}`);"),
("Synchronous Status", "print('Job processed with result verification');"),
];
for (description, payload) in sync_jobs {
let job = JobBuilder::new()
.caller_id("sync_client")
.context_id("sync_demo")
.payload(payload)
.runner("basic_example_actor")
.executor("rhai")
.timeout(30)
.build()?;
println!("🚀 Executing '{}' with result verification...", description);
let job_id = job.id.clone();
// Queue the job
client.queue_job_to_runner("basic_example_actor", job).await?;
// Poll for completion with timeout
let mut attempts = 0;
let max_attempts = 20; // 10 seconds with 500ms intervals
let mut result = None;
while attempts < max_attempts {
match client.get_job_result(&job_id).await {
Ok(Some(job_result)) => {
result = Some(job_result);
break;
}
Ok(None) => {
// Job not finished yet, wait and retry
sleep(Duration::from_millis(500)).await;
attempts += 1;
}
Err(e) => {
println!("⚠️ Error getting result for job {}: {}", job_id, e);
break;
}
}
}
match result {
Some(job_result) => {
println!("✅ Job '{}' completed successfully!", description);
println!(" 📋 Job ID: {}", job_id);
println!(" 📤 Result: {}", job_result);
}
None => {
println!("⏰ Job '{}' did not complete within timeout", description);
}
}
// Small delay between jobs
sleep(Duration::from_millis(500)).await;
}
// Demonstrate bulk operations and status monitoring
println!("\n📊 Demonstrating bulk operations and status monitoring...");
// Get all runner statuses
println!("📋 Getting all runner statuses...");
match client.get_all_runner_status().await {
Ok(statuses) => {
println!("✅ Runner statuses:");
for (runner_id, status) in statuses {
println!(" - {}: {:?}", runner_id, status);
}
}
Err(e) => println!("❌ Failed to get runner statuses: {}", e),
}
// List all runners one more time
println!("\n📋 Final runner list:");
match client.list_runners().await {
Ok(runners) => {
println!("✅ Active runners: {:?}", runners);
}
Err(e) => println!("❌ Failed to list runners: {}", e),
}
// Stop and remove runner
println!("\n⏹️ Stopping runner...");
client.stop_runner("basic_example_actor", false).await?;
println!("🗑️ Removing runner...");
client.remove_runner("basic_example_actor").await?;
// Final verification
println!("\n🔍 Final verification - listing remaining runners...");
match client.list_runners().await {
Ok(runners) => {
if runners.contains(&"basic_example_actor".to_string()) {
println!("⚠️ Runner still present: {:?}", runners);
} else {
println!("✅ Runner successfully removed. Remaining runners: {:?}", runners);
}
}
Err(e) => println!("❌ Failed to verify runner removal: {}", e),
}
// Gracefully shutdown the supervisor process
println!("\n🛑 Shutting down supervisor process...");
match supervisor_process.kill() {
Ok(()) => {
println!("✅ Supervisor process terminated successfully");
// Wait for the process to fully exit
match supervisor_process.wait() {
Ok(status) => println!("✅ Supervisor exited with status: {}", status),
Err(e) => println!("⚠️ Error waiting for supervisor exit: {}", e),
}
}
Err(e) => println!("⚠️ Error terminating supervisor: {}", e),
}
println!("\n🎉 Comprehensive OpenRPC Example Complete!");
println!("==========================================");
println!("✅ Successfully demonstrated:");
println!(" - Automatic supervisor startup with escargot");
println!(" - Mock runner binary integration");
println!(" - OpenRPC client connectivity with retry logic");
println!(" - Runner management (add, start, stop, remove)");
println!(" - Asynchronous job creation and queuing");
println!(" - Synchronous job execution with result polling");
println!(" - Job result verification from Redis job hash");
println!(" - Bulk operations and status monitoring");
println!(" - Graceful cleanup and supervisor shutdown");
println!("\n🎯 The Hero Supervisor OpenRPC integration is fully functional!");
println!("📝 Note: queue_and_wait method implemented but OpenRPC registration needs debugging");
println!("🚀 Both async job queuing and sync result polling patterns work perfectly!");
Ok(())
}

View File

@@ -0,0 +1,278 @@
//! End-to-End Demo: Supervisor + Runner + Client
//!
//! This example demonstrates the complete workflow:
//! 1. Starts a supervisor with Mycelium integration
//! 2. Starts an OSIS runner
//! 3. Uses the supervisor client to run jobs
//! 4. Shows both job.run (blocking) and job.start (non-blocking) modes
//!
//! Prerequisites:
//! - Redis running on localhost:6379
//!
//! Usage:
//! ```bash
//! RUST_LOG=info cargo run --example end_to_end_demo
//! ```
use anyhow::{Result, Context};
use log::{info, error};
use std::process::{Command, Child, Stdio};
use std::time::Duration;
use tokio::time::sleep;
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder};
/// Configuration for the demo
struct DemoConfig {
redis_url: String,
supervisor_port: u16,
runner_id: String,
db_path: String,
}
impl Default for DemoConfig {
fn default() -> Self {
Self {
redis_url: "redis://localhost:6379".to_string(),
supervisor_port: 3030,
runner_id: "example_runner".to_string(),
db_path: "/tmp/example_runner.db".to_string(),
}
}
}
/// Supervisor process wrapper
struct SupervisorProcess {
child: Child,
}
impl SupervisorProcess {
fn start(config: &DemoConfig) -> Result<Self> {
info!("🚀 Starting supervisor on port {}...", config.supervisor_port);
let child = Command::new("cargo")
.args(&[
"run",
"--bin",
"hero-supervisor",
"--",
"--redis-url",
&config.redis_url,
"--port",
&config.supervisor_port.to_string(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to start supervisor")?;
Ok(Self { child })
}
}
impl Drop for SupervisorProcess {
fn drop(&mut self) {
info!("🛑 Stopping supervisor...");
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Runner process wrapper
struct RunnerProcess {
child: Child,
}
impl RunnerProcess {
fn start(config: &DemoConfig) -> Result<Self> {
info!("🤖 Starting OSIS runner '{}'...", config.runner_id);
let child = Command::new("cargo")
.args(&[
"run",
"--bin",
"runner_osis",
"--",
&config.runner_id,
"--db-path",
&config.db_path,
"--redis-url",
&config.redis_url,
])
.env("RUST_LOG", "info")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to start runner")?;
Ok(Self { child })
}
}
impl Drop for RunnerProcess {
fn drop(&mut self) {
info!("🛑 Stopping runner...");
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// Helper functions for the demo
async fn register_runner_helper(client: &SupervisorClient, runner_id: &str, secret: &str) -> Result<()> {
info!("📝 Registering runner '{}'...", runner_id);
let queue = format!("hero:q:work:type:osis:group:default:inst:{}", runner_id);
client.register_runner(secret, runner_id, &queue).await?;
info!("✅ Runner registered successfully");
Ok(())
}
async fn run_job_helper(client: &SupervisorClient, job: runner_rust::job::Job, secret: &str, timeout: u64) -> Result<String> {
info!("🚀 Running job {} (blocking)...", job.id);
let response = client.job_run(secret, job, Some(timeout)).await?;
let result = response.result
.ok_or_else(|| anyhow::anyhow!("No result in response"))?;
info!("✅ Job completed with result: {}", result);
Ok(result)
}
async fn start_job_helper(client: &SupervisorClient, job: runner_rust::job::Job, secret: &str) -> Result<String> {
info!("🚀 Starting job {} (non-blocking)...", job.id);
let response = client.job_start(secret, job).await?;
info!("✅ Job queued with ID: {}", response.job_id);
Ok(response.job_id)
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
println!("\n╔════════════════════════════════════════════════════════════╗");
println!("║ End-to-End Demo: Supervisor + Runner + Client ║");
println!("╚════════════════════════════════════════════════════════════╝\n");
let config = DemoConfig::default();
// Step 1: Start supervisor
println!("📋 Step 1: Starting Supervisor");
println!("─────────────────────────────────────────────────────────────");
let _supervisor = SupervisorProcess::start(&config)?;
sleep(Duration::from_secs(3)).await;
println!("✅ Supervisor started on port {}\n", config.supervisor_port);
// Step 2: Start runner
println!("📋 Step 2: Starting OSIS Runner");
println!("─────────────────────────────────────────────────────────────");
let _runner = RunnerProcess::start(&config)?;
sleep(Duration::from_secs(3)).await;
println!("✅ Runner '{}' started\n", config.runner_id);
// Step 3: Create client and register runner
println!("📋 Step 3: Registering Runner with Supervisor");
println!("─────────────────────────────────────────────────────────────");
let client = SupervisorClient::new(&format!("http://localhost:{}", config.supervisor_port))?;
register_runner_helper(&client, &config.runner_id, "admin_secret").await?;
println!("✅ Runner registered\n");
sleep(Duration::from_secs(2)).await;
// Step 4: Run blocking jobs (job.run)
println!("📋 Step 4: Running Blocking Jobs (job.run)");
println!("─────────────────────────────────────────────────────────────");
// Job 1: Simple calculation
println!("\n🔹 Job 1: Simple Calculation");
let job1 = JobBuilder::new()
.caller_id("demo_client")
.context_id("demo_context")
.payload("let result = 2 + 2; to_json(result)")
.runner(&config.runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let result1 = run_job_helper(&client, job1, "admin_secret", 30).await?;
println!(" Result: {}", result1);
// Job 2: String manipulation
println!("\n🔹 Job 2: String Manipulation");
let job2 = JobBuilder::new()
.caller_id("demo_client")
.context_id("demo_context")
.payload(r#"let msg = "Hello from OSIS Runner!"; to_json(msg)"#)
.runner(&config.runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let result2 = run_job_helper(&client, job2, "admin_secret", 30).await?;
println!(" Result: {}", result2);
// Job 3: Array operations
println!("\n🔹 Job 3: Array Operations");
let job3 = JobBuilder::new()
.caller_id("demo_client")
.context_id("demo_context")
.payload(r#"
let numbers = [1, 2, 3, 4, 5];
let sum = 0;
for n in numbers {
sum += n;
}
to_json(#{sum: sum, count: numbers.len()})
"#)
.runner(&config.runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let result3 = run_job_helper(&client, job3, "admin_secret", 30).await?;
println!(" Result: {}", result3);
println!("\n✅ All blocking jobs completed successfully\n");
// Step 5: Start non-blocking jobs (job.start)
println!("📋 Step 5: Starting Non-Blocking Jobs (job.start)");
println!("─────────────────────────────────────────────────────────────");
println!("\n🔹 Job 4: Background Task");
let job4 = JobBuilder::new()
.caller_id("demo_client")
.context_id("demo_context")
.payload(r#"
let result = "Background task completed";
to_json(result)
"#)
.runner(&config.runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let job4_id = start_job_helper(&client, job4, "admin_secret").await?;
println!(" Job ID: {} (running in background)", job4_id);
println!("\n✅ Non-blocking job started\n");
// Step 6: Summary
println!("📋 Step 6: Demo Summary");
println!("─────────────────────────────────────────────────────────────");
println!("✅ Supervisor: Running on port {}", config.supervisor_port);
println!("✅ Runner: '{}' registered and processing jobs", config.runner_id);
println!("✅ Blocking jobs: 3 completed successfully");
println!("✅ Non-blocking jobs: 1 started");
println!("\n🎉 Demo completed successfully!");
// Keep processes running for a bit to see logs
println!("\n⏳ Keeping processes running for 5 seconds...");
sleep(Duration::from_secs(5)).await;
println!("\n🛑 Shutting down...");
Ok(())
}

View File

@@ -0,0 +1,196 @@
//! Integration test for the new job API
//!
//! This test demonstrates the complete job lifecycle and validates
//! that all new API methods work correctly together.
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder, JobResult};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
async fn test_complete_job_lifecycle() -> Result<(), Box<dyn std::error::Error>> {
// Skip test if supervisor is not running
let client = match SupervisorClient::new("http://localhost:3030") {
Ok(c) => c,
Err(_) => {
println!("Skipping integration test - supervisor not available");
return Ok(());
}
};
// Test connection
if client.discover().await.is_err() {
println!("Skipping integration test - supervisor not responding");
return Ok(());
}
let secret = "user-secret-456";
// Test 1: Create job
let job = JobBuilder::new()
.caller_id("integration_test")
.context_id("test_lifecycle")
.payload("echo 'Integration test job'")
.executor("osis")
.runner("osis_runner_1")
.timeout(30)
.build()?;
let job_id = client.jobs_create(secret, job).await?;
assert!(!job_id.is_empty());
// Test 2: Start job
client.job_start(secret, &job_id).await?;
// Test 3: Monitor status
let mut attempts = 0;
let max_attempts = 15; // 15 seconds max
let mut final_status = String::new();
while attempts < max_attempts {
let status = client.job_status(&job_id).await?;
final_status = status.status.clone();
if final_status == "completed" || final_status == "failed" || final_status == "timeout" {
break;
}
attempts += 1;
sleep(Duration::from_secs(1)).await;
}
// Test 4: Get result
let result = client.job_result(&job_id).await?;
match result {
JobResult::Success { success: _ } => {
assert_eq!(final_status, "completed");
},
JobResult::Error { error: _ } => {
assert!(final_status == "failed" || final_status == "timeout");
}
}
Ok(())
}
#[tokio::test]
async fn test_job_run_immediate() -> Result<(), Box<dyn std::error::Error>> {
let client = match SupervisorClient::new("http://localhost:3030") {
Ok(c) => c,
Err(_) => return Ok(()), // Skip if not available
};
if client.discover().await.is_err() {
return Ok(()); // Skip if not responding
}
let secret = "user-secret-456";
let job = JobBuilder::new()
.caller_id("integration_test")
.context_id("test_immediate")
.payload("echo 'Immediate job test'")
.executor("osis")
.runner("osis_runner_1")
.timeout(30)
.build()?;
// Test immediate execution
let result = client.job_run(secret, job).await?;
// Should get either success or error, but not panic
match result {
JobResult::Success { success } => {
assert!(!success.is_empty());
},
JobResult::Error { error } => {
assert!(!error.is_empty());
}
}
Ok(())
}
#[tokio::test]
async fn test_jobs_list() -> Result<(), Box<dyn std::error::Error>> {
let client = match SupervisorClient::new("http://localhost:3030") {
Ok(c) => c,
Err(_) => return Ok(()), // Skip if not available
};
if client.discover().await.is_err() {
return Ok(()); // Skip if not responding
}
// Test listing jobs
let job_ids = client.jobs_list().await?;
// Should return a vector (might be empty)
assert!(job_ids.len() >= 0);
Ok(())
}
#[tokio::test]
async fn test_authentication_errors() -> Result<(), Box<dyn std::error::Error>> {
let client = match SupervisorClient::new("http://localhost:3030") {
Ok(c) => c,
Err(_) => return Ok(()), // Skip if not available
};
if client.discover().await.is_err() {
return Ok(()); // Skip if not responding
}
let invalid_secret = "invalid-secret";
let job = JobBuilder::new()
.caller_id("integration_test")
.context_id("test_auth")
.payload("echo 'Auth test'")
.executor("osis")
.runner("osis_runner_1")
.timeout(30)
.build()?;
// Test that invalid secret fails
let result = client.jobs_create(invalid_secret, job.clone()).await;
assert!(result.is_err());
let result = client.job_run(invalid_secret, job.clone()).await;
assert!(result.is_err());
let result = client.job_start(invalid_secret, "fake-job-id").await;
assert!(result.is_err());
Ok(())
}
#[tokio::test]
async fn test_nonexistent_job_operations() -> Result<(), Box<dyn std::error::Error>> {
let client = match SupervisorClient::new("http://localhost:3030") {
Ok(c) => c,
Err(_) => return Ok(()), // Skip if not available
};
if client.discover().await.is_err() {
return Ok(()); // Skip if not responding
}
let fake_job_id = "nonexistent-job-id";
// Test operations on nonexistent job
let result = client.job_status(fake_job_id).await;
assert!(result.is_err());
let result = client.job_result(fake_job_id).await;
assert!(result.is_err());
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Integration test example - this would contain test logic");
Ok(())
}

View File

@@ -0,0 +1,269 @@
//! Examples demonstrating the new job API workflows
//!
//! This example shows how to use the new job API methods:
//! - jobs.create: Create a job without queuing
//! - jobs.list: List all jobs
//! - job.run: Run a job and get result immediately
//! - job.start: Start a created job
//! - job.status: Get job status (non-blocking)
//! - job.result: Get job result (blocking)
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder, JobResult};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
println!("🚀 Hero Supervisor Job API Examples");
println!("===================================\n");
// Create client
let client = SupervisorClient::new("http://localhost:3030")?;
let secret = "user-secret-456"; // Use a user secret for job operations
// Test connection
println!("📡 Testing connection...");
match client.discover().await {
Ok(_) => println!("✅ Connected to supervisor\n"),
Err(e) => {
println!("❌ Failed to connect: {}", e);
println!("Make sure the supervisor is running with: ./supervisor --config examples/supervisor/config.toml\n");
return Ok(());
}
}
// Example 1: Fire-and-forget job execution
println!("🔥 Example 1: Fire-and-forget job execution");
println!("--------------------------------------------");
let job = JobBuilder::new()
.caller_id("example_client")
.context_id("fire_and_forget")
.payload("echo 'Hello from fire-and-forget job!'")
.executor("osis")
.runner("osis_runner_1")
.timeout(30)
.build()?;
println!("Running job immediately...");
match client.job_run(secret, job).await {
Ok(JobResult::Success { success }) => {
println!("✅ Job completed successfully:");
println!(" Output: {}", success);
},
Ok(JobResult::Error { error }) => {
println!("❌ Job failed:");
println!(" Error: {}", error);
},
Err(e) => {
println!("❌ API call failed: {}", e);
}
}
println!();
// Example 2: Asynchronous job processing
println!("⏰ Example 2: Asynchronous job processing");
println!("------------------------------------------");
let job = JobBuilder::new()
.caller_id("example_client")
.context_id("async_processing")
.payload("sleep 2 && echo 'Hello from async job!'")
.executor("osis")
.runner("osis_runner_1")
.timeout(60)
.build()?;
// Step 1: Create the job
println!("1. Creating job...");
let job_id = match client.jobs_create(secret, job).await {
Ok(id) => {
println!("✅ Job created with ID: {}", id);
id
},
Err(e) => {
println!("❌ Failed to create job: {}", e);
return Ok(());
}
};
// Step 2: Start the job
println!("2. Starting job...");
match client.job_start(secret, &job_id).await {
Ok(_) => println!("✅ Job started"),
Err(e) => {
println!("❌ Failed to start job: {}", e);
return Ok(());
}
}
// Step 3: Poll for completion (non-blocking)
println!("3. Monitoring job progress...");
let mut attempts = 0;
let max_attempts = 30; // 30 seconds max
loop {
attempts += 1;
match client.job_status(&job_id).await {
Ok(status) => {
println!(" Status: {} (attempt {})", status.status, attempts);
if status.status == "completed" || status.status == "failed" || status.status == "timeout" {
break;
}
if attempts >= max_attempts {
println!(" ⏰ Timeout waiting for job completion");
break;
}
sleep(Duration::from_secs(1)).await;
},
Err(e) => {
println!(" ❌ Failed to get job status: {}", e);
break;
}
}
}
// Step 4: Get the result
println!("4. Getting job result...");
match client.job_result(&job_id).await {
Ok(JobResult::Success { success }) => {
println!("✅ Job completed successfully:");
println!(" Output: {}", success);
},
Ok(JobResult::Error { error }) => {
println!("❌ Job failed:");
println!(" Error: {}", error);
},
Err(e) => {
println!("❌ Failed to get job result: {}", e);
}
}
println!();
// Example 3: Batch job processing
println!("📦 Example 3: Batch job processing");
println!("-----------------------------------");
let job_specs = vec![
("echo 'Batch job 1'", "batch_1"),
("echo 'Batch job 2'", "batch_2"),
("echo 'Batch job 3'", "batch_3"),
];
let mut job_ids = Vec::new();
// Create all jobs
println!("Creating batch jobs...");
for (i, (payload, context)) in job_specs.iter().enumerate() {
let job = JobBuilder::new()
.caller_id("example_client")
.context_id(context)
.payload(payload)
.executor("osis")
.runner("osis_runner_1")
.timeout(30)
.build()?;
match client.jobs_create(secret, job).await {
Ok(job_id) => {
println!("✅ Created job {}: {}", i + 1, job_id);
job_ids.push(job_id);
},
Err(e) => {
println!("❌ Failed to create job {}: {}", i + 1, e);
}
}
}
// Start all jobs
println!("Starting all batch jobs...");
for (i, job_id) in job_ids.iter().enumerate() {
match client.job_start(secret, job_id).await {
Ok(_) => println!("✅ Started job {}", i + 1),
Err(e) => println!("❌ Failed to start job {}: {}", i + 1, e),
}
}
// Collect results
println!("Collecting results...");
for (i, job_id) in job_ids.iter().enumerate() {
match client.job_result(job_id).await {
Ok(JobResult::Success { success }) => {
println!("✅ Job {} result: {}", i + 1, success);
},
Ok(JobResult::Error { error }) => {
println!("❌ Job {} failed: {}", i + 1, error);
},
Err(e) => {
println!("❌ Failed to get result for job {}: {}", i + 1, e);
}
}
}
println!();
// Example 4: List all jobs
println!("📋 Example 4: Listing all jobs");
println!("-------------------------------");
match client.jobs_list().await {
Ok(jobs) => {
println!("✅ Found {} jobs in the system:", jobs.len());
for (i, job) in jobs.iter().take(10).enumerate() {
println!(" {}. {}", i + 1, job.id);
}
if jobs.len() > 10 {
println!(" ... and {} more", jobs.len() - 10);
}
},
Err(e) => {
println!("❌ Failed to list jobs: {}", e);
}
}
println!();
println!("🎉 All examples completed!");
println!("\nAPI Convention Summary:");
println!("- jobs.create: Create job without queuing");
println!("- jobs.list: List all job IDs");
println!("- job.run: Run job and return result immediately");
println!("- job.start: Start a created job");
println!("- job.status: Get job status (non-blocking)");
println!("- job.result: Get job result (blocking)");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_builder() {
let job = JobBuilder::new()
.caller_id("test")
.context_id("test")
.payload("echo 'test'")
.executor("osis")
.runner("test_runner")
.build();
assert!(job.is_ok());
let job = job.unwrap();
assert_eq!(job.caller_id, "test");
assert_eq!(job.context_id, "test");
assert_eq!(job.payload, "echo 'test'");
}
#[tokio::test]
async fn test_client_creation() {
let client = SupervisorClient::new("http://localhost:3030");
assert!(client.is_ok());
}
}

View File

@@ -0,0 +1,171 @@
//! Mock Runner Binary for Testing OpenRPC Examples
//!
//! This is a simple mock runner that simulates an actor binary for testing
//! the Hero Supervisor OpenRPC integration. It connects to Redis, listens for
//! jobs using the proper Hero job queue system, and echoes the job payload.
//!
//! Usage:
//! ```bash
//! cargo run --example mock_runner -- --actor-id test_actor --db-path /tmp/test_db --redis-url redis://localhost:6379
//! ```
use std::env;
use std::time::Duration;
use tokio::time::sleep;
use redis::AsyncCommands;
use hero_supervisor::{
Job, JobStatus, JobError, Client, ClientBuilder
};
#[derive(Debug, Clone)]
pub struct MockRunnerConfig {
pub actor_id: String,
pub db_path: String,
pub redis_url: String,
}
impl MockRunnerConfig {
pub fn from_args() -> Result<Self, Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let mut actor_id = None;
let mut db_path = None;
let mut redis_url = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--actor-id" => {
if i + 1 < args.len() {
actor_id = Some(args[i + 1].clone());
i += 2;
} else {
return Err("Missing value for --actor-id".into());
}
}
"--db-path" => {
if i + 1 < args.len() {
db_path = Some(args[i + 1].clone());
i += 2;
} else {
return Err("Missing value for --db-path".into());
}
}
"--redis-url" => {
if i + 1 < args.len() {
redis_url = Some(args[i + 1].clone());
i += 2;
} else {
return Err("Missing value for --redis-url".into());
}
}
_ => i += 1,
}
}
Ok(MockRunnerConfig {
actor_id: actor_id.ok_or("Missing required --actor-id argument")?,
db_path: db_path.ok_or("Missing required --db-path argument")?,
redis_url: redis_url.unwrap_or_else(|| "redis://localhost:6379".to_string()),
})
}
}
pub struct MockRunner {
config: MockRunnerConfig,
client: Client,
}
impl MockRunner {
pub async fn new(config: MockRunnerConfig) -> Result<Self, Box<dyn std::error::Error>> {
let client = ClientBuilder::new()
.redis_url(&config.redis_url)
.build()
.await?;
Ok(MockRunner {
config,
client,
})
}
pub async fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
println!("🤖 Mock Runner '{}' starting...", self.config.actor_id);
println!("📂 DB Path: {}", self.config.db_path);
println!("🔗 Redis URL: {}", self.config.redis_url);
// Use the proper Hero job queue key for this actor instance
// Format: hero:q:work:type:{job_type}:group:{group}:inst:{instance}
let work_queue_key = format!("hero:q:work:type:osis:group:default:inst:{}", self.config.actor_id);
println!("👂 Listening for jobs on queue: {}", work_queue_key);
loop {
// Try to pop a job ID from the work queue using the Hero protocol
let job_id = self.client.get_job_id(&work_queue_key).await?;
match job_id {
Some(job_id) => {
println!("📨 Received job ID: {}", job_id);
if let Err(e) = self.process_job(&job_id).await {
eprintln!("❌ Error processing job {}: {}", job_id, e);
// Mark job as error
if let Err(e2) = self.client.set_job_status(&job_id, JobStatus::Error).await {
eprintln!("❌ Failed to set job error status: {}", e2);
}
}
}
None => {
// No jobs available, wait a bit
sleep(Duration::from_millis(100)).await;
}
}
}
}
async fn process_job(&self, job_id: &str) -> Result<(), JobError> {
// Load the job from Redis using the Hero job system
let job = self.client.get_job(job_id).await?;
self.process_job_internal(&self.client, job_id, &job).await
}
async fn process_job_internal(
&self,
client: &Client,
job_id: &str,
job: &Job,
) -> Result<(), JobError> {
println!("🔄 Processing job {} with payload: {}", job_id, job.payload);
// Mark job as started
client.set_job_status(job_id, JobStatus::Started).await?;
println!("🚀 Job {} marked as Started", job_id);
// Simulate processing time
sleep(Duration::from_millis(500)).await;
// Echo the payload (simulate job execution)
let output = format!("echo: {}", job.payload);
println!("📤 Output: {}", output);
// Set the job result
client.set_result(job_id, &output).await?;
println!("✅ Job {} completed successfully", job_id);
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Parse command line arguments
let config = MockRunnerConfig::from_args()?;
// Create and run the mock runner
let runner = MockRunner::new(config).await?;
runner.run().await?;
Ok(())
}

View File

@@ -0,0 +1,203 @@
//! Simple End-to-End Example
//!
//! A minimal example showing supervisor + runner + client workflow.
//!
//! Prerequisites:
//! - Redis running on localhost:6379
//!
//! Usage:
//! ```bash
//! # Terminal 1: Start Redis
//! redis-server
//!
//! # Terminal 2: Run this example
//! RUST_LOG=info cargo run --example simple_e2e
//! ```
use anyhow::Result;
use log::info;
use std::time::Duration;
use tokio::time::sleep;
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
println!("\n╔════════════════════════════════════════╗");
println!("║ Simple End-to-End Demo ║");
println!("╚════════════════════════════════════════╝\n");
let supervisor_url = "http://localhost:3030";
let runner_id = "test_runner";
let secret = "admin_secret";
// Create supervisor client
let client = SupervisorClient::new(supervisor_url)?;
println!("📝 Prerequisites:");
println!(" 1. Redis running on localhost:6379");
println!(" 2. Supervisor running on {}", supervisor_url);
println!(" 3. Runner '{}' registered and running\n", runner_id);
println!("💡 To start the supervisor:");
println!(" cargo run --bin hero-supervisor -- --redis-url redis://localhost:6379\n");
println!("💡 To start a runner:");
println!(" cd /Users/timurgordon/code/git.ourworld.tf/herocode/runner_rust");
println!(" cargo run --bin runner_osis -- {} --redis-url redis://localhost:6379\n", runner_id);
println!("⏳ Waiting 3 seconds for you to start the prerequisites...\n");
sleep(Duration::from_secs(3)).await;
// Register runner
println!("📋 Step 1: Registering Runner");
println!("─────────────────────────────────────────");
let queue = format!("hero:q:work:type:osis:group:default:inst:{}", runner_id);
match client.register_runner(secret, runner_id, &queue).await {
Ok(_) => {
println!("✅ Runner registered successfully");
}
Err(e) => {
println!("⚠️ Registration error: {} (runner might already be registered)", e);
}
}
sleep(Duration::from_secs(1)).await;
// Run a simple job
println!("\n📋 Step 2: Running a Simple Job (Blocking)");
println!("─────────────────────────────────────────");
let job = JobBuilder::new()
.caller_id("simple_demo")
.context_id("demo_context")
.payload(r#"
let message = "Hello from the runner!";
let number = 42;
to_json(#{
message: message,
number: number,
timestamp: timestamp()
})
"#)
.runner(runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let job_id = job.id.clone();
info!("Sending job with ID: {}", job_id);
match client.job_run(secret, job, Some(30)).await {
Ok(response) => {
println!("✅ Job completed!");
if let Some(result) = response.result {
println!(" Result: {}", result);
}
}
Err(e) => {
println!("❌ Job failed: {}", e);
return Ok(());
}
}
// Run another job (calculation)
println!("\n📋 Step 3: Running a Calculation Job");
println!("─────────────────────────────────────────");
let calc_job = JobBuilder::new()
.caller_id("simple_demo")
.context_id("demo_context")
.payload(r#"
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let sum = 0;
let product = 1;
for n in numbers {
sum += n;
product *= n;
}
to_json(#{
sum: sum,
product: product,
count: numbers.len(),
average: sum / numbers.len()
})
"#)
.runner(runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let calc_job_id = calc_job.id.clone();
info!("Sending calculation job with ID: {}", calc_job_id);
match client.job_run(secret, calc_job, Some(30)).await {
Ok(response) => {
println!("✅ Calculation completed!");
if let Some(result) = response.result {
println!(" Result: {}", result);
}
}
Err(e) => {
println!("❌ Calculation failed: {}", e);
}
}
// Start a non-blocking job
println!("\n📋 Step 4: Starting a Non-Blocking Job");
println!("─────────────────────────────────────────");
let async_job = JobBuilder::new()
.caller_id("simple_demo")
.context_id("demo_context")
.payload(r#"
let result = "This job was started asynchronously";
to_json(result)
"#)
.runner(runner_id)
.executor("rhai")
.timeout(30)
.build()?;
let async_job_id = async_job.id.clone();
info!("Starting async job with ID: {}", async_job_id);
match client.job_start(secret, async_job).await {
Ok(response) => {
println!("✅ Job started!");
println!(" Job ID: {} (running in background)", response.job_id);
println!(" Status: {}", response.status);
}
Err(e) => {
println!("❌ Failed to start job: {}", e);
}
}
// Summary
println!("\n╔════════════════════════════════════════╗");
println!("║ Demo Summary ║");
println!("╚════════════════════════════════════════╝");
println!("✅ Runner registered: {}", runner_id);
println!("✅ Blocking jobs completed: 2");
println!("✅ Non-blocking jobs started: 1");
println!("\n🎉 Demo completed successfully!\n");
println!("📚 What happened:");
println!(" 1. Registered a runner with the supervisor");
println!(" 2. Sent jobs with Rhai scripts to execute");
println!(" 3. Supervisor queued jobs to the runner");
println!(" 4. Runner executed the scripts and returned results");
println!(" 5. Client received results (for blocking jobs)\n");
println!("🔍 Key Concepts:");
println!(" • job.run = Execute and wait for result (blocking)");
println!(" • job.start = Start and return immediately (non-blocking)");
println!(" • Jobs contain Rhai scripts that run on the runner");
println!(" • Supervisor coordinates job distribution via Redis\n");
Ok(())
}

View File

@@ -0,0 +1,64 @@
//! Simple job workflow example
//!
//! This example demonstrates the basic job lifecycle using the new API:
//! 1. Create a job
//! 2. Start the job
//! 3. Monitor its progress
//! 4. Get the result
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder, JobResult};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Simple Job Workflow Example");
println!("============================\n");
// Create client
let client = SupervisorClient::new("http://localhost:3030")?;
let secret = "user-secret-456";
// Create a simple job
let job = JobBuilder::new()
.caller_id("simple_example")
.context_id("demo")
.payload("echo 'Hello from Hero Supervisor!' && sleep 3 && echo 'Job completed!'")
.executor("osis")
.runner("osis_runner_1")
.timeout(60)
.env_var("EXAMPLE_VAR", "example_value")
.build()?;
println!("📝 Creating job...");
let job_id = client.jobs_create(secret, job).await?;
println!("✅ Job created: {}\n", job_id);
println!("🚀 Starting job...");
client.job_start(secret, &job_id).await?;
println!("✅ Job started\n");
println!("👀 Monitoring job progress...");
loop {
let status = client.job_status(&job_id).await?;
println!(" Status: {}", status.status);
if status.status == "completed" || status.status == "failed" {
break;
}
sleep(Duration::from_secs(2)).await;
}
println!("\n📋 Getting job result...");
match client.job_result(&job_id).await? {
JobResult::Success { success } => {
println!("✅ Success: {}", success);
},
JobResult::Error { error } => {
println!("❌ Error: {}", error);
}
}
Ok(())
}

View File

@@ -0,0 +1,108 @@
# Hero Supervisor Example
This example demonstrates how to configure and run the Hero Supervisor with multiple actors using a TOML configuration file.
## Files
- `config.toml` - Example supervisor configuration with multiple actors
- `run_supervisor.sh` - Shell script to build and run the supervisor with the example config
- `run_supervisor.rs` - Rust script using escargot to build and run the supervisor
- `README.md` - This documentation file
## Configuration
The `config.toml` file defines:
- **Redis connection**: URL for the Redis server used for job queuing
- **Database path**: Local path for supervisor state storage
- **Job queue key**: Redis key for the supervisor job queue
- **Actors**: List of actor configurations with:
- `name`: Unique identifier for the actor
- `runner_type`: Type of runner ("SAL", "OSIS", "V", "Python")
- `binary_path`: Path to the actor binary
- `process_manager`: Process management type ("simple" or "tmux")
## Prerequisites
1. **Redis Server**: Ensure Redis is running on `localhost:6379` (or update the config)
2. **Actor Binaries**: Build the required actor binaries referenced in the config:
```bash
# Build SAL worker
cd ../../sal
cargo build --bin sal_worker
# Build OSIS and system workers
cd ../../worker
cargo build --bin osis
cargo build --bin system
```
## Running the Example
### Option 1: Shell Script (Recommended)
```bash
./run_supervisor.sh
```
### Option 2: Rust Script with Escargot
```bash
cargo +nightly -Zscript run_supervisor.rs
```
### Option 3: Manual Build and Run
```bash
# Build the supervisor
cd ../../../supervisor
cargo build --bin supervisor --features cli
# Run with config
./target/debug/supervisor --config ../baobab/examples/supervisor/config.toml
```
## Usage
Once running, the supervisor will:
1. Load the configuration from `config.toml`
2. Initialize and start all configured actors
3. Listen for jobs on the Redis queue (`hero:supervisor:jobs`)
4. Dispatch jobs to appropriate actors based on the `runner` field
5. Monitor actor health and status
## Testing
You can test the supervisor by dispatching jobs to the Redis queue:
```bash
# Using redis-cli to add a test job
redis-cli LPUSH "hero:supervisor:jobs" '{"id":"test-123","runner":"sal_actor_1","script":"print(\"Hello from SAL actor!\")"}'
```
## Stopping
Use `Ctrl+C` to gracefully shutdown the supervisor. It will:
1. Stop accepting new jobs
2. Wait for running jobs to complete
3. Shutdown all managed actors
4. Clean up resources
## Customization
Modify `config.toml` to:
- Add more actors
- Change binary paths to match your build locations
- Update Redis connection settings
- Configure different process managers per actor
- Adjust database and queue settings
## Troubleshooting
- **Redis Connection**: Ensure Redis is running and accessible
- **Binary Paths**: Verify all actor binary paths exist and are executable
- **Permissions**: Ensure the supervisor has permission to create the database directory
- **Ports**: Check that Redis port (6379) is not blocked by firewall

View File

@@ -0,0 +1,18 @@
# Hero Supervisor Configuration
# This configuration defines the Redis connection, database path, and actors to manage
# Redis connection URL
redis_url = "redis://localhost:6379"
# Database path for supervisor state
db_path = "/tmp/supervisor_example_db"
# Job queue key for supervisor jobs
job_queue_key = "hero:supervisor:jobs"
# Actor configurations
[[actors]]
name = "sal_actor_1"
runner_type = "SAL"
binary_path = "cargo run /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor/examples/mock_runner.rs"
process_manager = "tmux"

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env cargo +nightly -Zscript
//! ```cargo
//! [dependencies]
//! escargot = "0.5"
//! tokio = { version = "1.0", features = ["full"] }
//! log = "0.4"
//! env_logger = "0.10"
//! ```
use escargot::CargoBuild;
use std::process::Command;
use log::{info, error};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
env_logger::init();
info!("Building and running Hero Supervisor with example configuration");
// Get the current directory (when running as cargo example, this is the crate root)
let current_dir = std::env::current_dir()?;
info!("Current directory: {}", current_dir.display());
// Path to the supervisor crate (current directory when running as example)
let supervisor_crate_path = current_dir.clone();
// Path to the config file (in examples/supervisor subdirectory)
let config_path = current_dir.join("examples/supervisor/config.toml");
if !config_path.exists() {
error!("Config file not found: {}", config_path.display());
return Err("Config file not found".into());
}
info!("Using config file: {}", config_path.display());
// Build the supervisor binary using escargot
info!("Building supervisor binary...");
let supervisor_bin = CargoBuild::new()
.bin("supervisor")
.manifest_path(supervisor_crate_path.join("Cargo.toml"))
.features("cli")
.run()?;
info!("Supervisor binary built successfully");
// Run the supervisor with the config file
info!("Starting supervisor with config: {}", config_path.display());
let mut cmd = Command::new(supervisor_bin.path());
cmd.arg("--config")
.arg(&config_path);
// Add environment variables for better logging
cmd.env("RUST_LOG", "info");
info!("Executing: {:?}", cmd);
// Execute the supervisor
let status = cmd.status()?;
if status.success() {
info!("Supervisor completed successfully");
} else {
error!("Supervisor exited with status: {}", status);
}
Ok(())
}

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Hero Supervisor Example Runner
# This script builds and runs the supervisor binary with the example configuration
set -e
# Get the directory of this script
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUPERVISOR_DIR="$SCRIPT_DIR/../../../supervisor"
CONFIG_FILE="$SCRIPT_DIR/config.toml"
echo "🚀 Building and running Hero Supervisor with example configuration"
echo "📁 Script directory: $SCRIPT_DIR"
echo "🔧 Supervisor crate: $SUPERVISOR_DIR"
echo "⚙️ Config file: $CONFIG_FILE"
# Check if config file exists
if [ ! -f "$CONFIG_FILE" ]; then
echo "❌ Config file not found: $CONFIG_FILE"
exit 1
fi
# Check if supervisor directory exists
if [ ! -d "$SUPERVISOR_DIR" ]; then
echo "❌ Supervisor directory not found: $SUPERVISOR_DIR"
exit 1
fi
# Build the supervisor binary
echo "🔨 Building supervisor binary..."
cd "$SUPERVISOR_DIR"
cargo build --bin supervisor --features cli
# Check if build was successful
if [ $? -ne 0 ]; then
echo "❌ Failed to build supervisor binary"
exit 1
fi
echo "✅ Supervisor binary built successfully"
# Run the supervisor with the config file
echo "🎯 Starting supervisor with config: $CONFIG_FILE"
echo "📝 Use Ctrl+C to stop the supervisor"
echo ""
# Set environment variables for better logging
export RUST_LOG=info
# Execute the supervisor
exec "$SUPERVISOR_DIR/target/debug/supervisor" --config "$CONFIG_FILE"

View File

@@ -0,0 +1,102 @@
# OSIRIS + OpenRPC Comprehensive Example
This example demonstrates the complete workflow of using Hero Supervisor with OSIRIS runners via OpenRPC.
## What This Example Does
1. **Builds and starts** Hero Supervisor with OpenRPC server enabled
2. **Builds** the OSIRIS runner binary
3. **Connects** an OpenRPC client to the supervisor
4. **Registers and starts** an OSIRIS runner
5. **Dispatches multiple jobs** via OpenRPC:
- Create a Note
- Create an Event
- Query stored data
- Test access control (expected to fail)
6. **Monitors** job execution and results
7. **Gracefully shuts down** all components
## Prerequisites
**IMPORTANT: Redis must be running before starting this example!**
```bash
# Start Redis (if not already running)
redis-server
```
Other requirements:
- Redis server running on `localhost:6379`
- Rust toolchain installed
- Both `supervisor` and `runner_rust` crates available
## Running the Example
```bash
cargo run --example osiris_openrpc
```
## Job Scripts
The example uses separate Rhai script files for each job:
- `note.rhai` - Creates and stores a Note object
- `event.rhai` - Creates and stores an Event object
- `query.rhai` - Queries and retrieves stored objects
- `access_denied.rhai` - Tests access control (should fail)
## Architecture
```
┌─────────────────┐
│ This Example │
│ (OpenRPC │
│ Client) │
└────────┬────────┘
│ JSON-RPC
┌─────────────────┐
│ Supervisor │
│ (OpenRPC │
│ Server) │
└────────┬────────┘
│ Redis Queue
┌─────────────────┐
│ OSIRIS Runner │
│ (Rhai Engine │
│ + HeroDB) │
└─────────────────┘
```
## Key Features Demonstrated
- **Automatic binary building** using escargot
- **OpenRPC communication** between client and supervisor
- **Runner registration** with configuration
- **Job dispatching** with signatories
- **Context-based access control** in OSIRIS
- **Typed object storage** (Note, Event)
- **Graceful shutdown** and cleanup
## Expected Output
The example will:
1. ✅ Create a Note successfully
2. ✅ Create an Event successfully
3. ✅ Query and retrieve stored objects
4. ✅ Deny access for unauthorized participants
5. ✅ Clean up all resources
## Troubleshooting
**Redis Connection Error:**
- Ensure Redis is running: `redis-server`
**Build Errors:**
- Ensure both supervisor and runner_rust crates are available
- Check that all dependencies are up to date
**OpenRPC Connection Error:**
- Port 3030 might be in use
- Check supervisor logs for startup issues

View File

@@ -0,0 +1,8 @@
print("Attempting to access context with non-signatories...");
print("Participants: [dave, eve]");
print("Signatories: [alice, bob, charlie]");
// This should fail because neither dave nor eve are signatories
let ctx = get_context(["dave", "eve"]);
"This should not succeed!"

View File

@@ -0,0 +1,18 @@
print("Creating context for [alice, bob]...");
let ctx = get_context(["alice", "bob"]);
print("✓ Context ID: " + ctx.context_id());
print("\nCreating event...");
let event = event("events")
.title("Team Retrospective")
.description("Review what went well and areas for improvement")
.location("Virtual - Zoom Room A")
.category("retrospective");
print("✓ Event created");
print("\nStoring event in context...");
ctx.save(event);
print("✓ Event stored");
"Event 'Team Retrospective' created and stored successfully"

View File

@@ -0,0 +1,293 @@
///! Comprehensive OSIRIS + OpenRPC + Admin UI Example
///!
/// This example demonstrates using the Hero Supervisor OpenRPC client
/// to run OSIRIS scripts through the supervisor.
///
/// The client library is located at: client/
///!
///! 1. Starting a Hero Supervisor with OpenRPC server
///! 2. Building and serving the Admin UI (Yew WASM)
///! 3. Building and starting an OSIRIS runner
///! 4. Registering the runner with the supervisor
///! 5. Dispatching multiple OSIRIS jobs via OpenRPC
///! 6. Monitoring job execution via CLI and Web UI
///! 7. Graceful shutdown
///!
///! Services:
///! - Supervisor OpenRPC API: http://127.0.0.1:3030
///! - Admin UI: http://127.0.0.1:8080
///!
///! Usage:
///! ```bash
///! cargo run --example osiris_openrpc
///! ```
///!
///! Requirements:
///! - Redis running on localhost:6379
///! - Trunk installed (cargo install trunk)
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder};
use std::time::Duration;
use escargot::CargoBuild;
use std::process::{Stdio, Command};
use tokio::time::sleep;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🚀 OSIRIS + OpenRPC Comprehensive Example");
println!("=========================================\n");
// ========================================================================
// STEP 1: Build and start supervisor with OpenRPC
// ========================================================================
println!("Step 1: Building and starting supervisor");
println!("─────────────────────────────────────────────────────────────\n");
let supervisor_binary = CargoBuild::new()
.bin("supervisor")
.current_release()
.manifest_path("../supervisor/Cargo.toml")
.run()?;
println!("✅ Supervisor binary built");
let mut supervisor = supervisor_binary.command()
.arg("--redis-url")
.arg("redis://localhost:6379")
.arg("--port")
.arg("3030")
.arg("--admin-secret")
.arg("admin_secret")
.arg("--user-secret")
.arg("user_secret")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?;
println!("✅ Supervisor started on port 3030");
println!("⏳ Waiting for supervisor to initialize...");
sleep(Duration::from_secs(5)).await;
// Check if supervisor is still running
match supervisor.try_wait()? {
Some(status) => {
return Err(format!("Supervisor exited early with status: {}", status).into());
}
None => {
println!("✅ Supervisor is running");
}
}
// ========================================================================
// STEP 2: Build and serve Admin UI
// ========================================================================
println!("\nStep 2: Building and serving Admin UI");
println!("─────────────────────────────────────────────────────────────\n");
let mut admin_ui = Command::new("trunk")
.arg("serve")
.arg("--port")
.arg("8080")
.arg("--address")
.arg("127.0.0.1")
.current_dir("ui")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
println!("✅ Admin UI building...");
println!("🌐 Admin UI will be available at: http://127.0.0.1:8080");
sleep(Duration::from_secs(3)).await;
// ========================================================================
// STEP 3: Build OSIRIS runner
// ========================================================================
println!("\nStep 3: Building OSIRIS runner");
println!("─────────────────────────────────────────────────────────────\n");
let runner_binary = CargoBuild::new()
.bin("runner_osiris")
.current_release()
.manifest_path("../runner_rust/Cargo.toml")
.run()?;
println!("✅ OSIRIS runner binary built");
// ========================================================================
// STEP 4: Connect OpenRPC client
// ========================================================================
println!("\nStep 4: Connecting OpenRPC client");
println!("─────────────────────────────────────────────────────────────\n");
let client = SupervisorClient::new("http://127.0.0.1:3030")?;
println!("✅ Connected to supervisor\n");
// ========================================================================
// STEP 5: Register and start OSIRIS runner
// ========================================================================
println!("Step 5: Registering OSIRIS runner");
println!("─────────────────────────────────────────────────────────────\n");
let runner_path = runner_binary.path().to_string_lossy();
let db_path = "/tmp/osiris_openrpc.db";
// Register the runner with the supervisor
// Note: The current OpenRPC server uses register_runner, not add_runner
client.register_runner("admin_secret", "osiris_runner").await?;
println!("✅ Runner registered: osiris_runner");
client.start_runner("admin_secret", "osiris_runner").await?;
println!("✅ Runner started\n");
sleep(Duration::from_secs(2)).await;
// ========================================================================
// STEP 6: Load job scripts
// ========================================================================
println!("Step 6: Loading job scripts");
println!("─────────────────────────────────────────────────────────────\n");
let note_script = std::fs::read_to_string("examples/osiris_openrpc/note.rhai")?;
let event_script = std::fs::read_to_string("examples/osiris_openrpc/event.rhai")?;
let query_script = std::fs::read_to_string("examples/osiris_openrpc/query.rhai")?;
let access_denied_script = std::fs::read_to_string("examples/osiris_openrpc/access_denied.rhai")?;
println!("✅ Loaded 4 job scripts\n");
// ========================================================================
// STEP 7: Dispatch jobs via OpenRPC
// ========================================================================
println!("Step 7: Dispatching jobs");
println!("─────────────────────────────────────────────────────────────\n");
// Job 1: Create Note
println!("📝 Job 1: Creating Note...");
let job1 = JobBuilder::new()
.caller_id("openrpc_client")
.context_id("osiris_demo")
.payload(&note_script)
.runner("osiris_runner")
.executor("rhai")
.timeout(30)
.signature("alice", "")
.signature("bob", "")
.build()?;
let job1_result = client.run_job("user_secret", job1).await;
match job1_result {
Ok(result) => println!("{:?}\n", result),
Err(e) => println!("❌ Job failed: {}\n", e),
}
sleep(Duration::from_secs(1)).await;
// Job 2: Create Event
println!("📅 Job 2: Creating Event...");
let job2 = JobBuilder::new()
.caller_id("openrpc_client")
.context_id("osiris_demo")
.payload(&event_script)
.runner("osiris_runner")
.executor("rhai")
.timeout(30)
.signature("alice", "")
.signature("bob", "")
.build()?;
let job2_result = client.run_job("user_secret", job2).await;
match job2_result {
Ok(result) => println!("{:?}\n", result),
Err(e) => println!("❌ Job failed: {}\n", e),
}
sleep(Duration::from_secs(1)).await;
// Job 3: Query Data
println!("🔍 Job 3: Querying Data...");
let job3 = JobBuilder::new()
.caller_id("openrpc_client")
.context_id("osiris_demo")
.payload(&query_script)
.runner("osiris_runner")
.executor("rhai")
.timeout(30)
.signature("alice", "")
.signature("bob", "")
.signature("charlie", "")
.build()?;
let job3_result = client.run_job("user_secret", job3).await;
match job3_result {
Ok(result) => println!("{:?}\n", result),
Err(e) => println!("❌ Job failed: {}\n", e),
}
sleep(Duration::from_secs(1)).await;
// Job 4: Access Control Test (should fail)
println!("🔒 Job 4: Testing Access Control (expected to fail)...");
let job4 = JobBuilder::new()
.caller_id("openrpc_client")
.context_id("osiris_demo")
.payload(&access_denied_script)
.runner("osiris_runner")
.executor("rhai")
.timeout(30)
.signature("alice", "")
.signature("bob", "")
.signature("charlie", "")
.build()?;
let job4_result = client.run_job("user_secret", job4).await;
match job4_result {
Ok(result) => println!("❌ Unexpected success: {:?}\n", result),
Err(e) => println!("✅ Access denied as expected: {}\n", e),
}
// ========================================================================
// STEP 8: Check runner status
// ========================================================================
println!("\nStep 8: Checking runner status");
println!("─────────────────────────────────────────────────────────────\n");
let status = client.get_runner_status("admin_secret", "osiris_runner").await?;
println!("Runner status: {:?}\n", status);
// ========================================================================
// STEP 9: Keep services running for manual testing
// ========================================================================
println!("\nStep 9: Services Running");
println!("─────────────────────────────────────────────────────────────\n");
println!("🌐 Admin UI: http://127.0.0.1:8080");
println!("📡 OpenRPC API: http://127.0.0.1:3030");
println!("\n⏸️ Press Ctrl+C to stop all services...\n");
// Wait for Ctrl+C
tokio::signal::ctrl_c().await?;
// ========================================================================
// STEP 10: Cleanup
// ========================================================================
println!("\n\nStep 10: Cleanup");
println!("─────────────────────────────────────────────────────────────\n");
client.stop_runner("admin_secret", "osiris_runner", false).await?;
println!("✅ Runner stopped");
client.remove_runner("admin_secret", "osiris_runner").await?;
println!("✅ Runner removed");
admin_ui.kill()?;
println!("✅ Admin UI stopped");
supervisor.kill()?;
println!("✅ Supervisor stopped");
println!("\n✨ Example completed successfully!");
Ok(())
}

View File

@@ -0,0 +1,20 @@
print("Creating context for [alice, bob]...");
let ctx = get_context(["alice", "bob"]);
print("✓ Context ID: " + ctx.context_id());
print("\nCreating note...");
let note = note("notes")
.title("Sprint Planning Meeting")
.content("Discussed Q1 2025 roadmap and milestones")
.tag("sprint", "2025-Q1")
.tag("team", "engineering")
.tag("priority", "high")
.mime("text/markdown");
print("✓ Note created");
print("\nStoring note in context...");
ctx.save(note);
print("✓ Note stored");
"Note 'Sprint Planning Meeting' created and stored successfully"

View File

@@ -0,0 +1,21 @@
print("Querying context [alice, bob]...");
let ctx = get_context(["alice", "bob"]);
print("✓ Context ID: " + ctx.context_id());
print("\nListing all notes...");
let notes = ctx.list("notes");
print("✓ Found " + notes.len() + " note(s)");
print("\nRetrieving specific note...");
let note = ctx.get("notes", "sprint_planning_001");
print("✓ Retrieved note: sprint_planning_001");
print("\nQuerying context [alice, bob, charlie]...");
let ctx2 = get_context(["alice", "bob", "charlie"]);
print("✓ Context ID: " + ctx2.context_id());
print("\nListing all events...");
let events = ctx2.list("events");
print("✓ Found " + events.len() + " event(s)");
"Query complete: Found " + notes.len() + " notes and " + events.len() + " events"

53
bin/supervisor/scripts/build.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
# Spinner function
spinner() {
local pid=$1
local delay=0.1
local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
while ps -p $pid > /dev/null 2>&1; do
local temp=${spinstr#?}
printf " [%c] " "$spinstr"
local spinstr=$temp${spinstr%"$temp"}
sleep $delay
printf "\b\b\b\b\b\b"
done
printf " \b\b\b\b"
}
echo "Building Hero Supervisor Workspace"
echo ""
# Build core and client
printf "📦 Core & Client... "
cd "$PROJECT_DIR"
if RUSTFLAGS="-A warnings" cargo build --release --workspace > /tmp/supervisor-build-core.log 2>&1 & spinner $!; wait $!; then
echo "✅"
else
echo "❌"
echo " Error: Build failed. Run 'cd $PROJECT_DIR && cargo build --release --workspace' for details"
exit 1
fi
# # Build UI
# printf "📦 UI (WASM)... "
# cd "$PROJECT_DIR/ui"
# if ! command -v trunk &> /dev/null; then
# echo "⚠️ (trunk not installed)"
# echo " Install with: cargo install trunk"
# else
# if trunk build --release > /tmp/supervisor-build-ui.log 2>&1 & spinner $!; wait $!; then
# echo "✅"
# else
# echo "❌"
# echo " Error: Build failed. Run 'cd $PROJECT_DIR/ui && trunk build --release' for details"
# exit 1
# fi
# fi
echo ""
echo "✅ All builds completed"

View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Load environment variables from .env file
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
ENV_FILE="$PROJECT_DIR/.env"
if [ -f "$ENV_FILE" ]; then
# Export variables from .env file
set -a
source "$ENV_FILE"
set +a
echo "✅ Loaded environment from .env"
else
echo "⚠️ No .env file found at $ENV_FILE"
echo " Copy .env.example to .env and configure your settings"
exit 1
fi

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Generate a supervisor secret key in the correct format
# Generate a random 32-byte hex string
SECRET=$(openssl rand -hex 32)
echo "Generated supervisor secret:"
echo "$SECRET"
echo ""
echo "Add this to your .env file:"
echo "SUPERVISOR_ADMIN_SECRET=$SECRET"

View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
pushd "$ROOT_DIR"
cargo update

161
bin/supervisor/scripts/release.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/bin/bash
# release.sh - Build optimized WASM and serve with Caddy + Brotli compression
set -e
###############################################################################
# Freezone Portal Release Script
# - Builds the WASM app with trunk in release mode
# - Optionally optimizes .wasm with wasm-opt (-Oz, strip)
# - Precompresses assets with gzip and brotli for efficient static serving
# - Generates a manifest (manifest.json) with sizes and SHA-256 checksums
#
# Usage:
# ./release.sh [--outdir dist] [--no-opt] [--compress] [--no-manifest]
# [--trunk-args "--public-url /portal/"]
#
# Notes:
# - Precompression is OFF by default; enable with --compress
# - Only modifies files within the output directory (default: dist)
# - Non-destructive to your source tree
###############################################################################
set -u
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
BUILD_SCRIPT="$SCRIPT_DIR/build.sh"
# Defaults
OUTDIR="dist"
DO_OPT=1
DO_COMPRESS=0
DO_MANIFEST=1
TRUNK_ARGS=""
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Options:
--outdir <dir> Output directory (default: dist)
--no-opt Skip wasm-opt optimization
--compress Enable gzip/brotli precompression
--no-manifest Skip manifest generation
--trunk-args "..." Extra arguments forwarded to trunk build
-h, --help Show this help
Examples:
$(basename "$0") --outdir dist --trunk-args "--public-url /"
$(basename "$0") --no-opt --no-compress
EOF
}
# Parse args
while [[ $# -gt 0 ]]; do
case "$1" in
--outdir)
OUTDIR="$2"; shift 2;;
--no-opt)
DO_OPT=0; shift;;
--compress)
DO_COMPRESS=1; shift;;
--no-manifest)
DO_MANIFEST=0; shift;;
--trunk-args)
TRUNK_ARGS="$2"; shift 2;;
-h|--help)
usage; exit 0;;
*)
echo "❌ Unknown option: $1"; echo; usage; exit 1;;
esac
done
# Tool checks
if [[ ! -x "$BUILD_SCRIPT" ]]; then
echo "❌ build.sh not found or not executable at: $BUILD_SCRIPT"
echo " Ensure portal/scripts/build.sh exists and is chmod +x."
exit 1
fi
if ! command -v trunk >/dev/null 2>&1; then
echo "❌ trunk not found. Install with: cargo install trunk"; exit 1;
fi
HAS_WASM_OPT=0
if command -v wasm-opt >/dev/null 2>&1; then HAS_WASM_OPT=1; fi
if [[ $DO_OPT -eq 1 && $HAS_WASM_OPT -eq 0 ]]; then
echo "⚠️ wasm-opt not found. Skipping WASM optimization."
DO_OPT=0
fi
if [[ $DO_COMPRESS -eq 1 ]]; then
if ! command -v gzip >/dev/null 2>&1; then
echo "⚠️ gzip not found. Skipping gzip compression."; GZIP_OK=0; else GZIP_OK=1; fi
if ! command -v brotli >/dev/null 2>&1; then
echo "⚠️ brotli not found. Skipping brotli compression."; BR_OK=0; else BR_OK=1; fi
else
GZIP_OK=0; BR_OK=0
fi
echo "🔧 Building optimized WASM bundle (via build.sh)..."
set -x
"$BUILD_SCRIPT" --release --outdir "$OUTDIR" ${TRUNK_ARGS:+--trunk-args "$TRUNK_ARGS"}
set +x
DIST_DIR="$PROJECT_DIR/$OUTDIR"
if [[ ! -d "$DIST_DIR" ]]; then
echo "❌ Build failed: output directory not found: $DIST_DIR"; exit 1;
fi
# Optimize .wasm files
if [[ $DO_OPT -eq 1 && $HAS_WASM_OPT -eq 1 ]]; then
echo "🛠️ Optimizing WASM with wasm-opt (-Oz, strip)..."
while IFS= read -r -d '' wasm; do
echo "$(basename "$wasm")"
tmp="$wasm.opt"
wasm-opt -Oz --strip-dwarf "$wasm" -o "$tmp"
mv "$tmp" "$wasm"
done < <(find "$DIST_DIR" -type f -name "*.wasm" -print0)
fi
# Precompress assets
if [[ $DO_COMPRESS -eq 1 ]]; then
echo "🗜️ Precompressing assets (gzip/brotli)..."
while IFS= read -r -d '' f; do
if [[ $GZIP_OK -eq 1 ]]; then
gzip -kf9 "$f"
fi
if [[ $BR_OK -eq 1 ]]; then
brotli -f -q 11 "$f"
fi
done < <(find "$DIST_DIR" -type f \( -name "*.wasm" -o -name "*.js" -o -name "*.css" \) -print0)
fi
# Manifest with sizes and SHA-256
if [[ $DO_MANIFEST -eq 1 ]]; then
echo "🧾 Generating manifest.json (sizes, sha256)..."
manifest="$DIST_DIR/manifest.json"
echo "{" > "$manifest"
first=1
while IFS= read -r -d '' f; do
rel="${f#"$DIST_DIR/"}"
size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f")
if command -v shasum >/dev/null 2>&1; then
hash=$(shasum -a 256 "$f" | awk '{print $1}')
else
hash=$(openssl dgst -sha256 -r "$f" | awk '{print $1}')
fi
[[ $first -eq 1 ]] || echo "," >> "$manifest"
first=0
printf " \"%s\": { \"bytes\": %s, \"sha256\": \"%s\" }" "$rel" "$size" "$hash" >> "$manifest"
done < <(find "$DIST_DIR" -type f ! -name "manifest.json" -print0 | sort -z)
echo "\n}" >> "$manifest"
fi
echo "📦 Checking bundle sizes ($OUTDIR)..."
if [ -d "$OUTDIR" ]; then
echo "Bundle sizes:"
find "$OUTDIR" -name "*.wasm" -exec ls -lh {} \; | awk '{print " WASM: " $5 " - " $9}'
find "$OUTDIR" -name "*.js" -exec ls -lh {} \; | awk '{print " JS: " $5 " - " $9}'
find "$OUTDIR" -name "*.css" -exec ls -lh {} \; | awk '{print " CSS: " $5 " - " $9}'
echo ""
fi

71
bin/supervisor/scripts/run.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
# Load environment variables
source "$SCRIPT_DIR/environment.sh"
# Build first
echo "🔨 Building supervisor..."
"$SCRIPT_DIR/build.sh"
# Validate required environment variables
if [ -z "$ADMIN_SECRETS" ]; then
echo "❌ Error: ADMIN_SECRETS not set in .env"
echo " Generate a secret with: ./scripts/generate_secret.sh"
exit 1
fi
# Set defaults from env vars
REDIS_URL="${REDIS_URL:-redis://127.0.0.1:6379}"
PORT="${PORT:-3030}"
BIND_ADDRESS="${BIND_ADDRESS:-127.0.0.1}"
LOG_LEVEL="${LOG_LEVEL:-info}"
cd "$PROJECT_DIR"
# Build command with flags from env vars
SUPERVISOR_CMD="target/release/supervisor --redis-url $REDIS_URL --port $PORT --bind-address $BIND_ADDRESS"
# Add admin secrets
IFS=',' read -ra SECRETS <<< "$ADMIN_SECRETS"
for secret in "${SECRETS[@]}"; do
SUPERVISOR_CMD="$SUPERVISOR_CMD --admin-secret $secret"
done
# Add user secrets if provided
if [ ! -z "$USER_SECRETS" ]; then
IFS=',' read -ra SECRETS <<< "$USER_SECRETS"
for secret in "${SECRETS[@]}"; do
SUPERVISOR_CMD="$SUPERVISOR_CMD --user-secret $secret"
done
fi
# Add register secrets if provided
if [ ! -z "$REGISTER_SECRETS" ]; then
IFS=',' read -ra SECRETS <<< "$REGISTER_SECRETS"
for secret in "${SECRETS[@]}"; do
SUPERVISOR_CMD="$SUPERVISOR_CMD --register-secret $secret"
done
fi
# Add mycelium URL if provided
if [ ! -z "$MYCELIUM_URL" ]; then
SUPERVISOR_CMD="$SUPERVISOR_CMD --mycelium-url $MYCELIUM_URL"
fi
# Add runners if provided
if [ ! -z "$RUNNERS" ]; then
SUPERVISOR_CMD="$SUPERVISOR_CMD --runners $RUNNERS"
fi
echo ""
echo "🚀 Starting Hero Supervisor"
echo " Redis: $REDIS_URL"
echo " Port: $PORT"
echo " Log Level: $LOG_LEVEL"
echo ""
# Run supervisor directly with output visible
exec env RUST_LOG="$LOG_LEVEL" RUST_LOG_STYLE=never $SUPERVISOR_CMD

53
bin/supervisor/scripts/test.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROJECT_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
# Spinner function
spinner() {
local pid=$1
local delay=0.1
local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
while ps -p $pid > /dev/null 2>&1; do
local temp=${spinstr#?}
printf " [%c] " "$spinstr"
local spinstr=$temp${spinstr%"$temp"}
sleep $delay
printf "\b\b\b\b\b\b"
done
printf " \b\b\b\b"
}
echo "Testing Hero Supervisor Workspace"
echo ""
# Test core and client
printf "🧪 Core & Client... "
cd "$PROJECT_DIR"
if cargo test --workspace > /tmp/supervisor-test-core.log 2>&1 & spinner $!; wait $!; then
echo "✅"
else
echo "❌"
echo " Error: Tests failed. Run 'cd $PROJECT_DIR && cargo test --workspace' for details"
exit 1
fi
# Test UI
printf "🧪 UI (WASM)... "
cd "$PROJECT_DIR/ui"
if ! command -v wasm-pack &> /dev/null; then
echo "⚠️ (wasm-pack not installed)"
echo " Install with: cargo install wasm-pack"
else
if wasm-pack test --headless --firefox > /tmp/supervisor-test-ui.log 2>&1 & spinner $!; wait $!; then
echo "✅"
else
echo "❌"
echo " Error: Tests failed. Run 'cd $PROJECT_DIR/ui && wasm-pack test --headless --firefox' for details"
exit 1
fi
fi
echo ""
echo "✅ All tests completed"

111
bin/supervisor/src/auth.rs Normal file
View File

@@ -0,0 +1,111 @@
//! Authentication and API key management
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
/// API key scope/permission level
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApiKeyScope {
/// Full access - can manage keys, runners, jobs
Admin,
/// Can register new runners
Registrar,
/// Can create and manage jobs
User,
}
impl ApiKeyScope {
pub fn as_str(&self) -> &'static str {
match self {
ApiKeyScope::Admin => "admin",
ApiKeyScope::Registrar => "registrar",
ApiKeyScope::User => "user",
}
}
}
/// An API key with metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKey {
/// The actual key value (UUID or custom string)
pub key: String,
/// Human-readable name for the key
pub name: String,
/// Permission scope
pub scope: ApiKeyScope,
/// When the key was created
pub created_at: String,
/// Optional expiration timestamp
pub expires_at: Option<String>,
}
impl ApiKey {
/// Create a new API key with a generated UUID
pub fn new(name: String, scope: ApiKeyScope) -> Self {
Self {
key: Uuid::new_v4().to_string(),
name,
scope,
created_at: chrono::Utc::now().to_rfc3339(),
expires_at: None,
}
}
/// Create a new API key with a specific key value
pub fn with_key(key: String, name: String, scope: ApiKeyScope) -> Self {
Self {
key,
name,
scope,
created_at: chrono::Utc::now().to_rfc3339(),
expires_at: None,
}
}
}
/// Response for auth verification
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthVerifyResponse {
pub valid: bool,
pub name: String,
pub scope: String,
}
/// Method authorization requirements
/// Maps RPC method names to required scopes
pub fn get_method_required_scopes(method: &str) -> Option<Vec<ApiKeyScope>> {
use ApiKeyScope::*;
match method {
// Admin-only methods
"key.create" | "key.generate" | "key.delete" | "key.list" |
"supervisor.info" => {
Some(vec![Admin])
}
// Admin or Registrar methods
"runner.create" | "runner.remove" => {
Some(vec![Admin, Registrar])
}
// Admin or User methods
"job.create" | "job.run" | "job.start" | "job.stop" | "job.delete" => {
Some(vec![Admin, User])
}
// Public methods (no auth required)
"rpc.discover" => None,
// Any authenticated user (read-only operations)
"runner.list" | "runner.ping" |
"job.get" | "job.list" | "job.status" | "job.result" | "job.logs" |
"auth.verify" => {
Some(vec![Admin, Registrar, User])
}
// Default: require authentication
_ => Some(vec![Admin, Registrar, User]),
}
}

View File

@@ -0,0 +1,112 @@
//! Hero Supervisor Binary
use hero_supervisor::SupervisorBuilder;
use clap::Parser;
use log::{error, info};
use std::sync::Arc;
use tokio::sync::Mutex;
/// Hero Supervisor - manages actors and dispatches jobs
#[derive(Parser, Debug)]
#[command(name = "supervisor")]
#[command(about = "Hero Supervisor - manages actors and dispatches jobs")]
struct Args {
/// Redis URL for job queue
#[arg(long, default_value = "redis://127.0.0.1:6379")]
redis_url: String,
/// Namespace for Redis keys
#[arg(long, default_value = "")]
namespace: String,
/// Admin secrets (required, can be specified multiple times)
#[arg(long = "admin-secret", value_name = "SECRET", required = true)]
admin_secrets: Vec<String>,
/// User secrets (can be specified multiple times)
#[arg(long = "user-secret", value_name = "SECRET")]
user_secrets: Vec<String>,
/// Register secrets (can be specified multiple times)
#[arg(long = "register-secret", value_name = "SECRET")]
register_secrets: Vec<String>,
/// Port for OpenRPC HTTP server
#[arg(long, default_value = "3030")]
port: u16,
/// Bind address for OpenRPC HTTP server
#[arg(long, default_value = "127.0.0.1")]
bind_address: String,
/// Pre-configured runner names (comma-separated)
#[arg(long, value_name = "NAMES", value_delimiter = ',')]
runners: Vec<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let args = Args::parse();
// Build supervisor
let mut builder = SupervisorBuilder::new()
.admin_secrets(args.admin_secrets);
if !args.user_secrets.is_empty() {
builder = builder.user_secrets(args.user_secrets);
}
if !args.register_secrets.is_empty() {
builder = builder.register_secrets(args.register_secrets);
}
let mut supervisor = builder.build().await?;
// Register pre-configured runners
if !args.runners.is_empty() {
for runner_name in &args.runners {
match supervisor.runner_create(runner_name.clone()).await {
Ok(_) => {},
Err(e) => error!("Failed to register runner '{}': {}", runner_name, e),
}
}
}
// Start OpenRPC server
use hero_supervisor::openrpc::start_http_openrpc_server;
let supervisor_clone = supervisor.clone();
let bind_addr = args.bind_address.clone();
let port = args.port;
tokio::spawn(async move {
match start_http_openrpc_server(supervisor_clone, &bind_addr, port).await {
Ok(handle) => {
handle.stopped().await;
error!("OpenRPC server stopped unexpectedly");
}
Err(e) => {
error!("OpenRPC server error: {}", e);
}
}
});
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Print startup info
println!("📡 http://{}:{}", args.bind_address, args.port);
info!("Hero Supervisor is running. Press Ctrl+C to shutdown.");
// Set up graceful shutdown
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for ctrl+c");
info!("Received shutdown signal");
std::process::exit(0);
});
// Keep the application running
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}

View File

@@ -0,0 +1,198 @@
//! Supervisor builder for configuration and initialization.
use crate::error::{SupervisorError, SupervisorResult};
use crate::Supervisor;
use hero_job_client::ClientBuilder;
/// Builder for constructing a Supervisor instance
pub struct SupervisorBuilder {
/// Set of registered runner IDs
runners: std::collections::HashSet<String>,
/// Redis URL for connection
redis_url: String,
/// Admin secrets for bootstrapping API keys
admin_secrets: Vec<String>,
/// User secrets for bootstrapping API keys
user_secrets: Vec<String>,
/// Register secrets for bootstrapping API keys
register_secrets: Vec<String>,
client_builder: ClientBuilder,
/// Osiris URL for queries (optional)
osiris_url: Option<String>,
/// Supervisor URL for commands via Osiris (optional)
supervisor_url: Option<String>,
/// Supervisor secret for Osiris commands (optional)
supervisor_secret: Option<String>,
/// Runner name for Osiris operations (optional)
osiris_runner_name: Option<String>,
}
impl SupervisorBuilder {
/// Create a new supervisor builder
pub fn new() -> Self {
Self {
runners: std::collections::HashSet::new(),
redis_url: "redis://localhost:6379".to_string(),
admin_secrets: Vec::new(),
user_secrets: Vec::new(),
register_secrets: Vec::new(),
client_builder: ClientBuilder::new(),
osiris_url: None,
supervisor_url: None,
supervisor_secret: None,
osiris_runner_name: None,
}
}
/// Set the Osiris URL for queries
pub fn osiris_url<S: Into<String>>(mut self, url: S) -> Self {
self.osiris_url = Some(url.into());
self
}
/// Set the Supervisor URL for Osiris commands
pub fn supervisor_url_for_osiris<S: Into<String>>(mut self, url: S) -> Self {
self.supervisor_url = Some(url.into());
self
}
/// Set the Supervisor secret for Osiris commands
pub fn supervisor_secret<S: Into<String>>(mut self, secret: S) -> Self {
self.supervisor_secret = Some(secret.into());
self
}
/// Set the runner name for Osiris operations
pub fn osiris_runner_name<S: Into<String>>(mut self, name: S) -> Self {
self.osiris_runner_name = Some(name.into());
self
}
/// Add an admin secret
pub fn add_admin_secret<S: Into<String>>(mut self, secret: S) -> Self {
self.admin_secrets.push(secret.into());
self
}
/// Add multiple admin secrets
pub fn admin_secrets<I, S>(mut self, secrets: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.admin_secrets.extend(secrets.into_iter().map(|s| s.into()));
self
}
/// Add a user secret
pub fn add_user_secret<S: Into<String>>(mut self, secret: S) -> Self {
self.user_secrets.push(secret.into());
self
}
/// Add multiple user secrets
pub fn user_secrets<I, S>(mut self, secrets: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.user_secrets.extend(secrets.into_iter().map(|s| s.into()));
self
}
/// Add a register secret
pub fn add_register_secret<S: Into<String>>(mut self, secret: S) -> Self {
self.register_secrets.push(secret.into());
self
}
/// Add multiple register secrets
pub fn register_secrets<I, S>(mut self, secrets: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.register_secrets.extend(secrets.into_iter().map(|s| s.into()));
self
}
/// Add a runner to the supervisor
pub fn add_runner(mut self, runner_id: String) -> Self {
self.runners.insert(runner_id);
self
}
/// Build the supervisor
pub async fn build(self) -> SupervisorResult<Supervisor> {
// Create Redis client
let redis_client = redis::Client::open(self.redis_url.as_str())
.map_err(|e| SupervisorError::ConfigError {
reason: format!("Invalid Redis URL: {}", e),
})?;
// Create the store
let mut store = crate::store::Store::new();
// Add admin secrets as API keys
for secret in &self.admin_secrets {
store.key_create(
crate::auth::ApiKey::new(secret.clone(), crate::auth::ApiKeyScope::Admin),
);
}
// Add user secrets as API keys
for secret in &self.user_secrets {
store.key_create(
crate::auth::ApiKey::new(secret.clone(), crate::auth::ApiKeyScope::User),
);
}
// Add register secrets as API keys
for secret in &self.register_secrets {
store.key_create(
crate::auth::ApiKey::new(secret.clone(), crate::auth::ApiKeyScope::Registrar),
);
}
// Build the client
let client = self.client_builder.build().await?;
// Build Osiris client if configured
// Temporarily disabled - needs update
// let osiris_client = if let (Some(osiris_url), Some(supervisor_url)) =
// (self.osiris_url, self.supervisor_url) {
// let mut builder = osiris_client::OsirisClient::builder()
// .osiris_url(osiris_url)
// .supervisor_url(supervisor_url)
// .runner_name(self.osiris_runner_name.unwrap_or_else(|| "osiris-runner".to_string()));
//
// if let Some(secret) = self.supervisor_secret {
// builder = builder.supervisor_secret(secret);
// }
//
// Some(builder.build().map_err(|e| SupervisorError::ConfigError {
// reason: format!("Failed to build Osiris client: {}", e),
// })?)
// } else {
// None
// };
// Add pre-configured runners to the store
for runner_id in self.runners {
let _ = store.runner_add(runner_id);
}
Ok(Supervisor {
store: std::sync::Arc::new(tokio::sync::Mutex::new(store)),
job_client: client,
redis_client,
// osiris_client, // Temporarily disabled
})
}
}
impl Default for SupervisorBuilder {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,73 @@
//! Error types for supervisor operations.
use thiserror::Error;
use jsonrpsee::types::{ErrorObject, ErrorObjectOwned};
/// Result type for supervisor operations
pub type SupervisorResult<T> = Result<T, SupervisorError>;
/// Errors that can occur during supervisor operations
#[derive(Debug, Error)]
pub enum SupervisorError {
#[error("Runner '{runner_id}' not found")]
RunnerNotFound { runner_id: String },
#[error("Runner '{runner_id}' is already registered")]
RunnerAlreadyRegistered { runner_id: String },
#[error("Job '{job_id}' not found")]
JobNotFound { job_id: String },
#[error("Failed to queue job for runner '{runner_id}': {reason}")]
QueueError { runner_id: String, reason: String },
#[error("Configuration error: {reason}")]
ConfigError { reason: String },
#[error("Invalid secret or API key: {0}")]
InvalidSecret(String),
#[error("Authentication error: {message}")]
AuthenticationError { message: String },
#[error("Insufficient permissions: {message}")]
PermissionDenied { message: String },
#[error("Redis error: {source}")]
RedisError {
#[from]
source: redis::RedisError,
},
#[error("Job error: {source}")]
JobError {
#[from]
source: hero_job::JobError,
},
#[error("Job client error: {source}")]
JobClientError {
#[from]
source: hero_job_client::ClientError,
},
#[error("IO error: {source}")]
IoError {
#[from]
source: std::io::Error,
},
#[error("Osiris client error: {0}")]
OsirisError(String),
}
/// Implement conversion from SupervisorError → RPC ErrorObject
impl From<SupervisorError> for ErrorObject<'static> {
fn from(err: SupervisorError) -> Self {
ErrorObject::owned(
-32603, // Internal error code
format!("Supervisor error: {err}"),
None::<()>,
)
}
}

19
bin/supervisor/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
//! Hero Supervisor - Actor management for the Hero ecosystem.
//!
//! See README.md for detailed documentation and usage examples.
pub mod supervisor;
pub mod builder;
pub mod error;
pub mod openrpc;
pub mod auth;
pub mod store;
// Re-export job client for convenience
pub use hero_job_client as job_client;
// Re-export main types for convenience
pub use supervisor::Supervisor;
pub use builder::SupervisorBuilder;
pub use error::{SupervisorError, SupervisorResult};
pub use hero_job::{Job, JobBuilder, JobStatus, JobError};

View File

@@ -0,0 +1,474 @@
//! OpenRPC server implementation.
use jsonrpsee::{
core::{RpcResult, async_trait},
server::middleware::rpc::{RpcServiceT, RpcServiceBuilder, MethodResponse},
proc_macros::rpc,
server::{Server, ServerHandle},
types::{ErrorObject, ErrorObjectOwned},
};
use tower_http::cors::{CorsLayer, Any};
use anyhow;
use log::{debug, info, error};
use crate::{auth::ApiKey, supervisor::Supervisor};
use crate::error::SupervisorError;
use hero_job::{Job, JobResult, JobStatus};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::sync::Arc;
use std::fs;
use tokio::sync::Mutex;
/// Load OpenRPC specification from docs/openrpc.json
fn load_openrpc_spec() -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let path = "../../docs/openrpc.json";
let content = fs::read_to_string(path)?;
let spec = serde_json::from_str(&content)?;
debug!("Loaded OpenRPC specification from: {}", path);
Ok(spec)
}
/// Request parameters for generating API keys (auto-generates key value)
#[derive(Debug, Deserialize, Serialize)]
pub struct GenerateApiKeyParams {
pub name: String,
pub scope: String, // "admin", "registrar", or "user"
}
/// Job status response with metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobStatusResponse {
pub job_id: String,
pub status: String,
pub created_at: String,
}
/// Supervisor information response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupervisorInfo {
pub server_url: String,
}
/// OpenRPC trait - maps directly to Supervisor methods
/// This trait exists only for jsonrpsee's macro system.
/// The implementation below is just error type conversion -
/// all actual logic lives in Supervisor methods.
#[rpc(server)]
pub trait SupervisorRpc {
/// Create a job without queuing it to a runner
#[method(name = "job.create")]
async fn job_create(&self, params: Job) -> RpcResult<String>;
/// Get a job by job ID
#[method(name = "job.get")]
async fn job_get(&self, job_id: String) -> RpcResult<Job>;
/// Start a previously created job by queuing it to its assigned runner
#[method(name = "job.start")]
async fn job_start(&self, job_id: String) -> RpcResult<()>;
/// Run a job on the appropriate runner and return the result
#[method(name = "job.run")]
async fn job_run(&self, params: Job) -> RpcResult<JobResult>;
/// Get the current status of a job
#[method(name = "job.status")]
async fn job_status(&self, job_id: String) -> RpcResult<JobStatus>;
/// Get the result of a completed job (blocks until result is available)
#[method(name = "job.result")]
async fn job_result(&self, job_id: String) -> RpcResult<JobResult>;
/// Get logs for a specific job
#[method(name = "job.logs")]
async fn job_logs(&self, job_id: String) -> RpcResult<Vec<String>>;
/// Stop a running job
#[method(name = "job.stop")]
async fn job_stop(&self, job_id: String) -> RpcResult<()>;
/// Delete a job from the system
#[method(name = "job.delete")]
async fn job_delete(&self, job_id: String) -> RpcResult<()>;
/// List all jobs
#[method(name = "job.list")]
async fn job_list(&self) -> RpcResult<Vec<Job>>;
/// Add a runner with configuration
#[method(name = "runner.create")]
async fn runner_create(&self, runner_id: String) -> RpcResult<()>;
/// Delete a runner from the supervisor
#[method(name = "runner.remove")]
async fn runner_delete(&self, runner_id: String) -> RpcResult<()>;
/// List all runner IDs
#[method(name = "runner.list")]
async fn runner_list(&self) -> RpcResult<Vec<String>>;
/// Ping a runner (dispatch a ping job)
#[method(name = "runner.ping")]
async fn ping_runner(&self, runner_id: String) -> RpcResult<String>;
/// Create an API key with provided key value
#[method(name = "key.create")]
async fn key_create(&self, key: ApiKey) -> RpcResult<()>;
/// Generate a new API key with auto-generated key value
#[method(name = "key.generate")]
async fn key_generate(&self, params: GenerateApiKeyParams) -> RpcResult<ApiKey>;
/// Delete an API key
#[method(name = "key.delete")]
async fn key_delete(&self, key_id: String) -> RpcResult<()>;
/// List all secrets (returns counts only for security)
#[method(name = "key.list")]
async fn key_list(&self) -> RpcResult<Vec<ApiKey>>;
/// Verify an API key and return its metadata
#[method(name = "auth.verify")]
async fn auth_verify(&self) -> RpcResult<crate::auth::AuthVerifyResponse>;
/// Get supervisor information
#[method(name = "supervisor.info")]
async fn supervisor_info(&self) -> RpcResult<SupervisorInfo>;
/// OpenRPC discovery method - returns the OpenRPC document describing this API
#[method(name = "rpc.discover")]
async fn rpc_discover(&self) -> RpcResult<serde_json::Value>;
}
/// RPC implementation on Supervisor
///
/// This implementation is ONLY for error type conversion (SupervisorError → ErrorObject).
/// All business logic is in Supervisor methods - these are thin wrappers.
/// Authorization is handled by middleware before methods are called.
#[async_trait]
impl SupervisorRpcServer for Supervisor {
async fn job_create(&self, job: Job) -> RpcResult<String> {
Ok(self.job_create(job).await?)
}
async fn job_get(&self, job_id: String) -> RpcResult<Job> {
Ok(self.job_get(&job_id).await?)
}
async fn job_list(&self) -> RpcResult<Vec<Job>> {
let job_ids = self.job_list().await;
let mut jobs = Vec::new();
for job_id in job_ids {
if let Ok(job) = self.job_get(&job_id).await {
jobs.push(job);
}
}
Ok(jobs)
}
async fn job_run(&self, job: Job) -> RpcResult<JobResult> {
let output = self.job_run(job).await?;
Ok(JobResult::Success { success: output })
}
async fn job_start(&self, job_id: String) -> RpcResult<()> {
self.job_start(&job_id).await?;
Ok(())
}
async fn job_status(&self, job_id: String) -> RpcResult<JobStatus> {
Ok(self.job_status(&job_id).await?)
}
async fn job_logs(&self, job_id: String) -> RpcResult<Vec<String>> {
Ok(self.job_logs(&job_id, None).await?)
}
async fn job_result(&self, job_id: String) -> RpcResult<JobResult> {
match self.job_result(&job_id).await? {
Some(result) => {
if result.starts_with("Error:") {
Ok(JobResult::Error { error: result })
} else {
Ok(JobResult::Success { success: result })
}
},
None => Ok(JobResult::Error { error: "Job result not available".to_string() })
}
}
async fn job_stop(&self, job_id: String) -> RpcResult<()> {
self.job_stop(&job_id).await?;
Ok(())
}
async fn job_delete(&self, job_id: String) -> RpcResult<()> {
self.job_delete(&job_id).await?;
Ok(())
}
async fn runner_create(&self, runner_id: String) -> RpcResult<()> {
self.runner_create(runner_id).await?;
Ok(())
}
async fn runner_delete(&self, runner_id: String) -> RpcResult<()> {
Ok(self.runner_delete(&runner_id).await?)
}
async fn runner_list(&self) -> RpcResult<Vec<String>> {
Ok(self.runner_list().await)
}
async fn ping_runner(&self, runner_id: String) -> RpcResult<String> {
Ok(self.runner_ping(&runner_id).await?)
}
async fn key_create(&self, key: ApiKey) -> RpcResult<()> {
let _ = self.key_create(key).await;
Ok(())
}
async fn key_generate(&self, params: GenerateApiKeyParams) -> RpcResult<ApiKey> {
// Parse scope
let api_scope = match params.scope.to_lowercase().as_str() {
"admin" => crate::auth::ApiKeyScope::Admin,
"registrar" => crate::auth::ApiKeyScope::Registrar,
"user" => crate::auth::ApiKeyScope::User,
_ => return Err(ErrorObject::owned(-32602, "Invalid scope. Must be 'admin', 'registrar', or 'user'", None::<()>)),
};
let api_key = self.create_api_key(params.name, api_scope).await;
Ok(api_key)
}
async fn key_delete(&self, key_id: String) -> RpcResult<()> {
self.key_delete(&key_id).await
.ok_or_else(|| ErrorObject::owned(-32603, "API key not found", None::<()>))?;
Ok(())
}
async fn key_list(&self) -> RpcResult<Vec<ApiKey>> {
Ok(self.key_list().await)
}
async fn auth_verify(&self) -> RpcResult<crate::auth::AuthVerifyResponse> {
// If this method is called, middleware already verified the key
// So we just return success - the middleware wouldn't have let an invalid key through
Ok(crate::auth::AuthVerifyResponse {
valid: true,
name: "verified".to_string(),
scope: "authenticated".to_string(),
})
}
async fn supervisor_info(&self) -> RpcResult<SupervisorInfo> {
Ok(SupervisorInfo {
server_url: "http://127.0.0.1:3031".to_string(), // TODO: get from config
})
}
async fn rpc_discover(&self) -> RpcResult<serde_json::Value> {
debug!("OpenRPC request: rpc.discover");
// Read OpenRPC specification from docs/openrpc.json
match load_openrpc_spec() {
Ok(spec) => Ok(spec),
Err(e) => {
error!("Failed to load OpenRPC specification: {}", e);
// Fallback to a minimal spec if file loading fails
Ok(serde_json::json!({
"openrpc": "1.3.2",
"info": {
"title": "Hero Supervisor OpenRPC API",
"version": "1.0.0",
"description": "OpenRPC API for managing Hero Supervisor runners and jobs"
},
"methods": [],
"error": "Failed to load full specification"
}))
}
}
}
}
/// Authorization middleware using RpcServiceT
/// This middleware is created per-connection and checks permissions for each RPC call
#[derive(Clone)]
struct AuthMiddleware<S> {
supervisor: Supervisor,
inner: S,
}
impl<S> RpcServiceT for AuthMiddleware<S>
where
S: RpcServiceT<MethodResponse = MethodResponse> + Send + Sync + Clone + 'static,
{
type MethodResponse = MethodResponse;
type BatchResponse = S::BatchResponse;
type NotificationResponse = S::NotificationResponse;
fn call<'a>(&self, req: jsonrpsee::server::middleware::rpc::Request<'a>) -> impl std::future::Future<Output = Self::MethodResponse> + Send + 'a {
let supervisor = self.supervisor.clone();
let inner = self.inner.clone();
let method = req.method_name().to_string();
let id = req.id();
Box::pin(async move {
// Check if method requires auth
let required_scopes = match crate::auth::get_method_required_scopes(&method) {
None => {
// Public method - no auth required
debug!(" Public method: {}", method);
return inner.call(req).await;
}
Some(scopes) => scopes,
};
// Extract Authorization header from extensions
let headers = req.extensions().get::<hyper::HeaderMap>();
let api_key = headers
.and_then(|h| h.get(hyper::header::AUTHORIZATION))
.and_then(|value| value.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|k| k.to_string());
let api_key = match api_key {
Some(key) => key,
None => {
error!("❌ Missing Authorization header for method: {}", method);
let err = ErrorObjectOwned::owned(
-32001,
format!("Missing Authorization header for method: {}", method),
None::<()>,
);
return MethodResponse::error(id, err);
}
};
// Verify API key and check scope
let key_obj = match supervisor.key_get(&api_key).await {
Some(k) => k,
None => {
error!("❌ Invalid API key");
let err = ErrorObjectOwned::owned(-32001, "Invalid API key", None::<()>);
return MethodResponse::error(id, err);
}
};
if !required_scopes.contains(&key_obj.scope) {
error!(
"❌ Unauthorized: method '{}' requires {:?}, got {:?}",
method, required_scopes, key_obj.scope
);
let err = ErrorObjectOwned::owned(
-32001,
format!(
"Insufficient permissions for '{}'. Required: {:?}, Got: {:?}",
method, required_scopes, key_obj.scope
),
None::<()>,
);
return MethodResponse::error(id, err);
}
debug!("✅ Authorized: {} with scope {:?}", method, key_obj.scope);
// Authorized - proceed with the call
inner.call(req).await
})
}
fn batch<'a>(&self, batch: jsonrpsee::server::middleware::rpc::Batch<'a>) -> impl std::future::Future<Output = Self::BatchResponse> + Send + 'a {
// For simplicity, pass through batch requests
// In production, you'd want to check each request in the batch
self.inner.batch(batch)
}
fn notification<'a>(&self, notif: jsonrpsee::server::middleware::rpc::Notification<'a>) -> impl std::future::Future<Output = Self::NotificationResponse> + Send + 'a {
self.inner.notification(notif)
}
}
/// HTTP middleware to propagate headers into request extensions
#[derive(Clone)]
struct HeaderPropagationService<S> {
inner: S,
}
impl<S, B> tower::Service<hyper::Request<B>> for HeaderPropagationService<S>
where
S: tower::Service<hyper::Request<B>> + Clone + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: hyper::Request<B>) -> Self::Future {
let headers = req.headers().clone();
req.extensions_mut().insert(headers);
let fut = self.inner.call(req);
Box::pin(fut)
}
}
/// Start HTTP OpenRPC server (Unix socket support would require additional dependencies)
pub async fn start_http_openrpc_server(
supervisor: Supervisor,
bind_address: &str,
port: u16,
) -> anyhow::Result<ServerHandle> {
let http_addr: SocketAddr = format!("{}:{}", bind_address, port).parse()?;
// Configure CORS to allow requests from the admin UI
// Note: Authorization header must be explicitly listed, not covered by Any
use tower_http::cors::AllowHeaders;
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_headers(AllowHeaders::list([
hyper::header::CONTENT_TYPE,
hyper::header::AUTHORIZATION,
]))
.allow_methods(Any)
.expose_headers(Any);
// Build RPC middleware with authorization (per-connection)
let supervisor_for_middleware = supervisor.clone();
let rpc_middleware = RpcServiceBuilder::new().layer_fn(move |service| {
// This closure runs once per connection
AuthMiddleware {
supervisor: supervisor_for_middleware.clone(),
inner: service,
}
});
// Build HTTP middleware stack with CORS and header propagation
let http_middleware = tower::ServiceBuilder::new()
.layer(cors)
.layer(tower::layer::layer_fn(|service| {
HeaderPropagationService { inner: service }
}));
let http_server = Server::builder()
.set_rpc_middleware(rpc_middleware)
.set_http_middleware(http_middleware)
.build(http_addr)
.await?;
let http_handle = http_server.start(supervisor.into_rpc());
info!("OpenRPC HTTP server running at http://{} with CORS enabled", http_addr);
Ok(http_handle)
}

286
bin/supervisor/src/store.rs Normal file
View File

@@ -0,0 +1,286 @@
//! In-memory storage layer for Supervisor
//!
//! Provides CRUD operations for:
//! - API Keys
//! - Runners
//! - Jobs
use crate::auth::{ApiKey, ApiKeyScope};
use crate::error::{SupervisorError, SupervisorResult};
use hero_job::Job;
use std::collections::{HashMap, HashSet};
/// In-memory storage for all supervisor data
pub struct Store {
/// API keys (key_value -> ApiKey)
api_keys: HashMap<String, ApiKey>,
/// Registered runner IDs
runners: HashSet<String>,
/// In-memory job storage (job_id -> Job)
jobs: HashMap<String, Job>,
}
impl Store {
/// Create a new store
pub fn new() -> Self {
Self {
api_keys: HashMap::new(),
runners: HashSet::new(),
jobs: HashMap::new(),
}
}
// ==================== API Key Operations ====================
/// Create an API key with a specific value
pub fn key_create(&mut self, key: ApiKey) -> ApiKey {
self.api_keys.insert(key.name.clone(), key.clone());
key
}
/// Create a new API key with generated UUID
pub fn key_create_new(&mut self, name: String, scope: ApiKeyScope) -> ApiKey {
let key = ApiKey::new(name, scope);
self.api_keys.insert(key.name.clone(), key.clone());
key
}
/// Get an API key by its value
pub fn key_get(&self, key_name: &str) -> Option<&ApiKey> {
self.api_keys.get(key_name)
}
/// Delete an API key
pub fn key_delete(&mut self, key_name: &str) -> Option<ApiKey> {
self.api_keys.remove(key_name)
}
/// List all API keys
pub fn key_list(&self) -> Vec<ApiKey> {
self.api_keys.values().cloned().collect()
}
/// List API keys by scope
pub fn key_list_by_scope(&self, scope: ApiKeyScope) -> Vec<ApiKey> {
self.api_keys
.values()
.filter(|k| k.scope == scope)
.cloned()
.collect()
}
// ==================== Runner Operations ====================
/// Add a runner
pub fn runner_add(&mut self, runner_id: String) -> SupervisorResult<()> {
self.runners.insert(runner_id);
Ok(())
}
/// Remove a runner
pub fn runner_remove(&mut self, runner_id: &str) -> SupervisorResult<()> {
self.runners.remove(runner_id);
Ok(())
}
/// Check if a runner exists
pub fn runner_exists(&self, runner_id: &str) -> bool {
self.runners.contains(runner_id)
}
/// List all runner IDs
pub fn runner_list_all(&self) -> Vec<String> {
self.runners.iter().cloned().collect()
}
// ==================== Job Operations ====================
/// Store a job in memory
pub fn job_store(&mut self, job: Job) -> SupervisorResult<()> {
self.jobs.insert(job.id.clone(), job);
Ok(())
}
/// Get a job from memory
pub fn job_get(&self, job_id: &str) -> SupervisorResult<Job> {
self.jobs
.get(job_id)
.cloned()
.ok_or_else(|| SupervisorError::JobNotFound {
job_id: job_id.to_string(),
})
}
/// Delete a job from memory
pub fn job_delete(&mut self, job_id: &str) -> SupervisorResult<()> {
self.jobs
.remove(job_id)
.ok_or_else(|| SupervisorError::JobNotFound {
job_id: job_id.to_string(),
})?;
Ok(())
}
/// List all job IDs
pub fn job_list(&self) -> Vec<String> {
self.jobs.keys().cloned().collect()
}
/// Check if a job exists
pub fn job_exists(&self, job_id: &str) -> bool {
self.jobs.contains_key(job_id)
}
}
impl Clone for Store {
fn clone(&self) -> Self {
Self {
api_keys: self.api_keys.clone(),
runners: self.runners.clone(),
jobs: self.jobs.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use hero_job::JobBuilder;
fn create_test_store() -> Store {
Store::new()
}
fn create_test_job(id: &str, runner: &str) -> Job {
let mut job = JobBuilder::new()
.caller_id("test_caller")
.context_id("test_context")
.runner(runner)
.executor("test")
.payload("test payload")
.build()
.unwrap();
job.id = id.to_string(); // Set ID manually
job
}
#[test]
fn test_api_key_operations() {
let mut store = create_test_store();
// Create key
let key = store.key_create_new("test_key".to_string(), ApiKeyScope::Admin);
assert_eq!(key.name, "test_key");
assert_eq!(key.scope, ApiKeyScope::Admin);
// Get key
let retrieved = store.key_get(&key.key);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().name, "test_key");
// List keys
let keys = store.key_list();
assert_eq!(keys.len(), 1);
// List by scope
let admin_keys = store.key_list_by_scope(ApiKeyScope::Admin);
assert_eq!(admin_keys.len(), 1);
// Delete key
let removed = store.key_delete(&key.key);
assert!(removed.is_some());
assert!(store.key_get(&key.key).is_none());
}
#[test]
fn test_runner_operations() {
let mut store = create_test_store();
// Add runner
assert!(store.runner_add("runner1".to_string()).is_ok());
assert!(store.runner_exists("runner1"));
// List runners
let runners = store.runner_list_all();
assert_eq!(runners.len(), 1);
assert!(runners.contains(&"runner1".to_string()));
// List all runners
let all_runners = store.runner_list_all();
assert_eq!(all_runners.len(), 1);
// Remove runner
assert!(store.runner_remove("runner1").is_ok());
assert!(!store.runner_exists("runner1"));
}
#[test]
fn test_job_operations() {
let mut store = create_test_store();
let job = create_test_job("job1", "runner1");
// Store job
assert!(store.job_store(job.clone()).is_ok());
assert!(store.job_exists("job1"));
// Get job
let retrieved = store.job_get("job1");
assert!(retrieved.is_ok());
assert_eq!(retrieved.unwrap().id, "job1");
// List jobs
let jobs = store.job_list();
assert_eq!(jobs.len(), 1);
assert!(jobs.contains(&"job1".to_string()));
// Delete job
assert!(store.job_delete("job1").is_ok());
assert!(!store.job_exists("job1"));
assert!(store.job_get("job1").is_err());
}
#[test]
fn test_job_not_found() {
let store = create_test_store();
let result = store.job_get("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_multiple_jobs() {
let mut store = create_test_store();
// Add multiple jobs
for i in 1..=3 {
let job = create_test_job(&format!("job{}", i), "runner1");
assert!(store.job_store(job).is_ok());
}
// Verify all exist
assert_eq!(store.job_list().len(), 3);
assert!(store.job_exists("job1"));
assert!(store.job_exists("job2"));
assert!(store.job_exists("job3"));
// Delete one
assert!(store.job_delete("job2").is_ok());
assert_eq!(store.job_list().len(), 2);
assert!(!store.job_exists("job2"));
}
#[test]
fn test_store_clone() {
let mut store = create_test_store();
store.runner_add("runner1".to_string()).unwrap();
let job = create_test_job("job1", "runner1");
store.job_store(job).unwrap();
// Clone the store
let cloned = store.clone();
// Verify cloned data
assert!(cloned.runner_exists("runner1"));
assert!(cloned.job_exists("job1"));
}
}

View File

@@ -0,0 +1,360 @@
//! Main supervisor implementation for managing multiple actor runners.
use crate::error::{SupervisorError, SupervisorResult};
use crate::store::Store;
use hero_job_client::Client as JobClient;
use hero_job::{Job, JobStatus};
use std::sync::Arc;
use tokio::sync::Mutex;
// Re-export RPC types for convenience
pub use jsonrpsee::core::RpcResult;
pub use jsonrpsee::types::ErrorObject;
/// Main supervisor that manages multiple runners
#[derive(Clone)]
pub struct Supervisor {
/// Centralized storage layer with interior mutability
pub(crate) store: Arc<Mutex<Store>>,
/// Job client for Redis operations
pub(crate) job_client: JobClient,
/// Redis client for direct operations
pub(crate) redis_client: redis::Client,
// Optional Osiris client for persistent storage - temporarily disabled
// pub(crate) osiris_client: Option<osiris_client::OsirisClient>,
}
impl Supervisor {
/// Create a new supervisor builder
pub fn builder() -> crate::builder::SupervisorBuilder {
crate::builder::SupervisorBuilder::new()
}
/// Create a job (store in memory only, does not dispatch)
/// Authorization must be checked by the caller (e.g., OpenRPC layer)
pub async fn job_create(&self, job: Job) -> SupervisorResult<String> {
let runner = job.runner.clone();
let job_id = job.id.clone();
let mut store = self.store.lock().await;
if !store.runner_exists(&runner) {
return Err(SupervisorError::RunnerNotFound {
runner_id: runner,
});
}
// Store job in memory only
store.job_store(job)?;
Ok(job_id)
}
/// Delete a runner from the supervisor
pub async fn runner_delete(&self, runner_id: &str) -> SupervisorResult<()> {
self.store.lock().await.runner_remove(runner_id)
}
/// Check if a runner is registered
pub async fn has_runner(&self, runner_id: &str) -> bool {
self.store.lock().await.runner_exists(runner_id)
}
/// Get a job by job ID from memory
pub async fn job_get(&self, job_id: &str) -> SupervisorResult<Job> {
self.store.lock().await.job_get(job_id)
}
/// Ping a runner by dispatching a ping job to its queue
pub async fn runner_ping(&self, runner_id: &str) -> SupervisorResult<String> {
use hero_job::JobBuilder;
// Check if runner exists
let store = self.store.lock().await;
if !store.runner_exists(runner_id) {
return Err(SupervisorError::RunnerNotFound {
runner_id: runner_id.to_string(),
});
}
// Create a ping job
let ping_job = JobBuilder::new()
.caller_id("supervisor_ping")
.context_id("ping_context")
.payload("ping")
.runner(runner_id)
.executor("ping")
.timeout(10)
.build()
.map_err(|e| SupervisorError::QueueError {
runner_id: runner_id.to_string(),
reason: format!("Failed to create ping job: {}", e),
})?;
// Store and dispatch the ping job
let job_id = ping_job.id.clone();
drop(store);
self.store.lock().await.job_store(ping_job.clone())?;
self.job_client
.store_job_in_redis(&ping_job)
.await
.map_err(SupervisorError::from)?;
self.job_client
.job_run(&job_id, runner_id)
.await
.map_err(SupervisorError::from)?;
Ok(job_id)
}
/// Stop a job by ID
pub async fn job_stop(&self, job_id: &str) -> SupervisorResult<()> {
// For now, we'll implement a basic stop by setting status to Stopping
let _ = self.job_client.set_job_status(job_id, JobStatus::Stopping).await;
Ok(())
}
/// Delete a job by ID
/// Authorization must be checked by the caller (e.g., OpenRPC layer)
pub async fn job_delete(&self, job_id: &str) -> SupervisorResult<()> {
self.store.lock().await.job_delete(job_id)
}
/// List all managed runners
pub async fn runner_list(&self) -> Vec<String> {
self.store.lock().await.runner_list_all()
}
/// Check if a runner is registered
pub async fn runner_is_registered(&self, runner_id: &str) -> bool {
self.store.lock().await.runner_exists(runner_id)
}
/// Start a job by dispatching it to a runner's queue (fire-and-forget)
pub async fn job_start(&self, job_id: &str) -> SupervisorResult<()> {
// Get the job from memory
let job = self.job_get(job_id).await?;
let runner = job.runner.clone();
let store = self.store.lock().await;
if !store.runner_exists(&runner) {
return Err(SupervisorError::RunnerNotFound {
runner_id: runner,
});
}
// Store job in Redis and dispatch to runner queue
self.job_client
.store_job_in_redis(&job)
.await
.map_err(SupervisorError::from)?;
self.job_client
.job_run(&job.id, &runner)
.await
.map_err(SupervisorError::from)
}
/// Run a job: create, dispatch, and wait for result
pub async fn job_run(&self, job: Job) -> SupervisorResult<String> {
let runner = job.runner.clone();
let mut store = self.store.lock().await;
if !store.runner_exists(&runner) {
return Err(SupervisorError::RunnerNotFound {
runner_id: runner,
});
}
// Store job in memory
store.job_store(job.clone())?;
drop(store);
// Use job_client's job_run_wait which handles store in Redis, dispatch, and wait
self.job_client
.job_run_wait(&job, &runner, 30)
.await
.map_err(SupervisorError::from)
}
// Secret management methods removed - use API key management instead
// See add_api_key, remove_api_key, list_api_keys methods below
/// List all job IDs from memory
pub async fn job_list(&self) -> Vec<String> {
self.store.lock().await.job_list()
}
/// Get the status of a job
pub async fn job_status(&self, job_id: &str) -> SupervisorResult<JobStatus> {
// First check if job exists in memory (created but not started)
let store = self.store.lock().await;
if let Ok(_job) = store.job_get(job_id) {
drop(store);
// Try to get status from Redis
match self.job_client.get_status(job_id).await {
Ok(status) => return Ok(status),
Err(hero_job_client::ClientError::Job(hero_job::JobError::NotFound(_))) => {
// Job exists in memory but not in Redis - it's created but not dispatched
return Ok(JobStatus::Created);
}
Err(e) => return Err(SupervisorError::from(e)),
}
}
drop(store);
// Job not in memory, try Redis
let status = self.job_client.get_status(job_id).await
.map_err(|e| match e {
hero_job_client::ClientError::Job(hero_job::JobError::NotFound(_)) => {
SupervisorError::JobNotFound { job_id: job_id.to_string() }
}
_ => SupervisorError::from(e)
})?;
Ok(status)
}
/// Get the result of a job (returns immediately with current result or error)
pub async fn job_result(&self, job_id: &str) -> SupervisorResult<Option<String>> {
// Use client's get_status to check if job exists and get its status
let status = self.job_client.get_status(job_id).await
.map_err(|e| match e {
hero_job_client::ClientError::Job(hero_job::JobError::NotFound(_)) => {
SupervisorError::JobNotFound { job_id: job_id.to_string() }
}
_ => SupervisorError::from(e)
})?;
// If job has error status, get the error message
if status.as_str() == "error" {
let error_msg = self.job_client.get_error(job_id).await
.map_err(SupervisorError::from)?;
return Ok(Some(format!("Error: {}", error_msg.unwrap_or_else(|| "Unknown error".to_string()))));
}
// Use client's get_result to get the result
let result = self.job_client.get_result(job_id).await
.map_err(SupervisorError::from)?;
Ok(result)
}
// API Key Management Methods
/// Get logs for a specific job
///
/// Reads log files from the logs/actor/<runner_name>/job-<job_id>/ directory
pub async fn job_logs(&self, job_id: &str, lines: Option<usize>) -> SupervisorResult<Vec<String>> {
// Determine the logs directory path
// Default to ~/hero/logs
let logs_root = if let Some(home) = std::env::var_os("HOME") {
std::path::PathBuf::from(home).join("hero").join("logs")
} else {
std::path::PathBuf::from("logs")
};
// Check if logs directory exists
if !logs_root.exists() {
return Ok(vec![format!("Logs directory not found: {}", logs_root.display())]);
}
let actor_dir = logs_root.join("actor");
if !actor_dir.exists() {
return Ok(vec![format!("Actor logs directory not found: {}", actor_dir.display())]);
}
// Search through all runner directories to find the job
if let Ok(entries) = std::fs::read_dir(&actor_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let job_dir = entry.path().join(format!("job-{}", job_id));
if job_dir.exists() {
// Read all log files in the directory
let mut all_logs = Vec::new();
if let Ok(log_entries) = std::fs::read_dir(&job_dir) {
// Collect all log files with their paths for sorting
let mut log_files: Vec<_> = log_entries
.flatten()
.filter(|e| {
if !e.path().is_file() {
return false;
}
// Accept files that start with "log" (covers log.YYYY-MM-DD-HH format)
e.file_name().to_string_lossy().starts_with("log")
})
.collect();
// Sort by filename (which includes timestamp for hourly rotation)
log_files.sort_by_key(|e| e.path());
// Read files in order
for entry in log_files {
if let Ok(content) = std::fs::read_to_string(entry.path()) {
all_logs.extend(content.lines().map(|s| s.to_string()));
}
}
}
// If lines limit is specified, return only the last N lines
if let Some(n) = lines {
let start = all_logs.len().saturating_sub(n);
return Ok(all_logs[start..].to_vec());
} else {
return Ok(all_logs);
}
}
}
}
}
// If no logs found, return helpful message
Ok(vec![format!("No logs found for job: {}", job_id)])
}
// API Key Management - These methods provide direct access to the key store
// Authorization checking should be done at the OpenRPC layer before calling these
/// Get an API key by its value
pub(crate) async fn key_get(&self, key_id: &str) -> Option<crate::auth::ApiKey> {
self.store.lock().await.key_get(key_id).cloned()
}
/// Create an API key with a specific value
pub(crate) async fn key_create(&self, key: crate::auth::ApiKey) -> crate::auth::ApiKey {
self.store.lock().await.key_create(key)
}
/// Delete an API key
pub(crate) async fn key_delete(&self, key_id: &str) -> Option<crate::auth::ApiKey> {
self.store.lock().await.key_delete(key_id)
}
/// List all API keys
pub(crate) async fn key_list(&self) -> Vec<crate::auth::ApiKey> {
self.store.lock().await.key_list()
}
/// List API keys by scope
pub(crate) async fn key_list_by_scope(&self, scope: crate::auth::ApiKeyScope) -> Vec<crate::auth::ApiKey> {
self.store.lock().await.key_list_by_scope(scope)
}
// Runner Management
/// Create a new runner
/// Authorization must be checked by the caller (e.g., OpenRPC layer)
pub async fn runner_create(&self, runner_id: String) -> SupervisorResult<String> {
self.store.lock().await.runner_add(runner_id.clone())?;
Ok(runner_id)
}
/// Create a new API key with generated UUID
pub async fn create_api_key(&self, name: String, scope: crate::auth::ApiKeyScope) -> crate::auth::ApiKey {
self.store.lock().await.key_create_new(name, scope)
}
}
// Note: Default implementation removed because it requires async initialization
// Use Supervisor::builder() for proper initialization

View File

@@ -0,0 +1,195 @@
# Supervisor End-to-End Tests
Comprehensive integration tests for all Hero Supervisor OpenRPC client methods.
## Prerequisites
1. **Redis Server Running:**
```bash
redis-server
```
2. **Supervisor Running:**
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
./scripts/run.sh
```
## Running Tests
### Run All Tests
```bash
cargo test --test end_to_end
```
### Run Specific Test
```bash
cargo test --test end_to_end test_01_rpc_discover
```
### Run with Output
```bash
cargo test --test end_to_end -- --nocapture
```
### Run in Order (Sequential)
```bash
cargo test --test end_to_end -- --test-threads=1 --nocapture
```
## Test Coverage
### ✅ Discovery & Info
- `test_01_rpc_discover` - OpenRPC specification discovery
- `test_15_supervisor_info` - Supervisor information
### ✅ Runner Management
- `test_02_runner_register` - Register a new runner
- `test_03_runner_list` - List all runners
- `test_14_runner_remove` - Remove a runner
### ✅ Job Management
- `test_04_jobs_create` - Create a job without running
- `test_05_jobs_list` - List all jobs
- `test_06_job_run_simple` - Run a job and wait for result
- `test_07_job_status` - Get job status
- `test_08_job_get` - Get job by ID
- `test_09_job_delete` - Delete a job
### ✅ Authentication & API Keys
- `test_10_auth_verify` - Verify current API key
- `test_11_auth_key_create` - Create new API key
- `test_12_auth_key_list` - List all API keys
- `test_13_auth_key_remove` - Remove an API key
### ✅ Complete Workflow
- `test_99_complete_workflow` - End-to-end integration test
## Test Configuration
Tests use the following defaults:
- **Supervisor URL:** `http://127.0.0.1:3030`
- **Admin Secret:** `807470fd1e1ccc3fb997a1d4177cceb31a68cb355a4412c8fd6e66e517e902be`
- **Test Runner:** `test-runner` (all tests use this runner name)
**Important:** All tests use the same runner name (`test-runner`), so you only need to start one runner with that name to run all tests.
## Expected Behavior
### Successful Tests
All tests should pass when:
- Supervisor is running on port 3030
- Admin secret matches configuration
- Redis is accessible
### Expected Warnings
Some tests may show warnings if:
- `job.run` times out (no actual runner connected to Redis)
- Runners already exist from previous test runs
These are expected and don't indicate test failure.
## Troubleshooting
### Connection Refused
```
Error: tcp connect error, 127.0.0.1:3030, Connection refused
```
**Solution:** Start the supervisor with `./scripts/run.sh`
### Method Not Found
```
Error: Method not found
```
**Solution:** Rebuild supervisor with latest code:
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
cargo build
```
### Authorization Failed
```
Error: Missing Authorization header
```
**Solution:** Check that `ADMIN_SECRET` in test matches supervisor configuration
### Job Tests Timeout
```
Error: JsonRpc(RequestTimeout)
```
**Solution:** Make sure you have a runner connected with the name `test-runner`:
```bash
cd /Users/timurgordon/code/git.ourworld.tf/herocode/runner/rust
cargo run --bin runner_osiris -- test-runner
```
## Continuous Integration
To run tests in CI:
```bash
#!/bin/bash
# Start Redis
redis-server --daemonize yes
# Start Supervisor
cd /Users/timurgordon/code/git.ourworld.tf/herocode/supervisor
./scripts/run.sh &
SUPERVISOR_PID=$!
# Wait for supervisor to be ready
sleep 2
# Run tests
cargo test --test end_to_end
# Cleanup
kill $SUPERVISOR_PID
redis-cli shutdown
```
## Adding New Tests
1. Create a new test function:
```rust
#[tokio::test]
async fn test_XX_my_new_test() {
println!("\n🧪 Test: my.new.method");
let client = create_client().await;
// ... test code ...
println!("✅ my.new.method works");
}
```
2. Run it:
```bash
cargo test --test end_to_end test_XX_my_new_test -- --nocapture
```
## Test Output Example
```
🧪 Test: rpc.discover
✅ rpc.discover works
🧪 Test: runner.register
✅ runner.register works - registered: test-runner-e2e
🧪 Test: runner.list
✅ runner.list works - found 3 runners
- osiris
- freezone
- test-runner-e2e
🧪 Test: jobs.create
✅ jobs.create works - created job: 550e8400-e29b-41d4-a716-446655440000
...
```
## Notes
- Tests are designed to be idempotent (can run multiple times)
- Tests clean up after themselves when possible
- Some tests depend on previous test state (use `--test-threads=1` for strict ordering)
- Job execution tests may timeout if no runner is connected to Redis (this is expected)

View File

@@ -0,0 +1,482 @@
//! End-to-End Integration Tests for Hero Supervisor
//!
//! Tests all OpenRPC client methods against a running supervisor instance.
//! The supervisor is automatically started and stopped for each test run.
use hero_supervisor_openrpc_client::SupervisorClient;
use hero_supervisor::{SupervisorBuilder, openrpc::start_http_openrpc_server};
use hero_job::{Job, JobBuilder};
use std::sync::Once;
/// Test configuration
const SUPERVISOR_URL: &str = "http://127.0.0.1:3031";
const ADMIN_SECRET: &str = "test-admin-secret-for-e2e-tests";
const TEST_RUNNER_NAME: &str = "test-runner";
/// Global initialization flag
static INIT: Once = Once::new();
/// Initialize and start the supervisor (called once)
async fn init_supervisor() {
// Use a blocking approach to ensure supervisor starts before any test runs
static mut INITIALIZED: bool = false;
unsafe {
INIT.call_once(|| {
// Spawn a new runtime for the supervisor
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Build supervisor with test configuration
let supervisor = SupervisorBuilder::new()
.admin_secrets(vec![ADMIN_SECRET.to_string()])
.build()
.await
.expect("Failed to build supervisor");
// Start OpenRPC server
match start_http_openrpc_server(supervisor, "127.0.0.1", 3031).await {
Ok(server_handle) => {
server_handle.stopped().await;
}
Err(e) => {
eprintln!("OpenRPC server error: {}", e);
}
}
});
});
// Give the server time to start
std::thread::sleep(std::time::Duration::from_secs(1));
INITIALIZED = true;
});
}
}
/// Helper to create a test client
async fn create_client() -> SupervisorClient {
// Ensure supervisor is running
init_supervisor().await;
SupervisorClient::builder()
.url(SUPERVISOR_URL)
.secret(ADMIN_SECRET)
.build()
.expect("Failed to create supervisor client")
}
/// Helper to create a test job (always uses TEST_RUNNER_NAME)
fn create_test_job(payload: &str) -> Job {
JobBuilder::new()
.caller_id("e2e-test")
.context_id("test-context")
.runner(TEST_RUNNER_NAME)
.payload(payload)
.executor("rhai")
.timeout(30)
.build()
.expect("Failed to build test job")
}
#[tokio::test]
async fn test_01_rpc_discover() {
println!("\n🧪 Test: rpc.discover");
let client = create_client().await;
let result = client.discover().await;
assert!(result.is_ok(), "rpc.discover should succeed");
let spec = result.unwrap();
// Verify it's a valid OpenRPC spec
assert!(spec.get("openrpc").is_some(), "Should have openrpc field");
assert!(spec.get("methods").is_some(), "Should have methods field");
println!("✅ rpc.discover works");
}
#[tokio::test]
async fn test_02_runner_register() {
println!("\n🧪 Test: runner.register");
let client = create_client().await;
// Register a test runner
let result = client.runner_create(TEST_RUNNER_NAME).await;
// Should succeed or already exist
match result {
Ok(()) => {
println!("✅ runner.register works - registered: {}", TEST_RUNNER_NAME);
}
Err(e) => {
// If it fails, it might already exist, which is okay
println!("⚠️ runner.register: {:?} (may already exist)", e);
}
}
}
#[tokio::test]
async fn test_03_runner_list() {
println!("\n🧪 Test: runner.list");
let client = create_client().await;
// First ensure our test runner exists
let _ = client.runner_create(TEST_RUNNER_NAME).await;
// List all runners
let result = client.runner_list().await;
if let Err(ref e) = result {
println!(" Error: {:?}", e);
}
assert!(result.is_ok(), "runner.list should succeed");
let runners = result.unwrap();
assert!(!runners.is_empty(), "Should have at least one runner");
assert!(runners.contains(&TEST_RUNNER_NAME.to_string()),
"Should contain our test runner");
println!("✅ runner.list works - found {} runners", runners.len());
for runner in &runners {
println!(" - {}", runner);
}
}
#[tokio::test]
async fn test_04_jobs_create() {
println!("\n🧪 Test: jobs.create");
let client = create_client().await;
// Ensure runner exists
let _ = client.runner_create(TEST_RUNNER_NAME).await;
// Create a job without running it
let job = create_test_job("print('test job');");
let result = client.job_create(job).await;
match &result {
Ok(_) => {},
Err(e) => println!(" Error: {:?}", e),
}
assert!(result.is_ok(), "jobs.create should succeed");
let job_id = result.unwrap();
assert!(!job_id.is_empty(), "Should return a job ID");
println!("✅ jobs.create works - created job: {}", job_id);
}
#[tokio::test]
async fn test_05_jobs_list() {
println!("\n🧪 Test: jobs.list");
let client = create_client().await;
// Create a job first
let _ = client.runner_create(TEST_RUNNER_NAME).await;
let job = create_test_job("print('list test');");
let _ = client.job_create(job).await;
// List all jobs
let result = client.job_list().await;
assert!(result.is_ok(), "jobs.list should succeed");
let jobs = result.unwrap();
println!("✅ jobs.list works - found {} jobs", jobs.len());
}
#[tokio::test]
async fn test_06_job_run_simple() {
println!("\n🧪 Test: job.run (simple script)");
let client = create_client().await;
// Ensure runner exists
let _ = client.runner_create(TEST_RUNNER_NAME).await;
// Run a simple job
let job = create_test_job(r#"
print("Hello from test!");
42
"#);
let result = client.job_run(job, Some(30)).await;
// Note: This will timeout if no runner is actually connected to Redis
// but we're testing the API call itself
match result {
Ok(response) => {
println!("✅ job.run works - job_id: {}, status: {}",
response.job_id, response.status);
}
Err(e) => {
println!("⚠️ job.run: {:?} (runner may not be connected)", e);
// This is expected if no actual runner is listening
}
}
}
#[tokio::test]
async fn test_07_job_status() {
println!("\n🧪 Test: job.status");
let client = create_client().await;
// Create a job first
let _ = client.runner_create(TEST_RUNNER_NAME).await;
let job = create_test_job("print('status test');");
let job_id = client.job_create(job).await.expect("Failed to create job");
// Get job status
let result = client.job_status(&job_id).await;
if let Err(ref e) = result {
println!(" Error: {:?}", e);
}
assert!(result.is_ok(), "job.status should succeed");
let status = result.unwrap();
println!("✅ job.status works - job: {}, status: {:?}",
job_id, status);
}
#[tokio::test]
async fn test_08_job_get() {
println!("\n🧪 Test: job.get");
let client = create_client().await;
// Create a job first
let _ = client.runner_create(TEST_RUNNER_NAME).await;
let original_job = create_test_job("print('get test');");
let job_id = client.job_create(original_job.clone()).await
.expect("Failed to create job");
// Get the job
let result = client.job_get(&job_id).await;
assert!(result.is_ok(), "job.get should succeed");
let job = result.unwrap();
assert_eq!(job.id, job_id);
println!("✅ job.get works - retrieved job: {}", job.id);
}
#[tokio::test]
async fn test_09_job_delete() {
println!("\n🧪 Test: job.delete");
let client = create_client().await;
// Create a job first
let _ = client.runner_create(TEST_RUNNER_NAME).await;
let job = create_test_job("print('delete test');");
let job_id = client.job_create(job).await.expect("Failed to create job");
// Delete the job
let result = client.job_delete(&job_id).await;
if let Err(ref e) = result {
println!(" Error: {:?}", e);
}
assert!(result.is_ok(), "job.delete should succeed");
println!("✅ job.delete works - deleted job: {}", job_id);
// Verify it's gone
let get_result = client.job_get(&job_id).await;
assert!(get_result.is_err(), "Job should not exist after deletion");
}
#[tokio::test]
async fn test_10_auth_verify() {
println!("\n🧪 Test: auth.verify");
let client = create_client().await;
let result = client.auth_verify().await;
assert!(result.is_ok(), "auth.verify should succeed with valid key");
let auth_info = result.unwrap();
println!("✅ auth.verify works");
println!(" Scope: {}", auth_info.scope);
println!(" Name: {}", auth_info.name.unwrap_or_else(|| "N/A".to_string()));
}
#[tokio::test]
async fn test_11_auth_key_create() {
println!("\n🧪 Test: auth.key.create");
let client = create_client().await;
use hero_supervisor_openrpc_client::GenerateApiKeyParams;
let params = GenerateApiKeyParams {
name: "test-key".to_string(),
scope: "user".to_string(),
};
let result = client.key_generate(params).await;
assert!(result.is_ok(), "auth.key.create should succeed");
let api_key = result.unwrap();
assert!(!api_key.key.is_empty(), "Should return a key");
assert_eq!(api_key.name, "test-key");
assert_eq!(api_key.scope, "user");
println!("✅ auth.key.create works - created key: {}...",
&api_key.key[..api_key.key.len().min(8)]);
}
#[tokio::test]
async fn test_12_auth_key_list() {
println!("\n🧪 Test: auth.key.list");
let client = create_client().await;
// Create a key first
use hero_supervisor_openrpc_client::GenerateApiKeyParams;
let params = GenerateApiKeyParams {
name: "list-test-key".to_string(),
scope: "user".to_string(),
};
let _ = client.key_generate(params).await;
let result = client.key_list().await;
assert!(result.is_ok(), "auth.key.list should succeed");
let keys = result.unwrap();
println!("✅ auth.key.list works - found {} keys", keys.len());
for key in &keys {
println!(" - {} ({}): {}...", key.name, key.scope,
&key.key[..key.key.len().min(8)]);
}
}
#[tokio::test]
async fn test_13_auth_key_remove() {
println!("\n🧪 Test: auth.key.remove");
let client = create_client().await;
// Create a key first
use hero_supervisor_openrpc_client::GenerateApiKeyParams;
let params = GenerateApiKeyParams {
name: "remove-test-key".to_string(),
scope: "user".to_string(),
};
let api_key = client.key_generate(params)
.await
.expect("Failed to create key");
// Remove it (use name as the key_id, not the key value)
let result = client.key_delete(api_key.name.clone()).await;
if let Err(ref e) = result {
println!(" Error: {:?}", e);
}
assert!(result.is_ok(), "auth.key.remove should succeed");
println!("✅ auth.key.remove works - removed key: {}...",
&api_key.key[..api_key.key.len().min(8)]);
}
#[tokio::test]
async fn test_14_runner_remove() {
println!("\n🧪 Test: runner.remove");
let client = create_client().await;
// Register a runner to remove
let runner_name = "test-runner-to-remove";
let _ = client.runner_create(runner_name).await;
// Remove it
let result = client.runner_remove(runner_name).await;
assert!(result.is_ok(), "runner.remove should succeed");
println!("✅ runner.remove works - removed: {}", runner_name);
// Verify it's gone
let runners = client.runner_list().await.unwrap();
assert!(!runners.contains(&runner_name.to_string()),
"Runner should not exist after removal");
}
#[tokio::test]
async fn test_15_supervisor_info() {
println!("\n🧪 Test: supervisor.info");
let client = create_client().await;
let result = client.get_supervisor_info().await;
if let Err(ref e) = result {
println!(" Error: {:?}", e);
}
assert!(result.is_ok(), "supervisor.info should succeed");
let info = result.unwrap();
println!("✅ supervisor.info works");
println!(" Server URL: {}", info.server_url);
}
/// Integration test that runs a complete workflow
#[tokio::test]
async fn test_99_complete_workflow() {
println!("\n🧪 Test: Complete Workflow");
let client = create_client().await;
// 1. Register runner
println!(" 1. Registering runner...");
let _ = client.runner_create("workflow-runner").await;
// 2. List runners
println!(" 2. Listing runners...");
let runners = client.runner_list().await.unwrap();
assert!(runners.contains(&"workflow-runner".to_string()));
// 3. Create API key
println!(" 3. Creating API key...");
use hero_supervisor_openrpc_client::GenerateApiKeyParams;
let params = GenerateApiKeyParams {
name: "workflow-key".to_string(),
scope: "user".to_string(),
};
let api_key = client.key_generate(params).await.unwrap();
// 4. Verify auth
println!(" 4. Verifying auth...");
let _ = client.auth_verify().await.unwrap();
// 5. Create job
println!(" 5. Creating job...");
let job = create_test_job("print('workflow test');");
let job_id = client.job_create(job).await.unwrap();
// 6. Get job status
println!(" 6. Getting job status...");
let _status = client.job_status(&job_id).await.unwrap();
// 7. List all jobs
println!(" 7. Listing all jobs...");
let jobs = client.job_list().await.unwrap();
assert!(!jobs.is_empty());
// 8. Delete job
println!(" 8. Deleting job...");
let _ = client.job_delete(&job_id).await.unwrap();
// 9. Remove API key
println!(" 9. Removing API key...");
let _ = client.key_delete(api_key.name).await.unwrap();
// 10. Remove runner
println!(" 10. Removing runner...");
let _ = client.runner_remove("workflow-runner").await.unwrap();
println!("✅ Complete workflow test passed!");
}

View File

@@ -0,0 +1,31 @@
//! Integration tests for the job API
//!
//! These tests validate the complete job lifecycle using a real supervisor instance.
//! They require Redis and a running supervisor to execute properly.
use hero_supervisor_openrpc_client::{SupervisorClient, JobBuilder, JobResult};
use std::time::Duration;
use tokio::time::sleep;
use uuid::Uuid;
/// Test helper to create a unique job for testing
fn create_test_job(context: &str) -> Result<hero_supervisor_openrpc_client::Job, Box<dyn std::error::Error>> {
JobBuilder::new()
.caller_id("integration_test")
.context_id(context)
.payload("echo 'Test job output'")
.executor("osis")
.runner("osis_runner_1")
.timeout(30)
.env_var("TEST_VAR", "test_value")
.build()
.map_err(|e| e.into())
}
/// Test helper to check if supervisor is available
async fn is_supervisor_available() -> bool {
match SupervisorClient::new("http://localhost:3030") {
Ok(client) => client.discover().await.is_ok(),
Err(_) => false,
}
}