feat(shipote): throughput card + rate-limit + snapshot incremental (fase Q)

- shipote-shell Flow channels card extiende con bytes_total + bytes/s
  por socket. Lookup helper evita borrows en closures.
- DiscernPolicy.max_bytes_per_sec: splitter task hace sleep proporcional
  al tamaño de chunk tras cada broadcast. Token-bucket simple v1.
- WorkspaceManager.dirty: AtomicBool. mark_dirty() en mutaciones que
  afectan al snapshot. save_snapshot skip si clean y path existe.
  restore_snapshot resetea dirty=false (hidratación no es mutation).

85 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8,
shipote-core 26, 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 16:20:50 +00:00
parent 3486949d24
commit 18c0344a52
5 changed files with 134 additions and 25 deletions
+48 -23
View File
@@ -6,8 +6,8 @@
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
use shipote_protocol::{
default_socket_path, read_frame, write_frame, CommandInfo, FlowInfo, QuotaReportInfo, Request,
Response, WorkspaceStatsInfo, WorkspaceSummary,
default_socket_path, read_frame, write_frame, CommandInfo, FlowInfo, FlowThroughputInfo,
QuotaReportInfo, Request, Response, WorkspaceStatsInfo, WorkspaceSummary,
};
use std::path::PathBuf;
use std::time::Duration;
@@ -44,6 +44,8 @@ struct Shell {
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
saved_pipelines: Vec<String>,
flows: Vec<FlowInfo>,
/// Throughput por flow socket (bytes_total + bytes/s).
flow_throughput: Vec<FlowThroughputInfo>,
/// History de RSS por workspace (últimas N samples).
stats_history: std::collections::BTreeMap<String, std::collections::VecDeque<WorkspaceStatsInfo>>,
/// Quota report fresco por workspace.
@@ -81,6 +83,7 @@ impl Shell {
me.commands = snap.commands;
me.saved_pipelines = snap.saved_pipelines;
me.flows = snap.flows;
me.flow_throughput = snap.flow_throughput;
me.quotas = snap.quotas;
// Hidratar history server-side para workspaces
// que no tenían history local (primer probe).
@@ -122,6 +125,7 @@ impl Shell {
me.commands.clear();
me.saved_pipelines.clear();
me.flows.clear();
me.flow_throughput.clear();
me.quotas.clear();
me.caps = None;
me.recent_log = None;
@@ -142,6 +146,7 @@ impl Shell {
commands: std::collections::BTreeMap::new(),
saved_pipelines: Vec::new(),
flows: Vec::new(),
flow_throughput: Vec::new(),
stats_history: std::collections::BTreeMap::new(),
quotas: std::collections::BTreeMap::new(),
caps: None,
@@ -157,6 +162,7 @@ struct Snapshot {
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
saved_pipelines: Vec<String>,
flows: Vec<FlowInfo>,
flow_throughput: Vec<FlowThroughputInfo>,
/// Stats fresco por workspace (id.toString → stats).
fresh_stats: std::collections::BTreeMap<String, WorkspaceStatsInfo>,
/// Quota report fresco por workspace.
@@ -254,6 +260,17 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
Response::FlowList { items } => items,
_ => Vec::new(),
};
// Throughput per-socket.
write_frame(&mut stream, &Request::FlowThroughput)
.await
.map_err(|e| format!("write throughput: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read throughput: {e}"))?;
let flow_throughput = match resp {
Response::FlowThroughput { items } => items,
_ => Vec::new(),
};
// Live tail: log del comando más reciente con bytes>0.
let recent_log = {
@@ -330,6 +347,7 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
commands: commands_map,
saved_pipelines,
flows,
flow_throughput,
fresh_stats,
quotas,
hydrate_history,
@@ -509,31 +527,38 @@ impl Render for Shell {
"ws_suffix · recurso · uso > limit".to_string()
};
// Flow channels (data plane).
// Flow channels (data plane) con throughput.
let flow_count: usize = self.flows.iter().map(|f| f.sockets.len()).sum();
let flow_items: Vec<String> = self
.flows
.iter()
.flat_map(|f| {
let pipe = f.pipeline.to_string();
let short = &pipe[pipe.len() - 6..];
f.sockets
.iter()
.map(move |s| {
format!(
"{short} {}",
s.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.display().to_string())
)
})
.collect::<Vec<_>>()
})
.collect();
// Lookup helper que NO captura por ref (evita issue de borrow
// en el closure de flat_map).
let find_tp = |s: &std::path::PathBuf| -> (f64, f64) {
for t in &self.flow_throughput {
if t.socket == *s {
return (t.bytes_total as f64 / 1024.0, t.bytes_per_sec / 1024.0);
}
}
(0.0, 0.0)
};
let mut flow_items: Vec<String> = Vec::new();
for f in &self.flows {
let pipe = f.pipeline.to_string();
let short_pipe = &pipe[pipe.len() - 6..];
for s in &f.sockets {
let name = s
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.display().to_string());
let (total_kib, rate_kib) = find_tp(s);
flow_items.push(format!(
"{short_pipe} {:<48} {:>7.1} KiB {:>6.2} KiB/s",
name, total_kib, rate_kib
));
}
}
let flow_descr = if flow_count == 0 {
"pipelines con --tap exponen sockets aquí".to_string()
} else {
"shipote flow tail <socket> para suscribirse".to_string()
"pipe6 · socket · total · rate".to_string()
};
let body = div()