feat(sandokan): CLI de prueba + fix wire serialization

apps/sandokan (binario `sandokan`): CLI para probar el orquestador.
Subcomandos: daemon, run <exec> [args], list, status, telemetry, stop.

Fix: Intent serializaba Card directo, pero Card tiene un campo
`#[serde(flatten)] extensions` incompatible con postcard ("sequence
length must be known"). Intent::card ahora usa #[serde(with)] que
proyecta Card↔WireCard en el límite de serialización (las extensions
locales se descartan al cruzar el wire — comportamiento correcto).

Smoke test verificado end-to-end: daemon + run /bin/sleep + list +
status Running + telemetry + stop + status Killed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 15:05:03 +00:00
parent 590572b5bb
commit 94ea0eaa53
6 changed files with 182 additions and 0 deletions
Generated
+12
View File
@@ -10139,6 +10139,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "sandokan-cli"
version = "0.1.0"
dependencies = [
"anyhow",
"brahman-card",
"clap",
"sandokan",
"tokio",
"ulid",
]
[[package]] [[package]]
name = "sandokan-core" name = "sandokan-core"
version = "0.1.0" version = "0.1.0"
+1
View File
@@ -177,6 +177,7 @@ members = [
# ============================================================ # ============================================================
"crates/apps/brahman-broker-explorer", "crates/apps/brahman-broker-explorer",
"crates/apps/brahman-demo", "crates/apps/brahman-demo",
"crates/apps/sandokan",
"crates/apps/nahual-shell", "crates/apps/nahual-shell",
"crates/apps/nahual-file-explorer", "crates/apps/nahual-file-explorer",
"crates/apps/nahual-database-explorer", "crates/apps/nahual-database-explorer",
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "sandokan-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "CLI de prueba del orquestador sandokan: daemon + run/list/status/stop/telemetry."
[[bin]]
name = "sandokan"
path = "src/main.rs"
[dependencies]
sandokan = { path = "../../runtime/sandokan" }
brahman-card = { path = "../../protocol/brahman-card" }
clap = { workspace = true }
tokio = { workspace = true }
ulid = { workspace = true }
anyhow = { workspace = true }
+125
View File
@@ -0,0 +1,125 @@
//! `sandokan` — CLI de prueba del orquestador.
//!
//! Uso típico (dos terminales):
//! terminal 1: sandokan daemon
//! terminal 2: sandokan run /bin/sleep 300
//! sandokan list
//! sandokan status <card-id>
//! sandokan stop <card-id>
//!
//! Sin daemon, `run` igual encarna el proceso, pero el registro vive en
//! el proceso del CLI y se pierde al salir — `list` no lo verá. Para
//! probar el lifecycle completo, corré el daemon primero.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use brahman_card::{Card, Payload};
use clap::{Parser, Subcommand};
use sandokan::{auto, default_socket_path, serve, Intent, LocalEngine};
use ulid::Ulid;
#[derive(Parser)]
#[command(name = "sandokan", about = "Orquestador brahman — CLI de prueba")]
struct Cli {
/// Socket del daemon (default: $XDG_RUNTIME_DIR/sandokan.sock).
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Corre el daemon: sirve un LocalEngine sobre el socket.
Daemon,
/// Encarna un ejecutable como Card y lo orquesta.
Run {
/// Ruta del ejecutable.
exec: String,
/// Argumentos del ejecutable.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Lista las entidades activas.
List,
/// Estado de una entidad.
Status { card_id: String },
/// Telemetría puntual de una entidad.
Telemetry { card_id: String },
/// Detiene una entidad (SIGTERM + gracia + SIGKILL).
Stop {
card_id: String,
#[arg(long, default_value = "1000")]
grace_ms: u64,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let socket = cli.socket.unwrap_or_else(default_socket_path);
match cli.cmd {
Cmd::Daemon => {
if let Some(parent) = socket.parent() {
let _ = std::fs::create_dir_all(parent);
}
println!("sandokan-daemon escuchando en {}", socket.display());
println!("(Ctrl-C para salir)");
let engine = Arc::new(LocalEngine::new());
serve(engine, &socket).await?;
}
Cmd::Run { exec, args } => {
let mut card = Card::new(format!("run:{exec}"));
card.payload = Payload::Native {
exec,
argv: args,
envp: vec![],
};
let engine = auto(&socket).await;
let handle = engine.run(Intent::new(card)).await?;
println!("encarnado:");
println!(" card_id : {}", handle.card_id);
println!(" label : {}", handle.label);
}
Cmd::List => {
let engine = auto(&socket).await;
let list = engine.list().await?;
if list.is_empty() {
println!("(sin entidades activas)");
}
for h in list {
println!("{} {}", h.card_id, h.label);
}
}
Cmd::Status { card_id } => {
let engine = auto(&socket).await;
let state = engine.status(parse_id(&card_id)?).await?;
println!("{state:?}");
}
Cmd::Telemetry { card_id } => {
let engine = auto(&socket).await;
let t = engine.telemetry(parse_id(&card_id)?).await?;
println!(
"mem={} KiB nproc={} cpu={:.1}%",
t.mem_bytes / 1024,
t.nproc,
t.cpu_pct
);
}
Cmd::Stop { card_id, grace_ms } => {
let engine = auto(&socket).await;
engine
.stop(parse_id(&card_id)?, Duration::from_millis(grace_ms))
.await?;
println!("detenido");
}
}
Ok(())
}
fn parse_id(s: &str) -> anyhow::Result<Ulid> {
Ulid::from_string(s).map_err(|e| anyhow::anyhow!("card-id inválido `{s}`: {e}"))
}
@@ -32,11 +32,30 @@ pub struct ExecContext {
/// Una intención de ejecución: la `Card` a encarnar + su contexto. /// Una intención de ejecución: la `Card` a encarnar + su contexto.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Intent { pub struct Intent {
/// La Card se serializa vía `WireCard` (proyección postcard-friendly):
/// el campo `extensions` de `Card` usa `#[serde(flatten)]`, que no es
/// compatible con formatos no auto-descriptivos como postcard.
#[serde(with = "card_wire")]
pub card: Card, pub card: Card,
#[serde(default)] #[serde(default)]
pub context: ExecContext, pub context: ExecContext,
} }
/// Serde adapter: `Card` ↔ `WireCard` en el límite de serialización.
/// Las `extensions` locales de la Card se descartan al cruzar el wire.
mod card_wire {
use brahman_card::{Card, WireCard};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(card: &Card, s: S) -> Result<S::Ok, S::Error> {
WireCard::from(card.clone()).serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Card, D::Error> {
Ok(Card::from(WireCard::deserialize(d)?))
}
}
impl Intent { impl Intent {
/// Intención mínima: encarnar una Card con el contexto por defecto. /// Intención mínima: encarnar una Card con el contexto por defecto.
pub fn new(card: Card) -> Self { pub fn new(card: Card) -> Self {
+5
View File
@@ -178,3 +178,8 @@ WARNING: Restricted methods will be blocked in a future release unless native ac
(thunar:793578): thunar-WARNING **: 18:49:46.732: ThunarThumbnailer: failed to create proxy: Cannot autolaunch D-Bus without X11 $DISPLAY (thunar:793578): thunar-WARNING **: 18:49:46.732: ThunarThumbnailer: failed to create proxy: Cannot autolaunch D-Bus without X11 $DISPLAY
Gdk-Message: 05:42:34.451: Error reading events from display: Broken pipe Gdk-Message: 05:42:34.451: Error reading events from display: Broken pipe
[Parent 2593, Main Thread] WARNING: Failed to create DBus proxy for org.a11y.Bus: Cannot autolaunch D-Bus without X11 $DISPLAY
: 'glib warning', file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/toolkit/xre/nsSigHandlers.cpp:201
** (zen:2593): WARNING **: 15:00:19.012: Failed to create DBus proxy for org.a11y.Bus: Cannot autolaunch D-Bus without X11 $DISPLAY