gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definiciones

feat(shuma): núcleo del shell — parser de intenciones + grafo de contexto

shuma-intent: el corazón agnóstico del shell shuma.

- parse — Intention: una línea del prompt parseada en etapas separadas
  por pipe. Ref (%cN comando / %pN buffer) + Stage (Exec | Inject).
  Parsea el ejemplo de la spec: `ssh nodo 'cat data.json' | %p1 | sort`.
- graph — SessionGraph: el grafo de contexto de la sesión. record()
  registra una intención (%cN), complete() le asigna buffer de salida
  (%pN) + estado, resolve() resuelve referencias, dangling_refs()
  valida una intención antes de ejecutar (la validación previa del
  prompt), collapse_succeeded() retrae nodos OK (quietud visual).

Todo puro y serializable (sesiones exportables). El front-end GPUI
(zonas RUN/SENS + lienzo central) lo rehidrata; la ejecución la hace
sandokan. 8 tests verdes. cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

1da4ee11d7525a84d65df447bfde39c4ed085ab9 · por sergio · (hace 5 meses) · padre 6884b3f8c · ver el árbol en este commit

Delta semántico · 32

crates/modules/shuma/shuma-intent/src/graph.rs

crates/modules/shuma/shuma-intent/src/lib.rs

crates/modules/shuma/shuma-intent/src/parse.rs

7 archivos · +337 −0

diff --git a/Cargo.lock b/Cargo.lock
index 33895b2..9082301 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11200,6 +11200,13 @@ dependencies = [
  "tracing-subscriber",
 ]
 
+[[package]]
+name = "shuma-intent"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "shuma-protocol"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index f8277ea..467eebc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -133,6 +133,7 @@ members = [
     "crates/modules/shuma/shuma-protocol",
     "crates/modules/shuma/shuma-discern",
     "crates/modules/shuma/shuma-core",
+    "crates/modules/shuma/shuma-intent",
 
     # ============================================================
     # modules/gioser/ — Landing WASM (chacana + 4 elementos)
diff --git a/crates/modules/shuma/shuma-intent/Cargo.toml b/crates/modules/shuma/shuma-intent/Cargo.toml
new file mode 100644
index 0000000..ce32375
--- /dev/null
+++ b/crates/modules/shuma/shuma-intent/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-intent"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — núcleo agnóstico del shell: parser de intenciones (tokens %cN/%pN) + grafo de contexto de la sesión."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/crates/modules/shuma/shuma-intent/src/graph.rs b/crates/modules/shuma/shuma-intent/src/graph.rs
new file mode 100644
index 0000000..c858dfb
--- /dev/null
+++ b/crates/modules/shuma/shuma-intent/src/graph.rs
@@ -0,0 +1,175 @@
+//! Grafo de contexto de una sesión de shuma.
+//!
+//! Registra cada intención ejecutada como un nodo `%cN`; al terminar, el
+//! nodo expone su buffer de salida `%pN`. El grafo permite resolver las
+//! referencias del prompt, validar intenciones nuevas antes de ejecutar,
+//! y colapsar nodos exitosos para la quietud visual.
+
+use crate::parse::{Intention, Ref};
+use serde::{Deserialize, Serialize};
+
+/// Estado de un nodo del grafo de contexto.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum NodeStatus {
+    Running,
+    Ok,
+    Failed,
+}
+
+/// Un comando registrado en la sesión.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CommandNode {
+    /// Identificador `%cN`.
+    pub id: u32,
+    /// Texto de la intención original.
+    pub intention: String,
+    /// Buffer `%pN` producido como salida, si el comando ya terminó.
+    pub output_buffer: Option<u32>,
+    pub status: NodeStatus,
+    /// Colapsado en la UI (nodo exitoso retraído por quietud visual).
+    pub collapsed: bool,
+    /// Bytes del buffer de salida (para dimensionar el grafo visual).
+    pub output_bytes: u64,
+}
+
+/// Grafo de intenciones y flujos de una sesión de shell.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SessionGraph {
+    commands: Vec<CommandNode>,
+    next_command: u32,
+    next_buffer: u32,
+}
+
+impl Default for SessionGraph {
+    fn default() -> Self {
+        Self { commands: Vec::new(), next_command: 1, next_buffer: 1 }
+    }
+}
+
+impl SessionGraph {
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn len(&self) -> usize {
+        self.commands.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.commands.is_empty()
+    }
+
+    pub fn commands(&self) -> &[CommandNode] {
+        &self.commands
+    }
+
+    /// Registra una intención nueva en estado `Running`. Devuelve su `%cN`.
+    pub fn record(&mut self, intention: impl Into<String>) -> u32 {
+        let id = self.next_command;
+        self.next_command += 1;
+        self.commands.push(CommandNode {
+            id,
+            intention: intention.into(),
+            output_buffer: None,
+            status: NodeStatus::Running,
+            collapsed: false,
+            output_bytes: 0,
+        });
+        id
+    }
+
+    /// Marca un comando como terminado y le asigna un buffer de salida.
+    /// Devuelve el `%pN` asignado, o `None` si el `%cN` no existe.
+    pub fn complete(&mut self, command_id: u32, ok: bool, output_bytes: u64) -> Option<u32> {
+        let buffer = self.next_buffer;
+        let node = self.commands.iter_mut().find(|c| c.id == command_id)?;
+        node.status = if ok { NodeStatus::Ok } else { NodeStatus::Failed };
+        node.output_bytes = output_bytes;
+        node.output_buffer = Some(buffer);
+        self.next_buffer += 1;
+        Some(buffer)
+    }
+
+    /// Resuelve una referencia a su nodo de comando.
+    pub fn resolve(&self, r: Ref) -> Option<&CommandNode> {
+        match r {
+            Ref::Command(n) => self.commands.iter().find(|c| c.id == n),
+            Ref::Buffer(n) => self.commands.iter().find(|c| c.output_buffer == Some(n)),
+        }
+    }
+
+    /// Referencias de la intención que NO se pueden resolver en esta
+    /// sesión. Vacío = la intención es ejecutable (validación previa
+    /// del prompt).
+    pub fn dangling_refs(&self, intention: &Intention) -> Vec<Ref> {
+        intention
+            .refs()
+            .into_iter()
+            .filter(|r| self.resolve(*r).is_none())
+            .collect()
+    }
+
+    /// Colapsa los nodos exitosos (quietud visual: los flujos que ya
+    /// funcionaron se retraen).
+    pub fn collapse_succeeded(&mut self) {
+        for c in &mut self.commands {
+            if c.status == NodeStatus::Ok {
+                c.collapsed = true;
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn record_assigns_increasing_command_ids() {
+        let mut g = SessionGraph::new();
+        assert_eq!(g.record("cat a"), 1);
+        assert_eq!(g.record("cat b"), 2);
+        assert_eq!(g.len(), 2);
+    }
+
+    #[test]
+    fn complete_assigns_buffer_and_status() {
+        let mut g = SessionGraph::new();
+        let c1 = g.record("cat data.json");
+        let buf = g.complete(c1, true, 2_400_000).expect("c1 existe");
+        assert_eq!(buf, 1);
+        let node = g.resolve(Ref::Command(c1)).unwrap();
+        assert_eq!(node.status, NodeStatus::Ok);
+        assert_eq!(node.output_buffer, Some(1));
+        assert_eq!(node.output_bytes, 2_400_000);
+        // Se resuelve también por su buffer.
+        assert!(g.resolve(Ref::Buffer(1)).is_some());
+    }
+
+    #[test]
+    fn dangling_refs_validates_an_intention() {
+        let mut g = SessionGraph::new();
+        let c1 = g.record("cat data.json");
+        g.complete(c1, true, 100).unwrap(); // produce %p1
+
+        // `%p1` existe, `%p9` no.
+        let ok = Intention::parse("sort | %p1");
+        assert!(g.dangling_refs(&ok).is_empty());
+
+        let bad = Intention::parse("sort | %p9");
+        assert_eq!(g.dangling_refs(&bad), vec![Ref::Buffer(9)]);
+    }
+
+    #[test]
+    fn collapse_only_retracts_successful_nodes() {
+        let mut g = SessionGraph::new();
+        let c1 = g.record("ok cmd");
+        let c2 = g.record("fail cmd");
+        let _c3 = g.record("running cmd");
+        g.complete(c1, true, 0).unwrap();
+        g.complete(c2, false, 0).unwrap();
+        g.collapse_succeeded();
+        assert!(g.resolve(Ref::Command(c1)).unwrap().collapsed);
+        assert!(!g.resolve(Ref::Command(c2)).unwrap().collapsed);
+    }
+}
diff --git a/crates/modules/shuma/shuma-intent/src/lib.rs b/crates/modules/shuma/shuma-intent/src/lib.rs
new file mode 100644
index 0000000..8b0ad23
--- /dev/null
+++ b/crates/modules/shuma/shuma-intent/src/lib.rs
@@ -0,0 +1,19 @@
+//! `shuma-intent` — núcleo agnóstico del shell shuma.
+//!
+//! El shell shuma trabaja con **intenciones**, no comandos sueltos: cada
+//! línea del prompt es una [`Intention`] (etapas conectadas por pipes,
+//! con tokens de referencia `%cN`/`%pN`). El [`SessionGraph`] mantiene el
+//! historial como un grafo de contexto navegable: cada comando es un
+//! nodo, cada salida un buffer intermedio referenciable.
+//!
+//! Todo acá es lógica pura y serializable — el front-end GPUI (las tres
+//! zonas: RUN, SENS y el lienzo central) lo rehidrata; la ejecución real
+//! la hace `sandokan`.
+
+#![forbid(unsafe_code)]
+
+pub mod parse;
+pub mod graph;
+
+pub use graph::{CommandNode, NodeStatus, SessionGraph};
+pub use parse::{Intention, Ref, Stage};
diff --git a/crates/modules/shuma/shuma-intent/src/parse.rs b/crates/modules/shuma/shuma-intent/src/parse.rs
new file mode 100644
index 0000000..2fbb95a
--- /dev/null
+++ b/crates/modules/shuma/shuma-intent/src/parse.rs
@@ -0,0 +1,118 @@
+//! Parser de intenciones del prompt de shuma.
+//!
+//! Una "intención" es una línea del prompt: etapas separadas por `|`.
+//! Cada etapa es un comando a ejecutar, o un token de referencia a un
+//! resultado previo de la sesión (`%cN` un comando, `%pN` un buffer
+//! intermedio). Ej: `ssh nodo 'cat data.json' | %p1 | sort`.
+
+use serde::{Deserialize, Serialize};
+
+/// Referencia a un resultado de la sesión.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Ref {
+    /// `%cN` — un comando registrado de la sesión.
+    Command(u32),
+    /// `%pN` — un buffer intermedio producido por un comando.
+    Buffer(u32),
+}
+
+impl Ref {
+    /// Parsea un token aislado `%c3` / `%p12`. `None` si no es un token.
+    pub fn parse(token: &str) -> Option<Ref> {
+        let rest = token.trim().strip_prefix('%')?;
+        let mut chars = rest.chars();
+        let kind = chars.next()?;
+        let num: u32 = chars.as_str().parse().ok()?;
+        match kind {
+            'c' => Some(Ref::Command(num)),
+            'p' => Some(Ref::Buffer(num)),
+            _ => None,
+        }
+    }
+}
+
+/// Una etapa del pipeline de una intención.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum Stage {
+    /// Comando a ejecutar (texto crudo; puede ser `ssh host '...'`).
+    Exec(String),
+    /// Inyección de un resultado previo de la sesión.
+    Inject(Ref),
+}
+
+/// Una intención parseada: etapas conectadas por pipes.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Intention {
+    pub stages: Vec<Stage>,
+}
+
+impl Intention {
+    /// Parsea una línea del prompt. Las etapas se separan por `|`; una
+    /// etapa que es exactamente un token `%pN`/`%cN` es `Inject`, el
+    /// resto es `Exec`. Las etapas vacías se descartan.
+    pub fn parse(line: &str) -> Intention {
+        let stages = line
+            .split('|')
+            .map(str::trim)
+            .filter(|s| !s.is_empty())
+            .map(|s| match Ref::parse(s) {
+                Some(r) => Stage::Inject(r),
+                None => Stage::Exec(s.to_string()),
+            })
+            .collect();
+        Intention { stages }
+    }
+
+    /// `true` si la intención no tiene etapas (línea vacía).
+    pub fn is_empty(&self) -> bool {
+        self.stages.is_empty()
+    }
+
+    /// Todas las referencias que la intención consume.
+    pub fn refs(&self) -> Vec<Ref> {
+        self.stages
+            .iter()
+            .filter_map(|s| match s {
+                Stage::Inject(r) => Some(*r),
+                Stage::Exec(_) => None,
+            })
+            .collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_ref_tokens() {
+        assert_eq!(Ref::parse("%c3"), Some(Ref::Command(3)));
+        assert_eq!(Ref::parse("%p12"), Some(Ref::Buffer(12)));
+        assert_eq!(Ref::parse(" %p1 "), Some(Ref::Buffer(1)));
+        assert_eq!(Ref::parse("sort"), None);
+        assert_eq!(Ref::parse("%x9"), None);
+        assert_eq!(Ref::parse("%p"), None);
+    }
+
+    #[test]
+    fn parses_the_spec_example() {
+        // ssh nodo 'cat data.json' | %p1 | sort
+        let i = Intention::parse("ssh nodo 'cat data.json' | %p1 | sort");
+        assert_eq!(i.stages.len(), 3);
+        assert_eq!(i.stages[0], Stage::Exec("ssh nodo 'cat data.json'".into()));
+        assert_eq!(i.stages[1], Stage::Inject(Ref::Buffer(1)));
+        assert_eq!(i.stages[2], Stage::Exec("sort".into()));
+    }
+
+    #[test]
+    fn refs_extracts_only_injections() {
+        let i = Intention::parse("cat x | %p1 | %c2 | wc -l");
+        assert_eq!(i.refs(), vec![Ref::Buffer(1), Ref::Command(2)]);
+    }
+
+    #[test]
+    fn empty_line_is_empty_intention() {
+        assert!(Intention::parse("   ").is_empty());
+        assert!(Intention::parse("| |").is_empty());
+    }
+}
diff --git a/nohup.out b/nohup.out
index fcc1e82..8acf06c 100644
--- a/nohup.out
+++ b/nohup.out
@@ -183,3 +183,9 @@ Gdk-Message: 05:42:34.451: Error reading events from display: Broken pipe
 
 ** (zen:2593): WARNING **: 15:00:19.012: Failed to create DBus proxy for org.a11y.Bus: Cannot autolaunch D-Bus without X11 $DISPLAY
 
+[Parent 2593, Main Thread] WARNING: Failed to connect to proxy: 'glib warning', file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/toolkit/xre/nsSigHandlers.cpp:201
+
+(zen:2593): libnotify-WARNING **: 15:43:24.617: Failed to connect to proxy
+[Child 27081, MediaDecoderStateMachine #2] WARNING: 72495d46ca60 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279
+[Child 27081, MediaDecoderStateMachine #2] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630
+[Child 27081, MediaDecoderStateMachine #2] WARNING: Decoder=724971e98800 Decode error: NS_ERROR_DOM_MEDIA_MEDIASINK_ERR (0x806e000b) - OnMediaSinkAudioError: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachineBase.cpp:168