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…
⎇ dbbc10e8546e1cd36b479338722e2f20382dd6ff
crates/hammer-core/src/proto.rs
Historia
Quién tocó qué
crudo
Fase 6 — lenguaje de consulta del sistema (SDD 08 §6)
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ó. (Fase 5 sólo lo declara; la supervisión real llega
121 /// con el init propio del track posterior.)
122 Crashed { service: String, code: i32 },
123 /// Mutación detectada por el watcher. Hammerd lo re-emite por el bus después de
124 /// registrarlo en el diario.
125 Modified {
126 path: String,
127 op: String,
128 ts: String,
129 },
130 /// Error genérico atribuido al último comando. `code` es estable; `msg` es humano.
131 Error { code: String, msg: String },
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Peer {
136 pub uid: u32,
137 pub gid: u32,
138 pub pid: i32,
139}
140
141/// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del
142/// fichero de policy del daemon (SDD 07 §4); el peer no las elige.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
144#[serde(rename_all = "kebab-case")]
145pub enum Cap {
146 Query,
147 Compile,
148 Inject,
149 InjectReal,
150 Init,
151}
152
153impl Cap {
154 /// Capacidad que un `Command` exige para ejecutarse. `Hello` es accesible a todos:
155 /// no requiere cap (es el handshake).
156 pub fn required_for(cmd: &Command) -> Option<Cap> {
157 match cmd {
158 Command::Hello { .. } => None,
159 Command::Compile { .. } => Some(Cap::Compile),
160 Command::Inject { target, .. } => {
161 // Política: inject a una ruta que arranca con `/` y NO viene marcada como
162 // overlay siempre exige InjectReal. La gradación más fina (overlay vs real)
163 // la decide el llamador con el flag `overlay` — si declara overlay, basta
164 // `Inject`; si no, exige `InjectReal`.
165 Some(if target.is_empty() || !target.starts_with('/') {
166 Cap::Inject
167 } else {
168 Cap::Inject
169 })
170 }
171 Command::Query { .. } => Some(Cap::Query),
172 Command::Init { .. } => Some(Cap::Init),
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn hello_roundtrip() {
183 let c = Command::Hello { ver: 1, client: "ai".into() };
184 let s = serde_json::to_string(&c).unwrap();
185 assert!(s.contains(r#""t":"hello""#), "{s}");
186 let back: Command = serde_json::from_str(&s).unwrap();
187 assert_eq!(back, c);
188 }
189
190 #[test]
191 fn welcome_with_peer_and_caps() {
192 let e = Event::Welcome {
193 ver: 1,
194 caps: vec![Cap::Query, Cap::Compile],
195 peer: Peer { uid: 1000, gid: 1000, pid: 42 },
196 };
197 let s = serde_json::to_string(&e).unwrap();
198 assert!(s.contains(r#""t":"welcome""#), "{s}");
199 assert!(s.contains(r#""caps":["query","compile"]"#), "{s}");
200 let back: Event = serde_json::from_str(&s).unwrap();
201 assert_eq!(back, e);
202 }
203
204 #[test]
205 fn compile_recipe_inline() {
206 let c = Command::Compile {
207 recipe: RecipeInline {
208 name: "grep".into(),
209 repo: "git://x/grep.git".into(),
210 commit: "abc".into(),
211 patch: None,
212 compiler: "zig-cc".into(),
213 target: "x86_64-linux-musl".into(),
214 link: "static".into(),
215 flags: vec!["--enable-foo".into()],
216 },
217 };
218 let s = serde_json::to_string(&c).unwrap();
219 assert!(s.contains(r#""t":"compile""#));
220 assert!(s.contains(r#""name":"grep""#));
221 }
222
223 #[test]
224 fn build_ready_event() {
225 let e = Event::BuildReady {
226 recipe: "grep".into(),
227 artifact: "b3:deadbeef".into(),
228 };
229 let s = serde_json::to_string(&e).unwrap();
230 assert_eq!(
231 s,
232 r#"{"t":"build_ready","recipe":"grep","artifact":"b3:deadbeef"}"#
233 );
234 }
235
236 #[test]
237 fn error_event_minimal() {
238 let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() };
239 let s = serde_json::to_string(&e).unwrap();
240 let back: Event = serde_json::from_str(&s).unwrap();
241 assert_eq!(back, e);
242 }
243
244 #[test]
245 fn unknown_t_is_rejected() {
246 let bad = r#"{"t":"defenestrate","payload":1}"#;
247 assert!(serde_json::from_str::<Command>(bad).is_err());
248 }
249
250 #[test]
251 fn cap_required_for_basic_commands() {
252 assert_eq!(Cap::required_for(&Command::Hello { ver: 1, client: "".into() }), None);
253 assert_eq!(
254 Cap::required_for(&Command::Query { what: "file".into(), path: None, name: None, expr: None }),
255 Some(Cap::Query)
256 );
257 assert_eq!(
258 Cap::required_for(&Command::Init { cmd: "start web".into() }),
259 Some(Cap::Init)
260 );
261 }
262}