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…
⎇ f41022d81f87b38135f1faca5821e31ceaf7ee90
crates/hammerd/src/proto.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
Fase 5 — bus de agente (/run/agent.sock) + FIFO de control humano
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}