diff --git a/CHANGELOG.md b/CHANGELOG.md index 3661db1..9f74919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,45 @@ ratio/diff ver `git show `. ## 2026-05-09 +### refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path +Cierra el único debt estructural detectado en el audit de +independencia: `nouser-explorer` ya no arrastra `nouser-core` +(que aportaba `notify`/`walkdir`/`sled`/`blake3` al grafo de +compilación de una UI que sólo habla JSON contra un socket). + +Cambios: +- **Cliente movido**: `engine_socket::client::list_monads` (~60 LOC, + std + serde_json puros) emigra de `nouser_core::engine_socket` a + `nouser_card::query::client`. Vive donde viven los wire types, + consistente con el principio "un consumer importa el contrato, + no el runtime del productor". +- **Drop dep**: `nouser-explorer` deja de dependener de + `nouser-core`. Verificado con `cargo tree`: `notify`, `sled`, + `blake3` desaparecen del grafo del binario. (`walkdir` sigue + pero llega vía `gpui_util` → `rust-embed`, fuera de nuestro + control y pre-existente.) +- **Fallback "falla hacia la simplicidad"**: nueva función + `resolve_socket()` en el explorer intenta primero broker + discovery; si el broker no responde / no hay init vivo, + fallback directo a `nouser_card::query::transport::default_socket_path()`. + El explorer queda funcional contra un daemon "huérfano" + (corriendo standalone sin init) — completa la cadena + "consciente cuando hay ecosistema, soberano cuando está solo". +- `socket_source` en el header gana un tercer estado + `"default-path"` para que el usuario vea por dónde se conectó. + +Audit estructural confirmó que el resto del ecosistema ya +respeta el principio: todos los `yahweh-*` viewers, `minga-cli`, +`minga-core`, `nouser-card`, `nouser-nous`, los providers +`nouser-nous-{mock,real}` y `nakui-core` corren standalone con +soft-fail hacia infra brahman cuando está ausente. Brahman es +"pegamento opcional, no chasis obligatorio" — y ahora el grafo +de Cargo lo enforcea, no sólo la convención. + +Tests: 4 (sidecar) + 10 (nouser-card) + 27 (nouser-core) verdes. +El cliente movido se ejercita end-to-end por los 3 tests integración +de `engine_socket` (importa ahora `nouser_card::query::client`). + ### feat(explorer+daemon): discovery dinámico vía broker + query socket La UI deja de hardcodear el socket admin: ahora descubre al daemon nouser vía `MatchEvent::Available` del broker brahman y le consulta diff --git a/Cargo.lock b/Cargo.lock index 51beaa6..4f1aef7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6386,7 +6386,6 @@ dependencies = [ "brahman-sidecar", "gpui", "nouser-card", - "nouser-core", ] [[package]] diff --git a/crates/apps/nouser-explorer/Cargo.toml b/crates/apps/nouser-explorer/Cargo.toml index a050959..a3befc9 100644 --- a/crates/apps/nouser-explorer/Cargo.toml +++ b/crates/apps/nouser-explorer/Cargo.toml @@ -9,7 +9,6 @@ description = "Explorador GPUI de Mónadas: panel que descubre al daemon nouser brahman-card = { path = "../../core/brahman-card" } brahman-sidecar = { path = "../../shared/brahman-sidecar" } nouser-card = { path = "../../modules/nouser/card" } -nouser-core = { path = "../../modules/nouser/core" } gpui = { workspace = true } [[bin]] diff --git a/crates/apps/nouser-explorer/src/main.rs b/crates/apps/nouser-explorer/src/main.rs index ef05d3b..16b0021 100644 --- a/crates/apps/nouser-explorer/src/main.rs +++ b/crates/apps/nouser-explorer/src/main.rs @@ -26,9 +26,9 @@ use gpui::{ div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render, SharedString, Window, WindowBounds, WindowOptions, }; -use nouser_card::query::{ListMonadsResponse, FLOW_MONAD_LIST, FLOW_TYPE_NAME}; +use nouser_card::query::client as query_client; +use nouser_card::query::{transport, ListMonadsResponse, FLOW_MONAD_LIST, FLOW_TYPE_NAME}; use nouser_card::Lens; -use nouser_core::engine_socket::client as query_client; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3); @@ -127,14 +127,21 @@ enum TickOutcome { QueryFailed(String), } -/// Resuelve el socket (cache o discovery) y consulta `ListMonads`. -/// Pensado para correr en background: no toca GPUI, sólo I/O. +/// Resuelve el socket (cache → broker → default path) y consulta +/// `ListMonads`. Pensado para correr en background: no toca GPUI, +/// sólo I/O. +/// +/// **Falla hacia la simplicidad**: si el broker brahman no está vivo +/// (init caído / no instalado), intentamos directo el path canónico +/// del daemon vía `transport::default_socket_path()`. El explorer +/// sigue funcionando contra un daemon "huérfano" que no se publicó +/// al broker — útil para correr la UI sin todo el stack. fn tick(prior_socket: Option) -> TickOutcome { let (socket, source) = match prior_socket { Some(p) => (p, "cache"), - None => match discover() { - Ok(p) => (p, "discovery"), - Err(e) => return TickOutcome::DiscoveryFailed(format!("discovery: {e}")), + None => match resolve_socket() { + Ok(found) => found, + Err(e) => return TickOutcome::DiscoveryFailed(e), }, }; @@ -151,10 +158,35 @@ fn tick(prior_socket: Option) -> TickOutcome { } } +/// Resuelve el socket del daemon en dos pasos: +/// 1. **Broker**: consumer Card + `await_provider_blocking`. Path +/// "consciente" (ecosistema brahman activo). +/// 2. **Default path**: si el broker no responde, probamos +/// `transport::default_socket_path()` directo. Path "soberano" +/// (daemon corriendo solo, sin init). +/// +/// Falla únicamente si ninguno responde. +fn resolve_socket() -> Result<(PathBuf, &'static str), String> { + match discover_via_broker() { + Ok(p) => Ok((p, "broker")), + Err(broker_err) => { + let fallback = transport::default_socket_path(); + if fallback.exists() { + Ok((fallback, "default-path")) + } else { + Err(format!( + "broker: {broker_err}; fallback {} no existe", + fallback.display() + )) + } + } + } +} + /// Discovery del daemon vía broker brahman. Construye un consumer /// Card con `flow.input = monad-list:json`, espera al primer /// `MatchEvent::Available`, devuelve el `producer_service_socket`. -fn discover() -> Result { +fn discover_via_broker() -> Result { let card = build_consumer_card("nouser-explorer", FLOW_MONAD_LIST, FLOW_TYPE_NAME); await_provider_blocking(card, DISCOVERY_TIMEOUT) } diff --git a/crates/modules/nouser/card/src/query.rs b/crates/modules/nouser/card/src/query.rs index a0eb61a..3d0c432 100644 --- a/crates/modules/nouser/card/src/query.rs +++ b/crates/modules/nouser/card/src/query.rs @@ -151,6 +151,75 @@ pub mod transport { } } +// ===================================================================== +// Cliente blocking — vive con los wire types para que un consumer +// (UI, CLI, otro módulo) pueda hablar con el daemon importando sólo +// `nouser-card`, sin arrastrar `nouser-core` (notify/walkdir/sled/blake3). +// ===================================================================== + +/// Cliente síncrono para el query socket del daemon. Sólo Unix (el +/// resto del ecosistema brahman es Unix-only de facto). +#[cfg(unix)] +pub mod client { + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::path::Path; + use std::time::Duration; + + use super::{ErrorResponse, ListMonadsResponse, QueryRequest}; + + #[derive(Debug, thiserror::Error)] + pub enum QueryError { + #[error("conectar a {path}: {source}")] + Connect { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + #[error("I/O: {0}")] + Io(#[from] std::io::Error), + #[error("serializacion: {0}")] + Serde(#[from] serde_json::Error), + #[error("daemon: {0}")] + Daemon(String), + #[error("response vacía del daemon")] + Empty, + } + + /// Envía `ListMonads` al daemon en `socket` y devuelve la response. + /// `timeout` se aplica tanto al read como al write del stream. + pub fn list_monads( + socket: &Path, + timeout: Duration, + ) -> Result { + let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect { + path: socket.to_path_buf(), + source: e, + })?; + stream.set_read_timeout(Some(timeout))?; + stream.set_write_timeout(Some(timeout))?; + + let req = QueryRequest::ListMonads; + let line = serde_json::to_string(&req)?; + stream.write_all(line.as_bytes())?; + stream.write_all(b"\n")?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + let n = reader.read_line(&mut response)?; + if n == 0 { + return Err(QueryError::Empty); + } + + if let Ok(resp) = serde_json::from_str::(response.trim()) { + return Ok(resp); + } + let err: ErrorResponse = serde_json::from_str(response.trim())?; + Err(QueryError::Daemon(err.error)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/modules/nouser/core/src/engine_socket.rs b/crates/modules/nouser/core/src/engine_socket.rs index 326515e..495639e 100644 --- a/crates/modules/nouser/core/src/engine_socket.rs +++ b/crates/modules/nouser/core/src/engine_socket.rs @@ -126,74 +126,16 @@ fn encode_error(msg: String) -> String { serde_json::to_string(&err).unwrap_or_else(|_| "{\"error\":\"encode\"}".into()) } -/// Cliente blocking — `client::list_monads(socket)` para que la UI no -/// reimplemente el handshake JSON cada vez. -pub mod client { - use std::io::{BufRead, BufReader, Write}; - use std::os::unix::net::UnixStream; - use std::path::Path; - use std::time::Duration; - - use nouser_card::query::{ErrorResponse, ListMonadsResponse, QueryRequest}; - - #[derive(Debug, thiserror::Error)] - pub enum QueryError { - #[error("conectar a {path}: {source}")] - Connect { - path: std::path::PathBuf, - #[source] - source: std::io::Error, - }, - #[error("I/O: {0}")] - Io(#[from] std::io::Error), - #[error("serializacion: {0}")] - Serde(#[from] serde_json::Error), - #[error("daemon: {0}")] - Daemon(String), - #[error("timeout esperando response")] - Timeout, - #[error("response vacía del daemon")] - Empty, - } - - /// Envía `ListMonads` y devuelve la response. Timeout aplicado - /// tanto al connect como al read. - pub fn list_monads( - socket: &Path, - timeout: Duration, - ) -> Result { - let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect { - path: socket.to_path_buf(), - source: e, - })?; - stream.set_read_timeout(Some(timeout))?; - stream.set_write_timeout(Some(timeout))?; - - let req = QueryRequest::ListMonads; - let line = serde_json::to_string(&req)?; - stream.write_all(line.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; - - let mut reader = BufReader::new(stream); - let mut response = String::new(); - let n = reader.read_line(&mut response)?; - if n == 0 { - return Err(QueryError::Empty); - } - - if let Ok(resp) = serde_json::from_str::(response.trim()) { - return Ok(resp); - } - let err: ErrorResponse = serde_json::from_str(response.trim())?; - Err(QueryError::Daemon(err.error)) - } -} +// El cliente blocking vive en `nouser_card::query::client` — junto a +// los wire types — para que un consumer pueda hablar con el daemon +// importando sólo `nouser-card`, sin arrastrar el peso de +// `nouser-core` (scanner / db / sled / notify / walkdir / blake3). #[cfg(test)] mod tests { use super::*; use crate::db::MonadDb; + use nouser_card::query::client as query_client; use nouser_card::MonadManifest; use std::time::Duration; @@ -225,7 +167,7 @@ mod tests { // de wait_for(socket.exists()). std::thread::sleep(Duration::from_millis(50)); - let resp = client::list_monads(&socket, Duration::from_secs(2)).unwrap(); + let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap(); assert_eq!(resp.engine.id, engine_id); assert_eq!(resp.engine.label, "test-engine"); assert_eq!(resp.engine.watching.as_deref(), Some("/tmp/x")); @@ -256,7 +198,7 @@ mod tests { .unwrap(); std::thread::sleep(Duration::from_millis(50)); - let resp = client::list_monads(&socket, Duration::from_secs(2)).unwrap(); + let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap(); assert_eq!(resp.monads.len(), 2); let labels: Vec<_> = resp.monads.iter().map(|m| m.label.as_str()).collect(); assert!(labels.contains(&"alpha"));