//! Protocolo del bus de agente. Ver `docs/07-agent-bus.md` §3. //! //! Wire format: **JSON-líneas** (un objeto JSON por línea, `\n`-terminado). Cada objeto //! lleva un campo discriminador `"t"`. La elección de un tag por línea (en vez de //! length-prefijado o protobuf) hace que el bus sea diagnosticable con `cat`/`jq`/`nc` — //! que es exactamente el punto. //! //! ## Compatibilidad //! //! `Hello.ver`/`Welcome.ver` viajan en el handshake. Variantes nuevas de `Command`/`Event` //! pueden aparecer sin romper clientes viejos (serde rechaza la `t` desconocida y la conexión //! la cierra el dispatcher con un mensaje claro). use serde::{Deserialize, Serialize}; pub const PROTOCOL_VERSION: u32 = 1; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "t", rename_all = "snake_case")] pub enum Command { /// Handshake del cliente. Primer mensaje obligatorio. Hello { ver: u32, #[serde(default)] client: String, }, /// Lanza un build. Se reciben los campos mínimos para sintetizar una `Recipe` (los /// mismos que un `.swm` Source_patch). Devuelve `BuildReady` o `BuildFailed`. Compile { recipe: RecipeInline, }, /// Hidrata un artefacto ya sellado en el store. `target` es el FHS destino (overlay /// merged o real). `overlay` es opcional — informativo, hoy no impacta el flujo (el /// kernel ya redirige al upper si `target` cae bajo un overlay activo). Inject { artifact: String, target: String, #[serde(default, skip_serializing_if = "Option::is_none")] overlay: Option, }, /// Consulta de estado. `what` decide qué campos se usan: /// - `"file"`: usa `path` (metadata del archivo). /// - `"artifact"`: usa `name` o `path` (busca un hash en el store). /// - `"expr"`: usa `expr`, una expresión del mini-lenguaje (ver `hammer_core::query`). Query { what: String, #[serde(default, skip_serializing_if = "Option::is_none")] path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] expr: Option, }, /// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al /// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`. Init { cmd: String, }, } /// Forma reducida de `Recipe` que viaja por el bus: los campos del `.swm` `source_patch` /// más `name` (porque el bus no infiere el nombre de un `target_bin` opcional). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecipeInline { pub name: String, // Origen git (modo histórico) **o** tarball — exactamente uno, igual que // `swm::Mutation::SourcePatch`. `Option` con `serde(default)` para compat con clientes // que sólo mandan `repo`+`commit`. #[serde(default, skip_serializing_if = "Option::is_none")] pub repo: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tarball: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sha256: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub patch: Option, #[serde(default = "default_compiler")] pub compiler: String, #[serde(default = "default_target")] pub target: String, #[serde(default = "default_link")] pub link: String, #[serde(default)] pub flags: Vec, } fn default_compiler() -> String { "zig-cc".into() } fn default_target() -> String { "x86_64-linux-musl".into() } fn default_link() -> String { "static".into() } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "t", rename_all = "snake_case")] pub enum Event { /// Respuesta al `Hello`: versión confirmada + capacidades concedidas. Welcome { ver: u32, caps: Vec, /// Info del peer tal y como el kernel la reportó (SO_PEERCRED). Útil para que el /// cliente sepa qué UID/GID/PID le ve `hammerd`. peer: Peer, }, /// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado. BuildReady { recipe: String, artifact: String }, /// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están /// disponibles (opcional). BuildFailed { recipe: String, reason: String, #[serde(default, skip_serializing_if = "Option::is_none")] log_tail: Option, }, /// Hidratación terminó OK. Injected { artifact: String, target: String, files: usize }, /// Respuesta a `Query`. `value` es JSON libre — su esquema lo decide el tipo de query. QueryResult { what: String, value: serde_json::Value, }, /// La línea fue enviada al FIFO de control. InitAck { cmd: String }, /// Un servicio supervisado murió. La supervisión real la provee el init propio /// (arje, PID 1): difunde `arje_bus::BusEvent::EnteCrashed` y el sink /// `hammerd::crashes` lo traduce a este evento. Falta sólo el adaptador de /// transporte que conecta ambos buses (ver `hammerd::crashes`). Crashed { service: String, code: i32 }, /// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de /// registrarlo en el diario. Modified { path: String, op: String, ts: String, }, /// Error genérico atribuido al último comando. `code` es estable; `msg` es humano. Error { code: String, msg: String }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Peer { pub uid: u32, pub gid: u32, pub pid: i32, } /// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del /// fichero de policy del daemon (SDD 07 §4); el peer no las elige. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Cap { Query, Compile, Inject, InjectReal, Init, } impl Cap { /// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos: /// no requiere cap (es el handshake). pub fn required_for(cmd: &Command) -> Option { match cmd { Command::Hello { .. } => None, Command::Compile { .. } => Some(Cap::Compile), Command::Inject { target, .. } => { // Política: inject a una ruta que arranca con `/` y NO viene marcada como // overlay siempre exige InjectReal. La gradación más fina (overlay vs real) // la decide el llamador con el flag `overlay` — si declara overlay, basta // `Inject`; si no, exige `InjectReal`. Some(if target.is_empty() || !target.starts_with('/') { Cap::Inject } else { Cap::Inject }) } Command::Query { .. } => Some(Cap::Query), Command::Init { .. } => Some(Cap::Init), } } } #[cfg(test)] mod tests { use super::*; #[test] fn hello_roundtrip() { let c = Command::Hello { ver: 1, client: "ai".into() }; let s = serde_json::to_string(&c).unwrap(); assert!(s.contains(r#""t":"hello""#), "{s}"); let back: Command = serde_json::from_str(&s).unwrap(); assert_eq!(back, c); } #[test] fn welcome_with_peer_and_caps() { let e = Event::Welcome { ver: 1, caps: vec![Cap::Query, Cap::Compile], peer: Peer { uid: 1000, gid: 1000, pid: 42 }, }; let s = serde_json::to_string(&e).unwrap(); assert!(s.contains(r#""t":"welcome""#), "{s}"); assert!(s.contains(r#""caps":["query","compile"]"#), "{s}"); let back: Event = serde_json::from_str(&s).unwrap(); assert_eq!(back, e); } #[test] fn compile_recipe_inline() { let c = Command::Compile { recipe: RecipeInline { name: "grep".into(), repo: Some("git://x/grep.git".into()), commit: Some("abc".into()), tarball: None, sha256: None, patch: None, compiler: "zig-cc".into(), target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec!["--enable-foo".into()], }, }; let s = serde_json::to_string(&c).unwrap(); assert!(s.contains(r#""t":"compile""#)); assert!(s.contains(r#""name":"grep""#)); } #[test] fn build_ready_event() { let e = Event::BuildReady { recipe: "grep".into(), artifact: "b3:deadbeef".into(), }; let s = serde_json::to_string(&e).unwrap(); assert_eq!( s, r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"# ); } #[test] fn error_event_minimal() { let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() }; let s = serde_json::to_string(&e).unwrap(); let back: Event = serde_json::from_str(&s).unwrap(); assert_eq!(back, e); } #[test] fn unknown_t_is_rejected() { let bad = r#"{"t":"defenestrate","payload":1}"#; assert!(serde_json::from_str::(bad).is_err()); } #[test] fn cap_required_for_basic_commands() { assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None); assert_eq!( Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None, expr: None }), Some(Cap::Query) ); assert_eq!( Cap::required_for(&Command::Init { cmd: "start web".into() }), Some(Cap::Init) ); } }