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, + 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, + 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) -> 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 { + 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 { + 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 { + 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, +} + +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 { + 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