feat(brahman-broker-explorer): nueva app probe del broker brahman
Iter 14. Cierra otro frente: visibilidad del broker handshake. La
app probe cada 5s vía await_provider_blocking y reporta 4 estados
claros (Pending / Down / UpNoProvider / UpWithProvider).
crates/apps/brahman-broker-explorer/:
- Deps: brahman-handshake + brahman-sidecar + stack yahweh themed.
- ProbeState enum con 4 variants.
- Polling cx.spawn cada 5s; el probe blocking se ejecuta en
cx.background_executor().spawn para no congelar el main thread.
- Configuración via env: BRAHMAN_INIT_SOCKET, BRAHMAN_BROKER_PROBE_FLOW
(default broker-health), BRAHMAN_BROKER_PROBE_TYPE (default ping).
- UI: header probe info + theme switcher; banner permanente
Error/Warning/Success según estado; stat card con accent color.
- 2 tests sanity.
Smoke run verificado: bootstrap OK, panic esperado sin display.
Apps GUI themed: 5 (nakui-ui + 3 explorers + ahora broker-explorer).
Limitaciones: observer Card se registra/desregistra en cada probe;
no muestra lista global de Cards (handshake no expone API). Para
timeline real de MatchEvents hace falta mantener el Client vivo
entre probes — scope futuro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1493d616fdad6bc2aa22ae26507c73622e121590 · ruwaq Sergio · 2026-05-10T12:23:16Z (5 killa ñawpaq) · tayta ce424eb6a · kay commitpi sach'ata qhaway
Semántico sayk'uy · 11
crates/apps/brahman-broker-explorer/src/main.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d299540..54c5fec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,70 @@ ratio/diff ver `git show <sha>`.
## 2026-05-10
+### feat(brahman-broker-explorer): nueva app probe del broker brahman
+Iter 14. Cierra otro frente: visibilidad del broker brahman (el
+broker handshake que matchea Cards consumer/producer). Hasta ahora
+no había forma de "ver" si el broker estaba up sin invocar otro
+binario CLI. Ahora hay una app GUI que probe cada 5s y reporta 3
+estados claros.
+
+Crate nuevo `crates/apps/brahman-broker-explorer/`:
+- **Deps**: `brahman-handshake`, `brahman-sidecar` + el stack
+ yahweh themed (theme + 3 widgets). Consume el mismo
+ `await_provider_blocking` que usa `nouser-explorer`.
+- **`ProbeState` enum** con 4 variants:
+ - `Pending` (estado inicial al boot, antes del primer probe).
+ - `Down { reason }` — connect failed, broker no escucha.
+ - `UpNoProvider { flow }` — broker reachable + sin productor
+ para el flow probado dentro del timeout.
+ - `UpWithProvider { flow, producer_socket }` — broker reachable
+ + matcheó algo, devuelve el socket del provider.
+- **Polling loop** en `cx.spawn` cada 5s; el probe (que es
+ bloqueante porque internamente usa tokio runtime) se ejecuta en
+ `cx.background_executor().spawn(...)` para no congelar el main
+ thread del UI.
+- **Configuración via env**:
+ - `BRAHMAN_INIT_SOCKET` — path del broker (default resuelto por
+ `brahman_handshake::transport`).
+ - `BRAHMAN_BROKER_PROBE_FLOW` — flow del Card observer
+ (default `broker-health`).
+ - `BRAHMAN_BROKER_PROBE_TYPE` — type name (default `ping`).
+- **UI**: header con probe info + theme switcher; banner permanente
+ (Error/Warning/Success/none según estado) debajo del header;
+ stat card con accent color por estado y descripción.
+- 2 tests sanity (default state es Pending; constants coherentes:
+ PROBE_TIMEOUT < POLL_INTERVAL >= 2s).
+
+Smoke run del binario verificado: bootstrap completo OK, panic
+esperado en open_window por falta de display.
+
+Beneficio operativo:
+- Si tenés un broker corriendo en `~/.local/share/brahman/init.sock`,
+ el explorer lo detecta + reporta estado verde con su socket.
+- Si no hay broker, banner rojo + msg claro indicando el path
+ probado.
+- Si hay broker pero ningún Card produce el flow probado, banner
+ amber — útil para distinguir "broker down" de "broker up,
+ no productor del tipo X".
+- Apuntando el flow/type via env, podés monitor productores
+ específicos: ej. `BRAHMAN_BROKER_PROBE_FLOW=monad-list
+ BRAHMAN_BROKER_PROBE_TYPE=json` para ver si nouser está sirviendo.
+
+Apps GUI integradas al stack themed: **5** (nakui-ui, nakui-explorer,
+nouser-explorer, minga-explorer, brahman-broker-explorer).
+
+Limitaciones documentadas:
+- El observer registra una Card temporal en cada probe (cada 5s).
+ Eso ensucia un poco las estadísticas del broker (Cards
+ registradas/desregistradas). No impacta funcionalidad pero
+ inflama el log si el broker tiene observability habilitada.
+- No muestra la **lista global** de Cards registradas en el broker
+ — el protocolo handshake actual no expone esa API. Para eso
+ habría que agregar un endpoint `ListSessions` al broker server.
+- No mantiene un buffer de MatchEvents. Cada probe es independiente.
+ Para timeline de matches, hace falta mantener el Client vivo
+ entre probes — scope futuro.
+
### feat(yahweh-theme): persistencia de la preferencia de theme entre runs
Iter 13. El theme switcher ya cambiaba el chrome en runtime, pero
al cerrar y reabrir la app el theme volvía a Nebula default. Ahora
diff --git a/Cargo.lock b/Cargo.lock
index e790fce..b783c09 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1235,6 +1235,19 @@ dependencies = [
"ulid",
]
+[[package]]
+name = "brahman-broker-explorer"
+version = "0.1.0"
+dependencies = [
+ "brahman-handshake",
+ "brahman-sidecar",
+ "gpui",
+ "yahweh-theme",
+ "yahweh-widget-banner",
+ "yahweh-widget-card",
+ "yahweh-widget-theme-switcher",
+]
+
[[package]]
name = "brahman-card"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index ba5fc2c..fb0e7e2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -93,6 +93,7 @@ members = [
"crates/apps/nakui-explorer",
"crates/apps/nakui-ui",
"crates/apps/minga-explorer",
+ "crates/apps/brahman-broker-explorer",
]
[workspace.package]
diff --git a/crates/apps/brahman-broker-explorer/Cargo.toml b/crates/apps/brahman-broker-explorer/Cargo.toml
new file mode 100644
index 0000000..b4aac68
--- /dev/null
+++ b/crates/apps/brahman-broker-explorer/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "brahman-broker-explorer"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Probe GUI del broker brahman: conecta cada N segundos vía await_provider_blocking con un Card observer agnóstico, reporta 3 estados (down / up sin provider / up con provider)."
+
+[dependencies]
+brahman-handshake = { path = "../../core/brahman-handshake" }
+brahman-sidecar = { path = "../../shared/brahman-sidecar" }
+yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
+yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" }
+yahweh-widget-card = { path = "../../modules/ui_engine/widgets/card" }
+yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" }
+gpui = { workspace = true }
+
+[[bin]]
+name = "brahman-broker-explorer"
+path = "src/main.rs"
diff --git a/crates/apps/brahman-broker-explorer/src/main.rs b/crates/apps/brahman-broker-explorer/src/main.rs
new file mode 100644
index 0000000..15539cb
--- /dev/null
+++ b/crates/apps/brahman-broker-explorer/src/main.rs
@@ -0,0 +1,316 @@
+//! `brahman-broker-explorer` — probe GUI del broker brahman.
+//!
+//! Cada [`POLL_INTERVAL`] arma un Card observer agnóstico y lo
+//! manda al broker via `brahman_sidecar::await_provider_blocking`
+//! (que internamente abre tokio runtime + Unix socket + handshake).
+//! Reporta 3 estados:
+//!
+//! - **Down**: connect failed (broker no escucha en el socket).
+//! - **Up sin provider**: connect OK, pero el broker no encontró
+//! productor para el flow probado dentro del timeout.
+//! - **Up con provider**: connect OK + el broker matcheó algo →
+//! muestra el `producer_service_socket` recibido.
+//!
+//! Configuración via env:
+//! - `BRAHMAN_INIT_SOCKET` — path del socket del broker (default
+//! resuelto por `brahman_handshake::transport`).
+//! - `BRAHMAN_BROKER_PROBE_FLOW` — nombre del flow probe (default
+//! `broker-health`).
+//! - `BRAHMAN_BROKER_PROBE_TYPE` — type name del flow probe
+//! (default `ping`).
+//!
+//! Usá un type name probable (ej. `monad-list:json`,
+//! `event-log:tail`) para detectar productores específicos del
+//! ecosistema.
+
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+
+use brahman_handshake::transport;
+use brahman_sidecar::{await_provider_blocking, build_consumer_card, ConsumerError};
+use gpui::{
+ div, prelude::*, px, App, Application, Bounds, Context, IntoElement, Render, SharedString,
+ Window, WindowBounds, WindowOptions,
+};
+use yahweh_theme::Theme;
+use yahweh_widget_banner::{banner_themed, Banner};
+use yahweh_widget_card::card_themed;
+use yahweh_widget_theme_switcher::theme_switcher;
+
+const POLL_INTERVAL: Duration = Duration::from_secs(5);
+const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
+
+fn main() {
+ Application::new().run(|cx: &mut App| {
+ Theme::install_default(cx);
+ let bounds = Bounds::centered(None, gpui::size(px(720.), px(480.)), cx);
+ cx.open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(bounds)),
+ titlebar: Some(gpui::TitlebarOptions {
+ title: Some(SharedString::from("Brahman Broker — Probe")),
+ ..Default::default()
+ }),
+ ..Default::default()
+ },
+ |_w, cx| cx.new(Explorer::new),
+ )
+ .expect("open window");
+ cx.activate(true);
+ });
+}
+
+/// Snapshot de un probe.
+#[derive(Clone, Debug)]
+enum ProbeState {
+ /// Aún no probó (boot, primer ciclo).
+ Pending,
+ /// Connect failed → broker no responde en el path.
+ Down { reason: String },
+ /// Connect OK, sin matching producer dentro del timeout.
+ UpNoProvider { flow: String },
+ /// Connect OK, broker matcheó al menos un producer.
+ UpWithProvider {
+ flow: String,
+ producer_socket: PathBuf,
+ },
+}
+
+struct Explorer {
+ socket_path: PathBuf,
+ flow: String,
+ type_name: String,
+ state: ProbeState,
+ last_probe_ms: u64,
+ last_probe_at: Option<Instant>,
+}
+
+impl Explorer {
+ fn new(cx: &mut Context<Self>) -> Self {
+ let socket_path = transport::default_socket_path();
+ let flow = std::env::var("BRAHMAN_BROKER_PROBE_FLOW")
+ .unwrap_or_else(|_| "broker-health".to_string());
+ let type_name = std::env::var("BRAHMAN_BROKER_PROBE_TYPE")
+ .unwrap_or_else(|_| "ping".to_string());
+
+ let flow_for_loop = flow.clone();
+ let type_for_loop = type_name.clone();
+ cx.spawn(async move |this, cx| {
+ let timer = cx.background_executor().clone();
+ let bg = cx.background_executor().clone();
+ loop {
+ let card = build_consumer_card(
+ "brahman-broker-explorer",
+ flow_for_loop.clone(),
+ type_for_loop.clone(),
+ );
+ let started = Instant::now();
+ // El probe es bloqueante (interno tokio runtime); va
+ // al background executor para no congelar el main.
+ let probe_flow = flow_for_loop.clone();
+ let result = bg
+ .spawn(async move { await_provider_blocking(card, PROBE_TIMEOUT) })
+ .await;
+ let elapsed = started.elapsed().as_millis() as u64;
+
+ let new_state = match result {
+ Ok(socket) => ProbeState::UpWithProvider {
+ flow: probe_flow.clone(),
+ producer_socket: socket,
+ },
+ Err(ConsumerError::NoProvider { .. }) => ProbeState::UpNoProvider {
+ flow: probe_flow.clone(),
+ },
+ Err(e) => ProbeState::Down {
+ reason: e.to_string(),
+ },
+ };
+
+ let _ = this.update(cx, |me, cx| {
+ me.state = new_state;
+ me.last_probe_ms = elapsed;
+ me.last_probe_at = Some(Instant::now());
+ cx.notify();
+ });
+
+ timer.timer(POLL_INTERVAL).await;
+ }
+ })
+ .detach();
+
+ Self {
+ socket_path,
+ flow,
+ type_name,
+ state: ProbeState::Pending,
+ last_probe_ms: 0,
+ last_probe_at: None,
+ }
+ }
+}
+
+impl Render for Explorer {
+ fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let theme = Theme::global(cx).clone();
+ let bg = theme.bg_app.clone();
+ let text = theme.fg_text;
+ let text_dim = theme.fg_muted;
+ // Acentos por estado: verde = arriba, amber = arriba sin
+ // provider, rojo = abajo, gris = pending.
+ let accent_up = gpui::rgb(0xa3be8c);
+ let accent_partial = gpui::rgb(0xebcb8b);
+ let accent_down = gpui::rgb(0xbf616a);
+ let accent_pending = gpui::rgb(0x6a7280);
+
+ let header_text = format!(
+ "Probe: {} · flow: {}/{} · reload {} ms",
+ self.socket_path.display(),
+ self.flow,
+ self.type_name,
+ self.last_probe_ms,
+ );
+
+ let header = div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .px(px(16.))
+ .py(px(12.))
+ .bg(theme.bg_panel.clone())
+ .border_b_1()
+ .border_color(theme.border)
+ .text_color(text)
+ .text_size(px(14.))
+ .child(div().flex_grow().child(header_text))
+ .child(theme_switcher(cx));
+
+ // Banner permanente debajo del header con el estado actual.
+ // Severidad acorde al kind.
+ let status_banner = match &self.state {
+ ProbeState::Pending => None,
+ ProbeState::Down { reason } => Some(banner_themed(
+ cx,
+ Banner::Error,
+ SharedString::from(format!("Broker DOWN — {reason}")),
+ )),
+ ProbeState::UpNoProvider { .. } => Some(banner_themed(
+ cx,
+ Banner::Warning,
+ SharedString::from("Broker UP, sin provider para el flow"),
+ )),
+ ProbeState::UpWithProvider { .. } => Some(banner_themed(
+ cx,
+ Banner::Success,
+ SharedString::from("Broker UP, provider matcheado"),
+ )),
+ };
+
+ let body = div()
+ .flex()
+ .flex_col()
+ .gap(px(8.))
+ .px(px(16.))
+ .py(px(16.))
+ .child(state_card(cx, &self.state, text, text_dim, accent_up,
+ accent_partial, accent_down, accent_pending));
+
+ div()
+ .flex()
+ .flex_col()
+ .size_full()
+ .bg(bg)
+ .child(header)
+ .when_some(status_banner, |d, b| d.child(b))
+ .child(body)
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn state_card(
+ cx: &mut Context<Explorer>,
+ state: &ProbeState,
+ text: gpui::Hsla,
+ text_dim: gpui::Hsla,
+ accent_up: gpui::Rgba,
+ accent_partial: gpui::Rgba,
+ accent_down: gpui::Rgba,
+ accent_pending: gpui::Rgba,
+) -> impl IntoElement {
+ let (label, accent, value, description): (&str, gpui::Rgba, String, String) = match state {
+ ProbeState::Pending => (
+ "Estado",
+ accent_pending,
+ "PENDING".into(),
+ "esperando primer probe…".into(),
+ ),
+ ProbeState::Down { reason } => (
+ "Estado",
+ accent_down,
+ "DOWN".into(),
+ format!("connect failed: {reason}"),
+ ),
+ ProbeState::UpNoProvider { flow } => (
+ "Estado",
+ accent_partial,
+ "UP / NO PROVIDER".into(),
+ format!("broker reachable; sin productor para flow `{flow}`"),
+ ),
+ ProbeState::UpWithProvider {
+ flow,
+ producer_socket,
+ } => (
+ "Estado",
+ accent_up,
+ "UP / PROVIDER".into(),
+ format!(
+ "flow `{flow}` matcheado en producer socket: {}",
+ producer_socket.display()
+ ),
+ ),
+ };
+
+ card_themed(cx)
+ .border_l_4()
+ .border_color(accent)
+ .child(
+ div()
+ .text_color(accent)
+ .text_size(px(11.))
+ .child(SharedString::from(label.to_string())),
+ )
+ .child(
+ div()
+ .text_color(text)
+ .text_size(px(28.))
+ .child(SharedString::from(value)),
+ )
+ .child(
+ div()
+ .text_color(text_dim)
+ .text_size(px(11.))
+ .child(SharedString::from(description)),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn pending_is_default_state_at_boot() {
+ // El estado inicial DEBE ser Pending — sino el banner del
+ // header arrancaría con un Error o Warning sin haber probado
+ // (mensaje engañoso).
+ let s = ProbeState::Pending;
+ assert!(matches!(s, ProbeState::Pending));
+ }
+
+ #[test]
+ fn poll_and_probe_constants_are_sane() {
+ // Sanity: el timeout del probe DEBE ser menor que el
+ // intervalo de polling, sino los probes se solapan.
+ assert!(PROBE_TIMEOUT < POLL_INTERVAL);
+ // El intervalo no debería ser tan corto que sature al broker.
+ assert!(POLL_INTERVAL >= Duration::from_secs(2));
+ }
+}