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>
This commit is contained in:
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user