gioser

tawasuyu/brahman

Rust 566TOML 214Markdown 67JSON 198093 Definiciones

⎇ d5ef7144b5a1d2e6604505cb4dc08e7a703ecc72 crates/apps/nakui-explorer/src/main.rs Rust · 453 líneas · 16.7 KB Historia Quién tocó qué crudo

feat(yahweh-meta-runtime): promover short_hash y preview_value desde nakui-explorer d5ef7144b · Sergio ·

1//! `nakui-explorer` — panel GPUI que renderea el event log de un
2//! repo Nakui: timeline de seeds + morphisms con sus parámetros y
3//! breakdown por entity type.
4//!
5//! ## Diseño
6//!
7//! Standalone, lee un archivo `.jsonl` (formato append-only del
8//! `nakui_core::event_log::EventLog`). Refresh por polling cada 2s
9//! para detectar nuevos eventos appended (típico de un nakui ERP en
10//! producción que va escribiendo). Sin discovery dinámico vía broker
11//! brahman porque nakui hoy es CLI/library/demos, no daemon — cuando
12//! se daemonice, sustituir el lector de archivo por un sidecar
13//! consumer (mismo patrón que `nouser-explorer`).
14//!
15//! ## Uso
16//!
17//! ```sh
18//! # Path explícito:
19//! NAKUI_EVENT_LOG=/tmp/nakui-demo.jsonl cargo run -p nakui-explorer
20//!
21//! # Default si la env no está: ./nakui.jsonl en pwd.
22//! cargo run -p nakui-explorer
23//! ```
24
25use std::path::PathBuf;
26use std::time::Duration;
27
28use gpui::{
29    div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render,
30    SharedString, Window, WindowBounds, WindowOptions,
31};
32use nakui_core::event_log::{EventLog, LogEntry};
33use yahweh_meta_runtime::{preview_value, short_hash, short_uuid};
34
35const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
36
37fn main() {
38    Application::new().run(|cx: &mut App| {
39        let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx);
40        cx.open_window(
41            WindowOptions {
42                window_bounds: Some(WindowBounds::Windowed(bounds)),
43                titlebar: Some(gpui::TitlebarOptions {
44                    title: Some(SharedString::from("Nakui — Event Log")),
45                    ..Default::default()
46                }),
47                ..Default::default()
48            },
49            |_w, cx| cx.new(Explorer::new),
50        )
51        .expect("open window");
52        cx.activate(true);
53    });
54}
55
56/// Estado de la vista. `entries` se reescribe en cada tick (el log
57/// es append-only así que reload completo es seguro y barato hasta
58/// decenas de miles de entries; optimizar a delta cuando duela).
59struct Explorer {
60    log_path: PathBuf,
61    entries: Vec<LogEntry>,
62    error: Option<SharedString>,
63    last_load_ms: u64,
64}
65
66impl Explorer {
67    fn new(cx: &mut Context<Self>) -> Self {
68        let log_path = std::env::var("NAKUI_EVENT_LOG")
69            .ok()
70            .map(PathBuf::from)
71            .unwrap_or_else(|| PathBuf::from("nakui.jsonl"));
72
73        // Loop de refresh.
74        let path_for_loop = log_path.clone();
75        cx.spawn(async move |this, cx| {
76            let timer = cx.background_executor().clone();
77            loop {
78                let started = std::time::Instant::now();
79                let result = load_log(&path_for_loop);
80                let elapsed_ms = started.elapsed().as_millis() as u64;
81                let _ = this.update(cx, |me, cx| {
82                    match result {
83                        Ok(entries) => {
84                            me.entries = entries;
85                            me.error = None;
86                        }
87                        Err(e) => {
88                            me.error = Some(SharedString::from(format!(
89                                "no pude leer {}: {}",
90                                me.log_path.display(),
91                                e
92                            )));
93                        }
94                    }
95                    me.last_load_ms = elapsed_ms;
96                    cx.notify();
97                });
98                timer.timer(REFRESH_INTERVAL).await;
99            }
100        })
101        .detach();
102
103        Self {
104            log_path,
105            entries: Vec::new(),
106            error: None,
107            last_load_ms: 0,
108        }
109    }
110
111    fn breakdown(&self) -> (usize, usize, Vec<(String, usize)>) {
112        let mut seeds = 0;
113        let mut morphisms = 0;
114        let mut entity_counts: std::collections::BTreeMap<String, usize> =
115            std::collections::BTreeMap::new();
116        for e in &self.entries {
117            match e {
118                LogEntry::Seed { entity, .. } => {
119                    seeds += 1;
120                    *entity_counts.entry(entity.clone()).or_default() += 1;
121                }
122                LogEntry::Morphism { morphism, .. } => {
123                    morphisms += 1;
124                    *entity_counts.entry(format!("→ {}", morphism)).or_default() += 1;
125                }
126            }
127        }
128        let mut ranked: Vec<_> = entity_counts.into_iter().collect();
129        ranked.sort_by(|a, b| b.1.cmp(&a.1));
130        (seeds, morphisms, ranked)
131    }
132}
133
134fn load_log(path: &std::path::Path) -> Result<Vec<LogEntry>, String> {
135    let log = EventLog::open(path).map_err(|e| format!("open: {e}"))?;
136    log.entries().map_err(|e| format!("read: {e}"))
137}
138
139impl Render for Explorer {
140    fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
141        let bg = rgb(0x14171c);
142        let card_bg = rgb(0x1d2128);
143        let text_dim = rgb(0x9ba1ad);
144        let text = rgb(0xe6e8ec);
145        let accent_seed = rgb(0x88c0d0);
146        let accent_morphism = rgb(0xa3be8c);
147        let _ = card_bg;
148
149        let (seed_count, morphism_count, top_breakdown) = self.breakdown();
150
151        let header_text = format!(
152            "Log: {}  ·  {} entries ({} seeds, {} morphisms)  ·  reload {} ms",
153            self.log_path.display(),
154            self.entries.len(),
155            seed_count,
156            morphism_count,
157            self.last_load_ms,
158        );
159
160        let header = div()
161            .px(px(16.))
162            .py(px(12.))
163            .bg(card_bg)
164            .border_b_1()
165            .border_color(rgb(0x2a2f38))
166            .text_color(text)
167            .text_size(px(14.))
168            .child(header_text);
169
170        let breakdown_line = if top_breakdown.is_empty() {
171            String::new()
172        } else {
173            let parts: Vec<String> = top_breakdown
174                .iter()
175                .take(5)
176                .map(|(k, v)| format!("{k}({v})"))
177                .collect();
178            format!("breakdown: {}", parts.join(", "))
179        };
180
181        let breakdown_div = (!breakdown_line.is_empty()).then(|| {
182            div()
183                .px(px(16.))
184                .py(px(6.))
185                .bg(rgb(0x16191f))
186                .text_color(text_dim)
187                .text_size(px(11.))
188                .child(breakdown_line)
189        });
190
191        let error_banner = self.error.as_ref().map(|e| {
192            div()
193                .px(px(16.))
194                .py(px(8.))
195                .bg(rgb(0x4a2020))
196                .text_color(rgb(0xffd0d0))
197                .text_size(px(12.))
198                .child(e.clone())
199        });
200
201        // Renderea las últimas N entries (la timeline crece hacia abajo
202        // en append-order; mostramos las más recientes primero para
203        // que el usuario vea actividad reciente sin scroll).
204        const MAX_VISIBLE: usize = 200;
205        let visible: Vec<_> = self
206            .entries
207            .iter()
208            .rev()
209            .take(MAX_VISIBLE)
210            .collect();
211
212        let cards: Vec<gpui::AnyElement> = visible
213            .iter()
214            .map(|e| match e {
215                LogEntry::Seed {
216                    seq,
217                    entity,
218                    id,
219                    data,
220                    schema_hash,
221                } => {
222                    let data_preview = preview_value(data, 80);
223                    let schema_label = schema_hash
224                        .as_ref()
225                        .map(|h| format!("schema={}", short_hash(h)))
226                        .unwrap_or_else(|| "schema=(legacy)".into());
227                    div()
228                        .flex()
229                        .flex_col()
230                        .px(px(12.))
231                        .py(px(8.))
232                        .mb(px(4.))
233                        .bg(card_bg)
234                        .rounded(px(4.))
235                        .border_l_4()
236                        .border_color(accent_seed)
237                        .gap(px(2.))
238                        .child(
239                            div()
240                                .flex()
241                                .flex_row()
242                                .gap(px(8.))
243                                .items_center()
244                                .child(
245                                    div()
246                                        .text_color(accent_seed)
247                                        .text_size(px(11.))
248                                        .child(format!("[#{seq} seed]")),
249                                )
250                                .child(
251                                    div()
252                                        .text_color(text)
253                                        .text_size(px(13.))
254                                        .child(entity.clone()),
255                                )
256                                .child(
257                                    div()
258                                        .text_color(text_dim)
259                                        .text_size(px(10.))
260                                        .child(format!("id={}", short_uuid(id))),
261                                ),
262                        )
263                        .child(
264                            div()
265                                .text_color(text_dim)
266                                .text_size(px(11.))
267                                .child(data_preview),
268                        )
269                        .child(
270                            div()
271                                .text_color(text_dim)
272                                .text_size(px(10.))
273                                .child(schema_label),
274                        )
275                        .into_any_element()
276                }
277                LogEntry::Morphism {
278                    seq,
279                    morphism,
280                    inputs,
281                    params,
282                    ops,
283                    schema_hash,
284                } => {
285                    let inputs_line = if inputs.is_empty() {
286                        String::new()
287                    } else {
288                        let parts: Vec<String> = inputs
289                            .iter()
290                            .map(|(name, id)| format!("{name}={}", short_uuid(id)))
291                            .collect();
292                        format!("inputs: {}", parts.join(", "))
293                    };
294                    let params_line = preview_value(params, 80);
295                    let ops_line = format!("{} op(s)", ops.len());
296                    let schema_label = schema_hash
297                        .as_ref()
298                        .map(|h| format!("schema={}", short_hash(h)))
299                        .unwrap_or_else(|| "schema=(legacy)".into());
300                    div()
301                        .flex()
302                        .flex_col()
303                        .px(px(12.))
304                        .py(px(8.))
305                        .mb(px(4.))
306                        .bg(card_bg)
307                        .rounded(px(4.))
308                        .border_l_4()
309                        .border_color(accent_morphism)
310                        .gap(px(2.))
311                        .child(
312                            div()
313                                .flex()
314                                .flex_row()
315                                .gap(px(8.))
316                                .items_center()
317                                .child(
318                                    div()
319                                        .text_color(accent_morphism)
320                                        .text_size(px(11.))
321                                        .child(format!("[#{seq} morph]")),
322                                )
323                                .child(
324                                    div()
325                                        .text_color(text)
326                                        .text_size(px(13.))
327                                        .child(morphism.clone()),
328                                )
329                                .child(
330                                    div()
331                                        .text_color(text_dim)
332                                        .text_size(px(10.))
333                                        .child(ops_line),
334                                ),
335                        )
336                        .when(!inputs_line.is_empty(), |d| {
337                            d.child(
338                                div()
339                                    .text_color(text_dim)
340                                    .text_size(px(11.))
341                                    .child(inputs_line),
342                            )
343                        })
344                        .when(!params_line.is_empty(), |d| {
345                            d.child(
346                                div()
347                                    .text_color(text_dim)
348                                    .text_size(px(11.))
349                                    .child(format!("params: {params_line}")),
350                            )
351                        })
352                        .child(
353                            div()
354                                .text_color(text_dim)
355                                .text_size(px(10.))
356                                .child(schema_label),
357                        )
358                        .into_any_element()
359                }
360            })
361            .collect();
362
363        let body = div()
364            .flex()
365            .flex_col()
366            .p(px(12.))
367            .overflow_hidden()
368            .children(cards);
369
370        div()
371            .flex()
372            .flex_col()
373            .size_full()
374            .bg(bg)
375            .child(header)
376            .when_some(breakdown_div, |d, b| d.child(b))
377            .when_some(error_banner, |d, b| d.child(b))
378            .child(body)
379    }
380}
381
382// Helpers `short_uuid`, `short_hash`, `preview_value` viven en
383// `yahweh_meta_runtime::format`. Se usan acá via el `use` de arriba.
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::io::Write;
389
390    fn write_sample_log() -> tempfile::NamedTempFile {
391        let mut f = tempfile::NamedTempFile::new().unwrap();
392        // 3 seeds + 2 morphisms, formato canónico de event_log.
393        let lines = [
394            r#"{"kind":"seed","seq":0,"entity":"product","id":"00000000-0000-0000-0000-000000000001","data":{"sku":"A"}}"#,
395            r#"{"kind":"seed","seq":1,"entity":"product","id":"00000000-0000-0000-0000-000000000002","data":{"sku":"B"}}"#,
396            r#"{"kind":"seed","seq":2,"entity":"customer","id":"00000000-0000-0000-0000-000000000003","data":{"name":"Acme"}}"#,
397            r#"{"kind":"morphism","seq":3,"morphism":"sale.create","inputs":{"product":"00000000-0000-0000-0000-000000000001"},"params":{"qty":1},"ops":[]}"#,
398            r#"{"kind":"morphism","seq":4,"morphism":"sale.refund","inputs":{},"params":{},"ops":[]}"#,
399        ];
400        for l in lines {
401            writeln!(f, "{l}").unwrap();
402        }
403        f.flush().unwrap();
404        f
405    }
406
407    #[test]
408    fn load_log_returns_all_entries_in_order() {
409        let f = write_sample_log();
410        let entries = load_log(f.path()).expect("load");
411        assert_eq!(entries.len(), 5);
412        for (i, e) in entries.iter().enumerate() {
413            assert_eq!(e.seq(), i as u64, "seqs should be 0..4 contiguous");
414        }
415    }
416
417    #[test]
418    fn breakdown_counts_seeds_morphisms_and_buckets() {
419        let f = write_sample_log();
420        let entries = load_log(f.path()).unwrap();
421        let me = Explorer {
422            log_path: f.path().to_path_buf(),
423            entries,
424            error: None,
425            last_load_ms: 0,
426        };
427        let (seeds, morphisms, ranked) = me.breakdown();
428        assert_eq!(seeds, 3);
429        assert_eq!(morphisms, 2);
430        // Buckets esperados: product (2), customer (1), → sale.create (1),
431        // → sale.refund (1).
432        assert_eq!(ranked.len(), 4);
433        let map: std::collections::BTreeMap<_, _> = ranked.into_iter().collect();
434        assert_eq!(map.get("product"), Some(&2));
435        assert_eq!(map.get("customer"), Some(&1));
436        assert_eq!(map.get("→ sale.create"), Some(&1));
437        assert_eq!(map.get("→ sale.refund"), Some(&1));
438    }
439
440    #[test]
441    fn load_missing_file_yields_empty_not_error() {
442        // EventLog::open de un archivo inexistente no falla; entries() devuelve [].
443        let path = std::env::temp_dir().join("nakui-explorer-missing-test.jsonl");
444        let _ = std::fs::remove_file(&path);
445        let result = load_log(&path).expect("missing path is OK per EventLog::open contract");
446        assert!(result.is_empty());
447    }
448
449    // Tests de `short_uuid` / `short_hash` / `preview_value` viven
450    // en `yahweh-meta-runtime::format` tras la migración. Si esos se
451    // vuelven a romper, los tests específicos del crate runtime los
452    // capturan; acá no duplicamos.
453}