feat(explorer+daemon): discovery dinamico via broker + query socket
Cierra el "explorer encuentra al daemon de forma totalmente dinamica"
del meta-plan. La UI deja de hardcodear el socket admin: descubre al
daemon nouser via MatchEvent::Available del broker y le consulta sus
Monadas directo.
Pipeline end-to-end:
- Daemon publica engine Card con service_socket = $XDG_RUNTIME_DIR/
nouser-engine.sock y flow.output = monad-list:json.
- Daemon binda Unix socket en ese path con listener blocking que
sirve nouser_card::query::QueryRequest::ListMonads, responde
ListMonadsResponse { engine, monads: Vec<MonadView> }.
- Explorer construye consumer Card con flow.input matched,
brahman_sidecar::await_provider_blocking le devuelve el socket,
y nouser_core::engine_socket::client::list_monads lo consulta.
- Cachea el socket; cualquier fallo de query lo invalida y la
proxima iteracion re-descubre.
Wire types nuevos en nouser_card::query:
- QueryRequest::ListMonads
- ListMonadsResponse { engine: EngineInfo, monads: Vec<MonadView> }
- MonadView: proyeccion slim de MonadManifest SIN centroid ni
members (KB que no tienen por que viajar cada poll).
- transport::default_socket_path() con env override.
Listener en nouser_core::engine_socket: spawn_listener + client
blocking con QueryError tipado. 3 tests integracion verdes.
Refactor explorer:
- Drop dep brahman-admin, add brahman-sidecar/nouser-card/nouser-core.
- State: socket cache + snapshot + socket_source informativo.
- TickOutcome enum desacopla la I/O del UI.
Trade-offs: polling 2s (no streaming — broker no empuja Data cards
hoy), re-discovery full en error (discovery es barato).
Tests: 10 (nouser-card +3 query) + 27 (nouser-core +3 engine_socket)
+ 4 (sidecar) verdes. Explorer compila clean.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user