refactor(explorer+card): independencia jerarquica enforced
Cierra el unico debt estructural detectado en el audit de
independencia: nouser-explorer ya no arrastra nouser-core (que
aportaba notify/walkdir/sled/blake3 al grafo de compilacion de una
UI que solo habla JSON contra un socket).
- 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 depender de nouser-core.
Verificado con cargo tree: notify, sled, blake3 desaparecen del
grafo del binario.
- Fallback "falla hacia la simplicidad": nueva resolve_socket() en
el explorer intenta primero broker discovery; si el broker no
responde / no hay init vivo, fallback directo al
default_socket_path. El explorer queda funcional contra un daemon
huerfano (standalone sin init) — completa "consciente cuando hay
ecosistema, soberano cuando esta solo".
- socket_source gana tercer estado "default-path" para visibilidad.
Audit estructural confirmo que el resto del ecosistema ya respeta
el principio. Brahman es pegamento opcional, no chasis obligatorio
— y ahora el grafo de Cargo lo enforcea, no solo la convencion.
Tests: 4 + 10 + 27 verdes. Cliente movido ejercitado end-to-end
por los 3 tests integracion de engine_socket.
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 <sha>`.
## 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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf, ConsumerError> {
+fn discover_via_broker() -> Result<PathBuf, ConsumerError> {
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<ListMonadsResponse, QueryError> {
+ 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::<ListMonadsResponse>(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<ListMonadsResponse, QueryError> {
- 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::<ListMonadsResponse>(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"));