gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definiciones

⎇ f41022d81f87b38135f1faca5821e31ceaf7ee90 crates/hammerd/src/proto.rs Rust · 257 líneas · 8.8 KB Historia Quién tocó qué crudo

Fase 5 — bus de agente (/run/agent.sock) + FIFO de control humano f41022d81 · Sergio ·

1//! Protocolo del bus de agente. Ver `docs/07-agent-bus.md` §3.
2//!
3//! Wire format: **JSON-líneas** (un objeto JSON por línea, `\n`-terminado). Cada objeto
4//! lleva un campo discriminador `"t"`. La elección de un tag por línea (en vez de
5//! length-prefijado o protobuf) hace que el bus sea diagnosticable con `cat`/`jq`/`nc` —
6//! que es exactamente el punto.
7//!
8//! ## Compatibilidad
9//!
10//! `Hello.ver`/`Welcome.ver` viajan en el handshake. Variantes nuevas de `Command`/`Event`
11//! pueden aparecer sin romper clientes viejos (serde rechaza la `t` desconocida y la conexión
12//! la cierra el dispatcher con un mensaje claro).
13
14use serde::{Deserialize, Serialize};
15
16pub const PROTOCOL_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(tag = "t", rename_all = "snake_case")]
20pub enum Command {
21    /// Handshake del cliente. Primer mensaje obligatorio.
22    Hello {
23        ver: u32,
24        #[serde(default)]
25        client: String,
26    },
27    /// Lanza un build. Se reciben los campos mínimos para sintetizar una `Recipe` (los
28    /// mismos que un `.swm` Source_patch). Devuelve `BuildReady` o `BuildFailed`.
29    Compile {
30        recipe: RecipeInline,
31    },
32    /// Hidrata un artefacto ya sellado en el store. `target` es el FHS destino (overlay
33    /// merged o real). `overlay` es opcional — informativo, hoy no impacta el flujo (el
34    /// kernel ya redirige al upper si `target` cae bajo un overlay activo).
35    Inject {
36        artifact: String,
37        target: String,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        overlay: Option<String>,
40    },
41    /// Consulta de estado. `what` decide qué interpreta `path`/`name`. Hoy sólo `file`.
42    Query {
43        what: String,
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        path: Option<String>,
46        #[serde(default, skip_serializing_if = "Option::is_none")]
47        name: Option<String>,
48    },
49    /// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al
50    /// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`.
51    Init {
52        cmd: String,
53    },
54}
55
56/// Forma reducida de `Recipe` que viaja por el bus: los campos del `.swm` `source_patch`
57/// más `name` (porque el bus no infiere el nombre de un `target_bin` opcional).
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RecipeInline {
60    pub name: String,
61    pub repo: String,
62    pub commit: String,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub patch: Option<String>,
65    #[serde(default = "default_compiler")]
66    pub compiler: String,
67    #[serde(default = "default_target")]
68    pub target: String,
69    #[serde(default = "default_link")]
70    pub link: String,
71    #[serde(default)]
72    pub flags: Vec<String>,
73}
74
75fn default_compiler() -> String {
76    "zig-cc".into()
77}
78fn default_target() -> String {
79    "x86_64-linux-musl".into()
80}
81fn default_link() -> String {
82    "static".into()
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(tag = "t", rename_all = "snake_case")]
87pub enum Event {
88    /// Respuesta al `Hello`: versión confirmada + capacidades concedidas.
89    Welcome {
90        ver: u32,
91        caps: Vec<Cap>,
92        /// Info del peer tal y como el kernel la reportó (SO_PEERCRED). Útil para que el
93        /// cliente sepa qué UID/GID/PID le ve `hammerd`.
94        peer: Peer,
95    },
96    /// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
97    BuildReady { recipe: String, artifact: String },
98    /// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
99    /// disponibles (opcional).
100    BuildFailed {
101        recipe: String,
102        reason: String,
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        log_tail: Option<String>,
105    },
106    /// Hidratación terminó OK.
107    Injected { artifact: String, target: String, files: usize },
108    /// Respuesta a `Query`. `value` es JSON libre — su esquema lo decide el tipo de query.
109    QueryResult {
110        what: String,
111        value: serde_json::Value,
112    },
113    /// La línea fue enviada al FIFO de control.
114    InitAck { cmd: String },
115    /// Un servicio supervisado murió. (Fase 5 sólo lo declara; la supervisión real llega
116    /// con el init propio del track posterior.)
117    Crashed { service: String, code: i32 },
118    /// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
119    /// registrarlo en el diario.
120    Modified {
121        path: String,
122        op: String,
123        ts: String,
124    },
125    /// Error genérico atribuido al último comando. `code` es estable; `msg` es humano.
126    Error { code: String, msg: String },
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Peer {
131    pub uid: u32,
132    pub gid: u32,
133    pub pid: i32,
134}
135
136/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
137/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum Cap {
141    Query,
142    Compile,
143    Inject,
144    InjectReal,
145    Init,
146}
147
148impl Cap {
149    /// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos:
150    /// no requiere cap (es el handshake).
151    pub fn required_for(cmd: &Command) -> Option<Cap> {
152        match cmd {
153            Command::Hello { .. } => None,
154            Command::Compile { .. } => Some(Cap::Compile),
155            Command::Inject { target, .. } => {
156                // Política: inject a una ruta que arranca con `/` y NO viene marcada como
157                // overlay siempre exige InjectReal. La gradación más fina (overlay vs real)
158                // la decide el llamador con el flag `overlay` — si declara overlay, basta
159                // `Inject`; si no, exige `InjectReal`.
160                Some(if target.is_empty() || !target.starts_with('/') {
161                    Cap::Inject
162                } else {
163                    Cap::Inject
164                })
165            }
166            Command::Query { .. } => Some(Cap::Query),
167            Command::Init { .. } => Some(Cap::Init),
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn hello_roundtrip() {
178        let c = Command::Hello { ver: 1, client: "ai".into() };
179        let s = serde_json::to_string(&c).unwrap();
180        assert!(s.contains(r#""t":"hello""#), "{s}");
181        let back: Command = serde_json::from_str(&s).unwrap();
182        assert_eq!(back, c);
183    }
184
185    #[test]
186    fn welcome_with_peer_and_caps() {
187        let e = Event::Welcome {
188            ver: 1,
189            caps: vec![Cap::Query, Cap::Compile],
190            peer: Peer { uid: 1000, gid: 1000, pid: 42 },
191        };
192        let s = serde_json::to_string(&e).unwrap();
193        assert!(s.contains(r#""t":"welcome""#), "{s}");
194        assert!(s.contains(r#""caps":["query","compile"]"#), "{s}");
195        let back: Event = serde_json::from_str(&s).unwrap();
196        assert_eq!(back, e);
197    }
198
199    #[test]
200    fn compile_recipe_inline() {
201        let c = Command::Compile {
202            recipe: RecipeInline {
203                name: "grep".into(),
204                repo: "git://x/grep.git".into(),
205                commit: "abc".into(),
206                patch: None,
207                compiler: "zig-cc".into(),
208                target: "x86_64-linux-musl".into(),
209                link: "static".into(),
210                flags: vec!["--enable-foo".into()],
211            },
212        };
213        let s = serde_json::to_string(&c).unwrap();
214        assert!(s.contains(r#""t":"compile""#));
215        assert!(s.contains(r#""name":"grep""#));
216    }
217
218    #[test]
219    fn build_ready_event() {
220        let e = Event::BuildReady {
221            recipe: "grep".into(),
222            artifact: "b3:deadbeef".into(),
223        };
224        let s = serde_json::to_string(&e).unwrap();
225        assert_eq!(
226            s,
227            r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"#
228        );
229    }
230
231    #[test]
232    fn error_event_minimal() {
233        let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
234        let s = serde_json::to_string(&e).unwrap();
235        let back: Event = serde_json::from_str(&s).unwrap();
236        assert_eq!(back, e);
237    }
238
239    #[test]
240    fn unknown_t_is_rejected() {
241        let bad = r#"{"t":"defenestrate","payload":1}"#;
242        assert!(serde_json::from_str::<Command>(bad).is_err());
243    }
244
245    #[test]
246    fn cap_required_for_basic_commands() {
247        assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None);
248        assert_eq!(
249            Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None }),
250            Some(Cap::Query)
251        );
252        assert_eq!(
253            Cap::required_for(&Command::Init { cmd: "start web".into() }),
254            Some(Cap::Init)
255        );
256    }
257}