feat(shipote): data plane + DAG fan-in/out + stats + lifecycle (fases F-I)
Pipeline runtime:
- Fan-out 1→N (splitter task replica al N consumers) y fan-in N→1 (merger
task con mpsc + reader-per-input). DAGs no lineales soportados.
- Flow channels: Unix socket + tokio broadcast con replay buffer
configurable por pipeline (DiscernPolicy.replay_chunks). Subscribers
externos vía `shipote flow tail <socket>`.
- Templating en specs con `${KEY}` (CLI `--var KEY=VALUE`). Walk
recursivo sobre serde_json::Value, soporta todos los strings del schema.
- Pipelines guardados (`pipeline save/saved-list/drop/run-saved`)
persisten con el snapshot.
Lifecycle de comandos:
- Log capture per-stream (stdout/stderr separados) via pipe O_CLOEXEC +
AsyncFd. CLI `shipote logs <ws> <cmd> --stream {stdout,stderr,both}`.
- Stop graceful con tiempo configurable: SIGTERM → grace → SIGKILL.
Tanto a nivel workspace como pipeline individual.
- TTL auto-stop ya existente (Fase C) sigue funcionando.
ente-incarnate:
- ChildStdio declarativo (Fase C) + ChildPreExec declarativo nuevo:
NoNewPrivs, ParentDeathSig, Dumpable, NewSession, Chdir, Umask.
- Aplicación pre-execve async-signal-safe en ambos paths (plain via
Command::pre_exec, namespaced via callback del clone(2)).
Observabilidad:
- WorkspaceStats: RSS + RSS peak (VmHWM o memory.peak cgroup) + CPU usec
+ uptime. Fuente per-proc o cgroup según delegation.
- shipote-shell con sparkline ASCII por workspace (history cap 24),
card de flow channels activos, vista de comandos + saved pipelines.
- Tap → broker: cada edge enriquecido con TypeRef se anuncia como Card
efímera vía SidecarPool (graceful si broker no corre).
Discern:
- Integrado en yahweh-provider-fs (mime_type en EntityNode).
- Integrado en nouser-core::cluster::pick_lens como fallback cuando la
extensión cae a Lens::Grid.
79 tests pasan: ente-incarnate (16), nouser-core (27), shipote-card (8),
shipote-core (20), shipote-discern (5), yahweh-provider-fs (3).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -63,11 +63,31 @@ enum Cmd {
|
||||
/// Bytes desde el final (0 = todo).
|
||||
#[arg(long, default_value_t = 0)]
|
||||
tail: usize,
|
||||
/// Stream a leer: stdout | stderr | both.
|
||||
#[arg(long, default_value = "both")]
|
||||
stream: String,
|
||||
},
|
||||
|
||||
/// Pipeline DAG con flujo tipado.
|
||||
#[command(subcommand)]
|
||||
Pipeline(PipeCmd),
|
||||
|
||||
/// Flow data plane (subscribirse a streams enriquecidos).
|
||||
#[command(subcommand)]
|
||||
Flow(FlowCmd),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum FlowCmd {
|
||||
/// Listar pipelines activos con sus sockets de flow.
|
||||
List,
|
||||
/// Cerrar el data plane de un pipeline (drop de todos sus sockets).
|
||||
Drop { pipeline: String },
|
||||
/// Suscribirse a un flow socket y volcar bytes a stdout.
|
||||
Tail {
|
||||
/// Path al Unix socket del flow.
|
||||
socket: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -80,6 +100,9 @@ enum PipeCmd {
|
||||
/// discernir el TypeRef del flujo.
|
||||
#[arg(long)]
|
||||
tap: bool,
|
||||
/// Variables `KEY=VALUE` para sustitución `${KEY}` en el spec.
|
||||
#[arg(long = "var", value_parser = parse_kv)]
|
||||
vars: Vec<(String, String)>,
|
||||
},
|
||||
/// Guardar un pipeline bajo un nombre (persiste con el snapshot).
|
||||
Save {
|
||||
@@ -90,16 +113,31 @@ enum PipeCmd {
|
||||
},
|
||||
/// Listar nombres de pipelines guardados.
|
||||
SavedList,
|
||||
/// Eliminar un pipeline guardado.
|
||||
/// Eliminar un pipeline guardado (no afecta runs en curso).
|
||||
Drop { name: String },
|
||||
/// Detener un pipeline en curso por ID (SIGTERM → grace → SIGKILL
|
||||
/// sólo a sus comandos).
|
||||
Stop {
|
||||
/// ULID del pipeline (devuelto por `pipeline run`).
|
||||
pipeline: String,
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
grace_ms: u64,
|
||||
},
|
||||
/// Ejecutar un pipeline guardado por nombre.
|
||||
RunSaved {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
tap: bool,
|
||||
#[arg(long = "var", value_parser = parse_kv)]
|
||||
vars: Vec<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||
let (k, v) = s.split_once('=').ok_or_else(|| format!("expected KEY=VALUE, got `{s}`"))?;
|
||||
Ok((k.to_string(), v.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum WsCmd {
|
||||
/// Crear un workspace desde un spec TOML/JSON.
|
||||
@@ -112,6 +150,13 @@ enum WsCmd {
|
||||
/// Detener un workspace por ID.
|
||||
Stop {
|
||||
id: String,
|
||||
/// Milisegundos de gracia tras SIGTERM antes de SIGKILL.
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
grace_ms: u64,
|
||||
},
|
||||
/// Resource accounting (RSS, CPU, comandos vivos).
|
||||
Stats {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -185,9 +230,33 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Workspace(WsCmd::Stop { id }) => {
|
||||
Cmd::Workspace(WsCmd::Stats { id }) => {
|
||||
let id = parse_ws_id(&id)?;
|
||||
let resp = round_trip(&mut stream, Request::WorkspaceStop { id }).await?;
|
||||
let resp = round_trip(&mut stream, Request::WorkspaceStats { workspace: id }).await?;
|
||||
match resp {
|
||||
Response::WorkspaceStats { info } => {
|
||||
println!("commands: {} alive / {} total", info.commands_alive, info.commands_total);
|
||||
let fmt_mib = |b: u64| format!("{:.2} MiB", b as f64 / 1024.0 / 1024.0);
|
||||
let rss = info.rss_bytes.map(fmt_mib).unwrap_or_else(|| "—".into());
|
||||
let peak = info.rss_peak_bytes.map(fmt_mib).unwrap_or_else(|| "—".into());
|
||||
let cpu = info
|
||||
.cpu_usec
|
||||
.map(|u| format!("{:.3} s", u as f64 / 1_000_000.0))
|
||||
.unwrap_or_else(|| "—".into());
|
||||
println!("rss: {rss}");
|
||||
println!("rss_peak: {peak}");
|
||||
println!("cpu: {cpu}");
|
||||
println!("source: {}", info.source);
|
||||
println!("uptime: {} ms", info.uptime_ms);
|
||||
}
|
||||
Response::Error { message } => return Err(anyhow!(message)),
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Workspace(WsCmd::Stop { id, grace_ms }) => {
|
||||
let id = parse_ws_id(&id)?;
|
||||
let resp = round_trip(&mut stream, Request::WorkspaceStop { id, grace_ms }).await?;
|
||||
match resp {
|
||||
Response::WorkspaceStopped { id, reaped } => {
|
||||
println!("stopped {id} (reaped {reaped})");
|
||||
@@ -218,9 +287,17 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::Run { spec, tap }) => {
|
||||
Cmd::Pipeline(PipeCmd::Run { spec, tap, vars }) => {
|
||||
let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?;
|
||||
let resp = round_trip(&mut stream, Request::PipelineRun { spec: p, tap }).await?;
|
||||
let resp = round_trip(
|
||||
&mut stream,
|
||||
Request::PipelineRun {
|
||||
spec: p,
|
||||
tap,
|
||||
vars: vars.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
print_pipeline_started(resp)?;
|
||||
}
|
||||
|
||||
@@ -249,6 +326,17 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::Stop { pipeline, grace_ms }) => {
|
||||
let pid = Ulid::from_string(&pipeline).map_err(|e| anyhow!("invalid pipeline id: {e}"))?;
|
||||
let resp = round_trip(&mut stream, Request::PipelineStop { pipeline: pid, grace_ms }).await?;
|
||||
match resp {
|
||||
Response::PipelineStopped { pipeline, reaped } => {
|
||||
println!("stopped pipeline {pipeline} (reaped {reaped})");
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::Drop { name }) => {
|
||||
let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?;
|
||||
match resp {
|
||||
@@ -263,8 +351,16 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Pipeline(PipeCmd::RunSaved { name, tap }) => {
|
||||
let resp = round_trip(&mut stream, Request::PipelineRunSaved { name, tap }).await?;
|
||||
Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars }) => {
|
||||
let resp = round_trip(
|
||||
&mut stream,
|
||||
Request::PipelineRunSaved {
|
||||
name,
|
||||
tap,
|
||||
vars: vars.into_iter().collect(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
print_pipeline_started(resp)?;
|
||||
}
|
||||
|
||||
@@ -292,7 +388,7 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Logs { workspace, command, tail } => {
|
||||
Cmd::Logs { workspace, command, tail, stream: which_stream } => {
|
||||
let ws = parse_ws_id(&workspace)?;
|
||||
let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?;
|
||||
let resp = round_trip(
|
||||
@@ -301,6 +397,7 @@ async fn main() -> Result<()> {
|
||||
workspace: ws,
|
||||
command: cmd_id,
|
||||
tail_bytes: tail,
|
||||
stream: which_stream,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -316,6 +413,57 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Flow(FlowCmd::List) => {
|
||||
let resp = round_trip(&mut stream, Request::FlowList).await?;
|
||||
match resp {
|
||||
Response::FlowList { items } => {
|
||||
if items.is_empty() {
|
||||
println!("(no active flows)");
|
||||
}
|
||||
for it in items {
|
||||
println!("{}", it.pipeline);
|
||||
for s in it.sockets {
|
||||
println!(" {}", s.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Flow(FlowCmd::Drop { pipeline }) => {
|
||||
let pid = Ulid::from_string(&pipeline).map_err(|e| anyhow!("invalid pipeline id: {e}"))?;
|
||||
let resp = round_trip(&mut stream, Request::FlowDrop { pipeline: pid }).await?;
|
||||
match resp {
|
||||
Response::FlowDropped { pipeline, existed } => {
|
||||
if existed {
|
||||
println!("dropped {pipeline}");
|
||||
} else {
|
||||
eprintln!("no existía: {pipeline}");
|
||||
}
|
||||
}
|
||||
other => print_unexpected(&other),
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Flow(FlowCmd::Tail { socket }) => {
|
||||
// Subscribirse directo al socket — no pasamos por el daemon.
|
||||
use tokio::io::AsyncReadExt;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Cmd::Discern { path } => {
|
||||
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
// Sample: hasta 4 KiB.
|
||||
|
||||
@@ -17,7 +17,7 @@ use shipote_core::WorkspaceManager;
|
||||
use shipote_discern::{DiscernPipeline, Hint};
|
||||
use shipote_protocol::{
|
||||
default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo,
|
||||
EdgeDiscernmentInfo, Request, Response, WorkspaceSummary,
|
||||
EdgeDiscernmentInfo, FlowInfo, Request, Response, WorkspaceStatsInfo, WorkspaceSummary,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
@@ -38,10 +38,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
||||
info!(socket = %sock.display(), "shipote-daemon listening");
|
||||
|
||||
// Sidecar al broker: shipote se anuncia como sesión. Si el Init no
|
||||
// está corriendo, el sidecar loguea y termina; el daemon sigue
|
||||
// standalone (UX de v1: ningún feature requiere broker).
|
||||
brahman_sidecar::spawn(build_daemon_card(&sock));
|
||||
// Sidecar pool: una sesión global del daemon + N sesiones efímeras
|
||||
// por edge enriquecido tras cada pipeline tap.
|
||||
let sidecar_pool = match brahman_sidecar::SidecarPool::new() {
|
||||
Ok(p) => Some(Arc::new(p)),
|
||||
Err(e) => {
|
||||
warn!(?e, "SidecarPool falló — broker integration disabled");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(pool) = &sidecar_pool {
|
||||
pool.spawn(build_daemon_card(&sock));
|
||||
}
|
||||
|
||||
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig {
|
||||
// El daemon aún no se conecta al broker; cuando lo haga, este path
|
||||
@@ -101,8 +109,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok((stream, _)) => {
|
||||
let mgr = mgr.clone();
|
||||
let disc = discerner.clone();
|
||||
let pool = sidecar_pool.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_client(stream, mgr, disc).await {
|
||||
if let Err(e) = handle_client(stream, mgr, disc, pool).await {
|
||||
warn!(?e, "client handler error");
|
||||
}
|
||||
});
|
||||
@@ -119,6 +128,7 @@ async fn handle_client(
|
||||
mut stream: UnixStream,
|
||||
mgr: Arc<WorkspaceManager>,
|
||||
disc: Arc<DiscernPipeline>,
|
||||
pool: Option<Arc<brahman_sidecar::SidecarPool>>,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
let req: Request = match read_frame(&mut stream).await {
|
||||
@@ -126,12 +136,17 @@ async fn handle_client(
|
||||
Err(shipote_protocol::ProtocolError::Closed) => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let resp = dispatch(&mgr, &disc, req).await;
|
||||
let resp = dispatch(&mgr, &disc, &pool, req).await;
|
||||
write_frame(&mut stream, &resp).await?;
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Request) -> Response {
|
||||
async fn dispatch(
|
||||
mgr: &Arc<WorkspaceManager>,
|
||||
disc: &DiscernPipeline,
|
||||
pool: &Option<Arc<brahman_sidecar::SidecarPool>>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
match req {
|
||||
Request::Ping => Response::Pong,
|
||||
|
||||
@@ -155,10 +170,15 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
Response::WorkspaceList { items }
|
||||
}
|
||||
|
||||
Request::WorkspaceStop { id } => match mgr.stop(id).await {
|
||||
Ok(reaped) => Response::WorkspaceStopped { id, reaped },
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
},
|
||||
Request::WorkspaceStop { id, grace_ms } => {
|
||||
match mgr
|
||||
.stop_with_grace(id, std::time::Duration::from_millis(grace_ms))
|
||||
.await
|
||||
{
|
||||
Ok(reaped) => Response::WorkspaceStopped { id, reaped },
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
}
|
||||
}
|
||||
|
||||
Request::Run { workspace, exec, argv, envp } => {
|
||||
match mgr.run(workspace, exec, argv, envp).await {
|
||||
@@ -171,7 +191,12 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
}
|
||||
}
|
||||
|
||||
Request::PipelineRun { spec, tap } => {
|
||||
Request::PipelineRun { spec, tap, vars } => {
|
||||
let vars_map: std::collections::HashMap<String, String> = vars.into_iter().collect();
|
||||
let spec = match shipote_card::substitute_vars(&spec, &vars_map) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Response::Error { message: format!("template: {e}") },
|
||||
};
|
||||
let disc = DiscernPipeline::default_pipeline();
|
||||
let inc = mgr.incarnator_handle();
|
||||
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
||||
@@ -181,27 +206,22 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
tap,
|
||||
std::sync::Arc::new(disc),
|
||||
inc,
|
||||
Some(mgr.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(launch) => Response::PipelineStarted {
|
||||
pipeline: launch.pipeline,
|
||||
command_pids: launch.command_pids,
|
||||
edges: launch
|
||||
.edge_discernments
|
||||
.into_iter()
|
||||
.map(|e| EdgeDiscernmentInfo {
|
||||
from_label: e.from_label,
|
||||
from_output: e.from_output,
|
||||
to_label: e.to_label,
|
||||
to_input: e.to_input,
|
||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Ok(launch) => {
|
||||
let pipeline_id = launch.pipeline;
|
||||
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
||||
let cmds = launch.command_pids;
|
||||
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
||||
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
||||
Response::PipelineStarted {
|
||||
pipeline: pipeline_id,
|
||||
command_pids: cmds,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
}
|
||||
}
|
||||
@@ -240,8 +260,13 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
Response::CommandList { items }
|
||||
}
|
||||
|
||||
Request::CommandLogs { workspace, command, tail_bytes } => {
|
||||
match mgr.get_command_logs(workspace, command, tail_bytes).await {
|
||||
Request::CommandLogs { workspace, command, tail_bytes, stream } => {
|
||||
let s = match stream.as_str() {
|
||||
"stdout" => shipote_core::LogStream::Stdout,
|
||||
"stderr" => shipote_core::LogStream::Stderr,
|
||||
_ => shipote_core::LogStream::Both,
|
||||
};
|
||||
match mgr.get_command_logs(workspace, command, tail_bytes, s).await {
|
||||
Some(bytes) => Response::CommandLogs { bytes },
|
||||
None => Response::Error {
|
||||
message: format!("no logs for command {command} in workspace {workspace}"),
|
||||
@@ -264,8 +289,13 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
Response::PipelineDropped { name, existed }
|
||||
}
|
||||
|
||||
Request::PipelineRunSaved { name, tap } => match mgr.get_saved_pipeline(&name).await {
|
||||
Request::PipelineRunSaved { name, tap, vars } => match mgr.get_saved_pipeline(&name).await {
|
||||
Some(spec) => {
|
||||
let vars_map: std::collections::HashMap<String, String> = vars.into_iter().collect();
|
||||
let spec = match shipote_card::substitute_vars(&spec, &vars_map) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Response::Error { message: format!("template: {e}") },
|
||||
};
|
||||
let disc = DiscernPipeline::default_pipeline();
|
||||
let inc = mgr.incarnator_handle();
|
||||
let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default();
|
||||
@@ -275,27 +305,22 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
tap,
|
||||
std::sync::Arc::new(disc),
|
||||
inc,
|
||||
Some(mgr.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(launch) => Response::PipelineStarted {
|
||||
pipeline: launch.pipeline,
|
||||
command_pids: launch.command_pids,
|
||||
edges: launch
|
||||
.edge_discernments
|
||||
.into_iter()
|
||||
.map(|e| EdgeDiscernmentInfo {
|
||||
from_label: e.from_label,
|
||||
from_output: e.from_output,
|
||||
to_label: e.to_label,
|
||||
to_input: e.to_input,
|
||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Ok(launch) => {
|
||||
let pipeline_id = launch.pipeline;
|
||||
announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments);
|
||||
let cmds = launch.command_pids;
|
||||
mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await;
|
||||
let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect();
|
||||
Response::PipelineStarted {
|
||||
pipeline: pipeline_id,
|
||||
command_pids: cmds,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
Err(e) => Response::Error { message: format!("{e}") },
|
||||
}
|
||||
}
|
||||
@@ -304,6 +329,45 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
},
|
||||
},
|
||||
|
||||
Request::PipelineStop { pipeline, grace_ms } => {
|
||||
let reaped = mgr
|
||||
.stop_pipeline(pipeline, std::time::Duration::from_millis(grace_ms))
|
||||
.await;
|
||||
Response::PipelineStopped { pipeline, reaped }
|
||||
}
|
||||
|
||||
Request::WorkspaceStats { workspace } => match mgr.workspace_stats(workspace).await {
|
||||
Some(s) => Response::WorkspaceStats {
|
||||
info: WorkspaceStatsInfo {
|
||||
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,
|
||||
source: s.source,
|
||||
uptime_ms: s.uptime_ms,
|
||||
},
|
||||
},
|
||||
None => Response::Error {
|
||||
message: format!("workspace {workspace} not found"),
|
||||
},
|
||||
},
|
||||
|
||||
Request::FlowList => {
|
||||
let items = mgr
|
||||
.list_flow_pipelines()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(pipeline, sockets)| FlowInfo { pipeline, sockets })
|
||||
.collect();
|
||||
Response::FlowList { items }
|
||||
}
|
||||
|
||||
Request::FlowDrop { pipeline } => {
|
||||
let existed = mgr.drop_pipeline_flows(pipeline).await;
|
||||
Response::FlowDropped { pipeline, existed }
|
||||
}
|
||||
|
||||
Request::Capabilities => {
|
||||
let c = mgr.incarnator().capabilities();
|
||||
Response::Capabilities {
|
||||
@@ -317,6 +381,69 @@ async fn dispatch(mgr: &Arc<WorkspaceManager>, disc: &DiscernPipeline, req: Requ
|
||||
}
|
||||
}
|
||||
|
||||
fn map_edge_to_info(e: shipote_core::pipeline::EdgeDiscernment) -> EdgeDiscernmentInfo {
|
||||
EdgeDiscernmentInfo {
|
||||
from_label: e.from_label,
|
||||
from_output: e.from_output,
|
||||
to_label: e.to_label,
|
||||
to_input: e.to_input,
|
||||
ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)),
|
||||
mime: e.discernment.as_ref().and_then(|d| d.mime.clone()),
|
||||
lens: e.discernment.as_ref().and_then(|d| d.lens.clone()),
|
||||
confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0),
|
||||
flow_socket: e.flow_socket,
|
||||
}
|
||||
}
|
||||
|
||||
/// Por cada edge con TypeRef detectado, spawneamos una Card efímera en el
|
||||
/// SidecarPool que se anuncia al broker como producer del TypeRef
|
||||
/// enriquecido. Esto permite a otros explorers (broker-explorer, etc.)
|
||||
/// ver que shipote vio JSON/text/wasm/etc. saliendo de un pipeline.
|
||||
fn announce_edges_to_broker(
|
||||
pool: Option<&brahman_sidecar::SidecarPool>,
|
||||
pipeline: &ulid::Ulid,
|
||||
edges: &[shipote_core::pipeline::EdgeDiscernment],
|
||||
) {
|
||||
let Some(pool) = pool else { return };
|
||||
for e in edges {
|
||||
let Some(d) = &e.discernment else { continue };
|
||||
let label = format!(
|
||||
"shipote.flow.{}.{}.{}.{}",
|
||||
short_ulid(pipeline),
|
||||
e.from_label,
|
||||
e.from_output,
|
||||
type_label(&d.ty)
|
||||
);
|
||||
let mut card = Card::new(label);
|
||||
card.kind = CardKind::Data;
|
||||
card.lifecycle = Lifecycle::Oneshot;
|
||||
card.payload = Payload::Virtual;
|
||||
card.supervision = Supervision::OneShot;
|
||||
card.flow = Flows {
|
||||
input: Vec::new(),
|
||||
output: vec![Flow {
|
||||
name: e.from_output.clone(),
|
||||
ty: d.ty.clone(),
|
||||
pin_to: None,
|
||||
}],
|
||||
};
|
||||
pool.spawn(card);
|
||||
info!(pipeline = %pipeline, from = %e.from_label, ty = ?d.ty, "edge announced to broker");
|
||||
}
|
||||
}
|
||||
|
||||
fn short_ulid(u: &ulid::Ulid) -> String {
|
||||
let s = u.to_string();
|
||||
s[s.len() - 6..].to_string()
|
||||
}
|
||||
|
||||
fn type_label(t: &TypeRef) -> String {
|
||||
match t {
|
||||
TypeRef::Primitive { name } => name.clone(),
|
||||
TypeRef::Wit { package, name, .. } => format!("{package}.{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Card del daemon. La presentamos al broker así otras sesiones pueden
|
||||
/// descubrir que shipote está corriendo y, eventualmente, conectarse
|
||||
/// como consumidoras del flow `workspaces` (futuro: que la GUI o el
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
|
||||
use shipote_protocol::{
|
||||
default_socket_path, read_frame, write_frame, CommandInfo, Request, Response, WorkspaceSummary,
|
||||
default_socket_path, read_frame, write_frame, CommandInfo, FlowInfo, Request, Response,
|
||||
WorkspaceStatsInfo, WorkspaceSummary,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -42,10 +43,16 @@ struct Shell {
|
||||
/// Comandos por workspace, indexados por workspace id.toString().
|
||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||
saved_pipelines: Vec<String>,
|
||||
flows: Vec<FlowInfo>,
|
||||
/// History de RSS por workspace (últimas N samples).
|
||||
stats_history: std::collections::BTreeMap<String, std::collections::VecDeque<WorkspaceStatsInfo>>,
|
||||
caps: Option<CapsSummary>,
|
||||
last_probe_ms: u64,
|
||||
recent_log: Option<(String, String)>,
|
||||
}
|
||||
|
||||
const STATS_HISTORY_LEN: usize = 24;
|
||||
|
||||
fn main() {
|
||||
launch_app("Shipote — Shell", (820., 560.), Shell::new);
|
||||
}
|
||||
@@ -71,14 +78,36 @@ impl Shell {
|
||||
me.workspaces = snap.workspaces;
|
||||
me.commands = snap.commands;
|
||||
me.saved_pipelines = snap.saved_pipelines;
|
||||
me.flows = snap.flows;
|
||||
// Append a la history por workspace.
|
||||
for (ws_id, fresh) in &snap.fresh_stats {
|
||||
let h = me
|
||||
.stats_history
|
||||
.entry(ws_id.clone())
|
||||
.or_default();
|
||||
if h.len() >= STATS_HISTORY_LEN {
|
||||
h.pop_front();
|
||||
}
|
||||
h.push_back(fresh.clone());
|
||||
}
|
||||
// Limpiar history de workspaces que ya no existen.
|
||||
let alive: std::collections::HashSet<String> = me
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|w| w.id.to_string())
|
||||
.collect();
|
||||
me.stats_history.retain(|k, _| alive.contains(k));
|
||||
me.caps = Some(snap.caps);
|
||||
me.recent_log = snap.recent_log;
|
||||
}
|
||||
Err(reason) => {
|
||||
me.state = DaemonState::Down { reason };
|
||||
me.workspaces.clear();
|
||||
me.commands.clear();
|
||||
me.saved_pipelines.clear();
|
||||
me.flows.clear();
|
||||
me.caps = None;
|
||||
me.recent_log = None;
|
||||
}
|
||||
}
|
||||
me.last_probe_ms = elapsed;
|
||||
@@ -95,8 +124,11 @@ impl Shell {
|
||||
workspaces: Vec::new(),
|
||||
commands: std::collections::BTreeMap::new(),
|
||||
saved_pipelines: Vec::new(),
|
||||
flows: Vec::new(),
|
||||
stats_history: std::collections::BTreeMap::new(),
|
||||
caps: None,
|
||||
last_probe_ms: 0,
|
||||
recent_log: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +138,12 @@ struct Snapshot {
|
||||
workspaces: Vec<WorkspaceSummary>,
|
||||
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
|
||||
saved_pipelines: Vec<String>,
|
||||
flows: Vec<FlowInfo>,
|
||||
/// Stats fresco por workspace (id.toString → stats).
|
||||
fresh_stats: std::collections::BTreeMap<String, WorkspaceStatsInfo>,
|
||||
caps: CapsSummary,
|
||||
/// tail del log del comando más reciente (label + bytes). None si no hay.
|
||||
recent_log: Option<(String, String)>,
|
||||
}
|
||||
|
||||
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
@@ -132,6 +169,7 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
|
||||
// Commands por workspace.
|
||||
let mut commands_map = std::collections::BTreeMap::new();
|
||||
let mut fresh_stats = std::collections::BTreeMap::new();
|
||||
for w in &workspaces {
|
||||
write_frame(&mut stream, &Request::CommandList { workspace: w.id })
|
||||
.await
|
||||
@@ -144,6 +182,16 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
commands_map.insert(w.id.to_string(), items);
|
||||
}
|
||||
}
|
||||
// Stats por workspace.
|
||||
write_frame(&mut stream, &Request::WorkspaceStats { workspace: w.id })
|
||||
.await
|
||||
.map_err(|e| format!("write stats: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read stats: {e}"))?;
|
||||
if let Response::WorkspaceStats { info } = resp {
|
||||
fresh_stats.insert(w.id.to_string(), info);
|
||||
}
|
||||
}
|
||||
|
||||
// Saved pipelines.
|
||||
@@ -158,6 +206,68 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Flow channels activos (data plane).
|
||||
write_frame(&mut stream, &Request::FlowList)
|
||||
.await
|
||||
.map_err(|e| format!("write flows: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read flows: {e}"))?;
|
||||
let flows = match resp {
|
||||
Response::FlowList { items } => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Live tail: log del comando más reciente con bytes>0.
|
||||
let recent_log = {
|
||||
// Pick: comando con id más alto que tiene log_bytes>0, en cualquier workspace.
|
||||
let mut best: Option<(&str, &CommandInfo)> = None;
|
||||
for (ws, cmds) in &commands_map {
|
||||
for c in cmds {
|
||||
if c.log_bytes == 0 {
|
||||
continue;
|
||||
}
|
||||
let take = match &best {
|
||||
Some((_, prev)) => c.id > prev.id,
|
||||
None => true,
|
||||
};
|
||||
if take {
|
||||
best = Some((ws.as_str(), c));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((ws_str, cmd)) => {
|
||||
let ws_id: shipote_card::WorkspaceId = ws_str
|
||||
.parse::<ulid::Ulid>()
|
||||
.map(shipote_card::WorkspaceId)
|
||||
.map_err(|e| format!("ulid parse: {e}"))?;
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Request::CommandLogs {
|
||||
workspace: ws_id,
|
||||
command: cmd.id,
|
||||
tail_bytes: 512,
|
||||
stream: "both".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("write logs: {e}"))?;
|
||||
let resp: Response = read_frame(&mut stream)
|
||||
.await
|
||||
.map_err(|e| format!("read logs: {e}"))?;
|
||||
match resp {
|
||||
Response::CommandLogs { bytes } => {
|
||||
let text = String::from_utf8_lossy(&bytes).to_string();
|
||||
Some((cmd.label.clone(), text))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
write_frame(&mut stream, &Request::Capabilities)
|
||||
.await
|
||||
.map_err(|e| format!("write caps: {e}"))?;
|
||||
@@ -182,11 +292,36 @@ fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
|
||||
workspaces,
|
||||
commands: commands_map,
|
||||
saved_pipelines,
|
||||
flows,
|
||||
fresh_stats,
|
||||
caps,
|
||||
recent_log,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Render ASCII de sparkline para una serie de valores. `chars` define los
|
||||
/// glifos (orden ascendente). Auto-scales al máximo de la serie.
|
||||
fn sparkline(values: &[u64], width: usize) -> String {
|
||||
if values.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
let take = values.len().min(width);
|
||||
let series = &values[values.len() - take..];
|
||||
let max = *series.iter().max().unwrap_or(&1);
|
||||
if max == 0 {
|
||||
return "▁".repeat(take);
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let idx = ((*v as f64 / max as f64) * (CHARS.len() as f64 - 1.0)).round() as usize;
|
||||
CHARS[idx.min(CHARS.len() - 1)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Render for Shell {
|
||||
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = Theme::global(cx).clone();
|
||||
@@ -247,7 +382,28 @@ impl Render for Shell {
|
||||
let ws_items: Vec<String> = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.map(|w| format!("{} {:<20} cmds={} uptime={}ms", w.id, w.label, w.commands, w.uptime_ms))
|
||||
.map(|w| {
|
||||
let key = w.id.to_string();
|
||||
let history = self.stats_history.get(&key);
|
||||
let rss_series: Vec<u64> = history
|
||||
.map(|h| h.iter().map(|s| s.rss_bytes.unwrap_or(0)).collect())
|
||||
.unwrap_or_default();
|
||||
let spark = sparkline(&rss_series, STATS_HISTORY_LEN);
|
||||
let (rss_now, peak) = history
|
||||
.and_then(|h| h.back())
|
||||
.map(|s| (s.rss_bytes.unwrap_or(0), s.rss_peak_bytes.unwrap_or(0)))
|
||||
.unwrap_or((0, 0));
|
||||
let rss_mb = rss_now as f64 / 1024.0 / 1024.0;
|
||||
let peak_mb = peak as f64 / 1024.0 / 1024.0;
|
||||
format!(
|
||||
"{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M",
|
||||
&w.id.to_string()[20..],
|
||||
w.label,
|
||||
spark,
|
||||
rss_mb,
|
||||
peak_mb,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let ws_count = self.workspaces.len().to_string();
|
||||
let ws_descr = if self.workspaces.is_empty() {
|
||||
@@ -294,6 +450,33 @@ impl Render for Shell {
|
||||
"definiciones reusables vía run-saved".to_string()
|
||||
};
|
||||
|
||||
// Flow channels (data plane).
|
||||
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();
|
||||
let flow_descr = if flow_count == 0 {
|
||||
"pipelines con --tap exponen sockets aquí".to_string()
|
||||
} else {
|
||||
"shipote flow tail <socket> para suscribirse".to_string()
|
||||
};
|
||||
|
||||
let body = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -349,8 +532,44 @@ impl Render for Shell {
|
||||
text,
|
||||
text_dim,
|
||||
&saved_items,
|
||||
))
|
||||
.child(stat_card(
|
||||
cx,
|
||||
"Flow channels",
|
||||
flow_count.to_string(),
|
||||
&flow_descr,
|
||||
accent_up,
|
||||
text,
|
||||
text_dim,
|
||||
&flow_items,
|
||||
));
|
||||
|
||||
// Live tail del comando más reciente con output.
|
||||
let live_card = self.recent_log.as_ref().map(|(label, content)| {
|
||||
// Cortamos a las últimas ~12 líneas para no inflar el panel.
|
||||
let lines: Vec<String> = content
|
||||
.lines()
|
||||
.rev()
|
||||
.take(12)
|
||||
.map(|l| l.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
stat_card(
|
||||
cx,
|
||||
"Live tail",
|
||||
label.clone(),
|
||||
"últimas líneas del comando más reciente",
|
||||
accent_up,
|
||||
text,
|
||||
text_dim,
|
||||
&lines,
|
||||
)
|
||||
});
|
||||
|
||||
let body = body.when_some(live_card, |d, c| d.child(c));
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
|
||||
Reference in New Issue
Block a user