feat(shipote): throughput + stats persistente + auth peer (fase P)
- FlowMeter (atomic u64 + rolling window 32 samples) en cada FlowChannel. flow_throughput() → (socket, bytes_total, bytes_per_sec). CLI: shipote flow throughput. Idle threshold 5s = rate 0.0. - Snapshot v4 con stats_history persistente por workspace (cap 16). PersistedStats separado para evitar Instant. Restore hidrata el VecDeque con source="persisted". - Auth SO_PEERCRED: daemon rechaza peers con uid distinto al propio. SHIPOTE_TRUST_ANYONE=1 = escape hatch documentado. 84 tests pasan (ente-incarnate 16, nouser-core 27, shipote-card 8, shipote-core 25, shipote-discern 5, yahweh-provider-fs 3). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,10 @@ use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// v2 agregó `saved_pipelines`. v3 agrega `live_pipelines` (pipelines
|
||||
/// con supervisor vivo al momento del snapshot — el daemon los relanza
|
||||
/// al restore). Versiones inferiores leen campos ausentes como vacío.
|
||||
pub const SNAPSHOT_VERSION: u16 = 3;
|
||||
/// v2 agregó `saved_pipelines`. v3 agrega `live_pipelines`. v4 agrega
|
||||
/// `stats_history` por workspace (sparkline survives daemon restart).
|
||||
/// Versiones inferiores leen campos ausentes como vacío.
|
||||
pub const SNAPSHOT_VERSION: u16 = 4;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShipoteSnapshot {
|
||||
@@ -32,6 +32,37 @@ pub struct ShipoteSnapshot {
|
||||
pub struct WorkspaceEntry {
|
||||
pub id: WorkspaceId,
|
||||
pub spec: WorkspaceSpec,
|
||||
/// Stats history persistida — cap reasonable para no inflar el JSON.
|
||||
/// Sólo se guardan campos serializables (no Instant).
|
||||
#[serde(default)]
|
||||
pub stats_history: Vec<PersistedStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedStats {
|
||||
pub commands_alive: u32,
|
||||
pub commands_total: u32,
|
||||
pub rss_bytes: Option<u64>,
|
||||
pub rss_peak_bytes: Option<u64>,
|
||||
pub cpu_usec: Option<u64>,
|
||||
pub cpu_percent: Option<f32>,
|
||||
pub cpu_cores: u32,
|
||||
pub uptime_ms: u64,
|
||||
}
|
||||
|
||||
impl From<&crate::stats::WorkspaceStats> for PersistedStats {
|
||||
fn from(s: &crate::stats::WorkspaceStats) -> Self {
|
||||
Self {
|
||||
commands_alive: s.commands_alive,
|
||||
commands_total: s.commands_total,
|
||||
rss_bytes: s.rss_bytes,
|
||||
rss_peak_bytes: s.rss_peak_bytes,
|
||||
cpu_usec: s.cpu_usec,
|
||||
cpu_percent: s.cpu_percent,
|
||||
cpu_cores: s.cpu_cores,
|
||||
uptime_ms: s.uptime_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -98,13 +129,27 @@ fn now_ms() -> u64 {
|
||||
impl WorkspaceManager {
|
||||
/// Toma snapshot del estado actual.
|
||||
pub async fn snapshot(&self) -> ShipoteSnapshot {
|
||||
const PERSIST_STATS_CAP: usize = 16;
|
||||
let g = self.inner.lock().await;
|
||||
let workspaces = g
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|(id, ws)| WorkspaceEntry {
|
||||
id: *id,
|
||||
spec: ws.spec.clone(),
|
||||
.map(|(id, ws)| {
|
||||
// Persist sólo los últimos N samples — el resto crece
|
||||
// y el JSON se infla.
|
||||
let take = ws.stats_history.len().min(PERSIST_STATS_CAP);
|
||||
let skip = ws.stats_history.len() - take;
|
||||
let stats_history: Vec<PersistedStats> = ws
|
||||
.stats_history
|
||||
.iter()
|
||||
.skip(skip)
|
||||
.map(PersistedStats::from)
|
||||
.collect();
|
||||
WorkspaceEntry {
|
||||
id: *id,
|
||||
spec: ws.spec.clone(),
|
||||
stats_history,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let saved_pipelines = g
|
||||
@@ -165,8 +210,33 @@ impl WorkspaceManager {
|
||||
// v2+: reusamos el id original así clients que tracking
|
||||
// workspace_id no se rompen al restart.
|
||||
let label = entry.spec.label.clone();
|
||||
match self.create_with_id(entry.id, entry.spec).await {
|
||||
Ok(_) => out.workspaces_restored += 1,
|
||||
let id = entry.id;
|
||||
let history = entry.stats_history;
|
||||
match self.create_with_id(id, entry.spec).await {
|
||||
Ok(_) => {
|
||||
out.workspaces_restored += 1;
|
||||
// Hidratar history persistida. Convertimos
|
||||
// PersistedStats → WorkspaceStats (perdemos
|
||||
// los campos no serializables como `source`).
|
||||
if !history.is_empty() {
|
||||
let mut g = self.inner.lock().await;
|
||||
if let Some(ws) = g.workspaces.get_mut(&id) {
|
||||
for ps in history {
|
||||
ws.stats_history.push_back(crate::stats::WorkspaceStats {
|
||||
commands_alive: ps.commands_alive,
|
||||
commands_total: ps.commands_total,
|
||||
rss_bytes: ps.rss_bytes,
|
||||
rss_peak_bytes: ps.rss_peak_bytes,
|
||||
cpu_usec: ps.cpu_usec,
|
||||
cpu_percent: ps.cpu_percent,
|
||||
cpu_cores: ps.cpu_cores,
|
||||
source: "persisted".into(),
|
||||
uptime_ms: ps.uptime_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(?e, %label, "skipped workspace en restore"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user