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:
@@ -6,6 +6,68 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### 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
|
||||||
|
sus Mónadas directo, sin pasar por brahman-admin. Cierra el "explorer
|
||||||
|
encuentra al daemon de forma totalmente dinámica" del meta-plan.
|
||||||
|
|
||||||
|
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 un Unix socket en ese path y monta un listener
|
||||||
|
blocking que sirve `nouser_card::query::QueryRequest::ListMonads`,
|
||||||
|
responde `ListMonadsResponse { engine, monads: Vec<MonadView> }`.
|
||||||
|
- Explorer construye un consumer Card con `flow.input = monad-list:json`
|
||||||
|
vía `brahman_sidecar::build_consumer_card`, llama
|
||||||
|
`await_provider_blocking(card, 3s)` y recibe el socket descubierto.
|
||||||
|
- Cachea ese socket; cada poll (2s) llama
|
||||||
|
`nouser_core::engine_socket::client::list_monads(socket, 2s)`.
|
||||||
|
Fallo de query → invalida cache → próximo tick re-descubre.
|
||||||
|
|
||||||
|
Wire types nuevos en `nouser_card::query`:
|
||||||
|
- `QueryRequest::ListMonads` (single variant por ahora).
|
||||||
|
- `ListMonadsResponse { engine: EngineInfo, monads: Vec<MonadView> }`.
|
||||||
|
- `MonadView`: proyección slim de `MonadManifest` SIN `centroid` ni
|
||||||
|
`members` — la UI no los necesita y eran KB por Mónada que no
|
||||||
|
tenían por qué viajar cada poll.
|
||||||
|
- `transport::default_socket_path()` con env override
|
||||||
|
`NOUSER_ENGINE_SOCKET`.
|
||||||
|
- Const `FLOW_MONAD_LIST = "monad-list"`, `FLOW_TYPE_NAME = "json"`.
|
||||||
|
|
||||||
|
Listener en `nouser_core::engine_socket`:
|
||||||
|
- `spawn_listener(config, db)` arma std::os::unix::net::UnixListener
|
||||||
|
en thread blocking dedicado. Frecuencia esperada (UI cada 2s) no
|
||||||
|
amerita tokio.
|
||||||
|
- `client::list_monads(socket, timeout)` — cliente blocking con
|
||||||
|
`QueryError` tipado (Connect / Io / Serde / Daemon / Timeout / Empty).
|
||||||
|
- 3 tests integración: roundtrip vacío, Mónadas reales, request
|
||||||
|
inválido devuelve ErrorResponse.
|
||||||
|
|
||||||
|
Refactor explorer:
|
||||||
|
- Drop dep `brahman-admin`, add deps `brahman-sidecar`, `nouser-card`,
|
||||||
|
`nouser-core`.
|
||||||
|
- State: `socket: Option<PathBuf>` cache + `snapshot: Option<ListMonadsResponse>`
|
||||||
|
+ `socket_source: "discovery"|"cache"` (sólo informativo).
|
||||||
|
- Tick: `tick(prior_socket)` separado del UI, devuelve un enum
|
||||||
|
`TickOutcome::{Ok, DiscoveryFailed, QueryFailed}`. Cualquier
|
||||||
|
fallo invalida la cache → re-discovery automática.
|
||||||
|
- Header reformulado: `Engine 'nouser_engine' · N mónada(s) ·
|
||||||
|
socket: /... (cache|discovery) · watching: /tmp/x`.
|
||||||
|
- Render pintado de un engine card + Mónadas, sin ya iterar
|
||||||
|
`BrokeredCard` del admin.
|
||||||
|
|
||||||
|
Trade-offs aceptados:
|
||||||
|
- Polling 2s (no streaming). El broker no empuja cambios de Data
|
||||||
|
cards hoy; agregar streaming requiere extender el protocolo
|
||||||
|
handshake. Para snapshot UI, polling 2s es suficiente.
|
||||||
|
- Re-descubrimiento full en cada error de query (en lugar de retry
|
||||||
|
con backoff). Discovery es barato (~ms vs broker), no vale la
|
||||||
|
pena la complejidad.
|
||||||
|
|
||||||
|
Tests: 10 (nouser-card, +3 query) + 27 (nouser-core, +3 engine_socket)
|
||||||
|
+ 4 (sidecar) verdes. Explorer compila clean.
|
||||||
|
|
||||||
### feat(nous-real): cache de embeddings + write-through al CAS de arje
|
### feat(nous-real): cache de embeddings + write-through al CAS de arje
|
||||||
Cierra el ciclo de la crítica del usuario: "Si un archivo no ha
|
Cierra el ciclo de la crítica del usuario: "Si un archivo no ha
|
||||||
cambiado su hash en el CAS, Nouser ni siquiera debería pedirle al
|
cambiado su hash en el CAS, Nouser ni siquiera debería pedirle al
|
||||||
|
|||||||
Generated
+3
-1
@@ -6382,9 +6382,11 @@ dependencies = [
|
|||||||
name = "nouser-explorer"
|
name = "nouser-explorer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brahman-admin",
|
|
||||||
"brahman-card",
|
"brahman-card",
|
||||||
|
"brahman-sidecar",
|
||||||
"gpui",
|
"gpui",
|
||||||
|
"nouser-card",
|
||||||
|
"nouser-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ name = "nouser-explorer"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Explorador GPUI de Mónadas: panel que consulta brahman-admin y renderea las sesiones como cards."
|
description = "Explorador GPUI de Mónadas: panel que descubre al daemon nouser vía broker brahman y consulta sus Mónadas dinámicamente."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
brahman-admin = { path = "../../core/brahman-admin" }
|
|
||||||
brahman-card = { path = "../../core/brahman-card" }
|
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 }
|
gpui = { workspace = true }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
//! `nouser-explorer` — panel GPUI que muestra las Mónadas (y demás
|
//! `nouser-explorer` — panel GPUI que descubre al daemon `nouser`
|
||||||
//! sesiones) registradas en el Init brahman.
|
//! vía broker brahman y muestra sus Mónadas en vivo.
|
||||||
//!
|
//!
|
||||||
//! Diseño: ventana standalone que cada N segundos consulta el socket
|
//! Diseño: ventana standalone que cada N segundos consulta el query
|
||||||
//! admin (`brahman_admin::client::query_blocking`) y renderea las
|
//! socket del daemon (`nouser_core::engine_socket::client::list_monads`).
|
||||||
//! sesiones como cards. Sin integración con yahweh-shell — es su
|
//! El path del socket NO está hardcoded — se descubre vía
|
||||||
//! propio binario para que el ecosistema sea visible incluso sin la
|
//! `brahman_sidecar::await_provider_blocking` para el flow
|
||||||
//! shell completa.
|
//! `monad-list:json`. Si el daemon cae, el socket cacheado se invalida
|
||||||
|
//! y la próxima iteración re-descubre.
|
||||||
|
//!
|
||||||
|
//! Sin integración con yahweh-shell — es su propio binario para que el
|
||||||
|
//! ecosistema sea visible incluso sin la shell completa.
|
||||||
//!
|
//!
|
||||||
//! Uso:
|
//! Uso:
|
||||||
//! ```sh
|
//! ```sh
|
||||||
//! cargo run -p nouser-explorer
|
//! cargo run -p nouser-explorer
|
||||||
//! # con override de socket admin:
|
//! # con override del init socket (heredado de brahman-handshake):
|
||||||
//! BRAHMAN_ADMIN_SOCKET=/tmp/brahman-admin.sock cargo run -p nouser-explorer
|
//! BRAHMAN_INIT_SOCKET=/tmp/init.sock cargo run -p nouser-explorer
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use brahman_admin::{client::query_blocking, transport, StatusSnapshot};
|
use brahman_sidecar::{await_provider_blocking, build_consumer_card, ConsumerError};
|
||||||
use brahman_card::CardKind;
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render, SharedString,
|
div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render, SharedString,
|
||||||
Window, WindowBounds, WindowOptions,
|
Window, WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
|
use nouser_card::query::{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 REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
Application::new().run(|cx: &mut App| {
|
Application::new().run(|cx: &mut App| {
|
||||||
@@ -44,35 +53,52 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vista raíz: contiene el último snapshot recibido y el último error.
|
/// Vista raíz: cachea el socket descubierto, el último snapshot y el
|
||||||
|
/// último error. El socket cacheado se invalida ante cualquier fallo
|
||||||
|
/// de query, forzando re-discovery en la próxima iteración.
|
||||||
struct Explorer {
|
struct Explorer {
|
||||||
snapshot: Option<StatusSnapshot>,
|
socket: Option<PathBuf>,
|
||||||
|
snapshot: Option<ListMonadsResponse>,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
|
/// Última fuente del socket activo: "discovery" (vía broker) o
|
||||||
|
/// "cache" (reusando el de la iteración anterior). Sólo informativo.
|
||||||
|
socket_source: Option<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Explorer {
|
impl Explorer {
|
||||||
fn new(cx: &mut Context<Self>) -> Self {
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
// Loop de refresh: cada `REFRESH_INTERVAL`, query al admin y
|
// Loop de refresh: cada `REFRESH_INTERVAL`:
|
||||||
// actualiza el modelo. `cx.spawn` corre en el GPUI executor;
|
// 1. Si no tenemos socket cacheado → discovery vía broker.
|
||||||
// el `query_blocking` sí bloquea pero sólo brevemente — admin
|
// 2. Si tenemos → query directo. Fallo → invalida cache.
|
||||||
// responde con un snapshot pequeño.
|
|
||||||
cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let timer = cx.background_executor().clone();
|
let timer = cx.background_executor().clone();
|
||||||
loop {
|
loop {
|
||||||
let path = transport::default_socket_path();
|
let prior_socket = this
|
||||||
let result = query_blocking(&path);
|
.read_with(cx, |me, _| me.socket.clone())
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let result = tick(prior_socket);
|
||||||
|
|
||||||
let _ = this.update(cx, |me, cx| {
|
let _ = this.update(cx, |me, cx| {
|
||||||
match result {
|
match result {
|
||||||
Ok(snap) => {
|
TickOutcome::Ok { socket, source, snapshot } => {
|
||||||
me.snapshot = Some(snap);
|
me.socket = Some(socket);
|
||||||
|
me.socket_source = Some(source);
|
||||||
|
me.snapshot = Some(snapshot);
|
||||||
me.error = None;
|
me.error = None;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
TickOutcome::DiscoveryFailed(msg) => {
|
||||||
me.error = Some(SharedString::from(format!(
|
me.socket = None;
|
||||||
"no conectado a {}: {}",
|
me.socket_source = None;
|
||||||
path.display(),
|
me.error = Some(SharedString::from(msg));
|
||||||
e
|
}
|
||||||
)));
|
TickOutcome::QueryFailed(msg) => {
|
||||||
|
// Invalidamos el socket cacheado: la
|
||||||
|
// próxima iteración re-descubre.
|
||||||
|
me.socket = None;
|
||||||
|
me.socket_source = None;
|
||||||
|
me.error = Some(SharedString::from(msg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -83,33 +109,79 @@ impl Explorer {
|
|||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
socket: None,
|
||||||
snapshot: None,
|
snapshot: None,
|
||||||
error: None,
|
error: None,
|
||||||
|
socket_source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TickOutcome {
|
||||||
|
Ok {
|
||||||
|
socket: PathBuf,
|
||||||
|
source: &'static str,
|
||||||
|
snapshot: ListMonadsResponse,
|
||||||
|
},
|
||||||
|
DiscoveryFailed(String),
|
||||||
|
QueryFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resuelve el socket (cache o discovery) y consulta `ListMonads`.
|
||||||
|
/// Pensado para correr en background: no toca GPUI, sólo I/O.
|
||||||
|
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}")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match query_client::list_monads(&socket, QUERY_TIMEOUT) {
|
||||||
|
Ok(resp) => TickOutcome::Ok {
|
||||||
|
socket,
|
||||||
|
source,
|
||||||
|
snapshot: resp,
|
||||||
|
},
|
||||||
|
Err(e) => TickOutcome::QueryFailed(format!(
|
||||||
|
"query a {}: {e} — re-descubriendo en próxima iteración",
|
||||||
|
socket.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> {
|
||||||
|
let card = build_consumer_card("nouser-explorer", FLOW_MONAD_LIST, FLOW_TYPE_NAME);
|
||||||
|
await_provider_blocking(card, DISCOVERY_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
impl Render for Explorer {
|
impl Render for Explorer {
|
||||||
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let bg = rgb(0x14171c);
|
let bg = rgb(0x14171c);
|
||||||
let card_bg = rgb(0x1d2128);
|
let card_bg = rgb(0x1d2128);
|
||||||
let text_dim = rgb(0x9ba1ad);
|
let text_dim = rgb(0x9ba1ad);
|
||||||
let text = rgb(0xe6e8ec);
|
let text = rgb(0xe6e8ec);
|
||||||
let accent_ente = rgb(0x88c0d0);
|
let accent_engine = rgb(0x88c0d0);
|
||||||
let accent_data = rgb(0xb48ead);
|
let accent_data = rgb(0xb48ead);
|
||||||
|
|
||||||
let header_text = match &self.snapshot {
|
let header_text = match (&self.snapshot, &self.socket, self.socket_source) {
|
||||||
Some(s) => format!(
|
(Some(s), Some(sock), Some(src)) => format!(
|
||||||
"Init · protocol={} · attached={} · {} sesión(es){}",
|
"Engine '{}' · {} mónada(s) · socket: {} ({}){}",
|
||||||
s.protocol_version,
|
s.engine.label,
|
||||||
s.init_attached,
|
s.monads.len(),
|
||||||
s.sessions.len(),
|
sock.display(),
|
||||||
s.current_context
|
src,
|
||||||
|
s.engine
|
||||||
|
.watching
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|c| format!(" · context: {}", c))
|
.map(|w| format!(" · watching: {}", w))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
),
|
),
|
||||||
None => "Esperando snapshot del Init brahman…".to_string(),
|
_ => "Buscando daemon nouser vía brahman-broker…".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let header = div()
|
let header = div()
|
||||||
@@ -134,62 +206,11 @@ impl Render for Explorer {
|
|||||||
|
|
||||||
let cards: Vec<gpui::AnyElement> = match &self.snapshot {
|
let cards: Vec<gpui::AnyElement> = match &self.snapshot {
|
||||||
None => vec![],
|
None => vec![],
|
||||||
Some(snap) => snap
|
Some(snap) => {
|
||||||
.sessions
|
let mut out = Vec::with_capacity(snap.monads.len() + 1);
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
let (kind_label, accent) = match s.kind {
|
|
||||||
CardKind::Ente => ("ente", accent_ente),
|
|
||||||
CardKind::Data => ("data", accent_data),
|
|
||||||
};
|
|
||||||
|
|
||||||
let summary_line = s
|
|
||||||
.data
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.summary.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let keywords = s
|
|
||||||
.data
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.keywords.join(", "))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let lens_line = s
|
|
||||||
.data
|
|
||||||
.as_ref()
|
|
||||||
.map(|d| d.presentation_hint.clone())
|
|
||||||
.filter(|h| !h.is_empty())
|
|
||||||
.map(|h| format!("lens: {h}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let sock_line = s
|
|
||||||
.service_socket
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| format!("socket: {}", p.display()))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let refs_line = if s.references.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
let parts: Vec<String> = s
|
|
||||||
.references
|
|
||||||
.iter()
|
|
||||||
.map(|r| {
|
|
||||||
format!(
|
|
||||||
"{:?}→{}",
|
|
||||||
r.kind,
|
|
||||||
if r.target_label.is_empty() {
|
|
||||||
"?"
|
|
||||||
} else {
|
|
||||||
r.target_label.as_str()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
format!("refs: {}", parts.join(" "))
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Engine card primero — el "ser" que owns las Mónadas.
|
||||||
|
out.push(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -198,7 +219,7 @@ impl Render for Explorer {
|
|||||||
.bg(card_bg)
|
.bg(card_bg)
|
||||||
.rounded(px(6.))
|
.rounded(px(6.))
|
||||||
.border_l_4()
|
.border_l_4()
|
||||||
.border_color(accent)
|
.border_color(accent_engine)
|
||||||
.gap(px(2.))
|
.gap(px(2.))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -208,35 +229,100 @@ impl Render for Explorer {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(accent)
|
.text_color(accent_engine)
|
||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.child(format!("[{kind_label}]")),
|
.child("[engine]"),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(text)
|
.text_color(text)
|
||||||
.text_size(px(15.))
|
.text_size(px(15.))
|
||||||
.child(s.label.clone()),
|
.child(snap.engine.label.clone()),
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_color(text_dim)
|
|
||||||
.text_size(px(11.))
|
|
||||||
.child(format!("{:?}", s.lifecycle)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_color(text_dim)
|
.text_color(text_dim)
|
||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.child(format!("id: {}", s.session)),
|
.child(format!("id: {}", snap.engine.id)),
|
||||||
)
|
)
|
||||||
.when(!summary_line.is_empty(), |d| {
|
.when_some(snap.engine.watching.clone(), |d, w| {
|
||||||
|
d.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!("watching: {w}")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.into_any_element(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mónadas (kind=Data por construcción).
|
||||||
|
for m in &snap.monads {
|
||||||
|
let lens = lens_label(m.dominant_lens);
|
||||||
|
let keywords = m.keywords.join(", ");
|
||||||
|
let path_hint_line = m
|
||||||
|
.path_hint
|
||||||
|
.as_deref()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(|p| format!("path: {p}"));
|
||||||
|
let model_line = m
|
||||||
|
.centroid_model
|
||||||
|
.as_deref()
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
.map(|m| format!("model: {m}"));
|
||||||
|
|
||||||
|
out.push(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.p(px(12.))
|
||||||
|
.mb(px(8.))
|
||||||
|
.bg(card_bg)
|
||||||
|
.rounded(px(6.))
|
||||||
|
.border_l_4()
|
||||||
|
.border_color(accent_data)
|
||||||
|
.gap(px(2.))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.gap(px(8.))
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(accent_data)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child("[monad]"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text)
|
||||||
|
.text_size(px(15.))
|
||||||
|
.child(m.label.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!(
|
||||||
|
"{} files · ent {:.2} · {}",
|
||||||
|
m.cardinality, m.entropy, lens
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(text_dim)
|
||||||
|
.text_size(px(11.))
|
||||||
|
.child(format!("id: {}", m.id)),
|
||||||
|
)
|
||||||
|
.when(!m.summary.is_empty(), |d| {
|
||||||
d.child(
|
d.child(
|
||||||
div()
|
div()
|
||||||
.text_color(text)
|
.text_color(text)
|
||||||
.text_size(px(12.))
|
.text_size(px(12.))
|
||||||
.child(summary_line.clone()),
|
.child(m.summary.clone()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!keywords.is_empty(), |d| {
|
.when(!keywords.is_empty(), |d| {
|
||||||
@@ -247,33 +333,27 @@ impl Render for Explorer {
|
|||||||
.child(format!("keywords: {keywords}")),
|
.child(format!("keywords: {keywords}")),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!lens_line.is_empty(), |d| {
|
.when_some(path_hint_line, |d, line| {
|
||||||
d.child(
|
d.child(
|
||||||
div()
|
div()
|
||||||
.text_color(text_dim)
|
.text_color(text_dim)
|
||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.child(lens_line),
|
.child(line),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!sock_line.is_empty(), |d| {
|
.when_some(model_line, |d, line| {
|
||||||
d.child(
|
d.child(
|
||||||
div()
|
div()
|
||||||
.text_color(text_dim)
|
.text_color(text_dim)
|
||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.child(sock_line),
|
.child(line),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(!refs_line.is_empty(), |d| {
|
.into_any_element(),
|
||||||
d.child(
|
);
|
||||||
div()
|
}
|
||||||
.text_color(text_dim)
|
out
|
||||||
.text_size(px(11.))
|
}
|
||||||
.child(refs_line),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = div()
|
let body = div()
|
||||||
@@ -293,3 +373,14 @@ impl Render for Explorer {
|
|||||||
.child(body)
|
.child(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lens_label(l: Lens) -> &'static str {
|
||||||
|
match l {
|
||||||
|
Lens::Grid => "grid",
|
||||||
|
Lens::Code => "code",
|
||||||
|
Lens::Gallery => "gallery",
|
||||||
|
Lens::Database => "database",
|
||||||
|
Lens::Markdown => "markdown",
|
||||||
|
Lens::Tree => "tree",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ use ulid::Ulid;
|
|||||||
// Re-export para consumidores
|
// Re-export para consumidores
|
||||||
pub use ::ulid;
|
pub use ::ulid;
|
||||||
|
|
||||||
|
pub mod query;
|
||||||
|
|
||||||
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
|
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
|
||||||
pub const MONAD_SCHEMA_VERSION: u16 = 1;
|
pub const MONAD_SCHEMA_VERSION: u16 = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,13 +179,21 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
|||||||
brahman_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
|
brahman_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Engine como Ente.
|
// 1. Decidir el path del query socket ANTES de armar el engine
|
||||||
let engine_card = build_engine_card();
|
// Card (porque viaja como service_socket en la Card).
|
||||||
|
let query_socket = nouser_card::query::transport::default_socket_path();
|
||||||
|
|
||||||
|
// 2. Engine como Ente. Declara service_socket + flow.output para
|
||||||
|
// que el broker pueda emitir MatchEvent::Available a consumers
|
||||||
|
// interesados en `flow.input = monad-list:json`.
|
||||||
|
let engine_card = build_engine_card(query_socket.clone());
|
||||||
let engine_id = engine_card.id;
|
let engine_id = engine_card.id;
|
||||||
let engine_label = engine_card.label.clone();
|
let engine_label = engine_card.label.clone();
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"nouser daemon: publicando engine '{}' (kind=Ente, id={})",
|
"nouser daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
|
||||||
engine_label, engine_id
|
engine_label,
|
||||||
|
engine_id,
|
||||||
|
query_socket.display()
|
||||||
);
|
);
|
||||||
pool.spawn(engine_card);
|
pool.spawn(engine_card);
|
||||||
|
|
||||||
@@ -272,11 +280,39 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
|||||||
scanned_count, newly_spawned
|
scanned_count, newly_spawned
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Engine query socket: bind antes del watcher para que cualquier
|
||||||
|
// consumer descubierto vía broker pueda consultarnos enseguida.
|
||||||
|
// Si el bind falla, seguimos sin él — la UI degrada a "no
|
||||||
|
// alcanzable" pero el daemon sigue procesando cambios.
|
||||||
|
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
|
||||||
|
let _query_listener = match nouser_core::engine_socket::spawn_listener(
|
||||||
|
nouser_core::engine_socket::ListenerConfig {
|
||||||
|
socket_path: query_socket.clone(),
|
||||||
|
engine_id,
|
||||||
|
engine_label: engine_label.clone(),
|
||||||
|
watching: Some(dir.clone()),
|
||||||
|
},
|
||||||
|
db_shared.clone(),
|
||||||
|
) {
|
||||||
|
Ok(h) => {
|
||||||
|
eprintln!(
|
||||||
|
"nouser daemon: query socket activo en {} (proto: nouser_card::query)",
|
||||||
|
query_socket.display()
|
||||||
|
);
|
||||||
|
Some(h)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"nouser daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Watcher: cada cambio en el árbol — coalescido con debounce de
|
// Watcher: cada cambio en el árbol — coalescido con debounce de
|
||||||
// 150ms — dispara un re-scan + re-cluster del directorio y
|
// 150ms — dispara un re-scan + re-cluster del directorio y
|
||||||
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
|
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
|
||||||
// gracias al replace en `SidecarPool::spawn`).
|
// gracias al replace en `SidecarPool::spawn`).
|
||||||
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
|
|
||||||
let _watcher = match spawn_fs_watcher(
|
let _watcher = match spawn_fs_watcher(
|
||||||
dir.clone(),
|
dir.clone(),
|
||||||
db_shared.clone(),
|
db_shared.clone(),
|
||||||
@@ -301,6 +337,8 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
|||||||
|
|
||||||
std::thread::park();
|
std::thread::park();
|
||||||
drop(_watcher);
|
drop(_watcher);
|
||||||
|
drop(_query_listener);
|
||||||
|
let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
|
||||||
drop(pool);
|
drop(pool);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -726,14 +764,32 @@ fn embed_via(
|
|||||||
|
|
||||||
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
|
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
|
||||||
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
|
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
|
||||||
fn build_engine_card() -> brahman_card::Card {
|
///
|
||||||
use brahman_card::{Card, CardKind, Lifecycle, Payload, Priority, Supervision};
|
/// Declara `service_socket` y `flow.output = monad-list:json` para
|
||||||
|
/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
|
||||||
|
/// MatchEvent y consultarle por sus Mónadas sin pasar por
|
||||||
|
/// brahman-admin.
|
||||||
|
fn build_engine_card(service_socket: std::path::PathBuf) -> brahman_card::Card {
|
||||||
|
use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
|
||||||
|
use nouser_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
|
||||||
|
|
||||||
Card {
|
Card {
|
||||||
payload: Payload::Virtual,
|
payload: Payload::Virtual,
|
||||||
supervision: Supervision::Delegate,
|
supervision: Supervision::Delegate,
|
||||||
lifecycle: Lifecycle::Daemon,
|
lifecycle: Lifecycle::Daemon,
|
||||||
priority: Priority::Normal,
|
priority: Priority::Normal,
|
||||||
kind: CardKind::Ente,
|
kind: CardKind::Ente,
|
||||||
|
service_socket: Some(service_socket),
|
||||||
|
flow: Flows {
|
||||||
|
input: vec![],
|
||||||
|
output: vec![Flow {
|
||||||
|
name: FLOW_MONAD_LIST.into(),
|
||||||
|
ty: TypeRef::Primitive {
|
||||||
|
name: FLOW_TYPE_NAME.into(),
|
||||||
|
},
|
||||||
|
pin_to: None,
|
||||||
|
}],
|
||||||
|
},
|
||||||
..Card::new("brahman.nouser_engine")
|
..Card::new("brahman.nouser_engine")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
//! Listener Unix-socket que sirve [`nouser_card::query::QueryRequest`].
|
||||||
|
//!
|
||||||
|
//! El daemon `nouser` lo monta para que cualquier consumer (UI, CLI,
|
||||||
|
//! otro módulo) pueda preguntarle por sus Mónadas sin pasar por
|
||||||
|
//! brahman-admin. El path del socket viaja en el `Card.service_socket`
|
||||||
|
//! del engine; el broker brahman lo enseña vía MatchEvent::Available
|
||||||
|
//! cuando un consumer declara `flow.input = monad-list:json`.
|
||||||
|
//!
|
||||||
|
//! Wire: line-delimited JSON, single-shot por conexión. Mismo patrón
|
||||||
|
//! que `nouser-nous` (mock/real ↔ nouser-core), reutilizado.
|
||||||
|
//!
|
||||||
|
//! Threading: un thread dedicado, blocking I/O. No vale la pena traer
|
||||||
|
//! tokio acá — la frecuencia esperada es muy baja (UI poll cada 2s)
|
||||||
|
//! y el handler es trivial (lock db → snapshot → write).
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use nouser_card::query::{
|
||||||
|
EngineInfo, ErrorResponse, ListMonadsResponse, MonadView, QueryRequest,
|
||||||
|
};
|
||||||
|
use nouser_card::ulid::Ulid;
|
||||||
|
|
||||||
|
use crate::db::MonadDb;
|
||||||
|
|
||||||
|
/// Configuración del listener.
|
||||||
|
pub struct ListenerConfig {
|
||||||
|
pub socket_path: PathBuf,
|
||||||
|
pub engine_id: Ulid,
|
||||||
|
pub engine_label: String,
|
||||||
|
/// Path del directorio que el daemon está observando, para incluir
|
||||||
|
/// en `EngineInfo.watching`. `None` si el daemon no observa nada.
|
||||||
|
pub watching: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind del socket + spawn de un thread con accept loop. Devuelve el
|
||||||
|
/// path final (útil para confirmar) y un `JoinHandle` para shutdown
|
||||||
|
/// explícito (drop = thread sigue, listener queda).
|
||||||
|
///
|
||||||
|
/// Si el socket ya existe (sesión anterior crasheada), se intenta
|
||||||
|
/// removerlo antes del bind. Errores de bind se propagan al caller.
|
||||||
|
pub fn spawn_listener(
|
||||||
|
config: ListenerConfig,
|
||||||
|
db: Arc<Mutex<MonadDb>>,
|
||||||
|
) -> std::io::Result<std::thread::JoinHandle<()>> {
|
||||||
|
if config.socket_path.exists() {
|
||||||
|
let _ = std::fs::remove_file(&config.socket_path);
|
||||||
|
}
|
||||||
|
if let Some(parent) = config.socket_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let listener = UnixListener::bind(&config.socket_path)?;
|
||||||
|
|
||||||
|
let handle = std::thread::Builder::new()
|
||||||
|
.name("nouser-engine-listener".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for conn in listener.incoming() {
|
||||||
|
match conn {
|
||||||
|
Ok(stream) => {
|
||||||
|
// Handler sincrónico inline. La frecuencia
|
||||||
|
// esperada (UI poll cada N segundos) no
|
||||||
|
// amerita spawn-per-connection; si en el
|
||||||
|
// futuro hay carga, agregar un threadpool.
|
||||||
|
if let Err(e) = handle_conn(stream, &db, &config) {
|
||||||
|
eprintln!("[engine-socket] conn falló: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[engine-socket] accept falló: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_conn(
|
||||||
|
mut stream: UnixStream,
|
||||||
|
db: &Arc<Mutex<MonadDb>>,
|
||||||
|
config: &ListenerConfig,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let mut reader = BufReader::new(stream.try_clone()?);
|
||||||
|
let mut line = String::new();
|
||||||
|
let n = reader.read_line(&mut line)?;
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp_bytes = match serde_json::from_str::<QueryRequest>(line.trim()) {
|
||||||
|
Ok(QueryRequest::ListMonads) => match handle_list_monads(db, config) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => encode_error(format!("list_monads falló: {e}")),
|
||||||
|
},
|
||||||
|
Err(e) => encode_error(format!("JSON inválido: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.write_all(resp_bytes.as_bytes())?;
|
||||||
|
stream.write_all(b"\n")?;
|
||||||
|
stream.flush()?;
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_list_monads(
|
||||||
|
db: &Arc<Mutex<MonadDb>>,
|
||||||
|
config: &ListenerConfig,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?;
|
||||||
|
let monads: Vec<MonadView> = db_lock.monads().map(MonadView::from_manifest).collect();
|
||||||
|
let resp = ListMonadsResponse {
|
||||||
|
engine: EngineInfo {
|
||||||
|
id: config.engine_id,
|
||||||
|
label: config.engine_label.clone(),
|
||||||
|
watching: config.watching.as_ref().map(|p| p.display().to_string()),
|
||||||
|
},
|
||||||
|
monads,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_error(msg: String) -> String {
|
||||||
|
let err = ErrorResponse { error: msg };
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::MonadDb;
|
||||||
|
use nouser_card::MonadManifest;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn fresh_socket_path(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir();
|
||||||
|
let unique = format!("{}-{}-{}.sock", name, std::process::id(), Ulid::new());
|
||||||
|
dir.join(unique)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_monads_roundtrip_empty() {
|
||||||
|
let socket = fresh_socket_path("nouser-engine-test");
|
||||||
|
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||||
|
let engine_id = Ulid::new();
|
||||||
|
let _h = spawn_listener(
|
||||||
|
ListenerConfig {
|
||||||
|
socket_path: socket.clone(),
|
||||||
|
engine_id,
|
||||||
|
engine_label: "test-engine".into(),
|
||||||
|
watching: Some(PathBuf::from("/tmp/x")),
|
||||||
|
},
|
||||||
|
db.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Pequeña espera para que el bind se asiente (en práctica el
|
||||||
|
// socket existe inmediatamente tras el bind, pero algunos FS
|
||||||
|
// necesitan un tick). Si esto resulta flaky, agregar un loop
|
||||||
|
// de wait_for(socket.exists()).
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
let resp = 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"));
|
||||||
|
assert!(resp.monads.is_empty());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_monads_returns_views() {
|
||||||
|
let socket = fresh_socket_path("nouser-engine-test-views");
|
||||||
|
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||||
|
let m1 = MonadManifest::new("alpha");
|
||||||
|
let m2 = MonadManifest::new("beta");
|
||||||
|
{
|
||||||
|
let mut g = db.lock().unwrap();
|
||||||
|
g.replace_monads(vec![m1.clone(), m2.clone()]);
|
||||||
|
}
|
||||||
|
let _h = spawn_listener(
|
||||||
|
ListenerConfig {
|
||||||
|
socket_path: socket.clone(),
|
||||||
|
engine_id: Ulid::new(),
|
||||||
|
engine_label: "test".into(),
|
||||||
|
watching: None,
|
||||||
|
},
|
||||||
|
db.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
let resp = 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"));
|
||||||
|
assert!(labels.contains(&"beta"));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_request_returns_error_response() {
|
||||||
|
let socket = fresh_socket_path("nouser-engine-test-bad");
|
||||||
|
let db = Arc::new(Mutex::new(MonadDb::new()));
|
||||||
|
let _h = spawn_listener(
|
||||||
|
ListenerConfig {
|
||||||
|
socket_path: socket.clone(),
|
||||||
|
engine_id: Ulid::new(),
|
||||||
|
engine_label: "test".into(),
|
||||||
|
watching: None,
|
||||||
|
},
|
||||||
|
db.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
|
||||||
|
// Bypass del cliente tipado: mandamos JSON inválido a mano.
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
let mut stream = UnixStream::connect(&socket).unwrap();
|
||||||
|
stream.write_all(b"not json\n").unwrap();
|
||||||
|
stream.flush().unwrap();
|
||||||
|
let mut reader = BufReader::new(stream);
|
||||||
|
let mut response = String::new();
|
||||||
|
reader.read_line(&mut response).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.contains("\"error\""),
|
||||||
|
"esperaba ErrorResponse, got: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
pub mod cluster;
|
pub mod cluster;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod embed;
|
pub mod embed;
|
||||||
|
pub mod engine_socket;
|
||||||
pub mod scanner;
|
pub mod scanner;
|
||||||
|
|
||||||
pub use nouser_card::*;
|
pub use nouser_card::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user