From 0f2f1033eb3498e3d2635cd89fb4ba6899165d95 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 18:32:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(shuma):=20shuma-session=20+=20shuma-exec?= =?UTF-8?q?=20=E2=80=94=20sesiones=20de=20trabajo=20y=20ejecuci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shuma-session: el shell trabaja dentro de una WorkSession — directorio actual (que es el identificador de aislamiento, hash estable del cwd), historial de comandos ejecutados (CommandRun con salida y estado) y grupos de comandos guardados y reutilizables (CommandGroup). shuma-exec: ejecutor con salida en streaming — lanza bash -c en un directorio y entrega stdout/stderr línea a línea por un canal, sin esperar al final. Es la capa que sandokan (poll-based, orquesta Cards) deliberadamente no cubre. 15 tests. Agnósticos de UI, #![forbid(unsafe_code)]. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 11 + Cargo.toml | 2 + crates/modules/shuma/shuma-exec/Cargo.toml | 10 + crates/modules/shuma/shuma-exec/src/lib.rs | 256 +++++++++++++++ crates/modules/shuma/shuma-session/Cargo.toml | 11 + crates/modules/shuma/shuma-session/src/lib.rs | 308 ++++++++++++++++++ nohup.out | 3 + 7 files changed, 601 insertions(+) create mode 100644 crates/modules/shuma/shuma-exec/Cargo.toml create mode 100644 crates/modules/shuma/shuma-exec/src/lib.rs create mode 100644 crates/modules/shuma/shuma-session/Cargo.toml create mode 100644 crates/modules/shuma/shuma-session/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c288713..5a15638 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11380,6 +11380,10 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "shuma-exec" +version = "0.1.0" + [[package]] name = "shuma-gateway" version = "0.1.0" @@ -11420,6 +11424,13 @@ dependencies = [ "ulid", ] +[[package]] +name = "shuma-session" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "shuma-shell" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 540c2af..61ff42c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -182,6 +182,8 @@ members = [ "crates/modules/shuma/shuma-intent", "crates/modules/shuma/shuma-line", "crates/modules/shuma/shuma-sysmon", + "crates/modules/shuma/shuma-session", + "crates/modules/shuma/shuma-exec", "crates/modules/shuma/shuma-shell-render", # ============================================================ diff --git a/crates/modules/shuma/shuma-exec/Cargo.toml b/crates/modules/shuma/shuma-exec/Cargo.toml new file mode 100644 index 0000000..f042f40 --- /dev/null +++ b/crates/modules/shuma/shuma-exec/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shuma-exec" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — ejecutor de comandos del shell con salida en streaming: lanza un proceso y entrega stdout/stderr línea a línea por un canal. Agnóstico de UI." + +[dependencies] diff --git a/crates/modules/shuma/shuma-exec/src/lib.rs b/crates/modules/shuma/shuma-exec/src/lib.rs new file mode 100644 index 0000000..504d8b8 --- /dev/null +++ b/crates/modules/shuma/shuma-exec/src/lib.rs @@ -0,0 +1,256 @@ +//! `shuma-exec` — ejecución de comandos del shell con salida en streaming. +//! +//! Lanza una línea de comandos en un shell (`bash -c …`) dentro de un +//! directorio, y entrega su salida **a medida que ocurre**: cada línea +//! de stdout o stderr llega como un [`RunEvent`] por un canal, sin +//! esperar a que el proceso termine. +//! +//! Esto es lo que `sandokan` no hace: el orquestador es poll-based y +//! orquesta *Cards* de brahman (entidades aisladas y supervisadas). El +//! shell, en cambio, corre líneas de shell ad-hoc y necesita ver la +//! salida fluir. Dos capas distintas, a propósito. +//! +//! El crate es agnóstico de frontend: el proceso y sus lectores corren +//! en hilos; el consumidor (shell GPUI o TUI) drena el canal cuando +//! quiere — sin `async`, sin acoplarse a ningún runtime. + +#![forbid(unsafe_code)] + +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use std::sync::mpsc::{self, Receiver, TryRecvError}; + +/// Qué ejecutar: una línea de comandos, en un directorio, con un shell. +#[derive(Debug, Clone)] +pub struct CommandSpec { + /// La línea completa — se pasa como `shell -c ""`. + pub line: String, + /// Directorio de trabajo del proceso. + pub cwd: String, + /// Programa de shell — `"bash"`, `"sh"`, `"fish"`… + pub shell: String, +} + +impl CommandSpec { + /// Spec con `bash` como shell. + pub fn bash(line: impl Into, cwd: impl Into) -> Self { + Self { line: line.into(), cwd: cwd.into(), shell: "bash".into() } + } +} + +/// Un evento de la ejecución de un comando. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunEvent { + /// Una línea de salida estándar. + Stdout(String), + /// Una línea de salida de error. + Stderr(String), + /// El proceso terminó con este código de salida. + Exited(i32), + /// El proceso no pudo siquiera lanzarse. + Failed(String), +} + +impl RunEvent { + /// `true` si el evento cierra la ejecución (`Exited` o `Failed`). + pub fn is_terminal(&self) -> bool { + matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_)) + } +} + +/// Asa de un comando en ejecución. El consumidor la conserva y drena sus +/// eventos cuando le conviene. +pub struct RunHandle { + rx: Receiver, + finished: bool, +} + +impl RunHandle { + /// Drena todos los eventos disponibles ahora mismo, sin bloquear. + /// Marca el asa como terminada al ver un evento terminal. + pub fn try_events(&mut self) -> Vec { + let mut out = Vec::new(); + loop { + match self.rx.try_recv() { + Ok(ev) => { + if ev.is_terminal() { + self.finished = true; + } + out.push(ev); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.finished = true; + break; + } + } + } + out + } + + /// Bloquea hasta que el proceso termine y devuelve todos sus eventos + /// en orden. Pensado para tests y para usos sincrónicos. + pub fn wait_all(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(ev) = self.rx.recv() { + let terminal = ev.is_terminal(); + out.push(ev); + if terminal { + self.finished = true; + } + } + self.finished = true; + out + } + + /// `true` si ya se observó el evento terminal. + pub fn is_finished(&self) -> bool { + self.finished + } +} + +/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la +/// salida. La función vuelve de inmediato: el proceso corre en hilos. +pub fn run(spec: &CommandSpec) -> RunHandle { + let (tx, rx) = mpsc::channel(); + let spec = spec.clone(); + + std::thread::spawn(move || { + let spawned = Command::new(&spec.shell) + .arg("-c") + .arg(&spec.line) + .current_dir(&spec.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let mut child = match spawned { + Ok(c) => c, + Err(e) => { + let _ = tx.send(RunEvent::Failed(e.to_string())); + return; + } + }; + + // Un hilo lector por flujo: stdout y stderr fluyen en paralelo. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let out_reader = stdout.map(|s| { + let tx = tx.clone(); + std::thread::spawn(move || { + for line in BufReader::new(s).lines().map_while(Result::ok) { + if tx.send(RunEvent::Stdout(line)).is_err() { + break; + } + } + }) + }); + let err_reader = stderr.map(|s| { + let tx = tx.clone(); + std::thread::spawn(move || { + for line in BufReader::new(s).lines().map_while(Result::ok) { + if tx.send(RunEvent::Stderr(line)).is_err() { + break; + } + } + }) + }); + + if let Some(h) = out_reader { + let _ = h.join(); + } + if let Some(h) = err_reader { + let _ = h.join(); + } + let code = child + .wait() + .ok() + .and_then(|s| s.code()) + .unwrap_or(-1); + let _ = tx.send(RunEvent::Exited(code)); + }); + + RunHandle { rx, finished: false } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `sh` está en cualquier entorno POSIX — más portable que bash + /// para los tests. + fn sh(line: &str) -> CommandSpec { + CommandSpec { line: line.into(), cwd: ".".into(), shell: "sh".into() } + } + + #[test] + fn captures_stdout_and_exit_code() { + let mut h = run(&sh("echo hola-mundo")); + let events = h.wait_all(); + assert!(events.contains(&RunEvent::Stdout("hola-mundo".into()))); + assert!(events.contains(&RunEvent::Exited(0))); + assert!(h.is_finished()); + } + + #[test] + fn captures_stderr() { + let mut h = run(&sh("echo problema 1>&2")); + let events = h.wait_all(); + assert!(events.contains(&RunEvent::Stderr("problema".into()))); + } + + #[test] + fn nonzero_exit_is_reported() { + let mut h = run(&sh("exit 3")); + let events = h.wait_all(); + assert!(events.contains(&RunEvent::Exited(3))); + } + + #[test] + fn multiple_output_lines_arrive_in_order() { + let mut h = run(&sh("echo uno; echo dos; echo tres")); + let lines: Vec = h + .wait_all() + .into_iter() + .filter_map(|e| match e { + RunEvent::Stdout(l) => Some(l), + _ => None, + }) + .collect(); + assert_eq!(lines, vec!["uno", "dos", "tres"]); + } + + #[test] + fn pipes_run_through_the_shell() { + let mut h = run(&sh("printf 'b\\na\\nc\\n' | sort")); + let lines: Vec = h + .wait_all() + .into_iter() + .filter_map(|e| match e { + RunEvent::Stdout(l) => Some(l), + _ => None, + }) + .collect(); + assert_eq!(lines, vec!["a", "b", "c"]); + } + + #[test] + fn missing_shell_fails_gracefully() { + let spec = CommandSpec { + line: "echo x".into(), + cwd: ".".into(), + shell: "/no/existe/shell-xyz".into(), + }; + let mut h = run(&spec); + let events = h.wait_all(); + assert!(matches!(events.first(), Some(RunEvent::Failed(_)))); + } + + #[test] + fn terminal_event_detection() { + assert!(RunEvent::Exited(0).is_terminal()); + assert!(RunEvent::Failed("x".into()).is_terminal()); + assert!(!RunEvent::Stdout("x".into()).is_terminal()); + } +} diff --git a/crates/modules/shuma/shuma-session/Cargo.toml b/crates/modules/shuma/shuma-session/Cargo.toml new file mode 100644 index 0000000..8cf6328 --- /dev/null +++ b/crates/modules/shuma/shuma-session/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shuma-session" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — el modelo de la sesión de trabajo: directorio actual (identificador de aislamiento), historial de comandos ejecutados y grupos reutilizables." + +[dependencies] +serde = { workspace = true } diff --git a/crates/modules/shuma/shuma-session/src/lib.rs b/crates/modules/shuma/shuma-session/src/lib.rs new file mode 100644 index 0000000..5cfe623 --- /dev/null +++ b/crates/modules/shuma/shuma-session/src/lib.rs @@ -0,0 +1,308 @@ +//! `shuma-session` — la sesión de trabajo del shell. +//! +//! El shell no es una sucesión suelta de comandos: trabaja *dentro de +//! una sesión*. Una [`WorkSession`] contiene: +//! +//! - el **directorio actual** — que es además el identificador de +//! aislamiento (cada directorio es un contexto separado); +//! - el **historial** de comandos ejecutados, cada uno con su salida y +//! su estado ([`CommandRun`]); +//! - los **grupos** de comandos guardados y reutilizables +//! ([`CommandGroup`]). +//! +//! Modelo puro y agnóstico: la ejecución real la hace `shuma-exec`, el +//! tiempo lo inyecta el caller. Determinista y testeable. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Identificador de un comando dentro de su sesión. +pub type RunId = u64; + +/// Estado de un comando ejecutado. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunStatus { + /// Ejecutándose; su salida sigue llegando. + Running, + /// Terminó con código 0. + Ok, + /// Terminó con código distinto de 0, o no pudo lanzarse. + Failed, +} + +/// Un comando ejecutado: la línea, el directorio, el estado y la salida. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandRun { + pub id: RunId, + /// La línea de comandos tal como se escribió. + pub line: String, + /// El directorio en que se ejecutó. + pub cwd: String, + pub status: RunStatus, + /// Código de salida, una vez terminado. + pub exit_code: Option, + /// Salida combinada (stdout + stderr), una línea por elemento. + pub output: Vec, + /// Segundo Unix en que arrancó. + pub started_at: u64, + /// Segundo Unix en que terminó. + pub finished_at: Option, +} + +impl CommandRun { + /// `true` si el comando sigue corriendo. + pub fn is_running(&self) -> bool { + self.status == RunStatus::Running + } + + /// Cantidad de líneas de salida. + pub fn line_count(&self) -> usize { + self.output.len() + } +} + +/// Un grupo de comandos guardado para reutilizar — una receta. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandGroup { + pub name: String, + /// Las líneas de comando, en orden de ejecución. + pub lines: Vec, +} + +/// La sesión de trabajo: directorio actual + historial + grupos. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkSession { + pub name: String, + cwd: String, + history: Vec, + groups: Vec, + next_id: RunId, +} + +/// FNV-1a de 64 bits — base del identificador de aislamiento. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + h +} + +impl WorkSession { + /// Abre una sesión con un nombre y un directorio inicial. + pub fn new(name: impl Into, cwd: impl Into) -> Self { + Self { + name: name.into(), + cwd: cwd.into(), + history: Vec::new(), + groups: Vec::new(), + next_id: 1, + } + } + + /// Directorio actual de la sesión. + pub fn cwd(&self) -> &str { + &self.cwd + } + + /// Cambia el directorio actual — y con él, el contexto de aislamiento. + pub fn set_cwd(&mut self, cwd: impl Into) { + self.cwd = cwd.into(); + } + + /// Identificador de aislamiento del directorio actual: un hash corto + /// y estable del `cwd`. Cada directorio es un contexto distinto, así + /// que el id cambia al hacer `cd`. + pub fn isolation_id(&self) -> String { + format!("{:012x}", fnv1a(self.cwd.as_bytes()) & 0xffff_ffff_ffff) + } + + // --- Historial de comandos --- + + /// Registra el inicio de un comando (estado `Running`) en el `cwd` + /// actual. Devuelve su id. + pub fn begin_run(&mut self, line: impl Into, now: u64) -> RunId { + let id = self.next_id; + self.next_id += 1; + self.history.push(CommandRun { + id, + line: line.into(), + cwd: self.cwd.clone(), + status: RunStatus::Running, + exit_code: None, + output: Vec::new(), + started_at: now, + finished_at: None, + }); + id + } + + pub fn run(&self, id: RunId) -> Option<&CommandRun> { + self.history.iter().find(|r| r.id == id) + } + + fn run_mut(&mut self, id: RunId) -> Option<&mut CommandRun> { + self.history.iter_mut().find(|r| r.id == id) + } + + /// Añade una línea de salida a un comando en curso. + pub fn append_output(&mut self, id: RunId, line: impl Into) { + if let Some(r) = self.run_mut(id) { + r.output.push(line.into()); + } + } + + /// Marca un comando como terminado con su código de salida. + pub fn finish_run(&mut self, id: RunId, exit_code: i32, now: u64) { + if let Some(r) = self.run_mut(id) { + r.exit_code = Some(exit_code); + r.status = if exit_code == 0 { RunStatus::Ok } else { RunStatus::Failed }; + r.finished_at = Some(now); + } + } + + /// Historial completo, del más antiguo al más reciente. + pub fn history(&self) -> &[CommandRun] { + &self.history + } + + /// Comandos que siguen corriendo. + pub fn running(&self) -> Vec { + self.history + .iter() + .filter(|r| r.is_running()) + .map(|r| r.id) + .collect() + } + + /// Vacía el historial (no toca los grupos ni el `cwd`). + pub fn clear_history(&mut self) { + self.history.clear(); + } + + // --- Grupos reutilizables --- + + /// Guarda un grupo de comandos. Si ya existe uno con ese nombre, lo + /// reemplaza. + pub fn save_group(&mut self, name: impl Into, lines: Vec) { + let name = name.into(); + self.groups.retain(|g| g.name != name); + self.groups.push(CommandGroup { name, lines }); + } + + /// Guarda como grupo las líneas de los últimos `n` comandos del + /// historial — la forma natural de "convertir lo que acabo de hacer + /// en una receta". + pub fn save_recent_as_group(&mut self, name: impl Into, n: usize) { + let lines: Vec = self + .history + .iter() + .rev() + .take(n) + .rev() + .map(|r| r.line.clone()) + .collect(); + self.save_group(name, lines); + } + + pub fn groups(&self) -> &[CommandGroup] { + &self.groups + } + + pub fn group(&self, name: &str) -> Option<&CommandGroup> { + self.groups.iter().find(|g| g.name == name) + } + + /// Quita un grupo. `true` si existía. + pub fn remove_group(&mut self, name: &str) -> bool { + let before = self.groups.len(); + self.groups.retain(|g| g.name != name); + self.groups.len() != before + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn isolation_id_follows_the_directory() { + let mut s = WorkSession::new("trabajo", "/home/sergio/brahman"); + let id_a = s.isolation_id(); + s.set_cwd("/tmp"); + let id_b = s.isolation_id(); + assert_ne!(id_a, id_b, "cd cambia el contexto de aislamiento"); + // Estable: el mismo directorio da el mismo id. + s.set_cwd("/home/sergio/brahman"); + assert_eq!(s.isolation_id(), id_a); + } + + #[test] + fn a_run_records_its_directory() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("ls -la", 1000); + assert_eq!(s.run(id).unwrap().cwd, "/home"); + assert_eq!(s.run(id).unwrap().status, RunStatus::Running); + } + + #[test] + fn output_accumulates_and_run_finishes() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("echo hola", 1000); + s.append_output(id, "hola"); + s.finish_run(id, 0, 1001); + let r = s.run(id).unwrap(); + assert_eq!(r.output, vec!["hola"]); + assert_eq!(r.status, RunStatus::Ok); + assert_eq!(r.exit_code, Some(0)); + assert_eq!(r.finished_at, Some(1001)); + } + + #[test] + fn nonzero_exit_marks_failed() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("false", 0); + s.finish_run(id, 1, 1); + assert_eq!(s.run(id).unwrap().status, RunStatus::Failed); + } + + #[test] + fn running_lists_unfinished_commands() { + let mut s = WorkSession::new("t", "/home"); + let a = s.begin_run("sleep 1", 0); + let b = s.begin_run("sleep 2", 0); + s.finish_run(a, 0, 1); + assert_eq!(s.running(), vec![b]); + } + + #[test] + fn save_and_recall_a_group() { + let mut s = WorkSession::new("t", "/home"); + s.save_group("deploy", vec!["cargo build".into(), "scp target host:/srv".into()]); + assert_eq!(s.group("deploy").unwrap().lines.len(), 2); + // Guardar con el mismo nombre reemplaza. + s.save_group("deploy", vec!["echo nuevo".into()]); + assert_eq!(s.group("deploy").unwrap().lines, vec!["echo nuevo"]); + } + + #[test] + fn save_recent_history_as_a_group() { + let mut s = WorkSession::new("t", "/home"); + for line in ["git add .", "git commit", "git push"] { + s.begin_run(line, 0); + } + s.save_recent_as_group("publicar", 2); + // Los 2 últimos, en orden cronológico. + assert_eq!(s.group("publicar").unwrap().lines, vec!["git commit", "git push"]); + } + + #[test] + fn remove_group() { + let mut s = WorkSession::new("t", "/home"); + s.save_group("x", vec!["echo x".into()]); + assert!(s.remove_group("x")); + assert!(!s.remove_group("x")); + } +} diff --git a/nohup.out b/nohup.out index a64455c..51542db 100644 --- a/nohup.out +++ b/nohup.out @@ -952,3 +952,6 @@ Crash Annotation GraphicsCriticalError: |[C0][GFX1-]: Managed to allocate after [Child 27081, MediaDecoderStateMachine #42] WARNING: 72497533eee0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279 [Child 27081, MediaDecoderStateMachine #42] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630 [Child 27081, MediaDecoderStateMachine #42] 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 +[Child 27081, MediaDecoderStateMachine #49] WARNING: 72496a597ca0 OpenCubeb() failed to init cubeb: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/AudioStream.cpp:279 +[Child 27081, MediaDecoderStateMachine #49] WARNING: Decoder=724971e98800 [OnMediaSinkAudioError]: file /home/ubuntu/actions-runner/_work/desktop/desktop/engine/dom/media/MediaDecoderStateMachine.cpp:4630 +[Child 27081, MediaDecoderStateMachine #49] 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