gioser

tawasuyu/brahman

Rust 563TOML 212Markdown 66JSON 198014 Sutichaykuna

⎇ main crates/modules/chasqui/card/src/query.rs Rust · 278 siq'ikuna · 9.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

refactor(naming): A1 — ente→arje, vista→revista, pluma→fana b83d40a83 · sergio ·

1//! Wire types para consultar al daemon `chasqui` por sus Mónadas.
2//!
3//! El daemon expone un Unix socket (cuyo path se publica en
4//! `Card.service_socket` y se descubre vía broker MatchEvent). Cada
5//! conexión es single-shot: una request JSON terminada en `\n`,
6//! una response JSON terminada en `\n`, cierre.
7//!
8//! Mismo patrón que `chasqui-nous` (mock/real ↔ chasqui-core), reusado
9//! ahora para que la UI (`chasqui-explorer`) descubra y consulte al
10//! daemon sin hardcodear sockets ni pasar por brahman-admin.
11//!
12//! ## Contrato
13//!
14//! ```text
15//! C → S: {"kind":"list_monads"}\n
16//! S → C: {"engine":{...},"monads":[...]}\n
17//! ```
18//!
19//! En caso de error:
20//!
21//! ```text
22//! S → C: {"error":"unsupported kind"}\n
23//! ```
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27use ulid::Ulid;
28
29use crate::{Lens, MonadId, MonadManifest};
30
31// =====================================================================
32// Constants compartidos para el broker brahman
33// =====================================================================
34
35/// Nombre del flow output del daemon (input del consumer/explorer).
36pub const FLOW_MONAD_LIST: &str = "monad-list";
37
38/// Tipo del flow: el wire es JSON, así que el TypeRef es `primitive::json`.
39pub const FLOW_TYPE_NAME: &str = "json";
40
41// =====================================================================
42// Wire request
43// =====================================================================
44
45/// Request al daemon. El wire es JSON line-delimited (un objeto + `\n`
46/// por conexión).
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(tag = "kind", rename_all = "snake_case")]
49pub enum QueryRequest {
50    /// Lista todas las Mónadas vivas del daemon, junto con metadata
51    /// del engine. Pensado para que la UI haga snapshot polling.
52    ListMonads,
53}
54
55// =====================================================================
56// Wire response
57// =====================================================================
58
59/// Response a `ListMonads`.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ListMonadsResponse {
62    /// Datos del engine (la Card que es "dueña" de las Mónadas).
63    pub engine: EngineInfo,
64    /// Mónadas vivas en este momento. Vista slim sin centroide ni
65    /// member set para que el wire sea liviano: una Mónada con 50k
66    /// archivos no debe transmitir 50k ULIDs cada poll.
67    pub monads: Vec<MonadView>,
68}
69
70/// Identidad del engine (Card kind=Ente que owns las Mónadas).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EngineInfo {
73    pub id: Ulid,
74    pub label: String,
75    /// Path del directorio que el daemon está observando. `None` si
76    /// el daemon corre sin watcher.
77    #[serde(default)]
78    pub watching: Option<String>,
79}
80
81/// Vista slim de una Mónada — los campos que la UI necesita para
82/// renderizar una card sin pull del centroide ni del member set.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct MonadView {
85    pub id: MonadId,
86    pub label: String,
87    #[serde(default)]
88    pub summary: String,
89    #[serde(default)]
90    pub keywords: Vec<String>,
91    pub cardinality: u32,
92    #[serde(default)]
93    pub entropy: f32,
94    #[serde(default)]
95    pub dominant_lens: Lens,
96    #[serde(default)]
97    pub path_hint: Option<String>,
98    #[serde(default)]
99    pub centroid_model: Option<String>,
100}
101
102impl MonadView {
103    /// Proyecta un MonadManifest completo a su vista slim para wire.
104    pub fn from_manifest(m: &MonadManifest) -> Self {
105        Self {
106            id: m.id,
107            label: m.label.clone(),
108            summary: m.summary.clone(),
109            keywords: m.keywords.clone(),
110            cardinality: m.cardinality,
111            entropy: m.entropy,
112            dominant_lens: m.dominant_lens,
113            path_hint: m.path_hint.clone(),
114            centroid_model: m.centroid_model.clone(),
115        }
116    }
117}
118
119/// Error de protocolo retornado en lugar de la response normal.
120#[derive(Debug, Clone, Serialize, Deserialize, Error)]
121#[error("chasqui-engine: {error}")]
122pub struct ErrorResponse {
123    pub error: String,
124}
125
126// =====================================================================
127// Transport
128// =====================================================================
129
130pub mod transport {
131    use std::path::PathBuf;
132
133    /// Variable de entorno para sobreescribir la ruta del socket del
134    /// daemon (útil para tests / multi-daemon).
135    pub const SOCKET_ENV: &str = "NOUSER_ENGINE_SOCKET";
136
137    /// Nombre por defecto del socket.
138    pub const SOCKET_NAME: &str = "chasqui-engine.sock";
139
140    /// Ruta canónica al socket del daemon. Honra `NOUSER_ENGINE_SOCKET`
141    /// si está set, sino arma sobre `$XDG_RUNTIME_DIR` (con fallback
142    /// `$TMPDIR`).
143    pub fn default_socket_path() -> PathBuf {
144        if let Ok(p) = std::env::var(SOCKET_ENV) {
145            return PathBuf::from(p);
146        }
147        std::env::var_os("XDG_RUNTIME_DIR")
148            .map(PathBuf::from)
149            .unwrap_or_else(std::env::temp_dir)
150            .join(SOCKET_NAME)
151    }
152}
153
154// =====================================================================
155// Cliente blocking — vive con los wire types para que un consumer
156// (UI, CLI, otro módulo) pueda hablar con el daemon importando sólo
157// `chasqui-card`, sin arrastrar `chasqui-core` (notify/walkdir/sled/blake3).
158// =====================================================================
159
160/// Cliente síncrono para el query socket del daemon. Sólo Unix (el
161/// resto del ecosistema brahman es Unix-only de facto).
162#[cfg(unix)]
163pub mod client {
164    use std::io::{BufRead, BufReader, Write};
165    use std::os::unix::net::UnixStream;
166    use std::path::Path;
167    use std::time::Duration;
168
169    use super::{ErrorResponse, ListMonadsResponse, QueryRequest};
170
171    #[derive(Debug, thiserror::Error)]
172    pub enum QueryError {
173        #[error("conectar a {path}: {source}")]
174        Connect {
175            path: std::path::PathBuf,
176            #[source]
177            source: std::io::Error,
178        },
179        #[error("I/O: {0}")]
180        Io(#[from] std::io::Error),
181        #[error("serializacion: {0}")]
182        Serde(#[from] serde_json::Error),
183        #[error("daemon: {0}")]
184        Daemon(String),
185        #[error("response vacía del daemon")]
186        Empty,
187    }
188
189    /// Envía `ListMonads` al daemon en `socket` y devuelve la response.
190    /// `timeout` se aplica tanto al read como al write del stream.
191    pub fn list_monads(
192        socket: &Path,
193        timeout: Duration,
194    ) -> Result<ListMonadsResponse, QueryError> {
195        let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect {
196            path: socket.to_path_buf(),
197            source: e,
198        })?;
199        stream.set_read_timeout(Some(timeout))?;
200        stream.set_write_timeout(Some(timeout))?;
201
202        let req = QueryRequest::ListMonads;
203        let line = serde_json::to_string(&req)?;
204        stream.write_all(line.as_bytes())?;
205        stream.write_all(b"\n")?;
206        stream.flush()?;
207
208        let mut reader = BufReader::new(stream);
209        let mut response = String::new();
210        let n = reader.read_line(&mut response)?;
211        if n == 0 {
212            return Err(QueryError::Empty);
213        }
214
215        if let Ok(resp) = serde_json::from_str::<ListMonadsResponse>(response.trim()) {
216            return Ok(resp);
217        }
218        let err: ErrorResponse = serde_json::from_str(response.trim())?;
219        Err(QueryError::Daemon(err.error))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn request_roundtrips_json_with_tag() {
229        let req = QueryRequest::ListMonads;
230        let s = serde_json::to_string(&req).unwrap();
231        assert_eq!(s, r#"{"kind":"list_monads"}"#);
232        let back: QueryRequest = serde_json::from_str(&s).unwrap();
233        assert_eq!(back, req);
234    }
235
236    #[test]
237    fn response_roundtrip_preserves_view() {
238        let m = MonadManifest::new("x/src");
239        let view = MonadView::from_manifest(&m);
240        let resp = ListMonadsResponse {
241            engine: EngineInfo {
242                id: Ulid::new(),
243                label: "brahman.nouser_engine".into(),
244                watching: Some("/tmp/x".into()),
245            },
246            monads: vec![view.clone()],
247        };
248        let s = serde_json::to_string(&resp).unwrap();
249        let back: ListMonadsResponse = serde_json::from_str(&s).unwrap();
250        assert_eq!(back.monads.len(), 1);
251        assert_eq!(back.monads[0].label, view.label);
252        assert_eq!(back.engine.label, "brahman.nouser_engine");
253    }
254
255    #[test]
256    fn view_is_slim_no_centroid_no_members() {
257        // Construimos una Mónada con centroid + members "pesados",
258        // proyectamos a view, verificamos que esos campos no viajan.
259        let mut m = MonadManifest::new("test");
260        m.centroid = vec![0.1; 384]; // peso "real-fastembed"
261        m.members.insert(Ulid::new());
262        m.members.insert(Ulid::new());
263        m.cardinality = 2;
264        let view = MonadView::from_manifest(&m);
265        let s = serde_json::to_string(&view).unwrap();
266        // Chequeo con `:` para distinguir el field "centroid" del
267        // field "centroid_model" (que sí es metadata liviana y debe ir).
268        assert!(
269            !s.contains("\"centroid\":"),
270            "MonadView no debe serializar el vector centroid: {s}"
271        );
272        assert!(
273            !s.contains("\"members\":"),
274            "MonadView no debe serializar members: {s}"
275        );
276        assert!(s.contains("\"cardinality\":2"), "cardinality sí va: {s}");
277    }
278}