From 1493d616fdad6bc2aa22ae26507c73622e121590 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 10 May 2026 12:23:16 +0000 Subject: [PATCH] feat(brahman-broker-explorer): nueva app probe del broker brahman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 64 ++++ Cargo.lock | 13 + Cargo.toml | 1 + .../apps/brahman-broker-explorer/Cargo.toml | 19 ++ .../apps/brahman-broker-explorer/src/main.rs | 316 ++++++++++++++++++ 5 files changed, 413 insertions(+) create mode 100644 crates/apps/brahman-broker-explorer/Cargo.toml create mode 100644 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 `. ## 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, +} + +impl Explorer { + fn new(cx: &mut Context) -> 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) -> 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, + 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)); + } +}