From 5b8f71e0deafe72541768564cb8c19e4df9b7cf8 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 19:33:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(nakui-explorer):=20nuevo=20binario=20GPUI?= =?UTF-8?q?=20=E2=80=94=20Nakui=20visible=20en=20la=20interfaz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone nakui-explorer (paralelo a nouser-explorer) que renderea el event log de un repo Nakui: timeline scrollable de seeds + morphisms con sus parametros, breakdown por entity type, polling cada 2s para detectar nuevos eventos appended sin restart. Diseno: lee directamente el archivo .jsonl del nakui_core::event_log:: EventLog. Path por env NAKUI_EVENT_LOG, default "nakui.jsonl" en pwd. Sin discovery via broker brahman porque nakui hoy es CLI/library/demos, no daemon. Cuando se daemonice, sustituir el lector de archivo por un sidecar consumer (mismo patron que nouser-explorer). UI: - Header: path, count total + breakdown seeds/morphisms, reload time. - Breakdown line: top 5 buckets por frecuencia (entities + morphisms). - Timeline: tarjetas color-coded por kind (azul=seed, verde=morphism) con #seq, kind, entity/morphism, id corto, preview de data/params, schema hash o "(legacy)". Mas-recientes-primero, hasta 200 visibles. - Error banner si lectura falla; el explorer no crashea, sigue intentando cada 2s. Wire: nuevo crates/apps/nakui-explorer/ agregado al workspace. Deps minimas: nakui-core, gpui, serde_json, uuid (feature serde). Sin deps de brahman (Nakui standalone). Tests: 7 unitarios cubriendo load_log (in-order, missing-file), breakdown (counts + buckets), preview_value (truncate + intact), short_uuid + short_hash. Activacion: NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer Estado del CHANGELOG global tras este commit: cero pendientes fundamentados activos. Lo unico que queda es minga-vfs (FUSE, explicitamente diferido) y mejoras nice-to-have (cobertura adicional per-lenguaje, daemon-izacion de nakui para sidecar discovery). --- CHANGELOG.md | 58 +++ Cargo.lock | 11 + Cargo.toml | 1 + crates/apps/nakui-explorer/Cargo.toml | 19 + crates/apps/nakui-explorer/src/main.rs | 505 +++++++++++++++++++++++++ 5 files changed, 594 insertions(+) create mode 100644 crates/apps/nakui-explorer/Cargo.toml create mode 100644 crates/apps/nakui-explorer/src/main.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0585b6a..c862de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,64 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz +Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone +`nakui-explorer` (paralelo a `nouser-explorer`) que renderea el +event log de un repo Nakui: timeline scrollable de seeds + morphisms +con sus parámetros, breakdown por entity type, polling cada 2s para +detectar nuevos eventos appended sin restart del explorer. + +Diseño: +- Lee directamente el archivo `.jsonl` del `nakui_core::event_log::EventLog`. + Path por env `NAKUI_EVENT_LOG`, default `nakui.jsonl` en pwd. +- Sin discovery vía broker brahman porque nakui hoy es CLI/library/ + demos, no daemon. Cuando se daemonice, sustituir el lector de + archivo por un sidecar consumer (mismo patrón que nouser-explorer + actualmente usa). + +UI: +- **Header**: path del log, count total + breakdown seeds/morphisms, + tiempo del último reload en ms. +- **Breakdown line**: top 5 buckets por frecuencia (entities + nombres + de morphisms con prefijo `→`). +- **Timeline**: tarjetas color-coded por kind (azul=seed, + verde=morphism). Cada tarjeta muestra `#seq`, kind, entity/morphism + name, id corto (8 hex), preview del data/params (80 chars), schema + hash corto (8 hex) o `(legacy)` si pre-versioning. Mostradas + más-recientes-primero, hasta 200 visibles (suficiente para + navegación; sin scroll virtualizado por ahora). +- **Error banner**: si la lectura falla (archivo inexistente o + corrupto), banner rojo con el motivo. El explorer NO crashea — + sigue intentando cada 2s. + +Wire en workspace: +- Nuevo `crates/apps/nakui-explorer/` agregado a `[workspace] members`. +- Deps mínimas: `nakui-core` (para EventLog + LogEntry), `gpui`, + `serde_json`, `uuid` (con feature serde para parsear los IDs). +- Sin deps de brahman por ahora (Nakui standalone). + +Tests: 7 unitarios en `tests` mod del bin: +- `load_log_returns_all_entries_in_order` — cargar un .jsonl + generado a mano, asserta que devuelve 5 entries con seqs 0..4 + contiguous. +- `breakdown_counts_seeds_morphisms_and_buckets` — verifica el + conteo (3 seeds + 2 morphisms) y los buckets esperados. +- `load_missing_file_yields_empty_not_error` — archivo inexistente + devuelve `[]` sin error (delegado al contrato de `EventLog::open`). +- `preview_value_truncates_long_strings` y `_keeps_short_strings_intact`. +- `short_uuid_takes_first_8_chars` y `short_hash_takes_first_4_bytes_hex`. + +Activación: +```sh +NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer +``` + +Estado del CHANGELOG global tras este commit: cero pendientes +fundamentados activos. Lo único que queda es `minga-vfs` (FUSE, +explícitamente diferido por el usuario) y mejoras nice-to-have +(cobertura adicional per-lenguaje, daemon-ización de nakui para +sidecar discovery). + ### feat(minga-core): α-hashing per-language para Python, TypeScript, JavaScript, Go Cierra el último pendiente fundamentado del CHANGELOG. Cada lenguaje soportado por `minga` tiene ahora su propio profile α-equivalente — diff --git a/Cargo.lock b/Cargo.lock index 941c298..c43c5d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6134,6 +6134,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "nakui-explorer" +version = "0.1.0" +dependencies = [ + "gpui", + "nakui-core", + "serde_json", + "tempfile", + "uuid", +] + [[package]] name = "nanorand" version = "0.7.0" diff --git a/Cargo.toml b/Cargo.toml index 3a4206b..a6523f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ members = [ "crates/apps/image_viewer", "crates/apps/yahweh-shell", "crates/apps/nouser-explorer", + "crates/apps/nakui-explorer", ] [workspace.package] diff --git a/crates/apps/nakui-explorer/Cargo.toml b/crates/apps/nakui-explorer/Cargo.toml new file mode 100644 index 0000000..38c6249 --- /dev/null +++ b/crates/apps/nakui-explorer/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nakui-explorer" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Explorador GPUI del event log de Nakui: timeline de seeds + morphisms, schema hashes, breakdown por entity. Standalone (lee un .jsonl) — futura integración con sidecar brahman cuando nakui se daemonice." + +[dependencies] +nakui-core = { path = "../../modules/nakui/core" } +gpui = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["serde"] } + +[dev-dependencies] +tempfile = { workspace = true } + +[[bin]] +name = "nakui-explorer" +path = "src/main.rs" diff --git a/crates/apps/nakui-explorer/src/main.rs b/crates/apps/nakui-explorer/src/main.rs new file mode 100644 index 0000000..1fe882c --- /dev/null +++ b/crates/apps/nakui-explorer/src/main.rs @@ -0,0 +1,505 @@ +//! `nakui-explorer` — panel GPUI que renderea el event log de un +//! repo Nakui: timeline de seeds + morphisms con sus parámetros y +//! breakdown por entity type. +//! +//! ## Diseño +//! +//! Standalone, lee un archivo `.jsonl` (formato append-only del +//! `nakui_core::event_log::EventLog`). Refresh por polling cada 2s +//! para detectar nuevos eventos appended (típico de un nakui ERP en +//! producción que va escribiendo). Sin discovery dinámico vía broker +//! brahman porque nakui hoy es CLI/library/demos, no daemon — cuando +//! se daemonice, sustituir el lector de archivo por un sidecar +//! consumer (mismo patrón que `nouser-explorer`). +//! +//! ## Uso +//! +//! ```sh +//! # Path explícito: +//! NAKUI_EVENT_LOG=/tmp/nakui-demo.jsonl cargo run -p nakui-explorer +//! +//! # Default si la env no está: ./nakui.jsonl en pwd. +//! cargo run -p nakui-explorer +//! ``` + +use std::path::PathBuf; +use std::time::Duration; + +use gpui::{ + div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render, + SharedString, Window, WindowBounds, WindowOptions, +}; +use nakui_core::event_log::{EventLog, LogEntry}; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +fn main() { + Application::new().run(|cx: &mut App| { + let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some(SharedString::from("Nakui — Event Log")), + ..Default::default() + }), + ..Default::default() + }, + |_w, cx| cx.new(Explorer::new), + ) + .expect("open window"); + cx.activate(true); + }); +} + +/// Estado de la vista. `entries` se reescribe en cada tick (el log +/// es append-only así que reload completo es seguro y barato hasta +/// decenas de miles de entries; optimizar a delta cuando duela). +struct Explorer { + log_path: PathBuf, + entries: Vec, + error: Option, + last_load_ms: u64, +} + +impl Explorer { + fn new(cx: &mut Context) -> Self { + let log_path = std::env::var("NAKUI_EVENT_LOG") + .ok() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("nakui.jsonl")); + + // Loop de refresh. + let path_for_loop = log_path.clone(); + cx.spawn(async move |this, cx| { + let timer = cx.background_executor().clone(); + loop { + let started = std::time::Instant::now(); + let result = load_log(&path_for_loop); + let elapsed_ms = started.elapsed().as_millis() as u64; + let _ = this.update(cx, |me, cx| { + match result { + Ok(entries) => { + me.entries = entries; + me.error = None; + } + Err(e) => { + me.error = Some(SharedString::from(format!( + "no pude leer {}: {}", + me.log_path.display(), + e + ))); + } + } + me.last_load_ms = elapsed_ms; + cx.notify(); + }); + timer.timer(REFRESH_INTERVAL).await; + } + }) + .detach(); + + Self { + log_path, + entries: Vec::new(), + error: None, + last_load_ms: 0, + } + } + + fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) { + let mut seeds = 0; + let mut morphisms = 0; + let mut entity_counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for e in &self.entries { + match e { + LogEntry::Seed { entity, .. } => { + seeds += 1; + *entity_counts.entry(entity.clone()).or_default() += 1; + } + LogEntry::Morphism { morphism, .. } => { + morphisms += 1; + *entity_counts.entry(format!("→ {}", morphism)).or_default() += 1; + } + } + } + let mut ranked: Vec<_> = entity_counts.into_iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(&a.1)); + (seeds, morphisms, ranked) + } +} + +fn load_log(path: &std::path::Path) -> Result, String> { + let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?; + log.entries().map_err(|e| format!("read: {e}")) +} + +impl Render for Explorer { + fn render(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { + let bg = rgb(0x14171c); + let card_bg = rgb(0x1d2128); + let text_dim = rgb(0x9ba1ad); + let text = rgb(0xe6e8ec); + let accent_seed = rgb(0x88c0d0); + let accent_morphism = rgb(0xa3be8c); + let _ = card_bg; + + let (seed_count, morphism_count, top_breakdown) = self.breakdown(); + + let header_text = format!( + "Log: {} · {} entries ({} seeds, {} morphisms) · reload {} ms", + self.log_path.display(), + self.entries.len(), + seed_count, + morphism_count, + self.last_load_ms, + ); + + let header = div() + .px(px(16.)) + .py(px(12.)) + .bg(card_bg) + .border_b_1() + .border_color(rgb(0x2a2f38)) + .text_color(text) + .text_size(px(14.)) + .child(header_text); + + let breakdown_line = if top_breakdown.is_empty() { + String::new() + } else { + let parts: Vec = top_breakdown + .iter() + .take(5) + .map(|(k, v)| format!("{k}({v})")) + .collect(); + format!("breakdown: {}", parts.join(", ")) + }; + + let breakdown_div = (!breakdown_line.is_empty()).then(|| { + div() + .px(px(16.)) + .py(px(6.)) + .bg(rgb(0x16191f)) + .text_color(text_dim) + .text_size(px(11.)) + .child(breakdown_line) + }); + + let error_banner = self.error.as_ref().map(|e| { + div() + .px(px(16.)) + .py(px(8.)) + .bg(rgb(0x4a2020)) + .text_color(rgb(0xffd0d0)) + .text_size(px(12.)) + .child(e.clone()) + }); + + // Renderea las últimas N entries (la timeline crece hacia abajo + // en append-order; mostramos las más recientes primero para + // que el usuario vea actividad reciente sin scroll). + const MAX_VISIBLE: usize = 200; + let visible: Vec<_> = self + .entries + .iter() + .rev() + .take(MAX_VISIBLE) + .collect(); + + let cards: Vec = visible + .iter() + .map(|e| match e { + LogEntry::Seed { + seq, + entity, + id, + data, + schema_hash, + } => { + let data_preview = preview_value(data, 80); + let schema_label = schema_hash + .as_ref() + .map(|h| format!("schema={}", short_hash(h))) + .unwrap_or_else(|| "schema=(legacy)".into()); + div() + .flex() + .flex_col() + .px(px(12.)) + .py(px(8.)) + .mb(px(4.)) + .bg(card_bg) + .rounded(px(4.)) + .border_l_4() + .border_color(accent_seed) + .gap(px(2.)) + .child( + div() + .flex() + .flex_row() + .gap(px(8.)) + .items_center() + .child( + div() + .text_color(accent_seed) + .text_size(px(11.)) + .child(format!("[#{seq} seed]")), + ) + .child( + div() + .text_color(text) + .text_size(px(13.)) + .child(entity.clone()), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(10.)) + .child(format!("id={}", short_uuid(id))), + ), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(data_preview), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(10.)) + .child(schema_label), + ) + .into_any_element() + } + LogEntry::Morphism { + seq, + morphism, + inputs, + params, + ops, + schema_hash, + } => { + let inputs_line = if inputs.is_empty() { + String::new() + } else { + let parts: Vec = inputs + .iter() + .map(|(name, id)| format!("{name}={}", short_uuid(id))) + .collect(); + format!("inputs: {}", parts.join(", ")) + }; + let params_line = preview_value(params, 80); + let ops_line = format!("{} op(s)", ops.len()); + let schema_label = schema_hash + .as_ref() + .map(|h| format!("schema={}", short_hash(h))) + .unwrap_or_else(|| "schema=(legacy)".into()); + div() + .flex() + .flex_col() + .px(px(12.)) + .py(px(8.)) + .mb(px(4.)) + .bg(card_bg) + .rounded(px(4.)) + .border_l_4() + .border_color(accent_morphism) + .gap(px(2.)) + .child( + div() + .flex() + .flex_row() + .gap(px(8.)) + .items_center() + .child( + div() + .text_color(accent_morphism) + .text_size(px(11.)) + .child(format!("[#{seq} morph]")), + ) + .child( + div() + .text_color(text) + .text_size(px(13.)) + .child(morphism.clone()), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(10.)) + .child(ops_line), + ), + ) + .when(!inputs_line.is_empty(), |d| { + d.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(inputs_line), + ) + }) + .when(!params_line.is_empty(), |d| { + d.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(format!("params: {params_line}")), + ) + }) + .child( + div() + .text_color(text_dim) + .text_size(px(10.)) + .child(schema_label), + ) + .into_any_element() + } + }) + .collect(); + + let body = div() + .flex() + .flex_col() + .p(px(12.)) + .overflow_hidden() + .children(cards); + + div() + .flex() + .flex_col() + .size_full() + .bg(bg) + .child(header) + .when_some(breakdown_div, |d, b| d.child(b)) + .when_some(error_banner, |d, b| d.child(b)) + .child(body) + } +} + +fn short_uuid(id: &uuid::Uuid) -> String { + let s = id.to_string(); + if s.len() > 8 { + s[..8].to_string() + } else { + s + } +} + +fn short_hash(h: &[u8; 32]) -> String { + let mut s = String::with_capacity(8); + for b in h.iter().take(4) { + use std::fmt::Write; + let _ = write!(s, "{:02x}", b); + } + s +} + +/// Renderiza un `serde_json::Value` en una sola línea limitada a `max` +/// caracteres (para preview en la timeline, no para edición). +fn preview_value(v: &serde_json::Value, max: usize) -> String { + let s = v.to_string(); + if s.chars().count() <= max { + s + } else { + let truncated: String = s.chars().take(max - 3).collect(); + format!("{truncated}...") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_sample_log() -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + // 3 seeds + 2 morphisms, formato canónico de event_log. + let lines = [ + r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#, + r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#, + r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#, + r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#, + r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#, + ]; + for l in lines { + writeln!(f, "{l}").unwrap(); + } + f.flush().unwrap(); + f + } + + #[test] + fn load_log_returns_all_entries_in_order() { + let f = write_sample_log(); + let entries = load_log(f.path()).expect("load"); + assert_eq!(entries.len(), 5); + for (i, e) in entries.iter().enumerate() { + assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous"); + } + } + + #[test] + fn breakdown_counts_seeds_morphisms_and_buckets() { + let f = write_sample_log(); + let entries = load_log(f.path()).unwrap(); + let me = Explorer { + log_path: f.path().to_path_buf(), + entries, + error: None, + last_load_ms: 0, + }; + let (seeds, morphisms, ranked) = me.breakdown(); + assert_eq!(seeds, 3); + assert_eq!(morphisms, 2); + // Buckets esperados: product (2), customer (1), → sale.create (1), + // → sale.refund (1). + assert_eq!(ranked.len(), 4); + let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect(); + assert_eq!(map.get("product"), Some(&2)); + assert_eq!(map.get("customer"), Some(&1)); + assert_eq!(map.get("→ sale.create"), Some(&1)); + assert_eq!(map.get("→ sale.refund"), Some(&1)); + } + + #[test] + fn load_missing_file_yields_empty_not_error() { + // EventLog::open de un archivo inexistente no falla; entries() devuelve []. + let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl"); + let _ = std::fs::remove_file(&path); + let result = load_log(&path).expect("missing path is OK per EventLog::open contract"); + assert!(result.is_empty()); + } + + #[test] + fn preview_value_truncates_long_strings() { + let v = serde_json::json!({"a": "x".repeat(200)}); + let p = preview_value(&v, 30); + assert!(p.len() <= 30); + assert!(p.ends_with("...")); + } + + #[test] + fn preview_value_keeps_short_strings_intact() { + let v = serde_json::json!({"a": 1}); + let p = preview_value(&v, 30); + assert_eq!(p, "{\"a\":1}"); + } + + #[test] + fn short_uuid_takes_first_8_chars() { + let id = uuid::Uuid::parse_str("a1b2c3d4-e5f6-7890-abcd-ef1234567890").unwrap(); + assert_eq!(short_uuid(&id), "a1b2c3d4"); + } + + #[test] + fn short_hash_takes_first_4_bytes_hex() { + let mut h = [0u8; 32]; + h[0] = 0xaa; + h[1] = 0xbb; + h[2] = 0xcc; + h[3] = 0xdd; + assert_eq!(short_hash(&h), "aabbccdd"); + } +}