refactor(naming): A1 — ente→arje, vista→revista, pluma→fana
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}