tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

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 · (5 killa ñawpaq) · tayta ce424eb6a · kay commitpi sach'ata qhaway

Semántico sayk'uy · 11

crates/apps/brahman-broker-explorer/src/main.rs

5 qillqakuna · +413 −0

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));
+    }
+}