tawasuyu

tawasuyu/brahman

Rust 328TOML 124JSON 17Markdown 144636 Definitions

feat(sandokan-core): B1.1 — contrato del orquestador

Primer crate de la Fase B. Define SOLO el contrato del orquestador
sandokan (library horizontal embebible, no daemon supremo):

- Intent / ExecContext / IsolationLevel — qué orquestar
- ExecHandle — referencia a una entidad encarnada
- LifecycleEvent / TelemetryFrame — observabilidad (wire types)
- EngineError — taxonomía de fallas
- trait Engine — run/stop/list/status/telemetry (poll-based, sin
  streams sobre trait objects, para que las 3 impls lo cumplan
  uniformemente)

Las impls concretas (LocalEngine, DaemonEngine, RemoteEngine) vendrán
en crates separados (sandokan-local, sandokan-daemon, sandokan-remote).

3 tests verdes. cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

af5d4a1f229ee8964b1afa3532da79aef9234739 · by sergio · (5 months ago) · parent f8a2547b4 · browse the tree at this commit

Semantic delta · 22

crates/runtime/sandokan-core/src/engine.rs

crates/runtime/sandokan-core/src/error.rs

crates/runtime/sandokan-core/src/event.rs

crates/runtime/sandokan-core/src/intent.rs

crates/runtime/sandokan-core/src/lib.rs

8 files · +272 −0

diff --git a/Cargo.lock b/Cargo.lock
index 97513ea..ebba677 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10127,6 +10127,18 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "sandokan-core"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "brahman-card",
+ "sandokan-lifecycle",
+ "serde",
+ "thiserror 2.0.18",
+ "ulid",
+]
+
 [[package]]
 name = "sandokan-lifecycle"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index da692e3..ac5cb00 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,6 +35,7 @@ members = [
     "crates/runtime/arje-brain",
     "crates/runtime/arje-echo",
     "crates/runtime/sandokan-lifecycle",
+    "crates/runtime/sandokan-core",
 
     # ============================================================
     # compat/ — Shims D-Bus para correr software systemd-aware
diff --git a/crates/runtime/sandokan-core/Cargo.toml b/crates/runtime/sandokan-core/Cargo.toml
new file mode 100644
index 0000000..a4b1310
--- /dev/null
+++ b/crates/runtime/sandokan-core/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "sandokan-core"
+description = "Contrato del orquestador sandokan: trait Engine + tipos (Intent, ExecHandle, eventos, telemetría). Sin transporte ni impl concreta."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+brahman-card = { path = "../../protocol/brahman-card" }
+sandokan-lifecycle = { path = "../sandokan-lifecycle" }
+serde = { workspace = true }
+ulid = { workspace = true }
+thiserror = { workspace = true }
+async-trait = { workspace = true }
diff --git a/crates/runtime/sandokan-core/src/engine.rs b/crates/runtime/sandokan-core/src/engine.rs
new file mode 100644
index 0000000..9d5270b
--- /dev/null
+++ b/crates/runtime/sandokan-core/src/engine.rs
@@ -0,0 +1,39 @@
+//! El trait `Engine` — contrato uniforme del orquestador.
+
+use crate::error::EngineError;
+use crate::event::TelemetryFrame;
+use crate::intent::{ExecHandle, Intent};
+use async_trait::async_trait;
+use sandokan_lifecycle::LifecycleState;
+use std::time::Duration;
+use ulid::Ulid;
+
+/// El orquestador. Tres implementaciones lo cumplen de forma intercambiable:
+///
+/// - `LocalEngine` — encarna in-process (`arje-incarnate` + `arje-brain-rules`).
+/// - `DaemonEngine` — delega a otro proceso vía Unix socket (postcard).
+/// - `RemoteEngine` — delega a otro host vía `brahman-ssh-multiplex`.
+///
+/// Un helper `Engine::auto()` (en `sandokan-local`) prueba si hay un
+/// daemon escuchando y elige `DaemonEngine`; si no, `LocalEngine`.
+///
+/// El contrato es poll-based (sin streams) para que las tres impls lo
+/// cumplan uniformemente sin complejidad de `Stream` sobre trait objects.
+#[async_trait]
+pub trait Engine: Send + Sync {
+    /// Encarna una intención. Devuelve un handle a la entidad corriendo.
+    async fn run(&self, intent: Intent) -> Result<ExecHandle, EngineError>;
+
+    /// Detiene una entidad con período de gracia (SIGTERM → espera →
+    /// SIGKILL). `grace == 0` = kill inmediato.
+    async fn stop(&self, card_id: Ulid, grace: Duration) -> Result<(), EngineError>;
+
+    /// Lista las entidades actualmente activas.
+    async fn list(&self) -> Result<Vec<ExecHandle>, EngineError>;
+
+    /// Estado actual de una entidad.
+    async fn status(&self, card_id: Ulid) -> Result<LifecycleState, EngineError>;
+
+    /// Telemetría puntual de una entidad.
+    async fn telemetry(&self, card_id: Ulid) -> Result<TelemetryFrame, EngineError>;
+}
diff --git a/crates/runtime/sandokan-core/src/error.rs b/crates/runtime/sandokan-core/src/error.rs
new file mode 100644
index 0000000..67a51ce
--- /dev/null
+++ b/crates/runtime/sandokan-core/src/error.rs
@@ -0,0 +1,28 @@
+//! Errores del orquestador.
+
+use ulid::Ulid;
+
+/// Falla de una operación del `Engine`. Las impls concretas mapean sus
+/// errores internos (encarnación, IPC, SSH) a estas variantes.
+#[derive(Debug, thiserror::Error)]
+pub enum EngineError {
+    /// No existe ninguna entidad activa con ese `card_id`.
+    #[error("card `{0}` no encontrada")]
+    NotFound(Ulid),
+
+    /// La encarnación falló (clone/namespaces/exec).
+    #[error("encarnación falló: {0}")]
+    Incarnate(String),
+
+    /// Falla de transporte (Unix socket del daemon, túnel SSH).
+    #[error("transporte: {0}")]
+    Transport(String),
+
+    /// La intención es inconsistente (Card inválida, contexto imposible).
+    #[error("intención inválida: {0}")]
+    InvalidIntent(String),
+
+    /// La operación excedió su deadline.
+    #[error("timeout")]
+    Timeout,
+}
diff --git a/crates/runtime/sandokan-core/src/event.rs b/crates/runtime/sandokan-core/src/event.rs
new file mode 100644
index 0000000..bf0854e
--- /dev/null
+++ b/crates/runtime/sandokan-core/src/event.rs
@@ -0,0 +1,57 @@
+//! Observabilidad: eventos de ciclo de vida y frames de telemetría.
+
+use sandokan_lifecycle::LifecycleState;
+use serde::{Deserialize, Serialize};
+use std::time::SystemTime;
+use ulid::Ulid;
+
+/// Un evento en la vida de una entidad encarnada. El orquestador los
+/// emite; los consumidores (shells, paneles) reaccionan.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum LifecycleEvent {
+    /// La entidad fue encarnada. `pid` es `None` si no aplica (Wasm, virtual).
+    Spawned { card_id: Ulid, pid: Option<i32> },
+    /// El estado de la entidad cambió.
+    StateChanged { card_id: Ulid, state: LifecycleState },
+    /// La entidad terminó (estado terminal).
+    Exited { card_id: Ulid, state: LifecycleState },
+}
+
+impl LifecycleEvent {
+    /// El `card_id` al que refiere el evento.
+    pub fn card_id(&self) -> Ulid {
+        match self {
+            LifecycleEvent::Spawned { card_id, .. }
+            | LifecycleEvent::StateChanged { card_id, .. }
+            | LifecycleEvent::Exited { card_id, .. } => *card_id,
+        }
+    }
+}
+
+/// Una medición puntual de recursos de una entidad. Los campos se
+/// inlinean (en vez de reusar `sandokan_lifecycle::ResourceUsage`) para
+/// que el frame sea un wire type estable e independiente.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TelemetryFrame {
+    pub card_id: Ulid,
+    pub at: SystemTime,
+    pub mem_bytes: u64,
+    pub nproc: u32,
+    /// Porcentaje de CPU (100.0 = 1 core saturado).
+    pub cpu_pct: f64,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn event_card_id_accessor() {
+        let id = Ulid::new();
+        let ev = LifecycleEvent::Exited {
+            card_id: id,
+            state: LifecycleState::Exited { code: 0 },
+        };
+        assert_eq!(ev.card_id(), id);
+    }
+}
diff --git a/crates/runtime/sandokan-core/src/intent.rs b/crates/runtime/sandokan-core/src/intent.rs
new file mode 100644
index 0000000..7151af0
--- /dev/null
+++ b/crates/runtime/sandokan-core/src/intent.rs
@@ -0,0 +1,93 @@
+//! Qué orquestar: la intención de ejecución y su contexto.
+
+use brahman_card::Card;
+use serde::{Deserialize, Serialize};
+use std::time::{Duration, SystemTime};
+use ulid::Ulid;
+
+/// Nivel de aislamiento pedido para una encarnación.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub enum IsolationLevel {
+    /// Sin sandbox — mismo namespace que el orquestador.
+    None,
+    /// Namespaces estándar (pid/mount/net/...) según `Card.soma`.
+    #[default]
+    Standard,
+    /// Namespaces + rootfs aislado (`pivot_root` + OverlayFS).
+    Sealed,
+}
+
+/// Contexto de ejecución: ajustes sobre cómo encarnar la Card.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct ExecContext {
+    /// Aislamiento pedido. `None` = derivar de `Card.soma`.
+    pub isolation: Option<IsolationLevel>,
+    /// Variables de entorno adicionales (sobre las del Card).
+    pub env: Vec<(String, String)>,
+    /// Time-to-live opcional: si se setea, el orquestador detiene la
+    /// entidad al vencer.
+    pub ttl: Option<Duration>,
+}
+
+/// Una intención de ejecución: la `Card` a encarnar + su contexto.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Intent {
+    pub card: Card,
+    #[serde(default)]
+    pub context: ExecContext,
+}
+
+impl Intent {
+    /// Intención mínima: encarnar una Card con el contexto por defecto.
+    pub fn new(card: Card) -> Self {
+        Self { card, context: ExecContext::default() }
+    }
+
+    /// Builder: fija el nivel de aislamiento.
+    pub fn with_isolation(mut self, level: IsolationLevel) -> Self {
+        self.context.isolation = Some(level);
+        self
+    }
+
+    /// Builder: fija un TTL.
+    pub fn with_ttl(mut self, ttl: Duration) -> Self {
+        self.context.ttl = Some(ttl);
+        self
+    }
+
+    /// El id de la Card que esta intención encarna.
+    pub fn card_id(&self) -> Ulid {
+        self.card.id
+    }
+}
+
+/// Referencia a una entidad encarnada por el orquestador.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ExecHandle {
+    /// Id de la Card encarnada (identidad estable en el fractal).
+    pub card_id: Ulid,
+    /// Label humano-legible (copiado de `Card.label`).
+    pub label: String,
+    /// Cuándo arrancó.
+    pub started_at: SystemTime,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn isolation_default_is_standard() {
+        assert_eq!(IsolationLevel::default(), IsolationLevel::Standard);
+    }
+
+    #[test]
+    fn intent_builders_compose() {
+        let card = Card::new("demo");
+        let intent = Intent::new(card)
+            .with_isolation(IsolationLevel::Sealed)
+            .with_ttl(Duration::from_secs(30));
+        assert_eq!(intent.context.isolation, Some(IsolationLevel::Sealed));
+        assert_eq!(intent.context.ttl, Some(Duration::from_secs(30)));
+    }
+}
diff --git a/crates/runtime/sandokan-core/src/lib.rs b/crates/runtime/sandokan-core/src/lib.rs
new file mode 100644
index 0000000..bb7c315
--- /dev/null
+++ b/crates/runtime/sandokan-core/src/lib.rs
@@ -0,0 +1,26 @@
+//! sandokan-core — el contrato del orquestador.
+//!
+//! `sandokan` es el orquestador del ecosistema brahman, diseñado como
+//! **library horizontal embebible**, no como daemon supremo. Cualquier
+//! binario (shuma, nahual-shell, matilda, un agente SSH) embebe un
+//! `Engine` y decide si lo corre in-process o delega a otro.
+//!
+//! Este crate define SOLO el contrato:
+//! - [`Intent`] — qué orquestar (una Card + contexto de ejecución).
+//! - [`ExecHandle`] — referencia a una entidad encarnada.
+//! - [`LifecycleEvent`] / [`TelemetryFrame`] — observabilidad.
+//! - [`Engine`] — el trait que `LocalEngine`/`DaemonEngine`/`RemoteEngine`
+//!   implementan.
+//!
+//! Las implementaciones concretas viven en crates separados
+//! (`sandokan-local`, `sandokan-daemon`, `sandokan-remote`).
+
+pub mod engine;
+pub mod error;
+pub mod event;
+pub mod intent;
+
+pub use engine::Engine;
+pub use error::EngineError;
+pub use event::{LifecycleEvent, TelemetryFrame};
+pub use intent::{ExecContext, ExecHandle, Intent, IsolationLevel};