tawasuyu

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 Sutichaykuna

⎇ 7c099afe4f940b9b6db3b5192660428779479039 crates/hammer-core/src/proto.rs Rust · 264 siq'ikuna · 9.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(bus): B.2 sink del CRASHED — hammerd::crashes traduce eventos de arje 7c099afe4 · 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é campos se usan:
42    /// - `"file"`: usa `path` (metadata del archivo).
43    /// - `"artifact"`: usa `name` o `path` (busca un hash en el store).
44    /// - `"expr"`: usa `expr`, una expresión del mini-lenguaje (ver `hammer_core::query`).
45    Query {
46        what: String,
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        path: Option<String>,
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        name: Option<String>,
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        expr: Option<String>,
53    },
54    /// Proxy autenticado al control humano (`/run/init.control`). Envía la línea cruda al
55    /// FIFO; el cliente recibe `InitAck` o `Error`. La gating por capacidad `init`.
56    Init {
57        cmd: String,
58    },
59}
60
61/// Forma reducida de `Recipe` que viaja por el bus: los campos del `.swm` `source_patch`
62/// más `name` (porque el bus no infiere el nombre de un `target_bin` opcional).
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct RecipeInline {
65    pub name: String,
66    pub repo: String,
67    pub commit: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub patch: Option<String>,
70    #[serde(default = "default_compiler")]
71    pub compiler: String,
72    #[serde(default = "default_target")]
73    pub target: String,
74    #[serde(default = "default_link")]
75    pub link: String,
76    #[serde(default)]
77    pub flags: Vec<String>,
78}
79
80fn default_compiler() -> String {
81    "zig-cc".into()
82}
83fn default_target() -> String {
84    "x86_64-linux-musl".into()
85}
86fn default_link() -> String {
87    "static".into()
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(tag = "t", rename_all = "snake_case")]
92pub enum Event {
93    /// Respuesta al `Hello`: versión confirmada + capacidades concedidas.
94    Welcome {
95        ver: u32,
96        caps: Vec<Cap>,
97        /// Info del peer tal y como el kernel la reportó (SO_PEERCRED). Útil para que el
98        /// cliente sepa qué UID/GID/PID le ve `hammerd`.
99        peer: Peer,
100    },
101    /// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado.
102    BuildReady { recipe: String, artifact: String },
103    /// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están
104    /// disponibles (opcional).
105    BuildFailed {
106        recipe: String,
107        reason: String,
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        log_tail: Option<String>,
110    },
111    /// Hidratación terminó OK.
112    Injected { artifact: String, target: String, files: usize },
113    /// Respuesta a `Query`. `value` es JSON libre — su esquema lo decide el tipo de query.
114    QueryResult {
115        what: String,
116        value: serde_json::Value,
117    },
118    /// La línea fue enviada al FIFO de control.
119    InitAck { cmd: String },
120    /// Un servicio supervisado murió. La supervisión real la provee el init propio
121    /// (arje, PID 1): difunde `arje_bus::BusEvent::EnteCrashed` y el sink
122    /// `hammerd::crashes` lo traduce a este evento. Falta sólo el adaptador de
123    /// transporte que conecta ambos buses (ver `hammerd::crashes`).
124    Crashed { service: String, code: i32 },
125    /// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
126    /// registrarlo en el diario.
127    Modified {
128        path: String,
129        op: String,
130        ts: String,
131    },
132    /// Error genérico atribuido al último comando. `code` es estable; `msg` es humano.
133    Error { code: String, msg: String },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct Peer {
138    pub uid: u32,
139    pub gid: u32,
140    pub pid: i32,
141}
142
143/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
144/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
146#[serde(rename_all = "kebab-case")]
147pub enum Cap {
148    Query,
149    Compile,
150    Inject,
151    InjectReal,
152    Init,
153}
154
155impl Cap {
156    /// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos:
157    /// no requiere cap (es el handshake).
158    pub fn required_for(cmd: &Command) -> Option<Cap> {
159        match cmd {
160            Command::Hello { .. } => None,
161            Command::Compile { .. } => Some(Cap::Compile),
162            Command::Inject { target, .. } => {
163                // Política: inject a una ruta que arranca con `/` y NO viene marcada como
164                // overlay siempre exige InjectReal. La gradación más fina (overlay vs real)
165                // la decide el llamador con el flag `overlay` — si declara overlay, basta
166                // `Inject`; si no, exige `InjectReal`.
167                Some(if target.is_empty() || !target.starts_with('/') {
168                    Cap::Inject
169                } else {
170                    Cap::Inject
171                })
172            }
173            Command::Query { .. } => Some(Cap::Query),
174            Command::Init { .. } => Some(Cap::Init),
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn hello_roundtrip() {
185        let c = Command::Hello { ver: 1, client: "ai".into() };
186        let s = serde_json::to_string(&c).unwrap();
187        assert!(s.contains(r#""t":"hello""#), "{s}");
188        let back: Command = serde_json::from_str(&s).unwrap();
189        assert_eq!(back, c);
190    }
191
192    #[test]
193    fn welcome_with_peer_and_caps() {
194        let e = Event::Welcome {
195            ver: 1,
196            caps: vec![Cap::Query, Cap::Compile],
197            peer: Peer { uid: 1000, gid: 1000, pid: 42 },
198        };
199        let s = serde_json::to_string(&e).unwrap();
200        assert!(s.contains(r#""t":"welcome""#), "{s}");
201        assert!(s.contains(r#""caps":["query","compile"]"#), "{s}");
202        let back: Event = serde_json::from_str(&s).unwrap();
203        assert_eq!(back, e);
204    }
205
206    #[test]
207    fn compile_recipe_inline() {
208        let c = Command::Compile {
209            recipe: RecipeInline {
210                name: "grep".into(),
211                repo: "git://x/grep.git".into(),
212                commit: "abc".into(),
213                patch: None,
214                compiler: "zig-cc".into(),
215                target: "x86_64-linux-musl".into(),
216                link: "static".into(),
217                flags: vec!["--enable-foo".into()],
218            },
219        };
220        let s = serde_json::to_string(&c).unwrap();
221        assert!(s.contains(r#""t":"compile""#));
222        assert!(s.contains(r#""name":"grep""#));
223    }
224
225    #[test]
226    fn build_ready_event() {
227        let e = Event::BuildReady {
228            recipe: "grep".into(),
229            artifact: "b3:deadbeef".into(),
230        };
231        let s = serde_json::to_string(&e).unwrap();
232        assert_eq!(
233            s,
234            r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"#
235        );
236    }
237
238    #[test]
239    fn error_event_minimal() {
240        let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
241        let s = serde_json::to_string(&e).unwrap();
242        let back: Event = serde_json::from_str(&s).unwrap();
243        assert_eq!(back, e);
244    }
245
246    #[test]
247    fn unknown_t_is_rejected() {
248        let bad = r#"{"t":"defenestrate","payload":1}"#;
249        assert!(serde_json::from_str::<Command>(bad).is_err());
250    }
251
252    #[test]
253    fn cap_required_for_basic_commands() {
254        assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None);
255        assert_eq!(
256            Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None, expr: None }),
257            Some(Cap::Query)
258        );
259        assert_eq!(
260            Cap::required_for(&Command::Init { cmd: "start web".into() }),
261            Some(Cap::Init)
262        );
263    }
264}