shell
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "shipote-protocol"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Wire protocol entre shipote-daemon y clientes (cli/gui). Postcard length-prefixed sobre Unix socket."
|
||||
|
||||
[dependencies]
|
||||
shipote-card = { path = "../shipote-card" }
|
||||
brahman-card = { path = "../../../core/brahman-card" }
|
||||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
@@ -0,0 +1,290 @@
|
||||
//! `shipote-protocol` — wire daemon ↔ cliente (cli/gui).
|
||||
//!
|
||||
//! Framing: u32 BE length-prefix + payload postcard. Mismo patrón que
|
||||
//! `ente-bus`/`brahman-handshake` para que clientes existentes compartan
|
||||
//! reader/writer helpers si quieren.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shipote_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::UnixStream;
|
||||
use ulid::Ulid;
|
||||
|
||||
pub const DEFAULT_SOCK_NAME: &str = "shipote.sock";
|
||||
pub const MAX_FRAME: usize = 1 << 20;
|
||||
|
||||
// =====================================================================
|
||||
// Mensajes
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Request {
|
||||
/// Health-check.
|
||||
Ping,
|
||||
|
||||
/// Crear un workspace nuevo.
|
||||
WorkspaceCreate { spec: WorkspaceSpec },
|
||||
|
||||
/// Listar todos los workspaces vivos.
|
||||
WorkspaceList,
|
||||
|
||||
/// Detener un workspace y reapear sus comandos.
|
||||
WorkspaceStop { id: WorkspaceId },
|
||||
|
||||
/// Ejecutar un comando one-shot dentro de un workspace existente.
|
||||
Run {
|
||||
workspace: WorkspaceId,
|
||||
exec: String,
|
||||
argv: Vec<String>,
|
||||
envp: Vec<(String, String)>,
|
||||
},
|
||||
|
||||
/// Lanzar un Pipeline completo dentro de un workspace.
|
||||
PipelineRun {
|
||||
spec: PipelineSpec,
|
||||
/// Si `true`, el daemon interpone un tap entre productor y
|
||||
/// consumidor de cada FlowEdge, sampleando los primeros bytes
|
||||
/// y discerniendo el TypeRef.
|
||||
tap: bool,
|
||||
},
|
||||
|
||||
/// Discernir un buffer ad-hoc (sin workspace). Útil para `shipote discern <file>`.
|
||||
Discern { sample: Vec<u8>, hint_path: Option<PathBuf> },
|
||||
|
||||
/// Capacidades runtime del kernel/proceso del daemon.
|
||||
Capabilities,
|
||||
|
||||
/// Listar comandos vivos+pasados de un workspace.
|
||||
CommandList { workspace: shipote_card::WorkspaceId },
|
||||
|
||||
/// Tail del log capturado para un comando.
|
||||
CommandLogs {
|
||||
workspace: shipote_card::WorkspaceId,
|
||||
command: Ulid,
|
||||
tail_bytes: usize,
|
||||
},
|
||||
|
||||
/// Guardar (o reemplazar) un PipelineSpec bajo un nombre.
|
||||
PipelineSave { name: String, spec: PipelineSpec },
|
||||
|
||||
/// Listar nombres de pipelines guardados.
|
||||
PipelineSavedList,
|
||||
|
||||
/// Eliminar un pipeline guardado.
|
||||
PipelineDrop { name: String },
|
||||
|
||||
/// Ejecutar un pipeline guardado.
|
||||
PipelineRunSaved { name: String, tap: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Response {
|
||||
Pong,
|
||||
|
||||
WorkspaceCreated {
|
||||
id: WorkspaceId,
|
||||
warnings: Vec<String>,
|
||||
},
|
||||
|
||||
WorkspaceList {
|
||||
items: Vec<WorkspaceSummary>,
|
||||
},
|
||||
|
||||
WorkspaceStopped {
|
||||
id: WorkspaceId,
|
||||
reaped: u32,
|
||||
},
|
||||
|
||||
RunStarted {
|
||||
workspace: WorkspaceId,
|
||||
command_id: Ulid,
|
||||
pid: i32,
|
||||
},
|
||||
|
||||
PipelineStarted {
|
||||
pipeline: Ulid,
|
||||
command_pids: Vec<(String, i32)>,
|
||||
/// Discernments por edge cuando tap=true. Vacío sin tap.
|
||||
edges: Vec<EdgeDiscernmentInfo>,
|
||||
},
|
||||
|
||||
Discernment {
|
||||
ty: String,
|
||||
confidence: f32,
|
||||
mime: Option<String>,
|
||||
lens: Option<String>,
|
||||
},
|
||||
|
||||
Capabilities {
|
||||
kernel_version: (u32, u32, u32),
|
||||
user_ns: String,
|
||||
cgroup_v2: String,
|
||||
cgroup_delegated: bool,
|
||||
has_cap_sys_admin: bool,
|
||||
},
|
||||
|
||||
CommandList {
|
||||
items: Vec<CommandInfo>,
|
||||
},
|
||||
|
||||
CommandLogs {
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
|
||||
PipelineSaved {
|
||||
name: String,
|
||||
},
|
||||
|
||||
PipelineSavedList {
|
||||
names: Vec<String>,
|
||||
},
|
||||
|
||||
PipelineDropped {
|
||||
name: String,
|
||||
existed: bool,
|
||||
},
|
||||
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandInfo {
|
||||
pub id: Ulid,
|
||||
pub label: String,
|
||||
pub pid: i32,
|
||||
pub alive: bool,
|
||||
pub exit_status: Option<i32>,
|
||||
pub log_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EdgeDiscernmentInfo {
|
||||
pub from_label: String,
|
||||
pub from_output: String,
|
||||
pub to_label: String,
|
||||
pub to_input: String,
|
||||
/// `Some(ty)` si el discerner detectó algo. `None` si no hubo data
|
||||
/// suficiente o no matcheó ningún discerner.
|
||||
pub ty: Option<String>,
|
||||
pub mime: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSummary {
|
||||
pub id: WorkspaceId,
|
||||
pub label: String,
|
||||
pub commands: u32,
|
||||
pub uptime_ms: u64,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Errores
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProtocolError {
|
||||
#[error("frame oversize: {0} bytes (max {MAX_FRAME})")]
|
||||
FrameOversize(usize),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("postcard: {0}")]
|
||||
Postcard(#[from] postcard::Error),
|
||||
#[error("connection closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Framing helpers
|
||||
// =====================================================================
|
||||
|
||||
pub async fn write_frame<T: Serialize>(stream: &mut UnixStream, msg: &T) -> Result<(), ProtocolError> {
|
||||
let bytes = postcard::to_allocvec(msg)?;
|
||||
if bytes.len() > MAX_FRAME {
|
||||
return Err(ProtocolError::FrameOversize(bytes.len()));
|
||||
}
|
||||
let len = (bytes.len() as u32).to_be_bytes();
|
||||
stream.write_all(&len).await?;
|
||||
stream.write_all(&bytes).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn read_frame<T: for<'de> Deserialize<'de>>(
|
||||
stream: &mut UnixStream,
|
||||
) -> Result<T, ProtocolError> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf).await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
ProtocolError::Closed
|
||||
} else {
|
||||
ProtocolError::Io(e)
|
||||
}
|
||||
})?;
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME {
|
||||
return Err(ProtocolError::FrameOversize(len));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stream.read_exact(&mut buf).await?;
|
||||
Ok(postcard::from_bytes(&buf)?)
|
||||
}
|
||||
|
||||
/// Path canónico del socket del daemon: `$XDG_RUNTIME_DIR/shipote.sock`,
|
||||
/// fallback `/run/user/$UID/shipote.sock`, fallback `/tmp/shipote-$UID.sock`.
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
return PathBuf::from(xdg).join(DEFAULT_SOCK_NAME);
|
||||
}
|
||||
let uid = nix::unistd::getuid().as_raw();
|
||||
let p = PathBuf::from(format!("/run/user/{uid}"));
|
||||
if p.exists() {
|
||||
return p.join(DEFAULT_SOCK_NAME);
|
||||
}
|
||||
PathBuf::from(format!("/tmp/shipote-{uid}.sock"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ping_roundtrip() {
|
||||
let bytes = postcard::to_allocvec(&Request::Ping).unwrap();
|
||||
let back: Request = postcard::from_bytes(&bytes).unwrap();
|
||||
assert!(matches!(back, Request::Ping));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_create_roundtrip() {
|
||||
let req = Request::WorkspaceCreate {
|
||||
spec: WorkspaceSpec {
|
||||
label: "demo".into(),
|
||||
soma: Default::default(),
|
||||
permissions: Default::default(),
|
||||
ttl: None,
|
||||
flow_dirs: vec![],
|
||||
on_exit: shipote_card::ExitPolicy::Reap,
|
||||
},
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&req).unwrap();
|
||||
let back: Request = postcard::from_bytes(&bytes).unwrap();
|
||||
match back {
|
||||
Request::WorkspaceCreate { spec } => assert_eq!(spec.label, "demo"),
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_socket_path_uses_runtime_dir() {
|
||||
let p = default_socket_path();
|
||||
assert!(p.to_string_lossy().ends_with("shipote.sock"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user