diff --git a/crates/ente-brain/src/introspect.rs b/crates/ente-brain/src/introspect.rs index 5f8ad7c..2590a12 100644 --- a/crates/ente-brain/src/introspect.rs +++ b/crates/ente-brain/src/introspect.rs @@ -423,7 +423,7 @@ impl IntrospectServer { "ReloadRules sin path y sin rules_out configurado".into() ), }; - let rules = match crate::kcl_loader::load_rules_file(&path) { + let rules = match crate::loader::load_rules_file(&path) { Ok(r) => r, Err(e) => return IntrospectResponse::Error(format!("load: {e}")), }; diff --git a/crates/ente-brain/src/kcl_loader.rs b/crates/ente-brain/src/kcl_loader.rs deleted file mode 100644 index cc447a6..0000000 --- a/crates/ente-brain/src/kcl_loader.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Loader de reglas desde archivos `.k` vía subprocess al CLI de KCL. -//! -//! ## ¿Por qué subprocess y no SDK Rust? -//! -//! El SDK Rust de KusionStack KCL (en el monorepo `kcl-lang/kcl`) no se -//! publica como crate independiente en crates.io. Los crates `kcl-*` que -//! sí están publicados (kcl-lib, kcl-api, etc.) pertenecen al proyecto -//! KittyCAD — un lenguaje CAD distinto pese al nombre. Verificado 2026-05. -//! -//! Subprocess al CLI `kcl` (instalable vía `go install kcl-lang.io/cli/cmd/kcl@latest` -//! o desde el release de GitHub) es funcionalmente equivalente al SDK: -//! produce JSON validado contra el schema KCL declarado, sin dependencia -//! de Go runtime en el binario final del fractal. -//! -//! Si `kcl` no está en PATH, el caller decide: cargar JSON crudo (skip KCL), -//! o fallar el boot. -//! -//! ## Formato esperado del .k file -//! -//! ```kcl -//! import .rule # schema/rule.k -//! -//! rules: [Rule] = [ -//! Rule { id = "...", priority = 5, when = ..., then = [...] }, -//! ... -//! ] -//! ``` -//! -//! Salida tras `kcl run --format json`: `{"rules": [...]}`. El loader busca -//! la primera array en el JSON (top-level o anidada un nivel) y la deserializa. - -use crate::rules::Rule; -use ente_card::EntityCard; -use std::path::Path; -use std::process::Command; -use tracing::{debug, info}; - -/// Detecta si `kcl` está disponible en PATH. Útil para degradar a JSON-only -/// en entornos sin la toolchain. -pub fn kcl_available() -> bool { - Command::new("kcl") - .arg("version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -/// Ejecuta `kcl run --format=json` y devuelve el JSON crudo. -pub fn run_kcl(path: &Path) -> anyhow::Result { - let output = Command::new("kcl") - .arg("run") - .arg(path) - .arg("--format=json") - .output() - .map_err(|e| anyhow::anyhow!("invocando `kcl`: {e}. ¿Instalado en PATH?"))?; - if !output.status.success() { - anyhow::bail!( - "kcl run {} falló: {}", - path.display(), - String::from_utf8_lossy(&output.stderr) - ); - } - debug!(path = %path.display(), out_bytes = output.stdout.len(), "kcl run ok"); - Ok(String::from_utf8(output.stdout)?) -} - -/// Carga reglas desde un archivo `.k` o JSON. Discrimina por extensión: -/// `.k` → invoca KCL, `.json` → directo. -pub fn load_rules_file(path: &Path) -> anyhow::Result> { - let raw = match path.extension().and_then(|e| e.to_str()) { - Some("k") => { - info!(path = %path.display(), "cargando reglas vía kcl"); - run_kcl(path)? - } - _ => { - info!(path = %path.display(), "cargando reglas como JSON crudo"); - std::fs::read_to_string(path)? - } - }; - extract_rules_from_json(&raw) -} - -/// Extrae un `Vec` de JSON que puede ser: -/// 1. Array directo: `[{...}, {...}]` -/// 2. Object con un campo array: `{"rules": [...]}` -pub fn extract_rules_from_json(raw: &str) -> anyhow::Result> { - let v: serde_json::Value = serde_json::from_str(raw)?; - let arr = match v { - serde_json::Value::Array(_) => v, - serde_json::Value::Object(map) => { - map.into_values() - .find(|x| x.is_array()) - .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))? - } - _ => anyhow::bail!("JSON debe ser array o object con campo array"), - }; - let rules: Vec = serde_json::from_value(arr)?; - Ok(rules) -} - -// ============================================================================ -// Carga de Cards desde KCL/JSON. Cierra la "puerta genética": ninguna Card -// se acepta sin pasar `validate()` extendido en ente-card. -// ============================================================================ - -/// Carga una `EntityCard` desde un archivo `.k` (vía kcl run) o `.json`. -/// Pasa por `EntityCard::validate()` antes de devolver — falla rápida. -pub fn load_card_file(path: &Path) -> anyhow::Result { - let raw = match path.extension().and_then(|e| e.to_str()) { - Some("k") => { - info!(path = %path.display(), "cargando Card vía kcl"); - run_kcl(path)? - } - _ => { - info!(path = %path.display(), "cargando Card como JSON crudo"); - std::fs::read_to_string(path)? - } - }; - let card = extract_card_from_json(&raw)?; - card.validate() - .map_err(|e| anyhow::anyhow!("Card inválida ({}): {e}", path.display()))?; - Ok(card) -} - -/// Extrae una `EntityCard` de JSON. Acepta: -/// 1. Object directamente serializable como EntityCard -/// 2. Object dict con un único valor que sea EntityCard (KCL output típico) -pub fn extract_card_from_json(raw: &str) -> anyhow::Result { - let v: serde_json::Value = serde_json::from_str(raw)?; - // Intento 1: deserializar el value directamente. - if let Ok(c) = serde_json::from_value::(v.clone()) { - return Ok(c); - } - // Intento 2: si es dict, buscar el primer value que parsee como Card. - if let serde_json::Value::Object(map) = v { - for (_, vv) in map { - if let Ok(c) = serde_json::from_value::(vv) { - return Ok(c); - } - } - } - anyhow::bail!("JSON no contiene una EntityCard válida") -} diff --git a/crates/ente-brain/src/lib.rs b/crates/ente-brain/src/lib.rs index 3723e1b..d9d3447 100644 --- a/crates/ente-brain/src/lib.rs +++ b/crates/ente-brain/src/lib.rs @@ -22,7 +22,7 @@ pub mod crystallize; pub mod dispatch; pub mod engine; pub mod introspect; -pub mod kcl_loader; +pub mod loader; pub mod metrics; pub mod observer; pub mod rules; @@ -32,7 +32,7 @@ pub use crystallize::{detect_crystals, Crystal, CrystallizationParams}; pub use dispatch::{dispatch_actions, ActionSink, NullSink}; pub use engine::{EventKindDiscriminant, RuleEngine, SubjectInfo}; pub use introspect::{IntrospectRequest, IntrospectResponse, IntrospectServer, BrainState}; -pub use kcl_loader::{kcl_available, load_card_file, load_rules_file}; +pub use loader::{load_card_file, load_rules_file}; pub use metrics::serve_metrics; pub use observer::{Observer, TimedEvent}; pub use rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope}; diff --git a/crates/ente-brain/src/loader.rs b/crates/ente-brain/src/loader.rs new file mode 100644 index 0000000..d7b7e48 --- /dev/null +++ b/crates/ente-brain/src/loader.rs @@ -0,0 +1,68 @@ +//! Loader de Cards y Reglas desde archivos JSON. +//! +//! Sustituye al antiguo `kcl_loader.rs` (eliminado): la rama KCL invocaba +//! un subprocess al CLI Go `kcl` que ningún target real tenía instalado y +//! cuya validación duplicaba `EntityCard::validate()`. La fuente de verdad +//! del shape de la Card es Rust + serde; en disco se guarda JSON crudo. +//! +//! Ergonomía de autoría futura (RON, Dhall, etc.) se añade como ramas +//! adicionales aquí cuando duela escribir JSON a mano. Hoy: una sola rama. + +use crate::rules::Rule; +use ente_card::EntityCard; +use std::path::Path; +use tracing::info; + +/// Carga una `EntityCard` desde un archivo JSON. Pasa por +/// `EntityCard::validate()` antes de devolver — falla rápida. +pub fn load_card_file(path: &Path) -> anyhow::Result { + info!(path = %path.display(), "cargando Card desde JSON"); + let raw = std::fs::read_to_string(path)?; + let card = extract_card_from_json(&raw)?; + card.validate() + .map_err(|e| anyhow::anyhow!("Card inválida ({}): {e}", path.display()))?; + Ok(card) +} + +/// Extrae una `EntityCard` de JSON. Acepta: +/// 1. Object directamente serializable como EntityCard. +/// 2. Object dict con un único valor que sea EntityCard (compat con +/// salidas de generadores que envuelven en `{"seed": {...}}`). +pub fn extract_card_from_json(raw: &str) -> anyhow::Result { + let v: serde_json::Value = serde_json::from_str(raw)?; + if let Ok(c) = serde_json::from_value::(v.clone()) { + return Ok(c); + } + if let serde_json::Value::Object(map) = v { + for (_, vv) in map { + if let Ok(c) = serde_json::from_value::(vv) { + return Ok(c); + } + } + } + anyhow::bail!("JSON no contiene una EntityCard válida") +} + +/// Carga reglas desde un archivo JSON. +pub fn load_rules_file(path: &Path) -> anyhow::Result> { + info!(path = %path.display(), "cargando reglas desde JSON"); + let raw = std::fs::read_to_string(path)?; + extract_rules_from_json(&raw) +} + +/// Extrae un `Vec` de JSON que puede ser: +/// 1. Array directo: `[{...}, {...}]` +/// 2. Object con un campo array: `{"rules": [...]}` +pub fn extract_rules_from_json(raw: &str) -> anyhow::Result> { + let v: serde_json::Value = serde_json::from_str(raw)?; + let arr = match v { + serde_json::Value::Array(_) => v, + serde_json::Value::Object(map) => map + .into_values() + .find(|x| x.is_array()) + .ok_or_else(|| anyhow::anyhow!("JSON no contiene ningún array"))?, + _ => anyhow::bail!("JSON debe ser array o object con campo array"), + }; + let rules: Vec = serde_json::from_value(arr)?; + Ok(rules) +} diff --git a/crates/ente-zero/src/seed.rs b/crates/ente-zero/src/seed.rs index 2b9c736..376bfcf 100644 --- a/crates/ente-zero/src/seed.rs +++ b/crates/ente-zero/src/seed.rs @@ -3,7 +3,7 @@ //! Tres caminos: //! 1. `--restore `: leer `FractalSnapshot` y reconstruir Semilla //! con seed_id preservado + entes anteriores como genesis. -//! 2. `seed.card` en disco: deserialize directo (prod o dev). +//! 2. `seed.card.json` en disco: deserialize directo (prod o dev). //! 3. Fallback dev: sintetizar Semilla + 6 genesis Entes que ejercitan //! todas las capacidades del fractal. @@ -63,13 +63,14 @@ fn load_from_snapshot(path: &Path) -> anyhow::Result { } fn load_or_synthesize(dev_mode: bool) -> anyhow::Result { - // Buscamos primero `.k` (KCL canónico, validado por su schema), luego - // `.json` para compatibilidad. La puerta genética se cruza vía - // `ente_brain::load_card_file` que pasa por `validate()` extendido. + // Buscamos primero `.json` (canónico), luego sin extensión por + // compatibilidad con instalaciones que dejan el archivo crudo. La puerta + // genética se cruza vía `ente_brain::load_card_file` que pasa por + // `validate()` extendido. let candidates: &[&str] = if dev_mode { - &["seed.card.k", SEED_PATH_DEV] + &["seed.card.json", SEED_PATH_DEV] } else { - &["/ente/seed.card.k", SEED_PATH_PROD] + &["/ente/seed.card.json", SEED_PATH_PROD] }; for cand in candidates { let path = PathBuf::from(cand); @@ -83,7 +84,7 @@ fn load_or_synthesize(dev_mode: bool) -> anyhow::Result { info!("sin seed.card — sintetizando semilla mínima (dev)"); return Ok(synthesize_dev_seed()); } - anyhow::bail!("seed.card no encontrada en /ente/seed.card[.k]") + anyhow::bail!("seed.card no encontrada en /ente/seed.card.json ni /ente/seed.card") } fn synthesize_dev_seed() -> EntityCard { diff --git a/docs/gnome-boot-test.md b/docs/gnome-boot-test.md index bb04f06..c48f5e4 100644 --- a/docs/gnome-boot-test.md +++ b/docs/gnome-boot-test.md @@ -35,14 +35,14 @@ escritorio realiza durante la sesión. - `ente-echo`, `brainctl`, `busctl`, `ente-journalctl` 3. Renombra el `/init` original a `/sbin/init.systemd` (backup) 4. Symlink `/init` → `/usr/local/bin/ente-zero` -5. Coloca la Card Semilla en `/ente/seed.card.k` +5. Coloca la Card Semilla en `/ente/seed.card.json` 6. Desinstala los services systemd que ahora son shims (logind, etc) o los enmascara con `systemctl mask` (en la imagen base, antes de reescribir `/init`) ## Card Semilla para el boot test -`/ente/seed.card.k` debe declarar como genesis los Entes esenciales: +`/ente/seed.card.json` debe declarar como genesis los Entes esenciales: - D-Bus daemon (`/usr/bin/dbus-daemon --system`) - Los 8 compat-shims - NetworkManager @@ -50,45 +50,13 @@ escritorio realiza durante la sesión. udev añade reglas de userspace — opcional) - gdm o sddm -Ejemplo mínimo: - -```kcl -import .card - -seed = EntityCard { - schema_version = 1 - id = "01KQ_BOOT_SEED_GNOME_TEST_0" - label = "boot-gnome-test" - provides = [ - Capability {kind = "Spawn"} - Capability {kind = "Journal"} - ] - soma = SomaSpec {} - payload = Payload {kind = "Virtual"} - supervision = Supervision {kind = "OneShot"} - genesis = [ - # dbus-daemon — todo lo demás depende de él. - EntityCard { - schema_version = 1 - id = "01KQ_BOOT_DBUS_DAEMON__________" - label = "dbus-daemon" - soma = SomaSpec {} - payload = Payload { - kind = "Native" - exec = "/usr/bin/dbus-daemon" - argv = ["--system", "--nofork"] - } - supervision = Supervision { - kind = "Restart" - initial_ms = 100 - max_ms = 30000 - } - } - # Aquí los 8 compat-shims (mismo patrón) ... - # Aquí gdm o sddm ... - ] -} -``` +El shape es la serialización serde de `EntityCard` (ver +`crates/ente-card/src/lib.rs`). Para el primer arranque sin GNOME hay un +ejemplo defensivo en `docs/seed-vps-min.json` (PID 1 + un `sleep infinity` +supervisado). Extiéndelo añadiendo entradas a `genesis[]` con `payload` de +forma `{"Native": {"exec": "...", "argv": [...], "envp": []}}` y +`supervision` `{"Restart": {"initial": 100, "max": 30000}}` para los +daemons que sí queremos restart-supervisados. ## Boot diff --git a/docs/seed-gnome-test.k b/docs/seed-gnome-test.k deleted file mode 100644 index b68d1ab..0000000 --- a/docs/seed-gnome-test.k +++ /dev/null @@ -1,155 +0,0 @@ -# Card Semilla para el boot test de GNOME bajo Ente #0. -# -# Este archivo se valida con `kcl run` contra el schema en -# crates/ente-card/schema/card.k antes de que ente-zero lo cargue. -# -# Genesis declara la constelación mínima para que GNOME arranque sin -# systemd: D-Bus daemon, los 8 compat-shims, NetworkManager, gdm. - -import .ente_card.schema.card - -# Card "supervisor genérico" reutilizable — dispara un binario con Restart. -schema NativeRestart(EnteBase): - soma = SomaSpec { - rlimits = ResourceLimits {nofile = 16384} - } - supervision = Supervision { - kind = "Restart" - initial_ms = 100 - max_ms = 30000 - } - - -# ----- La Semilla ----- - -seed = EntityCard { - schema_version = 1 - id = "01KQABOOTTESTSEEDFRACTAL00" - label = "boot-gnome-test" - provides = [ - Capability {kind = "Spawn"} - Capability {kind = "Journal"} - ] - soma = SomaSpec {} - payload = Payload {kind = "Virtual"} - supervision = Supervision {kind = "OneShot"} - - genesis = [ - # 1. dbus-daemon — pivote del system bus, todos los demás dependen de él. - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTDBUSDAEMON___" - label = "dbus-daemon" - soma = SomaSpec {} - payload = Payload { - kind = "Native" - exec = "/usr/bin/dbus-daemon" - argv = ["--system", "--nofork", "--nopidfile"] - } - supervision = Supervision { - kind = "Restart" - initial_ms = 100 - max_ms = 30000 - } - } - - # 2-9. Los 8 compat-shims D-Bus. - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTLOGIND_______" - label = "compat-logind" - provides = [Capability {kind = "LegacyLogind"}] - soma = SomaSpec {} - payload = Payload { - kind = "Native" - exec = "/usr/local/bin/ente-logind-compat" - } - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTHOSTNAMED____" - label = "compat-hostnamed" - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-hostnamed-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTTIMEDATED____" - label = "compat-timedated" - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-timedated-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTLOCALED______" - label = "compat-localed" - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-localed-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTJOURNALD_____" - label = "compat-journald" - provides = [Capability {kind = "Journal"}] - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-journald-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTRESOLVED_____" - label = "compat-resolved" - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-resolved-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTPOLKIT_______" - label = "compat-polkit" - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-polkit-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTMACHINED_____" - label = "compat-machined" - soma = SomaSpec {} - payload = Payload {kind = "Native", exec = "/usr/local/bin/ente-machined-compat"} - supervision = Supervision {kind = "Restart", initial_ms = 100, max_ms = 30000} - } - - # 10. NetworkManager — la mayoría de distros lo prefieren sobre networkd. - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTNETWORKMGR___" - label = "NetworkManager" - soma = SomaSpec {} - payload = Payload { - kind = "Native" - exec = "/usr/sbin/NetworkManager" - argv = ["--no-daemon"] - } - supervision = Supervision {kind = "Restart", initial_ms = 200, max_ms = 30000} - } - - # 11. gdm — display manager. GNOME settings panels via gnome-shell. - EntityCard { - schema_version = 1 - id = "01KQABOOTTESTGDMDAEMON____" - label = "gdm" - soma = SomaSpec {} - payload = Payload { - kind = "Native" - exec = "/usr/bin/gdm" - argv = ["--no-daemon"] - } - supervision = Supervision {kind = "Restart", initial_ms = 500, max_ms = 60000} - } - ] -} diff --git a/scripts/build-rootfs.sh b/scripts/build-rootfs.sh index a99bc96..77ae5b0 100755 --- a/scripts/build-rootfs.sh +++ b/scripts/build-rootfs.sh @@ -6,7 +6,7 @@ # $2 — path opcional a Card Semilla custom (.k o .json) # # Output: el rootfs queda con /init → ente-zero, binarios en -# /usr/local/bin, y la Semilla en /ente/seed.card.k. +# /usr/local/bin, y la Semilla en /ente/seed.card.json. set -euo pipefail @@ -76,14 +76,14 @@ echo "==> /init → ente-zero" ln -sf /usr/local/bin/ente-zero "$ROOTFS/init" ln -sf /usr/local/bin/ente-zero "$ROOTFS/sbin/init" -# 5. Card Semilla +# 5. Card Semilla — JSON crudo, validado en boot por EntityCard::validate(). mkdir -p "$ROOTFS/ente" if [[ -n "$SEED_CARD" && -f "$SEED_CARD" ]]; then - cp "$SEED_CARD" "$ROOTFS/ente/seed.card.k" + cp "$SEED_CARD" "$ROOTFS/ente/seed.card.json" echo "==> Semilla custom: $SEED_CARD" else - cp "$WORKSPACE/docs/seed-gnome-test.k" "$ROOTFS/ente/seed.card.k" 2>/dev/null \ - || echo "WARN: docs/seed-gnome-test.k no existe; ente-zero sintetizará dev seed" + cp "$WORKSPACE/docs/seed-vps-min.json" "$ROOTFS/ente/seed.card.json" 2>/dev/null \ + || echo "WARN: docs/seed-vps-min.json no existe; ente-zero sintetizará dev seed" fi # 6. Mascara servicios systemd que vamos a sustituir