6f993f4268
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.
279 lines
9.4 KiB
Rust
279 lines
9.4 KiB
Rust
//! Wire types para consultar al daemon `nouser` por sus Mónadas.
|
|
//!
|
|
//! El daemon expone un Unix socket (cuyo path se publica en
|
|
//! `Card.service_socket` y se descubre vía broker MatchEvent). Cada
|
|
//! conexión es single-shot: una request JSON terminada en `\n`,
|
|
//! una response JSON terminada en `\n`, cierre.
|
|
//!
|
|
//! Mismo patrón que `nouser-nous` (mock/real ↔ nouser-core), reusado
|
|
//! ahora para que la UI (`nouser-explorer`) descubra y consulte al
|
|
//! daemon sin hardcodear sockets ni pasar por brahman-admin.
|
|
//!
|
|
//! ## Contrato
|
|
//!
|
|
//! ```text
|
|
//! C → S: {"kind":"list_monads"}\n
|
|
//! S → C: {"engine":{...},"monads":[...]}\n
|
|
//! ```
|
|
//!
|
|
//! En caso de error:
|
|
//!
|
|
//! ```text
|
|
//! S → C: {"error":"unsupported kind"}\n
|
|
//! ```
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
use ulid::Ulid;
|
|
|
|
use crate::{Lens, MonadId, MonadManifest};
|
|
|
|
// =====================================================================
|
|
// Constants compartidos para el broker brahman
|
|
// =====================================================================
|
|
|
|
/// Nombre del flow output del daemon (input del consumer/explorer).
|
|
pub const FLOW_MONAD_LIST: &str = "monad-list";
|
|
|
|
/// Tipo del flow: el wire es JSON, así que el TypeRef es `primitive::json`.
|
|
pub const FLOW_TYPE_NAME: &str = "json";
|
|
|
|
// =====================================================================
|
|
// Wire request
|
|
// =====================================================================
|
|
|
|
/// Request al daemon. El wire es JSON line-delimited (un objeto + `\n`
|
|
/// por conexión).
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum QueryRequest {
|
|
/// Lista todas las Mónadas vivas del daemon, junto con metadata
|
|
/// del engine. Pensado para que la UI haga snapshot polling.
|
|
ListMonads,
|
|
}
|
|
|
|
// =====================================================================
|
|
// Wire response
|
|
// =====================================================================
|
|
|
|
/// Response a `ListMonads`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ListMonadsResponse {
|
|
/// Datos del engine (la Card que es "dueña" de las Mónadas).
|
|
pub engine: EngineInfo,
|
|
/// Mónadas vivas en este momento. Vista slim sin centroide ni
|
|
/// member set para que el wire sea liviano: una Mónada con 50k
|
|
/// archivos no debe transmitir 50k ULIDs cada poll.
|
|
pub monads: Vec<MonadView>,
|
|
}
|
|
|
|
/// Identidad del engine (Card kind=Ente que owns las Mónadas).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct EngineInfo {
|
|
pub id: Ulid,
|
|
pub label: String,
|
|
/// Path del directorio que el daemon está observando. `None` si
|
|
/// el daemon corre sin watcher.
|
|
#[serde(default)]
|
|
pub watching: Option<String>,
|
|
}
|
|
|
|
/// Vista slim de una Mónada — los campos que la UI necesita para
|
|
/// renderizar una card sin pull del centroide ni del member set.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MonadView {
|
|
pub id: MonadId,
|
|
pub label: String,
|
|
#[serde(default)]
|
|
pub summary: String,
|
|
#[serde(default)]
|
|
pub keywords: Vec<String>,
|
|
pub cardinality: u32,
|
|
#[serde(default)]
|
|
pub entropy: f32,
|
|
#[serde(default)]
|
|
pub dominant_lens: Lens,
|
|
#[serde(default)]
|
|
pub path_hint: Option<String>,
|
|
#[serde(default)]
|
|
pub centroid_model: Option<String>,
|
|
}
|
|
|
|
impl MonadView {
|
|
/// Proyecta un MonadManifest completo a su vista slim para wire.
|
|
pub fn from_manifest(m: &MonadManifest) -> Self {
|
|
Self {
|
|
id: m.id,
|
|
label: m.label.clone(),
|
|
summary: m.summary.clone(),
|
|
keywords: m.keywords.clone(),
|
|
cardinality: m.cardinality,
|
|
entropy: m.entropy,
|
|
dominant_lens: m.dominant_lens,
|
|
path_hint: m.path_hint.clone(),
|
|
centroid_model: m.centroid_model.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Error de protocolo retornado en lugar de la response normal.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
|
#[error("nouser-engine: {error}")]
|
|
pub struct ErrorResponse {
|
|
pub error: String,
|
|
}
|
|
|
|
// =====================================================================
|
|
// Transport
|
|
// =====================================================================
|
|
|
|
pub mod transport {
|
|
use std::path::PathBuf;
|
|
|
|
/// Variable de entorno para sobreescribir la ruta del socket del
|
|
/// daemon (útil para tests / multi-daemon).
|
|
pub const SOCKET_ENV: &str = "NOUSER_ENGINE_SOCKET";
|
|
|
|
/// Nombre por defecto del socket.
|
|
pub const SOCKET_NAME: &str = "nouser-engine.sock";
|
|
|
|
/// Ruta canónica al socket del daemon. Honra `NOUSER_ENGINE_SOCKET`
|
|
/// si está set, sino arma sobre `$XDG_RUNTIME_DIR` (con fallback
|
|
/// `$TMPDIR`).
|
|
pub fn default_socket_path() -> PathBuf {
|
|
if let Ok(p) = std::env::var(SOCKET_ENV) {
|
|
return PathBuf::from(p);
|
|
}
|
|
std::env::var_os("XDG_RUNTIME_DIR")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(std::env::temp_dir)
|
|
.join(SOCKET_NAME)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// 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::*;
|
|
|
|
#[test]
|
|
fn request_roundtrips_json_with_tag() {
|
|
let req = QueryRequest::ListMonads;
|
|
let s = serde_json::to_string(&req).unwrap();
|
|
assert_eq!(s, r#"{"kind":"list_monads"}"#);
|
|
let back: QueryRequest = serde_json::from_str(&s).unwrap();
|
|
assert_eq!(back, req);
|
|
}
|
|
|
|
#[test]
|
|
fn response_roundtrip_preserves_view() {
|
|
let m = MonadManifest::new("x/src");
|
|
let view = MonadView::from_manifest(&m);
|
|
let resp = ListMonadsResponse {
|
|
engine: EngineInfo {
|
|
id: Ulid::new(),
|
|
label: "brahman.nouser_engine".into(),
|
|
watching: Some("/tmp/x".into()),
|
|
},
|
|
monads: vec![view.clone()],
|
|
};
|
|
let s = serde_json::to_string(&resp).unwrap();
|
|
let back: ListMonadsResponse = serde_json::from_str(&s).unwrap();
|
|
assert_eq!(back.monads.len(), 1);
|
|
assert_eq!(back.monads[0].label, view.label);
|
|
assert_eq!(back.engine.label, "brahman.nouser_engine");
|
|
}
|
|
|
|
#[test]
|
|
fn view_is_slim_no_centroid_no_members() {
|
|
// Construimos una Mónada con centroid + members "pesados",
|
|
// proyectamos a view, verificamos que esos campos no viajan.
|
|
let mut m = MonadManifest::new("test");
|
|
m.centroid = vec![0.1; 384]; // peso "real-fastembed"
|
|
m.members.insert(Ulid::new());
|
|
m.members.insert(Ulid::new());
|
|
m.cardinality = 2;
|
|
let view = MonadView::from_manifest(&m);
|
|
let s = serde_json::to_string(&view).unwrap();
|
|
// Chequeo con `:` para distinguir el field "centroid" del
|
|
// field "centroid_model" (que sí es metadata liviana y debe ir).
|
|
assert!(
|
|
!s.contains("\"centroid\":"),
|
|
"MonadView no debe serializar el vector centroid: {s}"
|
|
);
|
|
assert!(
|
|
!s.contains("\"members\":"),
|
|
"MonadView no debe serializar members: {s}"
|
|
);
|
|
assert!(s.contains("\"cardinality\":2"), "cardinality sí va: {s}");
|
|
}
|
|
}
|