feat(shipote): CPU% + pipeline live-tail + replay por bytes (fase J)

- CPU% derivado server-side entre samples (WorkspaceState.last_cpu_sample).
  100% = 1 core saturado. Primer sample devuelve None (sin baseline).
- shipote pipeline run --tail: tras lanzar, suscribe al primer flow_socket
  y vuelca bytes hasta EOF. Auto-implica --tap.
- DiscernPolicy.replay_bytes: cap adicional por bytes para el replay
  buffer del FlowChannel. evict_for_incoming considera el chunk entrante
  para que post-push el buffer NUNCA exceda los caps.
- shipote-shell: stats history extiende sparkline con %CPU.

80 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8,
shipote-core 21, shipote-discern 5, yahweh-provider-fs 3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-11 00:57:04 +00:00
parent 36dac00c8d
commit d8727a3038
9 changed files with 216 additions and 37 deletions
+67 -11
View File
@@ -103,6 +103,10 @@ enum PipeCmd {
/// Variables `KEY=VALUE` para sustitución `${KEY}` en el spec. /// Variables `KEY=VALUE` para sustitución `${KEY}` en el spec.
#[arg(long = "var", value_parser = parse_kv)] #[arg(long = "var", value_parser = parse_kv)]
vars: Vec<(String, String)>, vars: Vec<(String, String)>,
/// Tras lanzar, suscribir al primer flow socket y volcar bytes
/// a stdout hasta EOF. Implica `--tap`.
#[arg(long)]
tail: bool,
}, },
/// Guardar un pipeline bajo un nombre (persiste con el snapshot). /// Guardar un pipeline bajo un nombre (persiste con el snapshot).
Save { Save {
@@ -130,6 +134,8 @@ enum PipeCmd {
tap: bool, tap: bool,
#[arg(long = "var", value_parser = parse_kv)] #[arg(long = "var", value_parser = parse_kv)]
vars: Vec<(String, String)>, vars: Vec<(String, String)>,
#[arg(long)]
tail: bool,
}, },
} }
@@ -243,9 +249,14 @@ async fn main() -> Result<()> {
.cpu_usec .cpu_usec
.map(|u| format!("{:.3} s", u as f64 / 1_000_000.0)) .map(|u| format!("{:.3} s", u as f64 / 1_000_000.0))
.unwrap_or_else(|| "".into()); .unwrap_or_else(|| "".into());
let cpu_pct = info
.cpu_percent
.map(|p| format!("{p:.1} %"))
.unwrap_or_else(|| "— (esperando 2do sample)".into());
println!("rss: {rss}"); println!("rss: {rss}");
println!("rss_peak: {peak}"); println!("rss_peak: {peak}");
println!("cpu: {cpu}"); println!("cpu: {cpu}");
println!("cpu_pct: {cpu_pct}");
println!("source: {}", info.source); println!("source: {}", info.source);
println!("uptime: {} ms", info.uptime_ms); println!("uptime: {} ms", info.uptime_ms);
} }
@@ -287,18 +298,28 @@ async fn main() -> Result<()> {
} }
} }
Cmd::Pipeline(PipeCmd::Run { spec, tap, vars }) => { Cmd::Pipeline(PipeCmd::Run { spec, tap, vars, tail }) => {
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?; let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
// --tail implica --tap (no hay flow socket sin tap).
let effective_tap = tap || tail;
let resp = round_trip( let resp = round_trip(
&mut stream, &mut stream,
Request::PipelineRun { Request::PipelineRun {
spec: p, spec: p,
tap, tap: effective_tap,
vars: vars.into_iter().collect(), vars: vars.into_iter().collect(),
}, },
) )
.await?; .await?;
print_pipeline_started(resp)?; let socket = print_pipeline_started_returning_socket(resp)?;
if tail {
if let Some(sock) = socket {
eprintln!("--- tailing {} ---", sock.display());
tail_socket(&sock).await?;
} else {
eprintln!("--tail: no hay flow socket disponible");
}
}
} }
Cmd::Pipeline(PipeCmd::Save { name, spec }) => { Cmd::Pipeline(PipeCmd::Save { name, spec }) => {
@@ -351,17 +372,26 @@ async fn main() -> Result<()> {
} }
} }
Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars }) => { Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars, tail }) => {
let effective_tap = tap || tail;
let resp = round_trip( let resp = round_trip(
&mut stream, &mut stream,
Request::PipelineRunSaved { Request::PipelineRunSaved {
name, name,
tap, tap: effective_tap,
vars: vars.into_iter().collect(), vars: vars.into_iter().collect(),
}, },
) )
.await?; .await?;
print_pipeline_started(resp)?; let socket = print_pipeline_started_returning_socket(resp)?;
if tail {
if let Some(sock) = socket {
eprintln!("--- tailing {} ---", sock.display());
tail_socket(&sock).await?;
} else {
eprintln!("--tail: no hay flow socket disponible");
}
}
} }
Cmd::Commands { workspace } => { Cmd::Commands { workspace } => {
@@ -511,29 +541,55 @@ fn print_unexpected(r: &Response) {
eprintln!("unexpected response: {r:?}"); eprintln!("unexpected response: {r:?}");
} }
fn print_pipeline_started(resp: Response) -> Result<()> { /// Imprime el resultado del launch del pipeline y retorna el path del
/// primer flow socket (si hay), útil para `--tail`.
fn print_pipeline_started_returning_socket(resp: Response) -> Result<Option<PathBuf>> {
match resp { match resp {
Response::PipelineStarted { pipeline, command_pids, edges } => { Response::PipelineStarted { pipeline, command_pids, edges } => {
println!("pipeline {pipeline}"); println!("pipeline {pipeline}");
for (label, pid) in command_pids { for (label, pid) in command_pids {
println!(" {:<20} pid={pid}", label); println!(" {:<20} pid={pid}", label);
} }
let mut first_socket: Option<PathBuf> = None;
if !edges.is_empty() { if !edges.is_empty() {
println!("edges:"); println!("edges:");
for e in edges { for e in &edges {
println!( println!(
" {}.{}{}.{} ty={:?} mime={:?} conf={:.2}", " {}.{}{}.{} ty={:?} mime={:?} conf={:.2}",
e.from_label, e.from_output, e.to_label, e.to_input, e.from_label, e.from_output, e.to_label, e.to_input,
e.ty, e.mime, e.confidence, e.ty, e.mime, e.confidence,
); );
if first_socket.is_none() {
first_socket = e.flow_socket.clone();
} }
} }
Ok(()) }
Ok(first_socket)
} }
Response::Error { message } => Err(anyhow!(message)), Response::Error { message } => Err(anyhow!(message)),
other => { other => {
print_unexpected(&other); print_unexpected(&other);
Ok(None)
}
}
}
async fn tail_socket(socket: &std::path::Path) -> Result<()> {
use tokio::io::AsyncReadExt;
// Pequeña ventana de retry — el daemon retiene el flow channel
// antes de retornar, así que en la práctica ya está bindeado.
let mut s = UnixStream::connect(socket)
.await
.with_context(|| format!("connect {}", socket.display()))?;
let mut buf = [0u8; 4096];
loop {
let n = s.read(&mut buf).await?;
if n == 0 {
break;
}
use std::io::Write;
let _ = std::io::stdout().write_all(&buf[..n]);
let _ = std::io::stdout().flush();
}
Ok(()) Ok(())
} }
}
}
+1
View File
@@ -344,6 +344,7 @@ async fn dispatch(
rss_bytes: s.rss_bytes, rss_bytes: s.rss_bytes,
rss_peak_bytes: s.rss_peak_bytes, rss_peak_bytes: s.rss_peak_bytes,
cpu_usec: s.cpu_usec, cpu_usec: s.cpu_usec,
cpu_percent: s.cpu_percent,
source: s.source, source: s.source,
uptime_ms: s.uptime_ms, uptime_ms: s.uptime_ms,
}, },
+10 -5
View File
@@ -389,19 +389,24 @@ impl Render for Shell {
.map(|h| h.iter().map(|s| s.rss_bytes.unwrap_or(0)).collect()) .map(|h| h.iter().map(|s| s.rss_bytes.unwrap_or(0)).collect())
.unwrap_or_default(); .unwrap_or_default();
let spark = sparkline(&rss_series, STATS_HISTORY_LEN); let spark = sparkline(&rss_series, STATS_HISTORY_LEN);
let (rss_now, peak) = history let latest = history.and_then(|h| h.back());
.and_then(|h| h.back()) let (rss_now, peak, cpu_pct) = latest
.map(|s| (s.rss_bytes.unwrap_or(0), s.rss_peak_bytes.unwrap_or(0))) .map(|s| (
.unwrap_or((0, 0)); s.rss_bytes.unwrap_or(0),
s.rss_peak_bytes.unwrap_or(0),
s.cpu_percent.unwrap_or(0.0),
))
.unwrap_or((0, 0, 0.0));
let rss_mb = rss_now as f64 / 1024.0 / 1024.0; let rss_mb = rss_now as f64 / 1024.0 / 1024.0;
let peak_mb = peak as f64 / 1024.0 / 1024.0; let peak_mb = peak as f64 / 1024.0 / 1024.0;
format!( format!(
"{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M", "{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M {:>5.1}%cpu",
&w.id.to_string()[20..], &w.id.to_string()[20..],
w.label, w.label,
spark, spark,
rss_mb, rss_mb,
peak_mb, peak_mb,
cpu_pct,
) )
}) })
.collect(); .collect();
@@ -216,6 +216,12 @@ pub struct DiscernPolicy {
/// querés que los consumidores tardíos vean toda la salida. /// querés que los consumidores tardíos vean toda la salida.
#[serde(default = "default_replay_chunks")] #[serde(default = "default_replay_chunks")]
pub replay_chunks: usize, pub replay_chunks: usize,
/// Tope adicional por **bytes** acumulados en el replay buffer. Lo
/// que se exceda primero (chunks o bytes) drop-ea el chunk más viejo.
/// `0` = sin tope por bytes (sólo aplica `replay_chunks`). Útil para
/// productores con chunks de tamaño variable.
#[serde(default)]
pub replay_bytes: usize,
} }
impl Default for DiscernPolicy { impl Default for DiscernPolicy {
@@ -224,6 +230,7 @@ impl Default for DiscernPolicy {
sample_bytes: default_sample_bytes(), sample_bytes: default_sample_bytes(),
enrich_producer: default_true(), enrich_producer: default_true(),
replay_chunks: default_replay_chunks(), replay_chunks: default_replay_chunks(),
replay_bytes: 0,
} }
} }
} }
@@ -45,16 +45,33 @@ pub const DEFAULT_REPLAY_CHUNKS: usize = 32;
pub struct FlowChannel { pub struct FlowChannel {
sender: broadcast::Sender<Arc<Vec<u8>>>, sender: broadcast::Sender<Arc<Vec<u8>>>,
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>, replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
replay_cap: usize, replay_caps: ReplayCaps,
socket_path: PathBuf, socket_path: PathBuf,
_accept_handle: AbortOnDrop, _accept_handle: AbortOnDrop,
} }
#[derive(Debug, Clone, Copy)]
pub struct ReplayCaps {
/// Máximo de chunks retenidos.
pub chunks: usize,
/// Máximo de bytes (sumando len de chunks). `0` = sin tope.
pub bytes: usize,
}
impl ReplayCaps {
pub fn chunks_only(chunks: usize) -> Self {
Self { chunks: chunks.max(1), bytes: 0 }
}
pub fn new(chunks: usize, bytes: usize) -> Self {
Self { chunks: chunks.max(1), bytes }
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct FlowSender { pub struct FlowSender {
sender: broadcast::Sender<Arc<Vec<u8>>>, sender: broadcast::Sender<Arc<Vec<u8>>>,
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>, replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
replay_cap: usize, replay_caps: ReplayCaps,
} }
impl FlowSender { impl FlowSender {
@@ -62,17 +79,37 @@ impl FlowSender {
/// el broadcast::send retorna Err pero igual guardamos en replay /// el broadcast::send retorna Err pero igual guardamos en replay
/// (subscribers tarde verán los chunks pasados). /// (subscribers tarde verán los chunks pasados).
pub fn send(&self, data: Arc<Vec<u8>>) { pub fn send(&self, data: Arc<Vec<u8>>) {
let cap = self.replay_cap; let incoming = data.len();
let caps = self.replay_caps;
if let Ok(mut g) = self.replay.lock() { if let Ok(mut g) = self.replay.lock() {
if g.len() >= cap { evict_for_incoming(&mut g, caps, incoming);
g.pop_front();
}
g.push_back(data.clone()); g.push_back(data.clone());
} }
let _ = self.sender.send(data); let _ = self.sender.send(data);
} }
} }
/// Evict los chunks más viejos para hacer espacio a un chunk entrante de
/// `incoming` bytes — el buffer post-push queda dentro de los caps.
fn evict_for_incoming(buf: &mut VecDeque<Arc<Vec<u8>>>, caps: ReplayCaps, incoming: usize) {
// 1) chunks: dejar lugar para 1 más.
while buf.len() + 1 > caps.chunks {
if buf.pop_front().is_none() {
break;
}
}
// 2) bytes (si está activado).
if caps.bytes > 0 {
let mut current: usize = buf.iter().map(|a| a.len()).sum();
while current + incoming > caps.bytes {
match buf.pop_front() {
Some(c) => current = current.saturating_sub(c.len()),
None => break,
}
}
}
}
impl std::fmt::Debug for FlowChannel { impl std::fmt::Debug for FlowChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FlowChannel") f.debug_struct("FlowChannel")
@@ -86,11 +123,14 @@ impl FlowChannel {
/// Crea un FlowChannel atado al path `socket_path`. Si el path ya /// Crea un FlowChannel atado al path `socket_path`. Si el path ya
/// existe, lo borra antes de bind (asume restart limpio). /// existe, lo borra antes de bind (asume restart limpio).
pub fn new(socket_path: PathBuf) -> std::io::Result<Self> { pub fn new(socket_path: PathBuf) -> std::io::Result<Self> {
Self::with_replay_cap(socket_path, DEFAULT_REPLAY_CHUNKS) Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(DEFAULT_REPLAY_CHUNKS))
} }
pub fn with_replay_cap(socket_path: PathBuf, replay_cap: usize) -> std::io::Result<Self> { pub fn with_replay_cap(socket_path: PathBuf, chunks: usize) -> std::io::Result<Self> {
let cap = replay_cap.max(1); Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(chunks))
}
pub fn with_replay_caps(socket_path: PathBuf, caps: ReplayCaps) -> std::io::Result<Self> {
if socket_path.exists() { if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path); let _ = std::fs::remove_file(&socket_path);
} }
@@ -100,7 +140,7 @@ impl FlowChannel {
let listener = UnixListener::bind(&socket_path)?; let listener = UnixListener::bind(&socket_path)?;
let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP); let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP);
let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> = let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(cap))); Arc::new(Mutex::new(VecDeque::with_capacity(caps.chunks)));
let tx_for_accept = tx.clone(); let tx_for_accept = tx.clone();
let replay_for_accept = replay.clone(); let replay_for_accept = replay.clone();
let path_for_log = socket_path.clone(); let path_for_log = socket_path.clone();
@@ -153,21 +193,21 @@ impl FlowChannel {
Ok(Self { Ok(Self {
sender: tx, sender: tx,
replay, replay,
replay_cap: cap, replay_caps: caps,
socket_path, socket_path,
_accept_handle: AbortOnDrop(join.abort_handle()), _accept_handle: AbortOnDrop(join.abort_handle()),
}) })
} }
/// Push un chunk al channel. Si no hay subscribers, drop silencioso. /// Push un chunk al channel. Si no hay subscribers, drop silencioso.
/// Siempre se guarda en el replay buffer (con cap rotation). /// Siempre se guarda en el replay buffer (con cap rotation por chunks
/// y opcionalmente por bytes).
pub fn send(&self, data: Vec<u8>) { pub fn send(&self, data: Vec<u8>) {
let incoming = data.len();
let arc = Arc::new(data); let arc = Arc::new(data);
let cap = self.replay_cap; let caps = self.replay_caps;
if let Ok(mut g) = self.replay.lock() { if let Ok(mut g) = self.replay.lock() {
if g.len() >= cap { evict_for_incoming(&mut g, caps, incoming);
g.pop_front();
}
g.push_back(arc.clone()); g.push_back(arc.clone());
} }
let _ = self.sender.send(arc); let _ = self.sender.send(arc);
@@ -184,7 +224,7 @@ impl FlowChannel {
FlowSender { FlowSender {
sender: self.sender.clone(), sender: self.sender.clone(),
replay: self.replay.clone(), replay: self.replay.clone(),
replay_cap: self.replay_cap, replay_caps: self.replay_caps,
} }
} }
@@ -303,6 +343,47 @@ mod tests {
assert!(s.contains("chunk-3"), "got: {s:?}"); assert!(s.contains("chunk-3"), "got: {s:?}");
} }
#[tokio::test]
async fn replay_evicts_by_bytes_cap() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("flow.sock");
// chunks=100 (no limita), bytes=20: deberíamos retener sólo los
// últimos chunks cuyos bytes sumen ≤ 20.
let ch = FlowChannel::with_replay_caps(path.clone(), ReplayCaps::new(100, 20)).unwrap();
ch.send(b"AAAAAAAA".to_vec()); // 8 bytes
ch.send(b"BBBBBBBB".to_vec()); // 8 → total 16
ch.send(b"CCCCCCCC".to_vec()); // 8 → total 24 > 20, evict A → 16
ch.send(b"DDDDDDDD".to_vec()); // 8 → total 24 > 20, evict B → 16
let path_clone = path.clone();
let task = tokio::spawn(async move {
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
let mut buf = vec![0u8; 64];
let mut total = Vec::new();
for _ in 0..20 {
let n = stream.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
total.extend_from_slice(&buf[..n]);
if total.len() >= 16 {
break;
}
}
total
});
let got = tokio::time::timeout(std::time::Duration::from_secs(2), task)
.await
.expect("timeout")
.unwrap();
let s = String::from_utf8_lossy(&got);
// Sólo C y D (los más viejos A y B fueron evicted).
assert!(!s.contains("AAAA"), "should have evicted A: {s:?}");
assert!(!s.contains("BBBB"), "should have evicted B: {s:?}");
assert!(s.contains("CCCC"), "should keep C: {s:?}");
assert!(s.contains("DDDD"), "should keep D: {s:?}");
}
#[tokio::test] #[tokio::test]
async fn drop_removes_socket() { async fn drop_removes_socket() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+23 -4
View File
@@ -48,6 +48,9 @@ pub struct WorkspaceState {
pub root_card: Card, pub root_card: Card,
pub commands: HashMap<Ulid, CommandState>, pub commands: HashMap<Ulid, CommandState>,
pub started: Instant, pub started: Instant,
/// Última muestra de `(wall_instant, cpu_usec)` usada para calcular
/// `cpu_percent` en la próxima medición. None hasta el primer measure.
pub last_cpu_sample: Option<(Instant, u64)>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -354,9 +357,12 @@ impl WorkspaceManager {
/// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara /// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara
/// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye /// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye
/// descendants). /// descendants).
///
/// `cpu_percent` se calcula entre samples consecutivos. Necesita ≥2
/// llamadas para tener un valor (la primera siempre retorna `None`).
pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> { pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> {
let g = self.inner.lock().await; let mut g = self.inner.lock().await;
let ws = g.workspaces.get(&id)?; let ws = g.workspaces.get_mut(&id)?;
let alive: Vec<i32> = ws let alive: Vec<i32> = ws
.commands .commands
.values() .values()
@@ -367,8 +373,6 @@ impl WorkspaceManager {
let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() { let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() {
None None
} else { } else {
// resolve_cgroup_path está en ente_incarnate, pero acá basta
// con el path absoluto bajo /sys/fs/cgroup. Resolución gruesa.
Some(std::path::PathBuf::from(format!( Some(std::path::PathBuf::from(format!(
"/sys/fs/cgroup{}", "/sys/fs/cgroup{}",
ws.spec.soma.cgroup.path ws.spec.soma.cgroup.path
@@ -376,6 +380,20 @@ impl WorkspaceManager {
}; };
let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started); let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started);
s.commands_total = total; s.commands_total = total;
// CPU%: diff entre el sample actual y el previo, dividido por
// wall time. 100% = 1 core saturado. >100% = varios cores.
let now = Instant::now();
if let Some(cpu_now) = s.cpu_usec {
if let Some((prev_t, prev_cpu)) = ws.last_cpu_sample {
let dt_us = now.duration_since(prev_t).as_micros() as u64;
let d_cpu = cpu_now.saturating_sub(prev_cpu);
if dt_us > 0 {
s.cpu_percent = Some(100.0 * d_cpu as f32 / dt_us as f32);
}
}
ws.last_cpu_sample = Some((now, cpu_now));
}
Some(s) Some(s)
} }
@@ -403,6 +421,7 @@ impl WorkspaceManager {
root_card: card, root_card: card,
commands: HashMap::new(), commands: HashMap::new(),
started: Instant::now(), started: Instant::now(),
last_cpu_sample: None,
}; };
self.inner.lock().await.workspaces.insert(id, state); self.inner.lock().await.workspaces.insert(id, state);
info!(%id, ?ttl, "workspace created"); info!(%id, ?ttl, "workspace created");
@@ -211,7 +211,10 @@ pub async fn run_pipeline(
i i
); );
let socket = crate::flow_channel::default_flow_socket_path(&id); let socket = crate::flow_channel::default_flow_socket_path(&id);
match crate::flow_channel::FlowChannel::with_replay_cap(socket.clone(), spec.discern.replay_chunks) { match crate::flow_channel::FlowChannel::with_replay_caps(
socket.clone(),
crate::flow_channel::ReplayCaps::new(spec.discern.replay_chunks, spec.discern.replay_bytes),
) {
Ok(fc) => { Ok(fc) => {
senders_per_edge.push(Some(fc.sender_handle())); senders_per_edge.push(Some(fc.sender_handle()));
paths_per_edge.push(Some(socket)); paths_per_edge.push(Some(socket));
@@ -693,6 +696,7 @@ mod tests {
sample_bytes: 4096, sample_bytes: 4096,
enrich_producer: true, enrich_producer: true,
replay_chunks: 32, replay_chunks: 32,
replay_bytes: 0,
}, },
}; };
let disc = Arc::new(DiscernPipeline::default_pipeline()); let disc = Arc::new(DiscernPipeline::default_pipeline());
@@ -23,6 +23,9 @@ pub struct WorkspaceStats {
pub rss_peak_bytes: Option<u64>, pub rss_peak_bytes: Option<u64>,
/// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir. /// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
pub cpu_usec: Option<u64>, pub cpu_usec: Option<u64>,
/// %CPU instantáneo derivado entre dos samples consecutivos. `None`
/// en el primer sample (no hay baseline). `100.0` = 1 core saturado.
pub cpu_percent: Option<f32>,
/// Fuente del dato: "proc" | "cgroup" | "mixed". /// Fuente del dato: "proc" | "cgroup" | "mixed".
pub source: String, pub source: String,
/// Wall-clock uptime del workspace en milisegundos. /// Wall-clock uptime del workspace en milisegundos.
@@ -67,6 +70,7 @@ pub fn measure(
rss_bytes: rss, rss_bytes: rss,
rss_peak_bytes: rss_peak, rss_peak_bytes: rss_peak,
cpu_usec: cpu, cpu_usec: cpu,
cpu_percent: None, // El caller lo rellena con el diff vs prev sample.
source, source,
uptime_ms: workspace_started.elapsed().as_millis() as u64, uptime_ms: workspace_started.elapsed().as_millis() as u64,
} }
@@ -216,6 +216,8 @@ pub struct WorkspaceStatsInfo {
#[serde(default)] #[serde(default)]
pub rss_peak_bytes: Option<u64>, pub rss_peak_bytes: Option<u64>,
pub cpu_usec: Option<u64>, pub cpu_usec: Option<u64>,
#[serde(default)]
pub cpu_percent: Option<f32>,
pub source: String, pub source: String,
pub uptime_ms: u64, pub uptime_ms: u64,
} }