feat(sandokan-remote): B1.4 — RemoteEngine vía SSH socket-forward
1//! sandokan — el orquestador del ecosistema brahman (umbrella).
2//!
3//! `sandokan` es una **library horizontal embebible**, no un daemon
4//! supremo. Cualquier binario lo embebe y elige cómo correrlo:
5//!
6//! - [`LocalEngine`] — orquesta in-process (encarna Cards localmente).
7//! - [`DaemonEngine`] — delega a otro proceso vía Unix socket.
8//! - `RemoteEngine` — delega a otro host vía SSH (crate `sandokan-remote`).
9//!
10//! [`auto`] implementa el patrón "el primero que arranca gana": prueba
11//! si hay un daemon escuchando y, si lo hay, se le suma como
12//! `DaemonEngine`; si no, corre su propio `LocalEngine`.
13
14pub use sandokan_core::{
15 Engine, EngineError, ExecContext, ExecHandle, Intent, IsolationLevel,
16 LifecycleEvent, TelemetryFrame,
17};
18pub use sandokan_daemon::{serve, DaemonEngine, DaemonRequest, DaemonResponse};
19pub use sandokan_local::LocalEngine;
20pub use sandokan_remote::RemoteEngine;
21
22/// Re-export de las primitivas de lifecycle.
23pub use sandokan_lifecycle as lifecycle;
24
25use std::path::{Path, PathBuf};
26
27/// Path por defecto del socket del daemon sandokan.
28///
29/// `$XDG_RUNTIME_DIR/sandokan.sock` si la variable está; si no,
30/// `/run/brahman/sandokan.sock`.
31pub fn default_socket_path() -> PathBuf {
32 match std::env::var_os("XDG_RUNTIME_DIR") {
33 Some(rt) => PathBuf::from(rt).join("sandokan.sock"),
34 None => PathBuf::from("/run/brahman/sandokan.sock"),
35 }
36}
37
38/// Elige el engine según el entorno: si hay un daemon escuchando en
39/// `socket_path`, devuelve un [`DaemonEngine`] (delega); si no, un
40/// [`LocalEngine`] (orquesta in-process).
41pub async fn auto(socket_path: &Path) -> Box<dyn Engine> {
42 let daemon = DaemonEngine::new(socket_path);
43 if daemon.is_reachable().await {
44 Box::new(daemon)
45 } else {
46 Box::new(LocalEngine::new())
47 }
48}
49
50/// [`auto`] con [`default_socket_path`].
51pub async fn auto_default() -> Box<dyn Engine> {
52 auto(&default_socket_path()).await
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58 use std::sync::Arc;
59 use std::time::Duration;
60
61 #[tokio::test]
62 async fn auto_falls_back_to_local_without_daemon() {
63 // Socket inexistente → debe caer a LocalEngine (list() vacío).
64 let dir = tempfile::tempdir().unwrap();
65 let sock = dir.path().join("nope.sock");
66 let engine = auto(&sock).await;
67 assert!(engine.list().await.unwrap().is_empty());
68 }
69
70 #[tokio::test]
71 async fn auto_picks_daemon_when_listening() {
72 let dir = tempfile::tempdir().unwrap();
73 let sock = dir.path().join("sandokan.sock");
74
75 let served = Arc::new(LocalEngine::new());
76 let sock_srv = sock.clone();
77 let srv = tokio::spawn(async move {
78 let _ = serve(served, &sock_srv).await;
79 });
80 for _ in 0..50 {
81 if sock.exists() {
82 break;
83 }
84 tokio::time::sleep(Duration::from_millis(10)).await;
85 }
86
87 // Con el daemon escuchando, auto() debe conectar y operar vía wire.
88 let engine = auto(&sock).await;
89 assert!(engine.list().await.unwrap().is_empty());
90
91 srv.abort();
92 }
93
94 #[test]
95 fn default_socket_path_is_absolute() {
96 assert!(default_socket_path().is_absolute());
97 }
98}