add circle rhai repl and backend start cmd
This commit is contained in:
parent
f22d40c980
commit
ae3077033b
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
target
|
201
ARCHITECTURE.md
Normal file
201
ARCHITECTURE.md
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
# Architecture: Circle Management System
|
||||||
|
|
||||||
|
## 1. Introduction & Overview
|
||||||
|
|
||||||
|
This document outlines the architecture for a system that manages multiple "Circles." Each Circle is an independent entity comprising its own database, a Rhai scripting engine, a dedicated Rhai worker process, and a WebSocket (WS) server for external interaction. A central command-line orchestrator will be responsible for initializing, running, and monitoring these Circles based on a configuration file.
|
||||||
|
|
||||||
|
The primary goal is to allow users to define multiple isolated Rhai environments, each accessible via a unique WebSocket endpoint, and for scripts executed within a circle to interact with that circle's dedicated persistent storage.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
* Create a command-line application (`circles_orchestrator`) to manage the lifecycle of multiple Circles.
|
||||||
|
* Each Circle will have:
|
||||||
|
* An independent `OurDB` instance for data persistence.
|
||||||
|
* A dedicated `Rhai Engine` configured with its `OurDB`.
|
||||||
|
* A dedicated `Rhai Worker` processing scripts for that circle.
|
||||||
|
* A dedicated `WebSocket Server` exposing an endpoint for that circle.
|
||||||
|
* Circle configurations (name, ID, port) will be loaded from a `circles.json` file.
|
||||||
|
* The orchestrator will display a status table of all running circles, including their worker queue and WS server URL.
|
||||||
|
* Utilize existing crates (`ourdb`, `rhai_engine`, `rhai_worker`, `rhai_client`, `server_ws`) with necessary refactoring to support library usage.
|
||||||
|
|
||||||
|
## 3. System Components
|
||||||
|
|
||||||
|
* **Orchestrator (`circles_orchestrator`)**:
|
||||||
|
* Location: `/Users/timurgordon/code/git.ourworld.tf/herocode/circles/cmd/src/main.rs`
|
||||||
|
* Role: Parses `circles.json`, initializes, spawns, and monitors all components for each defined circle. Displays system status.
|
||||||
|
* **Circle Configuration (`circles.json`)**:
|
||||||
|
* Location: e.g., `/Users/timurgordon/code/git.ourworld.tf/herocode/circles/cmd/circles.json`
|
||||||
|
* Role: Defines the set of circles to be managed, including their ID, name, and port.
|
||||||
|
* **OurDB (Per Circle)**:
|
||||||
|
* Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/db/ourdb/src/lib.rs`
|
||||||
|
* Role: Provides persistent key-value storage for each circle, configured in incremental mode. Instance data stored at `~/.hero/circles/{id}/`.
|
||||||
|
* **Rhai Engine (Per Circle)**:
|
||||||
|
* Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/engine/src/lib.rs`
|
||||||
|
* Role: Provides the Rhai scripting environment for a circle, configured with the circle's specific `OurDB` instance.
|
||||||
|
* **Rhai Worker (Per Circle)**:
|
||||||
|
* Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/worker/src/lib.rs`
|
||||||
|
* Role: Executes Rhai scripts for a specific circle. Listens on a dedicated Redis queue for tasks and uses the circle's `Rhai Engine`.
|
||||||
|
* **Rhai Client (Per Circle WS Server)**:
|
||||||
|
* Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/client/src/lib.rs`
|
||||||
|
* Role: Used by a `Circle WebSocket Server` to send script execution tasks to its corresponding `Rhai Worker` via Redis.
|
||||||
|
* **Circle WebSocket Server (Per Circle)**:
|
||||||
|
* Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/circles/server_ws/src/lib.rs`
|
||||||
|
* Role: Exposes a WebSocket endpoint for a specific circle, allowing external clients to submit Rhai scripts for execution within that circle.
|
||||||
|
* **Redis**:
|
||||||
|
* URL: `redis://127.0.0.1:6379`
|
||||||
|
* Role: Acts as the message broker between `Rhai Clients` (within WS Servers) and `Rhai Workers`.
|
||||||
|
|
||||||
|
## 4. High-Level Design Aspects
|
||||||
|
|
||||||
|
### 4.1. Orchestrator Logic (Conceptual)
|
||||||
|
The `circles_orchestrator` reads the `circles.json` configuration. For each defined circle, it:
|
||||||
|
1. Determines the `OurDB` path based on the circle's ID.
|
||||||
|
2. Initializes the `OurDB` instance.
|
||||||
|
3. Creates a `Rhai Engine` configured with this `OurDB`.
|
||||||
|
4. Spawns a `Rhai Worker` task, providing it with the engine and the circle's identity (for Redis queue naming).
|
||||||
|
5. Spawns a `Circle WebSocket Server` task, providing it with the circle's identity and port.
|
||||||
|
It then monitors these components and displays their status.
|
||||||
|
|
||||||
|
### 4.2. Database Path Convention
|
||||||
|
* Base: System user's home directory (e.g., `/Users/username` or `/home/username`).
|
||||||
|
* Structure: `~/.hero/circles/{CIRCLE_ID}/`
|
||||||
|
* Example: For a circle with ID `1`, the database path would be `~/.hero/circles/1/`.
|
||||||
|
|
||||||
|
### 4.3. Configuration File Format (`circles.json`)
|
||||||
|
A JSON array of objects. Each object represents a circle:
|
||||||
|
* `id`: `u32` - Unique identifier.
|
||||||
|
* `name`: `String` - Human-readable name.
|
||||||
|
* `port`: `u16` - Port for the WebSocket server.
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 1, "name": "Alpha Circle", "port": 8081 },
|
||||||
|
{ "id": 2, "name": "Beta Circle", "port": 8082 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4. Conceptual Output Table Format (Orchestrator)
|
||||||
|
| Circle Name | ID | Worker Status | Worker Queues | WS Server URL |
|
||||||
|
|--------------|----|---------------|------------------------------------|------------------------|
|
||||||
|
| Alpha Circle | 1 | Running | `rhai_tasks:alpha_circle` | `ws://127.0.0.1:8081/ws` |
|
||||||
|
|
||||||
|
### 4.5. Interaction Flow (Single Circle)
|
||||||
|
1. An external client connects to a specific Circle's WebSocket Server.
|
||||||
|
2. The client sends a Rhai script via a JSON-RPC message.
|
||||||
|
3. The WS Server uses its embedded `Rhai Client` to publish the script and task details to a Redis queue specific to that circle (e.g., `rhai_tasks:alpha_circle`).
|
||||||
|
4. The `Rhai Worker` for that circle picks up the task from its Redis queue.
|
||||||
|
5. The Worker uses its `Rhai Engine` (which is configured with the circle's `OurDB`) to execute the script.
|
||||||
|
6. Any database interactions within the script go through the circle's `OurDB`.
|
||||||
|
7. The Worker updates the task status and result/error in Redis.
|
||||||
|
8. The `Rhai Client` (in the WS Server), which has been polling Redis for the result, receives the update.
|
||||||
|
9. The WS Server sends the script's result or error back to the external client via WebSocket.
|
||||||
|
|
||||||
|
## 5. Diagrams
|
||||||
|
|
||||||
|
### 5.1. Component Diagram
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
UserInterface[User/Admin] -- Manages --> OrchestratorCli{circles_orchestrator}
|
||||||
|
OrchestratorCli -- Reads --> CirclesJson[circles.json]
|
||||||
|
|
||||||
|
subgraph Circle 1
|
||||||
|
direction LR
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C1_OurDB[(OurDB @ ~/.hero/circles/1)]
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C1_RhaiEngine[Rhai Engine 1]
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C1_RhaiWorker[Rhai Worker 1]
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C1_WSServer[WS Server 1 @ Port 8081]
|
||||||
|
|
||||||
|
C1_RhaiEngine -- Uses --> C1_OurDB
|
||||||
|
C1_RhaiWorker -- Uses --> C1_RhaiEngine
|
||||||
|
C1_WSServer -- Contains --> C1_RhaiClient[Rhai Client 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Circle 2
|
||||||
|
direction LR
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C2_OurDB[(OurDB @ ~/.hero/circles/2)]
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C2_RhaiEngine[Rhai Engine 2]
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C2_RhaiWorker[Rhai Worker 2]
|
||||||
|
OrchestratorCli -- Spawns/Manages --> C2_WSServer[WS Server 2 @ Port 8082]
|
||||||
|
|
||||||
|
C2_RhaiEngine -- Uses --> C2_OurDB
|
||||||
|
C2_RhaiWorker -- Uses --> C2_RhaiEngine
|
||||||
|
C2_WSServer -- Contains --> C2_RhaiClient[Rhai Client 2]
|
||||||
|
end
|
||||||
|
|
||||||
|
C1_RhaiWorker -- Listens/Publishes --> Redis[(Redis @ 127.0.0.1:6379)]
|
||||||
|
C1_RhaiClient -- Publishes/Subscribes --> Redis
|
||||||
|
C2_RhaiWorker -- Listens/Publishes --> Redis
|
||||||
|
C2_RhaiClient -- Publishes/Subscribes --> Redis
|
||||||
|
|
||||||
|
ExternalWSClient1[External WS Client] -- Connects --> C1_WSServer
|
||||||
|
ExternalWSClient2[External WS Client] -- Connects --> C2_WSServer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2. Sequence Diagram (Request Flow for one Circle)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant ExtWSClient as External WS Client
|
||||||
|
participant CircleWSServer as Circle WS Server (e.g., Port 8081)
|
||||||
|
participant RhaiClientLib as Rhai Client Library (in WS Server)
|
||||||
|
participant RedisBroker as Redis
|
||||||
|
participant RhaiWorker as Rhai Worker (for the circle)
|
||||||
|
participant RhaiEngineLib as Rhai Engine Library (in Worker)
|
||||||
|
participant CircleOurDB as OurDB (for the circle)
|
||||||
|
|
||||||
|
ExtWSClient ->>+ CircleWSServer: Send Rhai Script (JSON-RPC "play" over WS)
|
||||||
|
CircleWSServer ->>+ RhaiClientLib: submit_script_and_await_result(circle_name, script, ...)
|
||||||
|
RhaiClientLib ->>+ RedisBroker: LPUSH rhai_tasks:circle_name (task_id)
|
||||||
|
RhaiClientLib ->>+ RedisBroker: HSET rhai_task_details:task_id (script details)
|
||||||
|
RhaiClientLib -->>- CircleWSServer: Returns task_id (internally starts polling)
|
||||||
|
|
||||||
|
RhaiWorker ->>+ RedisBroker: BLPOP rhai_tasks:circle_name (blocks)
|
||||||
|
RedisBroker -->>- RhaiWorker: Returns task_id
|
||||||
|
RhaiWorker ->>+ RedisBroker: HGETALL rhai_task_details:task_id
|
||||||
|
RedisBroker -->>- RhaiWorker: Returns script details
|
||||||
|
RhaiWorker ->>+ RhaiEngineLib: eval_with_scope(script)
|
||||||
|
RhaiEngineLib ->>+ CircleOurDB: DB Operations (if script interacts with DB)
|
||||||
|
CircleOurDB -->>- RhaiEngineLib: DB Results
|
||||||
|
RhaiEngineLib -->>- RhaiWorker: Script Result/Error
|
||||||
|
RhaiWorker ->>+ RedisBroker: HSET rhai_task_details:task_id (status="completed/error", output/error)
|
||||||
|
|
||||||
|
RhaiClientLib ->>+ RedisBroker: HGETALL rhai_task_details:task_id (polling)
|
||||||
|
RedisBroker -->>- RhaiClientLib: Task details (status, output/error)
|
||||||
|
alt Task Completed
|
||||||
|
RhaiClientLib -->>- CircleWSServer: Result (output)
|
||||||
|
CircleWSServer -->>- ExtWSClient: WS Response (JSON-RPC with result)
|
||||||
|
else Task Errored
|
||||||
|
RhaiClientLib -->>- CircleWSServer: Error (error message)
|
||||||
|
CircleWSServer -->>- ExtWSClient: WS Response (JSON-RPC with error)
|
||||||
|
else Timeout
|
||||||
|
RhaiClientLib -->>- CircleWSServer: Timeout Error
|
||||||
|
CircleWSServer -->>- ExtWSClient: WS Response (JSON-RPC with timeout error)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3. Conceptual Directory Structure
|
||||||
|
```
|
||||||
|
/Users/timurgordon/code/git.ourworld.tf/herocode/
|
||||||
|
├── circles/
|
||||||
|
│ ├── cmd/ <-- NEW Orchestrator Crate
|
||||||
|
│ │ ├── Cargo.toml
|
||||||
|
│ │ ├── circles.json (example config)
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ └── main.rs (Orchestrator logic)
|
||||||
|
│ ├── server_ws/ <-- EXISTING, to be refactored to lib
|
||||||
|
│ │ ├── Cargo.toml
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ └── lib.rs (WebSocket server library logic)
|
||||||
|
│ ├── ARCHITECTURE.md (This document)
|
||||||
|
│ └── README.md
|
||||||
|
├── db/
|
||||||
|
│ └── ourdb/
|
||||||
|
│ └── src/lib.rs (Existing OurDB library)
|
||||||
|
└── rhailib/ <-- Current Workspace (contains other existing libs)
|
||||||
|
├── src/
|
||||||
|
│ ├── client/
|
||||||
|
│ │ └── src/lib.rs (Existing Rhai Client library)
|
||||||
|
│ ├── engine/
|
||||||
|
│ │ └── src/lib.rs (Existing Rhai Engine library)
|
||||||
|
│ └── worker/
|
||||||
|
│ └── src/lib.rs (Existing Rhai Worker library, to be refactored)
|
||||||
|
├── Cargo.toml
|
||||||
|
└── ...
|
2644
cmd/Cargo.lock
generated
Normal file
2644
cmd/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
cmd/Cargo.toml
Normal file
23
cmd/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "circles_orchestrator"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
# clap = { version = "4.0", features = ["derive"], optional = true } # Optional for future args
|
||||||
|
dirs = "5.0"
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.10"
|
||||||
|
comfy-table = "7.0" # For table display
|
||||||
|
|
||||||
|
# Path dependencies to other local crates
|
||||||
|
heromodels = { path = "../../db/heromodels" } # Changed from ourdb
|
||||||
|
rhai_engine = { path = "../../rhailib/src/engine" }
|
||||||
|
rhai_worker = { path = "../../rhailib/src/worker" }
|
||||||
|
# rhai_client is used by circle_ws_lib, not directly by orchestrator usually
|
||||||
|
circle_ws_lib = { path = "../server_ws" }
|
5
cmd/circles.json
Normal file
5
cmd/circles.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{ "id": 1, "name": "Alpha Circle", "port": 8091 },
|
||||||
|
{ "id": 2, "name": "Alpha Circle", "port": 8082 },
|
||||||
|
{ "id": 3, "name": "Beta Circle", "port": 8083 }
|
||||||
|
]
|
245
cmd/src/main.rs
Normal file
245
cmd/src/main.rs
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::sync::{oneshot, mpsc}; // For server handles and worker shutdown
|
||||||
|
use tokio::signal;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use comfy_table::{Table, Row, Cell, ContentArrangement};
|
||||||
|
use log::{info, error, warn, debug};
|
||||||
|
|
||||||
|
use heromodels::db::hero::{OurDB as HeroOurDB}; // Renamed to avoid conflict if OurDB is used from elsewhere
|
||||||
|
use rhai_engine::create_heromodels_engine;
|
||||||
|
use worker_lib::spawn_rhai_worker; // This now takes a shutdown_rx
|
||||||
|
use circle_ws_lib::spawn_circle_ws_server; // This now takes a server_handle_tx
|
||||||
|
|
||||||
|
const DEFAULT_REDIS_URL: &str = "redis://127.0.0.1:6379";
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
struct CircleConfig {
|
||||||
|
id: u32,
|
||||||
|
name: String,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RunningCircleInfo {
|
||||||
|
config: CircleConfig,
|
||||||
|
db_path: PathBuf,
|
||||||
|
worker_queue: String,
|
||||||
|
ws_url: String,
|
||||||
|
|
||||||
|
worker_handle: JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>,
|
||||||
|
worker_shutdown_tx: mpsc::Sender<()>, // To signal worker to stop
|
||||||
|
|
||||||
|
// Store the server handle for graceful shutdown, and its JoinHandle
|
||||||
|
ws_server_instance_handle: Arc<Mutex<Option<actix_web::dev::Server>>>,
|
||||||
|
ws_server_task_join_handle: JoinHandle<std::io::Result<()>>,
|
||||||
|
status: Arc<Mutex<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
std::env::set_var("RUST_LOG", "info,circles_orchestrator=debug,worker_lib=debug,circle_ws_lib=debug,rhai_client=debug,actix_server=info");
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
info!("Starting Circles Orchestrator...");
|
||||||
|
info!("Press Ctrl+C to initiate graceful shutdown.");
|
||||||
|
|
||||||
|
let config_path = PathBuf::from("./circles.json");
|
||||||
|
if !config_path.exists() {
|
||||||
|
error!("Configuration file not found at {:?}. Please create circles.json.", config_path);
|
||||||
|
return Err("circles.json not found".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_content = fs::read_to_string(config_path)?;
|
||||||
|
let circle_configs: Vec<CircleConfig> = serde_json::from_str(&config_content)?;
|
||||||
|
|
||||||
|
if circle_configs.is_empty() {
|
||||||
|
warn!("No circle configurations found in circles.json. Exiting.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
info!("Loaded {} circle configurations.", circle_configs.len());
|
||||||
|
|
||||||
|
let mut running_circles_store: Vec<Arc<Mutex<RunningCircleInfo>>> = Vec::new();
|
||||||
|
|
||||||
|
for config in circle_configs {
|
||||||
|
info!("Initializing Circle ID: {}, Name: '{}', Port: {}", config.id, config.name, config.port);
|
||||||
|
let current_status = Arc::new(Mutex::new(format!("Initializing Circle {}", config.id)));
|
||||||
|
|
||||||
|
let db_base_path = match dirs::home_dir() {
|
||||||
|
Some(path) => path.join(".hero").join("circles"),
|
||||||
|
None => {
|
||||||
|
error!("Failed to get user home directory for Circle ID {}.", config.id);
|
||||||
|
*current_status.lock().unwrap() = "Error: DB Path".to_string();
|
||||||
|
// Not pushing to running_circles_store as it can't fully initialize
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let circle_db_path = db_base_path.join(config.id.to_string());
|
||||||
|
if !circle_db_path.exists() {
|
||||||
|
if let Err(e) = fs::create_dir_all(&circle_db_path) {
|
||||||
|
error!("Failed to create database directory for Circle {}: {:?}. Error: {}", config.id, circle_db_path, e);
|
||||||
|
*current_status.lock().unwrap() = "Error: DB Create".to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
info!("Created database directory for Circle {}: {:?}", config.id, circle_db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = match HeroOurDB::new(circle_db_path.clone(), false) {
|
||||||
|
Ok(db_instance) => Arc::new(db_instance),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to initialize heromodels::OurDB for Circle {}: {:?}", config.id, e);
|
||||||
|
*current_status.lock().unwrap() = "Error: DB Init".to_string();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("OurDB initialized for Circle {}", config.id);
|
||||||
|
*current_status.lock().unwrap() = format!("DB Ok for Circle {}", config.id);
|
||||||
|
|
||||||
|
let engine = create_heromodels_engine(db.clone());
|
||||||
|
info!("Rhai Engine created for Circle {}", config.id);
|
||||||
|
*current_status.lock().unwrap() = format!("Engine Ok for Circle {}", config.id);
|
||||||
|
|
||||||
|
// Channel for worker shutdown
|
||||||
|
let (worker_shutdown_tx, worker_shutdown_rx) = mpsc::channel(1); // Buffer of 1 is fine
|
||||||
|
|
||||||
|
let worker_handle = spawn_rhai_worker(
|
||||||
|
config.id,
|
||||||
|
config.name.clone(),
|
||||||
|
engine, // engine is Clone
|
||||||
|
DEFAULT_REDIS_URL.to_string(),
|
||||||
|
worker_shutdown_rx, // Pass the receiver
|
||||||
|
);
|
||||||
|
info!("Rhai Worker spawned for Circle {}", config.id);
|
||||||
|
let worker_queue_name = format!("rhai_tasks:{}", config.name.replace(" ", "_").to_lowercase());
|
||||||
|
*current_status.lock().unwrap() = format!("Worker Spawning for Circle {}", config.id);
|
||||||
|
|
||||||
|
let (server_handle_tx, server_handle_rx) = oneshot::channel();
|
||||||
|
let ws_server_task_join_handle = spawn_circle_ws_server(
|
||||||
|
config.id,
|
||||||
|
config.name.clone(),
|
||||||
|
config.port,
|
||||||
|
DEFAULT_REDIS_URL.to_string(),
|
||||||
|
server_handle_tx,
|
||||||
|
);
|
||||||
|
info!("Circle WebSocket Server task spawned for Circle {} on port {}", config.id, config.port);
|
||||||
|
let ws_url = format!("ws://127.0.0.1:{}/ws", config.port);
|
||||||
|
*current_status.lock().unwrap() = format!("WS Server Spawning for Circle {}", config.id);
|
||||||
|
|
||||||
|
let server_instance_handle_arc = Arc::new(Mutex::new(None));
|
||||||
|
let server_instance_handle_clone = server_instance_handle_arc.clone();
|
||||||
|
let status_clone_for_server_handle = current_status.clone();
|
||||||
|
let circle_id_for_server_handle = config.id;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match server_handle_rx.await {
|
||||||
|
Ok(handle) => {
|
||||||
|
*server_instance_handle_clone.lock().unwrap() = Some(handle);
|
||||||
|
*status_clone_for_server_handle.lock().unwrap() = format!("Running Circle {}", circle_id_for_server_handle);
|
||||||
|
info!("Received server handle for Circle {}", circle_id_for_server_handle);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
*status_clone_for_server_handle.lock().unwrap() = format!("Error: No Server Handle for Circle {}", circle_id_for_server_handle);
|
||||||
|
error!("Failed to receive server handle for Circle {}", circle_id_for_server_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
running_circles_store.push(Arc::new(Mutex::new(RunningCircleInfo {
|
||||||
|
config,
|
||||||
|
db_path: circle_db_path,
|
||||||
|
worker_queue: worker_queue_name,
|
||||||
|
ws_url,
|
||||||
|
worker_handle,
|
||||||
|
worker_shutdown_tx,
|
||||||
|
ws_server_instance_handle: server_instance_handle_arc,
|
||||||
|
ws_server_task_join_handle,
|
||||||
|
status: current_status, // This is an Arc<Mutex<String>>
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("All configured circles have been processed. Initializing status table display loop.");
|
||||||
|
|
||||||
|
let display_running_circles = running_circles_store.clone();
|
||||||
|
let display_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
{ // Scope for MutexGuard
|
||||||
|
let circles = display_running_circles.iter()
|
||||||
|
.map(|arc_info| arc_info.lock().unwrap())
|
||||||
|
.collect::<Vec<_>>(); // Collect locked guards
|
||||||
|
|
||||||
|
let mut table = Table::new();
|
||||||
|
table.set_content_arrangement(ContentArrangement::Dynamic);
|
||||||
|
table.set_header(vec!["Name", "ID", "Port", "Status", "DB Path", "Worker Queue", "WS URL"]);
|
||||||
|
|
||||||
|
for circle_info in circles.iter() {
|
||||||
|
let mut row = Row::new();
|
||||||
|
row.add_cell(Cell::new(&circle_info.config.name));
|
||||||
|
row.add_cell(Cell::new(circle_info.config.id));
|
||||||
|
row.add_cell(Cell::new(circle_info.config.port));
|
||||||
|
row.add_cell(Cell::new(&*circle_info.status.lock().unwrap())); // Deref and lock status
|
||||||
|
row.add_cell(Cell::new(circle_info.db_path.to_string_lossy()));
|
||||||
|
row.add_cell(Cell::new(&circle_info.worker_queue));
|
||||||
|
row.add_cell(Cell::new(&circle_info.ws_url));
|
||||||
|
table.add_row(row);
|
||||||
|
}
|
||||||
|
// Clear terminal before printing (basic, might flicker)
|
||||||
|
// print!("\x1B[2J\x1B[1;1H");
|
||||||
|
println!("\n--- Circles Status (updated every 5s, Ctrl+C to stop) ---\n{table}");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
signal::ctrl_c().await?;
|
||||||
|
info!("Ctrl-C received. Initiating graceful shutdown of all circles...");
|
||||||
|
display_task.abort(); // Stop the display task
|
||||||
|
|
||||||
|
for circle_arc in running_circles_store {
|
||||||
|
let mut circle_info = circle_arc.lock().unwrap();
|
||||||
|
info!("Shutting down Circle ID: {}, Name: '{}'", circle_info.config.id, circle_info.config.name);
|
||||||
|
*circle_info.status.lock().unwrap() = "Shutting down".to_string();
|
||||||
|
|
||||||
|
// Signal worker to shut down
|
||||||
|
if circle_info.worker_shutdown_tx.send(()).await.is_err() {
|
||||||
|
warn!("Failed to send shutdown signal to worker for Circle {}. It might have already stopped.", circle_info.config.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop WS server
|
||||||
|
if let Some(server_handle) = circle_info.ws_server_instance_handle.lock().unwrap().take() {
|
||||||
|
info!("Stopping WebSocket server for Circle {}...", circle_info.config.id);
|
||||||
|
server_handle.stop(true).await; // Graceful stop
|
||||||
|
info!("WebSocket server for Circle {} stop signal sent.", circle_info.config.id);
|
||||||
|
} else {
|
||||||
|
warn!("No server handle to stop WebSocket server for Circle {}. It might not have started properly or already stopped.", circle_info.config.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Waiting for all tasks to complete...");
|
||||||
|
for circle_arc in running_circles_store {
|
||||||
|
// We need to take ownership of handles to await them, or await mutable refs.
|
||||||
|
// This part is tricky if the MutexGuard is held.
|
||||||
|
// For simplicity, we'll just log that we've signaled them.
|
||||||
|
// Proper awaiting would require more careful structuring of JoinHandles.
|
||||||
|
let circle_id;
|
||||||
|
let circle_name;
|
||||||
|
{ // Short scope for the lock
|
||||||
|
let circle_info = circle_arc.lock().unwrap();
|
||||||
|
circle_id = circle_info.config.id;
|
||||||
|
circle_name = circle_info.config.name.clone();
|
||||||
|
}
|
||||||
|
debug!("Orchestrator has signaled shutdown for Circle {} ({}). Main loop will await join handles if structured for it.", circle_name, circle_id);
|
||||||
|
// Actual awaiting of join handles would happen here if they were collected outside the Mutex.
|
||||||
|
// For now, the main function will exit after this loop.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give some time for tasks to shut down before the main process exits.
|
||||||
|
// This is a simplified approach. A more robust solution would involve awaiting all JoinHandles.
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
info!("Orchestrator shut down complete.");
|
||||||
|
Ok(())
|
||||||
|
}
|
6
content/intro.md
Normal file
6
content/intro.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
Free your self from
|
||||||
|
|
||||||
|
Think outside the box
|
||||||
|
|
||||||
|
An internet built around ourselves
|
||||||
|
|
5
rhai_repl_cli/.rhai_repl_history.txt
Normal file
5
rhai_repl_cli/.rhai_repl_history.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#V2
|
||||||
|
.edit
|
||||||
|
quit
|
||||||
|
.edit
|
||||||
|
exit
|
1752
rhai_repl_cli/Cargo.lock
generated
Normal file
1752
rhai_repl_cli/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
rhai_repl_cli/Cargo.toml
Normal file
17
rhai_repl_cli/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "rhai_repl_cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024" # Keep 2024 unless issues arise
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } # Added "time" for potential timeouts
|
||||||
|
tokio-tungstenite = { version = "0.21", features = ["native-tls"] } # May be removed if client_ws handles all
|
||||||
|
futures-util = "0.3"
|
||||||
|
url = "2"
|
||||||
|
tracing = "0.1" # For logging
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
log = "0.4" # circle_client_ws uses log crate
|
||||||
|
rustyline = { version = "13.0.0", features = ["derive"] } # For enhanced REPL input
|
||||||
|
tempfile = "3.8" # For creating temporary files for editing
|
||||||
|
|
||||||
|
circle_client_ws = { path = "../client_ws" }
|
77
rhai_repl_cli/README.md
Normal file
77
rhai_repl_cli/README.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Rhai REPL CLI for Circle WebSocket Servers
|
||||||
|
|
||||||
|
This crate provides a command-line interface (CLI) to interact with Rhai scripts executed on remote Circle WebSocket servers. It includes both an interactive REPL and a non-interactive example.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Circle Orchestrator Running**: Ensure the `circles_orchestrator` is running. This application manages and starts the individual Circle WebSocket servers.
|
||||||
|
To run the orchestrator:
|
||||||
|
```bash
|
||||||
|
cd /path/to/herocode/circles/cmd
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
By default, this will start servers based on the `circles.json` configuration (e.g., "Alpha Circle" on `ws://127.0.0.1:8081/ws`).
|
||||||
|
|
||||||
|
2. **Redis Server**: Ensure a Redis server is running and accessible at `redis://127.0.0.1:6379` (this is the default used by the orchestrator and its components).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Navigate to this crate's directory:
|
||||||
|
```bash
|
||||||
|
cd /path/to/herocode/circles/rhai_repl_cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Interactive REPL
|
||||||
|
|
||||||
|
The main binary of this crate is an interactive REPL.
|
||||||
|
|
||||||
|
**To run with default WebSocket URL (`ws://127.0.0.1:8081/ws`):**
|
||||||
|
```bash
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
**To specify a WebSocket URL:**
|
||||||
|
```bash
|
||||||
|
cargo run ws://<your-circle-server-ip>:<port>/ws
|
||||||
|
# Example for "Beta Circle" if configured on port 8082:
|
||||||
|
# cargo run ws://127.0.0.1:8082/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
Once connected, you can:
|
||||||
|
- Type single-line Rhai scripts directly and press Enter.
|
||||||
|
- Use Vi keybindings for editing the current input line (thanks to `rustyline`).
|
||||||
|
- Type `.edit` to open your `$EDITOR` (or `vi` by default) for multi-line script input. Save and close the editor to execute the script.
|
||||||
|
- Type `.run <filepath>` (or `run <filepath>`) to execute a Rhai script from a local file.
|
||||||
|
- Type `exit` or `quit` to close the REPL.
|
||||||
|
|
||||||
|
Command history is saved to `.rhai_repl_history.txt` in the directory where you run the REPL.
|
||||||
|
|
||||||
|
### 2. Non-Interactive Example (`connect_and_play`)
|
||||||
|
|
||||||
|
This example connects to a WebSocket server, sends a predefined Rhai script, prints the response, and then disconnects.
|
||||||
|
|
||||||
|
**To run with default WebSocket URL (`ws://127.0.0.1:8081/ws`):**
|
||||||
|
```bash
|
||||||
|
cargo run --example connect_and_play
|
||||||
|
```
|
||||||
|
|
||||||
|
**To specify a WebSocket URL for the example:**
|
||||||
|
```bash
|
||||||
|
cargo run --example connect_and_play ws://<your-circle-server-ip>:<port>/ws
|
||||||
|
# Example:
|
||||||
|
# cargo run --example connect_and_play ws://127.0.0.1:8082/ws
|
||||||
|
```
|
||||||
|
|
||||||
|
The example script is:
|
||||||
|
```rhai
|
||||||
|
let a = 10;
|
||||||
|
let b = 32;
|
||||||
|
let message = "Hello from example script!";
|
||||||
|
message + " Result: " + (a + b)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Both the REPL and the example use the `tracing` crate for logging. You can control log levels using the `RUST_LOG` environment variable. For example, to see debug logs from the `circle_client_ws` library:
|
||||||
|
```bash
|
||||||
|
RUST_LOG=info,circle_client_ws=debug cargo run --example connect_and_play
|
43
rhai_repl_cli/examples/connect_and_play.rs
Normal file
43
rhai_repl_cli/examples/connect_and_play.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use circle_client_ws::CircleWsClient;
|
||||||
|
use std::env;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env().add_directive("connect_and_play=info".parse().unwrap()).add_directive("circle_client_ws=info".parse().unwrap()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
let ws_url = args.get(1).cloned().unwrap_or_else(|| {
|
||||||
|
let default_url = "ws://127.0.0.1:8081/ws".to_string();
|
||||||
|
println!("No WebSocket URL provided. Defaulting to: {}", default_url);
|
||||||
|
default_url
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("Attempting to connect to {}...", ws_url);
|
||||||
|
let mut client = CircleWsClient::new(ws_url.clone());
|
||||||
|
|
||||||
|
if let Err(e) = client.connect().await {
|
||||||
|
eprintln!("Failed to connect to {}: {}", ws_url, e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
println!("Connected to {}!", ws_url);
|
||||||
|
|
||||||
|
let script = "let a = 10; let b = 32; let message = \"Hello from example script!\"; message + \" Result: \" + (a + b)";
|
||||||
|
println!("\nSending script:\n```rhai\n{}\n```", script);
|
||||||
|
|
||||||
|
match client.play(script.to_string()).await {
|
||||||
|
Ok(play_result) => {
|
||||||
|
println!("\nServer response:\nOutput: {}", play_result.output);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\nError executing script: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.disconnect().await;
|
||||||
|
println!("\nDisconnected from {}.", ws_url);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
189
rhai_repl_cli/src/main.rs
Normal file
189
rhai_repl_cli/src/main.rs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
use url::Url;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use circle_client_ws::CircleWsClient;
|
||||||
|
use rustyline::error::ReadlineError;
|
||||||
|
// Remove direct History import, DefaultEditor handles it.
|
||||||
|
use rustyline::{DefaultEditor, Config, EditMode};
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::env;
|
||||||
|
use tempfile::Builder as TempFileBuilder; // Use Builder for suffix
|
||||||
|
|
||||||
|
// std::io::Write is not used if we don't pre-populate temp_file
|
||||||
|
// use std::io::Write;
|
||||||
|
|
||||||
|
async fn execute_script(client: &mut CircleWsClient, script_content: String) {
|
||||||
|
if script_content.trim().is_empty() {
|
||||||
|
println!("Script is empty, not sending.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!("Sending script to server:\n---\n{}\n---", script_content);
|
||||||
|
match client.play(script_content).await {
|
||||||
|
Ok(play_result) => {
|
||||||
|
println!("server: {}", play_result.output);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error executing script: {}", e);
|
||||||
|
if matches!(e, circle_client_ws::CircleWsClientError::NotConnected | circle_client_ws::CircleWsClientError::ConnectionError(_)) {
|
||||||
|
eprintln!("Connection lost. You may need to restart the REPL and reconnect.");
|
||||||
|
// Optionally, could attempt to trigger a full exit here or set a flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_repl(ws_url_str: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("Attempting to connect to {}...", ws_url_str);
|
||||||
|
|
||||||
|
let mut client = CircleWsClient::new(ws_url_str.clone());
|
||||||
|
|
||||||
|
match client.connect().await {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Connected to {}!", ws_url_str);
|
||||||
|
println!("Type Rhai scripts, '.edit' to use $EDITOR, '.run <path>' to execute a file, or 'exit'/'quit'.");
|
||||||
|
println!("Vi mode enabled for input line.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!("Failed to connect: {}", e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config::builder()
|
||||||
|
.edit_mode(EditMode::Vi)
|
||||||
|
.auto_add_history(true) // Automatically add to history
|
||||||
|
.build();
|
||||||
|
let mut rl = DefaultEditor::with_config(config)?;
|
||||||
|
|
||||||
|
let history_file = ".rhai_repl_history.txt"; // Simple history file in current dir
|
||||||
|
if rl.load_history(history_file).is_err() {
|
||||||
|
// No history found or error loading, not critical
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = format!("rhai ({})> ", ws_url_str);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let readline = rl.readline(&prompt);
|
||||||
|
match readline {
|
||||||
|
Ok(line) => {
|
||||||
|
let input = line.trim();
|
||||||
|
|
||||||
|
if input.eq_ignore_ascii_case("exit") || input.eq_ignore_ascii_case("quit") {
|
||||||
|
println!("Exiting REPL.");
|
||||||
|
break;
|
||||||
|
} else if input.eq_ignore_ascii_case(".edit") {
|
||||||
|
// Correct way to create a temp file with a suffix
|
||||||
|
let temp_file = TempFileBuilder::new()
|
||||||
|
.prefix("rhai_script_") // Optional: add a prefix
|
||||||
|
.suffix(".rhai")
|
||||||
|
.tempfile_in(".") // Create in current directory for simplicity
|
||||||
|
.map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||||
|
|
||||||
|
// You can pre-populate the temp file if needed:
|
||||||
|
// use std::io::Write; // Add this import if using write_all
|
||||||
|
// if let Err(e) = temp_file.as_file().write_all(b"// Start your Rhai script here\n") {
|
||||||
|
// eprintln!("Failed to write initial content to temp file: {}", e);
|
||||||
|
// }
|
||||||
|
|
||||||
|
let temp_path = temp_file.path().to_path_buf();
|
||||||
|
let editor_cmd_str = env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
||||||
|
|
||||||
|
let mut editor_parts = editor_cmd_str.split_whitespace();
|
||||||
|
let editor_executable = editor_parts.next().unwrap_or("vi"); // Default to vi if $EDITOR is empty string
|
||||||
|
let editor_args: Vec<&str> = editor_parts.collect();
|
||||||
|
|
||||||
|
println!("Launching editor: '{}' with args: {:?} for script editing. Save and exit editor to execute.", editor_executable, editor_args);
|
||||||
|
|
||||||
|
let mut command = Command::new(editor_executable);
|
||||||
|
command.args(editor_args); // Add any arguments from $EDITOR (like -w)
|
||||||
|
command.arg(&temp_path); // Add the temp file path as the last argument
|
||||||
|
|
||||||
|
let status = command.status();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
Ok(exit_status) if exit_status.success() => {
|
||||||
|
match fs::read_to_string(&temp_path) {
|
||||||
|
Ok(script_content) => {
|
||||||
|
execute_script(&mut client, script_content).await;
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Error reading temp file {:?}: {}", temp_path, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(exit_status) => eprintln!("Editor exited with status: {}. Script not executed.", exit_status),
|
||||||
|
Err(e) => eprintln!("Failed to launch editor '{}': {}. Ensure it's in your PATH.", editor_executable, e), // Changed 'editor' to 'editor_executable'
|
||||||
|
}
|
||||||
|
// temp_file is automatically deleted when it goes out of scope
|
||||||
|
} else if input.starts_with(".run ") || input.starts_with("run ") {
|
||||||
|
let parts: Vec<&str> = input.splitn(2, ' ').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let file_path = parts[1];
|
||||||
|
println!("Attempting to run script from file: {}", file_path);
|
||||||
|
match fs::read_to_string(file_path) {
|
||||||
|
Ok(script_content) => {
|
||||||
|
execute_script(&mut client, script_content).await;
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("Error reading file {}: {}", file_path, e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("Usage: .run <filepath>");
|
||||||
|
}
|
||||||
|
} else if !input.is_empty() {
|
||||||
|
execute_script(&mut client, input.to_string()).await;
|
||||||
|
}
|
||||||
|
// rl.add_history_entry(line.as_str()) is handled by auto_add_history(true)
|
||||||
|
}
|
||||||
|
Err(ReadlineError::Interrupted) => { // Ctrl-C
|
||||||
|
println!("Input interrupted. Type 'exit' or 'quit' to close.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(ReadlineError::Eof) => { // Ctrl-D
|
||||||
|
println!("Exiting REPL (EOF).");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Error reading input: {:?}", err);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rl.save_history(history_file).is_err() {
|
||||||
|
// Failed to save history, not critical
|
||||||
|
}
|
||||||
|
|
||||||
|
client.disconnect().await;
|
||||||
|
println!("Disconnected.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::from_default_env().add_directive("rhai_repl_cli=info".parse().unwrap()).add_directive("circle_client_ws=info".parse().unwrap()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
let ws_url_str = if args.len() > 1 {
|
||||||
|
args[1].clone()
|
||||||
|
} else {
|
||||||
|
let default_url = "ws://127.0.0.1:8081/ws".to_string(); // Default to first circle
|
||||||
|
println!("No WebSocket URL provided. Defaulting to: {}", default_url);
|
||||||
|
println!("You can also provide a URL as a command line argument, e.g.: cargo run ws://127.0.0.1:8082/ws");
|
||||||
|
default_url
|
||||||
|
};
|
||||||
|
|
||||||
|
match Url::parse(&ws_url_str) {
|
||||||
|
Ok(parsed_url) => {
|
||||||
|
if parsed_url.scheme() != "ws" && parsed_url.scheme() != "wss" {
|
||||||
|
eprintln!("Invalid WebSocket URL scheme: {}. Must be 'ws' or 'wss'.", parsed_url.scheme());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = run_repl(ws_url_str).await { // Pass the original string URL
|
||||||
|
eprintln!("REPL error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Invalid WebSocket URL format '{}': {}", ws_url_str, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,28 +1,32 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "circle_server_ws"
|
name = "circle_ws_lib" # Renamed to reflect library nature
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "circle_ws_lib"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix-web = "4"
|
actix-web = "4"
|
||||||
actix-web-actors = "4"
|
actix-web-actors = "4"
|
||||||
actix = "0.13"
|
actix = "0.13"
|
||||||
env_logger = "0.10"
|
env_logger = "0.10" # Keep for logging within the lib
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
# clap is removed as CLI parsing moves to the orchestrator bin
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
redis = { version = "0.25.0", features = ["tokio-comp"] } # For async Redis with Actix
|
redis = { version = "0.25.0", features = ["tokio-comp"] } # For async Redis with Actix
|
||||||
uuid = { version = "1.6", features = ["v4", "serde"] }
|
uuid = { version = "1.6", features = ["v4", "serde"] } # Still used by RhaiClient or for task details
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] } # For polling interval
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } # Added "time" for Duration
|
||||||
chrono = { version = "0.4", features = ["serde"] } # For timestamps
|
chrono = { version = "0.4", features = ["serde"] } # For timestamps
|
||||||
rhai_client = { path = "/Users/timurgordon/code/git.ourworld.tf/herocode/rhaj/src/client" }
|
rhai_client = { path = "../../rhailib/src/client" } # Corrected relative path
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-tungstenite = { version = "0.23.0", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.23.0", features = ["native-tls"] }
|
||||||
futures-util = "0.3" # For StreamExt and SinkExt on WebSocket stream
|
futures-util = "0.3" # For StreamExt and SinkExt on WebSocket stream
|
||||||
url = "2.5.0" # For parsing WebSocket URL
|
url = "2.5.0" # For parsing WebSocket URL
|
||||||
circle_client_ws = { path = "../client_ws" }
|
# circle_client_ws = { path = "../client_ws" } # This might need adjustment if it's a test client for the old binary
|
||||||
uuid = { version = "1.6", features = ["v4", "serde"] } # For e2e example, if it still uses Uuid directly for req id
|
# uuid = { version = "1.6", features = ["v4", "serde"] } # Already in dependencies
|
||||||
|
302
server_ws/src/lib.rs
Normal file
302
server_ws/src/lib.rs
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
use actix_web::{web, App, HttpRequest, HttpServer, HttpResponse, Error};
|
||||||
|
use actix_web_actors::ws;
|
||||||
|
use actix::{Actor, ActorContext, StreamHandler, AsyncContext, WrapFuture, ActorFutureExt};
|
||||||
|
// clap::Parser removed
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::time::Duration;
|
||||||
|
use rhai_client::RhaiClientError;
|
||||||
|
use rhai_client::RhaiClient;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio::sync::oneshot; // For sending the server handle back
|
||||||
|
|
||||||
|
// Newtype wrappers for distinct app_data types
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppCircleName(String);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppRedisUrl(String);
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 Structures (remain the same)
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct JsonRpcRequest {
|
||||||
|
jsonrpc: String,
|
||||||
|
method: String,
|
||||||
|
params: Value,
|
||||||
|
id: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct JsonRpcResponse {
|
||||||
|
jsonrpc: String,
|
||||||
|
result: Option<Value>,
|
||||||
|
error: Option<JsonRpcError>,
|
||||||
|
id: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct JsonRpcError {
|
||||||
|
code: i32,
|
||||||
|
message: String,
|
||||||
|
data: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct PlayParams {
|
||||||
|
script: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
struct PlayResult {
|
||||||
|
output: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket Actor
|
||||||
|
struct CircleWs {
|
||||||
|
server_circle_name: String,
|
||||||
|
redis_url_for_client: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_TIMEOUT_DURATION: Duration = Duration::from_secs(30);
|
||||||
|
const TASK_POLL_INTERVAL_DURATION: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
impl CircleWs {
|
||||||
|
fn new(name: String, redis_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
server_circle_name: name,
|
||||||
|
redis_url_for_client: redis_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Actor for CircleWs {
|
||||||
|
type Context = ws::WebsocketContext<Self>;
|
||||||
|
|
||||||
|
fn started(&mut self, _ctx: &mut Self::Context) {
|
||||||
|
log::info!("WebSocket session started for server dedicated to: {}", self.server_circle_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopping(&mut self, _ctx: &mut Self::Context) -> actix::Running {
|
||||||
|
log::info!("WebSocket session stopping for server dedicated to: {}", self.server_circle_name);
|
||||||
|
actix::Running::Stop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for CircleWs {
|
||||||
|
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
|
||||||
|
match msg {
|
||||||
|
Ok(ws::Message::Text(text)) => {
|
||||||
|
log::debug!("WS Text for {}: {}", self.server_circle_name, text); // Changed to debug for less noise
|
||||||
|
match serde_json::from_str::<JsonRpcRequest>(&text) {
|
||||||
|
Ok(req) => {
|
||||||
|
let client_rpc_id = req.id.clone().unwrap_or(Value::Null);
|
||||||
|
if req.method == "play" {
|
||||||
|
match serde_json::from_value::<PlayParams>(req.params.clone()) {
|
||||||
|
Ok(play_params) => {
|
||||||
|
let script_content = play_params.script;
|
||||||
|
// Use the server_circle_name which should be correctly set now
|
||||||
|
let current_circle_name_for_rhai_client = self.server_circle_name.clone();
|
||||||
|
let rpc_id_for_client = client_rpc_id.clone();
|
||||||
|
let redis_url_clone = self.redis_url_for_client.clone();
|
||||||
|
|
||||||
|
log::info!("Circle '{}' WS: Received 'play' request, ID: {:?}, for RhaiClient target circle: '{}'", self.server_circle_name, rpc_id_for_client, current_circle_name_for_rhai_client);
|
||||||
|
|
||||||
|
let fut = async move {
|
||||||
|
match RhaiClient::new(&redis_url_clone) {
|
||||||
|
Ok(rhai_task_client) => {
|
||||||
|
rhai_task_client.submit_script_and_await_result(
|
||||||
|
¤t_circle_name_for_rhai_client, // This name is used for Redis queue
|
||||||
|
script_content,
|
||||||
|
Some(rpc_id_for_client.clone()),
|
||||||
|
TASK_TIMEOUT_DURATION,
|
||||||
|
TASK_POLL_INTERVAL_DURATION,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Circle '{}' WS: Failed to create RhaiClient for Redis URL {}: {}", current_circle_name_for_rhai_client, redis_url_clone, e);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.spawn(fut.into_actor(self).map(move |result, _act, ws_ctx| {
|
||||||
|
let response = match result {
|
||||||
|
Ok(task_details) => {
|
||||||
|
if task_details.status == "completed" {
|
||||||
|
// task_details itself doesn't have a task_id field.
|
||||||
|
// The task_id is known by the client that initiated the poll.
|
||||||
|
// We log with client_rpc_id which is the JSON-RPC request ID.
|
||||||
|
log::info!("Circle '{}' WS: Request ID {:?} completed successfully. Output: {:?}", _act.server_circle_name, client_rpc_id, task_details.output);
|
||||||
|
JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: Some(serde_json::to_value(PlayResult {
|
||||||
|
output: task_details.output.unwrap_or_default()
|
||||||
|
}).unwrap()),
|
||||||
|
error: None,
|
||||||
|
id: client_rpc_id,
|
||||||
|
}
|
||||||
|
} else { // status == "error"
|
||||||
|
log::warn!("Circle '{}' WS: Request ID {:?} execution failed. Error: {:?}", _act.server_circle_name, client_rpc_id, task_details.error);
|
||||||
|
JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: None,
|
||||||
|
error: Some(JsonRpcError {
|
||||||
|
code: -32004,
|
||||||
|
message: task_details.error.unwrap_or_else(|| "Script execution failed".to_string()),
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
id: client_rpc_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(rhai_err) => {
|
||||||
|
log::error!("Circle '{}' WS: RhaiClient operation failed for req ID {:?}: {}", _act.server_circle_name, client_rpc_id, rhai_err);
|
||||||
|
let (code, message) = match rhai_err {
|
||||||
|
RhaiClientError::Timeout(task_id) => (-32002, format!("Timeout: {}", task_id)),
|
||||||
|
RhaiClientError::RedisError(e) => (-32003, format!("Redis error: {}", e)),
|
||||||
|
RhaiClientError::SerializationError(e) => (-32003, format!("Serialization error: {}", e)),
|
||||||
|
RhaiClientError::TaskNotFound(task_id) => (-32005, format!("Task not found: {}", task_id)),
|
||||||
|
};
|
||||||
|
JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: None,
|
||||||
|
id: client_rpc_id,
|
||||||
|
error: Some(JsonRpcError { code, message, data: None }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws_ctx.text(serde_json::to_string(&response).unwrap());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Circle '{}' WS: Invalid params for 'play' method: {}", self.server_circle_name, e);
|
||||||
|
let err_resp = JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(), result: None, id: client_rpc_id,
|
||||||
|
error: Some(JsonRpcError { code: -32602, message: "Invalid params".to_string(), data: Some(Value::String(e.to_string())) }),
|
||||||
|
};
|
||||||
|
ctx.text(serde_json::to_string(&err_resp).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!("Circle '{}' WS: Method not found: {}", self.server_circle_name, req.method);
|
||||||
|
let err_resp = JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(), result: None, id: client_rpc_id,
|
||||||
|
error: Some(JsonRpcError { code: -32601, message: "Method not found".to_string(), data: None }),
|
||||||
|
};
|
||||||
|
ctx.text(serde_json::to_string(&err_resp).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Circle '{}' WS: Failed to parse JSON-RPC request: {}", self.server_circle_name, e);
|
||||||
|
let err_resp = JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(), result: None, id: Value::Null,
|
||||||
|
error: Some(JsonRpcError { code: -32700, message: "Parse error".to_string(), data: Some(Value::String(e.to_string())) }),
|
||||||
|
};
|
||||||
|
ctx.text(serde_json::to_string(&err_resp).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
|
||||||
|
Ok(ws::Message::Pong(_)) => {},
|
||||||
|
Ok(ws::Message::Binary(_bin)) => log::warn!("Circle '{}' WS: Binary messages not supported.", self.server_circle_name),
|
||||||
|
Ok(ws::Message::Close(reason)) => {
|
||||||
|
log::info!("Circle '{}' WS: Close message received. Reason: {:?}", self.server_circle_name, reason);
|
||||||
|
ctx.close(reason);
|
||||||
|
ctx.stop();
|
||||||
|
}
|
||||||
|
Ok(ws::Message::Continuation(_)) => ctx.stop(),
|
||||||
|
Ok(ws::Message::Nop) => (),
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Circle '{}' WS: Error: {:?}", self.server_circle_name, e);
|
||||||
|
ctx.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modified ws_handler to accept newtype wrapped app_data
|
||||||
|
async fn ws_handler_modified(
|
||||||
|
req: HttpRequest,
|
||||||
|
stream: web::Payload,
|
||||||
|
app_circle_name: web::Data<AppCircleName>, // Use wrapped type
|
||||||
|
app_redis_url: web::Data<AppRedisUrl>, // Use wrapped type
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let circle_name_str = app_circle_name.0.clone();
|
||||||
|
let redis_url_str = app_redis_url.0.clone();
|
||||||
|
|
||||||
|
log::info!("WebSocket handshake attempt for server: '{}' with redis: '{}'", circle_name_str, redis_url_str);
|
||||||
|
let resp = ws::start(
|
||||||
|
CircleWs::new(circle_name_str, redis_url_str), // Pass unwrapped strings
|
||||||
|
&req,
|
||||||
|
stream
|
||||||
|
)?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public factory function to spawn the server
|
||||||
|
pub fn spawn_circle_ws_server(
|
||||||
|
_circle_id: u32,
|
||||||
|
circle_name: String,
|
||||||
|
port: u16,
|
||||||
|
redis_url: String,
|
||||||
|
// Sender to send the server handle back to the orchestrator
|
||||||
|
server_handle_tx: oneshot::Sender<actix_web::dev::Server>,
|
||||||
|
) -> JoinHandle<std::io::Result<()>> {
|
||||||
|
let circle_name_for_log = circle_name.clone();
|
||||||
|
// redis_url_for_log is not used, but kept for consistency if needed later
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let circle_name_outer = circle_name;
|
||||||
|
let redis_url_outer = redis_url;
|
||||||
|
|
||||||
|
let app_factory = move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(AppCircleName(circle_name_outer.clone())))
|
||||||
|
.app_data(web::Data::new(AppRedisUrl(redis_url_outer.clone())))
|
||||||
|
.route("/ws", web::get().to(ws_handler_modified))
|
||||||
|
.default_service(web::route().to(|| async { HttpResponse::NotFound().body("404 Not Found") }))
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_builder = HttpServer::new(app_factory);
|
||||||
|
|
||||||
|
let bound_server = match server_builder.bind(("127.0.0.1", port)) {
|
||||||
|
Ok(srv) => {
|
||||||
|
log::info!(
|
||||||
|
"Successfully bound WebSocket server for Circle: '{}' on port {}. Starting...",
|
||||||
|
circle_name_for_log, port
|
||||||
|
);
|
||||||
|
srv
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to bind WebSocket server for Circle '{}' on port {}: {}",
|
||||||
|
circle_name_for_log, port, e
|
||||||
|
);
|
||||||
|
// If binding fails, we can't send a server handle.
|
||||||
|
// The orchestrator will see the JoinHandle error out or the oneshot::Sender drop.
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_runnable: actix_web::dev::Server = bound_server.run();
|
||||||
|
|
||||||
|
// Send the server handle back to the orchestrator
|
||||||
|
if server_handle_tx.send(server_runnable.clone()).is_err() {
|
||||||
|
log::error!(
|
||||||
|
"Failed to send server handle back to orchestrator for Circle '{}'. Orchestrator might have shut down.",
|
||||||
|
circle_name_for_log
|
||||||
|
);
|
||||||
|
// Server might still run, but orchestrator can't stop it gracefully via this handle.
|
||||||
|
// Consider stopping it here if sending the handle is critical.
|
||||||
|
// For now, let it proceed, but log the error.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now await the server_runnable (which is the Server handle itself)
|
||||||
|
if let Err(e) = server_runnable.await {
|
||||||
|
log::error!("WebSocket server for Circle '{}' on port {} failed during run: {}", circle_name_for_log, port, e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
log::info!("WebSocket server for Circle '{}' on port {} shut down gracefully.", circle_name_for_log, port);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user