swactor/crates/command/src/lib.rs
zacheryasc 4e56590f05 feat: wasm runner actor skeleton (#32)
Lay down the wasm-actor host, a frontend-agnostic command layer, and a
distribution registry.

- command (new crate): CommandRouter dispatching to built-in inspection handlers
  (overview/workers/actors) plus user-registered handlers, with line and
  query-param parsers; built for REPL/REST/TUI/WebSocket frontends.
- wasm-actor (new crate): skeleton host — WasmActor, Builder, Engine, error
  types — with echo/double/silent guest fixtures and integration tests.
- distribution: add Registry (member catalog + lookups) and Snapshot, with tests.
- core: extend the worker watch API; add watch_api integration tests.

Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-13 07:42:44 +00:00

242 lines
7.7 KiB
Rust

//! Frontend-agnostic command dispatch for swactor runtimes.
//!
//! Provides [`CommandRouter`] that maps command names to [`CommandHandler`]
//! implementations, with built-in commands for runtime inspection and management.
//!
//! # Architecture
//!
//! ```text
//! Frontend (REPL, REST, TUI, WebSocket)
//! │
//! ▼
//! CommandRouter::dispatch(CommandRequest, CommandContext)
//! │
//! ├── built-in handlers (overview, workers, actors, …)
//! └── custom handlers (user-registered)
//! ```
pub mod builtins;
mod parse;
pub use parse::{from_query_params, parse_line};
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use swactor::runtime::Runtime;
use swactor::stats::RuntimeStats;
// ─── Core Types ──────────────────────────────────────────────────────────────
/// A command request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandRequest {
pub command: String,
pub args: HashMap<String, serde_json::Value>,
}
/// A command response. Always JSON-serializable.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResponse {
pub ok: bool,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl CommandResponse {
pub fn ok(command: &str, data: impl Serialize) -> Self {
Self {
ok: true,
command: command.to_string(),
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
error: None,
}
}
pub fn err(command: &str, msg: impl Into<String>) -> Self {
Self {
ok: false,
command: command.to_string(),
data: None,
error: Some(msg.into()),
}
}
/// Serialize to a single JSON line (for REPL/wire protocol).
pub fn to_json_line(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|e| {
format!(r#"{{"ok":false,"command":"","error":"serialization: {e}"}}"#)
})
}
}
// ─── Handler Trait ───────────────────────────────────────────────────────────
/// Metadata about a command, used for help text and validation.
pub struct CommandMeta {
pub name: &'static str,
pub description: &'static str,
pub usage: &'static str,
pub is_write: bool,
}
/// A command handler. Implementations are stateless — all state
/// comes through [`CommandContext`].
pub trait CommandHandler: Send + Sync {
fn meta(&self) -> CommandMeta;
fn handle(
&self,
args: &HashMap<String, serde_json::Value>,
ctx: &CommandContext,
) -> CommandResponse;
}
// ─── Stats Enrichment ────────────────────────────────────────────────────────
/// Enriches [`RuntimeStats`] with per-actor detail.
///
/// Implement this on your stats collector so command handlers can access
/// enriched data without depending on the dashboard crate.
pub trait StatsEnricher: Send + Sync {
fn enrich(&self, stats: &mut RuntimeStats);
}
// ─── Context ─────────────────────────────────────────────────────────────────
/// Context available to command handlers.
pub struct CommandContext {
pub runtime: Arc<Runtime>,
pub enricher: Option<Arc<dyn StatsEnricher>>,
}
impl CommandContext {
pub fn new(runtime: Arc<Runtime>) -> Self {
Self {
runtime,
enricher: None,
}
}
pub fn with_enricher(
runtime: Arc<Runtime>,
enricher: Arc<dyn StatsEnricher>,
) -> Self {
Self {
runtime,
enricher: Some(enricher),
}
}
/// Raw runtime stats (no enrichment).
pub fn stats(&self) -> RuntimeStats {
self.runtime.stats()
}
/// Runtime stats enriched with per-actor detail (if an enricher is set).
pub fn enriched_stats(&self) -> RuntimeStats {
let mut s = self.runtime.stats();
if let Some(e) = &self.enricher {
e.enrich(&mut s);
}
s
}
}
// ─── Router ──────────────────────────────────────────────────────────────────
/// Central command dispatch.
pub struct CommandRouter {
handlers: HashMap<String, Box<dyn CommandHandler>>,
}
impl CommandRouter {
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
}
}
/// Create a router with all built-in commands registered.
pub fn with_builtins() -> Self {
let mut router = Self::new();
router.register(Box::new(builtins::OverviewCommand));
router.register(Box::new(builtins::WorkersCommand));
router.register(Box::new(builtins::WorkerCommand));
router.register(Box::new(builtins::ActorsCommand));
router.register(Box::new(builtins::ActorCommand));
router.register(Box::new(builtins::HotCommand));
router.register(Box::new(builtins::PhasesCommand));
router.register(Box::new(builtins::DiffCommand));
router.register(Box::new(builtins::ShutdownCommand));
router
}
/// Register a custom command handler.
pub fn register(&mut self, handler: Box<dyn CommandHandler>) {
let name = handler.meta().name.to_string();
self.handlers.insert(name, handler);
}
/// Dispatch a command request.
///
/// The `help` command is handled directly by the router (it needs
/// access to all registered handlers).
pub fn dispatch(
&self,
req: &CommandRequest,
ctx: &CommandContext,
) -> CommandResponse {
if req.command == "help" {
return self.cmd_help();
}
match self.handlers.get(&req.command) {
Some(handler) => handler.handle(&req.args, ctx),
None => CommandResponse::err(
&req.command,
format!("unknown command `{}` — try `help`", req.command),
),
}
}
fn cmd_help(&self) -> CommandResponse {
let mut commands: Vec<serde_json::Value> = self
.handlers
.values()
.map(|h| {
let m = h.meta();
serde_json::json!({
"name": m.name,
"usage": m.usage,
"description": m.description,
"is_write": m.is_write,
})
})
.collect();
// Add help itself
commands.push(serde_json::json!({
"name": "help",
"usage": "help",
"description": "List all available commands",
"is_write": false,
}));
commands.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
});
CommandResponse::ok("help", serde_json::json!({ "commands": commands }))
}
/// List names of all registered commands (sorted).
pub fn command_names(&self) -> Vec<&str> {
let mut names: Vec<_> = self.handlers.keys().map(|s| s.as_str()).collect();
names.push("help");
names.sort();
names
}
}