feat(sandokan): B1.5 — umbrella + Engine::auto()
Crate sandokan (umbrella): re-exporta core/local/daemon y provee la
selección de transporte.
- auto(socket) — patrón "el primero que arranca gana": prueba si hay
un daemon escuchando; si lo hay devuelve DaemonEngine, si no
LocalEngine. Box<dyn Engine> (el trait es object-safe vía async_trait).
- auto_default() — auto() con default_socket_path().
- default_socket_path() — $XDG_RUNTIME_DIR/sandokan.sock o
/run/brahman/sandokan.sock.
3 tests: fallback a Local sin daemon, pick Daemon con serve() activo,
default path absoluto. cargo check --workspace verde.
sandokan ya es usable end-to-end en modo local y daemon. Falta
RemoteEngine (B1.4, depende de brahman-ssh-multiplex).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8cd8003dd5fa3de2409835f819886b876b49967f · por sergio · 2026-05-20T14:05:32Z (hace 5 meses) · padre b7d9d7abd · ver el árbol en este commit
Delta semántico · 7
crates/runtime/sandokan/src/lib.rs
diff --git a/Cargo.lock b/Cargo.lock
index 6a0aeff..5ea7e91 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10127,6 +10127,18 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "sandokan"
+version = "0.1.0"
+dependencies = [
+ "sandokan-core",
+ "sandokan-daemon",
+ "sandokan-lifecycle",
+ "sandokan-local",
+ "tempfile",
+ "tokio",
+]
+
[[package]]
name = "sandokan-core"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index cfb8ffe..6798717 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,6 +38,7 @@ members = [
"crates/runtime/sandokan-core",
"crates/runtime/sandokan-local",
"crates/runtime/sandokan-daemon",
+ "crates/runtime/sandokan",
# ============================================================
# compat/ — Shims D-Bus para correr software systemd-aware
diff --git a/crates/runtime/sandokan/Cargo.toml b/crates/runtime/sandokan/Cargo.toml
new file mode 100644
index 0000000..66786bd
--- /dev/null
+++ b/crates/runtime/sandokan/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "sandokan"
+description = "Orquestador sandokan (umbrella): re-exporta core/local/daemon + Engine::auto() que elige transporte según haya un daemon escuchando."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+sandokan-core = { path = "../sandokan-core" }
+sandokan-lifecycle = { path = "../sandokan-lifecycle" }
+sandokan-local = { path = "../sandokan-local" }
+sandokan-daemon = { path = "../sandokan-daemon" }
+tokio = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
diff --git a/crates/runtime/sandokan/src/lib.rs b/crates/runtime/sandokan/src/lib.rs
new file mode 100644
index 0000000..afd72f5
--- /dev/null
+++ b/crates/runtime/sandokan/src/lib.rs
@@ -0,0 +1,97 @@
+//! sandokan — el orquestador del ecosistema brahman (umbrella).
+//!
+//! `sandokan` es una **library horizontal embebible**, no un daemon
+//! supremo. Cualquier binario lo embebe y elige cómo correrlo:
+//!
+//! - [`LocalEngine`] — orquesta in-process (encarna Cards localmente).
+//! - [`DaemonEngine`] — delega a otro proceso vía Unix socket.
+//! - `RemoteEngine` — delega a otro host vía SSH (crate `sandokan-remote`).
+//!
+//! [`auto`] implementa el patrón "el primero que arranca gana": prueba
+//! si hay un daemon escuchando y, si lo hay, se le suma como
+//! `DaemonEngine`; si no, corre su propio `LocalEngine`.
+
+pub use sandokan_core::{
+ Engine, EngineError, ExecContext, ExecHandle, Intent, IsolationLevel,
+ LifecycleEvent, TelemetryFrame,
+};
+pub use sandokan_daemon::{serve, DaemonEngine, DaemonRequest, DaemonResponse};
+pub use sandokan_local::LocalEngine;
+
+/// Re-export de las primitivas de lifecycle.
+pub use sandokan_lifecycle as lifecycle;
+
+use std::path::{Path, PathBuf};
+
+/// Path por defecto del socket del daemon sandokan.
+///
+/// `$XDG_RUNTIME_DIR/sandokan.sock` si la variable está; si no,
+/// `/run/brahman/sandokan.sock`.
+pub fn default_socket_path() -> PathBuf {
+ match std::env::var_os("XDG_RUNTIME_DIR") {
+ Some(rt) => PathBuf::from(rt).join("sandokan.sock"),
+ None => PathBuf::from("/run/brahman/sandokan.sock"),
+ }
+}
+
+/// Elige el engine según el entorno: si hay un daemon escuchando en
+/// `socket_path`, devuelve un [`DaemonEngine`] (delega); si no, un
+/// [`LocalEngine`] (orquesta in-process).
+pub async fn auto(socket_path: &Path) -> Box<dyn Engine> {
+ let daemon = DaemonEngine::new(socket_path);
+ if daemon.is_reachable().await {
+ Box::new(daemon)
+ } else {
+ Box::new(LocalEngine::new())
+ }
+}
+
+/// [`auto`] con [`default_socket_path`].
+pub async fn auto_default() -> Box<dyn Engine> {
+ auto(&default_socket_path()).await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+ use std::time::Duration;
+
+ #[tokio::test]
+ async fn auto_falls_back_to_local_without_daemon() {
+ // Socket inexistente → debe caer a LocalEngine (list() vacío).
+ let dir = tempfile::tempdir().unwrap();
+ let sock = dir.path().join("nope.sock");
+ let engine = auto(&sock).await;
+ assert!(engine.list().await.unwrap().is_empty());
+ }
+
+ #[tokio::test]
+ async fn auto_picks_daemon_when_listening() {
+ let dir = tempfile::tempdir().unwrap();
+ let sock = dir.path().join("sandokan.sock");
+
+ let served = Arc::new(LocalEngine::new());
+ let sock_srv = sock.clone();
+ let srv = tokio::spawn(async move {
+ let _ = serve(served, &sock_srv).await;
+ });
+ for _ in 0..50 {
+ if sock.exists() {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(10)).await;
+ }
+
+ // Con el daemon escuchando, auto() debe conectar y operar vía wire.
+ let engine = auto(&sock).await;
+ assert!(engine.list().await.unwrap().is_empty());
+
+ srv.abort();
+ }
+
+ #[test]
+ fn default_socket_path_is_absolute() {
+ assert!(default_socket_path().is_absolute());
+ }
+}