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.
This commit is contained in:
Sergio
2026-05-09 03:32:11 +00:00
parent 2ae888bc8f
commit 6f993f4268
6 changed files with 155 additions and 75 deletions
-1
View File
@@ -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]]
+40 -8
View File
@@ -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)
}
+69
View File
@@ -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::*;
@@ -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"));