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>
94ea0eaa539cf93d58167ea1687b5a242b196573 · ruwaq sergio · 2026-05-20T15:05:03Z (5 killa ñawpaq) · tayta 590572b5b · kay commitpi sach'ata qhaway
Semántico sayk'uy · 8
crates/apps/sandokan/src/main.rs
crates/runtime/sandokan-core/src/intent.rs
diff --git a/Cargo.lock b/Cargo.lock
index 5ea7e91..042b4a4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10139,6 +10139,18 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "sandokan-cli"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "brahman-card",
+ "clap",
+ "sandokan",
+ "tokio",
+ "ulid",
+]
+
[[package]]
name = "sandokan-core"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 6798717..14a3afd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -177,6 +177,7 @@ members = [
# ============================================================
"crates/apps/brahman-broker-explorer",
"crates/apps/brahman-demo",
+ "crates/apps/sandokan",
"crates/apps/nahual-shell",
"crates/apps/nahual-file-explorer",
"crates/apps/nahual-database-explorer",
diff --git a/crates/apps/sandokan/Cargo.toml b/crates/apps/sandokan/Cargo.toml
new file mode 100644
index 0000000..4973a95
--- /dev/null
+++ b/crates/apps/sandokan/Cargo.toml
@@ -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 }
diff --git a/crates/apps/sandokan/src/main.rs b/crates/apps/sandokan/src/main.rs
new file mode 100644
index 0000000..961e19e
--- /dev/null
+++ b/crates/apps/sandokan/src/main.rs
@@ -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}"))
+}
diff --git a/crates/runtime/sandokan-core/src/intent.rs b/crates/runtime/sandokan-core/src/intent.rs
index 7151af0..2fb278a 100644
--- a/crates/runtime/sandokan-core/src/intent.rs
+++ b/crates/runtime/sandokan-core/src/intent.rs
@@ -32,11 +32,30 @@ pub struct ExecContext {
/// Una intención de ejecución: la `Card` a encarnar + su contexto.
#[derive(Debug, Clone, Serialize, Deserialize)]
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,
#[serde(default)]
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 {
/// Intención mínima: encarnar una Card con el contexto por defecto.
pub fn new(card: Card) -> Self {
diff --git a/nohup.out b/nohup.out
index 23dff26..fcc1e82 100644
--- a/nohup.out
+++ b/nohup.out
@@ -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
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
+