feat(shipote): collision detection + stats history server-side (fase O)

- Flow socket names usan pipeline_id full (ULID 26 chars) + edge_idx.
  Cero colisiones entre pipelines (ULID es único global). Fallback con
  suffix -N si el path existe (cap 1000 retries).
- WorkspaceState.stats_history (VecDeque cap 64) — workspace_stats
  appendea cada call. API workspace_stats_history(id, tail). Protocol
  WorkspaceStatsHistory. Shell pide history al primer probe → sparkline
  hidratada al boot, sobrevive restart del shell.

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:
sergio
2026-05-11 10:55:21 +00:00
parent a823c40fe1
commit 1cce50b290
5 changed files with 154 additions and 10 deletions
+38 -1
View File
@@ -82,7 +82,20 @@ impl Shell {
me.saved_pipelines = snap.saved_pipelines;
me.flows = snap.flows;
me.quotas = snap.quotas;
// Append a la history por workspace.
// Hidratar history server-side para workspaces
// que no tenían history local (primer probe).
for ws in &me.workspaces {
let key = ws.id.to_string();
if !me.stats_history.contains_key(&key) {
if let Some(hydrated) = snap.hydrate_history.get(&key) {
me.stats_history.insert(
key.clone(),
hydrated.iter().cloned().collect(),
);
}
}
}
// Append fresh sample a la history por workspace.
for (ws_id, fresh) in &snap.fresh_stats {
let h = me
.stats_history
@@ -148,6 +161,9 @@ struct Snapshot {
fresh_stats: std::collections::BTreeMap<String, WorkspaceStatsInfo>,
/// Quota report fresco por workspace.
quotas: std::collections::BTreeMap<String, QuotaReportInfo>,
/// Workspaces nuevos (no en history local): hidratamos history
/// server-side al primer probe que los vea.
hydrate_history: std::collections::BTreeMap<String, Vec<WorkspaceStatsInfo>>,
caps: CapsSummary,
/// tail del log del comando más reciente (label + bytes). None si no hay.
recent_log: Option<(String, String)>,
@@ -175,9 +191,11 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
};
// Batched: stats+quota+commands+flow_sockets en 1 roundtrip por ws.
// Para workspaces nuevos, también pedimos history server-side.
let mut commands_map = std::collections::BTreeMap::new();
let mut fresh_stats = std::collections::BTreeMap::new();
let mut quotas = std::collections::BTreeMap::new();
let mut hydrate_history = std::collections::BTreeMap::new();
for w in &workspaces {
write_frame(&mut stream, &Request::WorkspaceFullSummary { workspace: w.id })
.await
@@ -193,6 +211,24 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
commands_map.insert(key, commands);
}
}
// History server-side (para hidratar si el shell es nuevo).
write_frame(
&mut stream,
&Request::WorkspaceStatsHistory {
workspace: w.id,
tail: 24, // mismo cap que STATS_HISTORY_LEN
},
)
.await
.map_err(|e| format!("write history: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read history: {e}"))?;
if let Response::WorkspaceStatsHistory { samples } = resp {
if !samples.is_empty() {
hydrate_history.insert(w.id.to_string(), samples);
}
}
}
// Saved pipelines.
@@ -296,6 +332,7 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
flows,
fresh_stats,
quotas,
hydrate_history,
caps,
recent_log,
})