⎇ e2da24239e1e1fe440aa4497e734b9fa8b3457eb
crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/service.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(tahuantinsuyu): fase 26 — data plane brahman real (service socket + CLI)
1//! Service socket de Tahuantinsuyu — protocolo y server.
2//!
3//! La Card de Tahuantinsuyu declara desde fase 1 los flows
4//! `chart-request` (input) y `chart-result` (output). Acá vive el
5//! **data plane** real que los implementa: un Unix socket sobre el que
6//! cualquier módulo brahman puede pedir un cómputo de carta y recibir
7//! el RenderModel ya armado.
8//!
9//! ## Protocolo
10//!
11//! Frame: `u32 length` little-endian + `postcard`-serialized payload.
12//! Misma forma que `brahman-handshake` para reducir sorpresas.
13//!
14//! ## Endpoints
15//!
16//! - `ComputeRequest::Natal { birth, config, offset_minutes }` →
17//! `ComputeResponse::Render { render }` o `Error { message }`.
18//! - `ComputeRequest::Ping` → `ComputeResponse::Pong`.
19//!
20//! El service no expone los overlays (transit / synastry / etc) por
21//! ahora — son una pasada futura. Cubre el caso 80%: "necesito la
22//! carta natal de estos datos".
23
24use std::path::{Path, PathBuf};
25
26use serde::{Deserialize, Serialize};
27use tahuantinsuyu_engine::{compose_with_options, NatalOptions, RenderModel};
28use tahuantinsuyu_model::{Chart, ChartId, ChartKind, ContactId, StoredBirthData, StoredChartConfig};
29use thiserror::Error;
30use tokio::io::{AsyncReadExt, AsyncWriteExt};
31use tokio::net::{UnixListener, UnixStream};
32use tracing::{debug, error, info, warn};
33
34/// Path canónico del service socket. Usa `XDG_RUNTIME_DIR` si está
35/// (por usuario, no persistente), sino cae a `/tmp/tahuantinsuyu.sock`.
36pub fn default_service_socket() -> PathBuf {
37 if let Some(rt) = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu") {
38 // ProjectDirs no expone runtime_dir directo en todas las
39 // plataformas — usamos cache_dir como fallback estable.
40 let mut p = rt.cache_dir().to_path_buf();
41 std::fs::create_dir_all(&p).ok();
42 p.push("service.sock");
43 return p;
44 }
45 PathBuf::from("/tmp/tahuantinsuyu.sock")
46}
47
48// =====================================================================
49// Tipos del protocolo
50// =====================================================================
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub enum ComputeRequest {
54 /// Salud del server. Usá para verificar que el sidecar está vivo.
55 Ping,
56 /// Pide el cómputo de una carta natal pura (sin overlays).
57 Natal {
58 birth: StoredBirthData,
59 config: StoredChartConfig,
60 /// Offset en minutos sobre el instante natal — útil para
61 /// rectificación rápida sin guardar variantes.
62 #[serde(default)]
63 offset_minutes: i64,
64 /// Label opcional para que el render lo lleve en su title.
65 #[serde(default)]
66 label: Option<String>,
67 },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub enum ComputeResponse {
72 Pong,
73 Render { render: RenderModel },
74 Error { message: String },
75}
76
77// =====================================================================
78// Errores
79// =====================================================================
80
81#[derive(Debug, Error)]
82pub enum ServiceError {
83 #[error("io: {0}")]
84 Io(#[from] std::io::Error),
85 #[error("postcard: {0}")]
86 Postcard(#[from] postcard::Error),
87 #[error("frame demasiado grande: {0} bytes")]
88 FrameTooLarge(u32),
89 #[error("connect a {path}: {source}")]
90 Connect {
91 path: PathBuf,
92 source: std::io::Error,
93 },
94}
95
96/// Cap de tamaño de frame — defensivo contra peers malformados.
97const MAX_FRAME_BYTES: u32 = 1024 * 1024; // 1 MiB
98
99// =====================================================================
100// Server
101// =====================================================================
102
103/// Arranca el server async sobre `socket_path`. Cada conexión nueva
104/// procesa una secuencia de Request/Response hasta que el peer cierra.
105pub async fn serve(socket_path: PathBuf) -> Result<(), ServiceError> {
106 // Si quedó un socket viejo del run anterior, lo borramos.
107 let _ = std::fs::remove_file(&socket_path);
108
109 let listener = UnixListener::bind(&socket_path)?;
110 info!(socket = %socket_path.display(), "tahuantinsuyu service socket arriba");
111
112 loop {
113 let (stream, _addr) = listener.accept().await?;
114 tokio::spawn(async move {
115 if let Err(e) = serve_connection(stream).await {
116 warn!(?e, "connection terminó con error");
117 }
118 });
119 }
120}
121
122async fn serve_connection(mut stream: UnixStream) -> Result<(), ServiceError> {
123 loop {
124 let request: ComputeRequest = match read_frame(&mut stream).await {
125 Ok(r) => r,
126 Err(ServiceError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
127 debug!("peer cerró");
128 return Ok(());
129 }
130 Err(e) => return Err(e),
131 };
132 let response = handle(request);
133 write_frame(&mut stream, &response).await?;
134 }
135}
136
137fn handle(req: ComputeRequest) -> ComputeResponse {
138 match req {
139 ComputeRequest::Ping => ComputeResponse::Pong,
140 ComputeRequest::Natal {
141 birth,
142 config,
143 offset_minutes,
144 label,
145 } => {
146 let chart = Chart {
147 id: ChartId::new(),
148 contact_id: ContactId::new(),
149 kind: ChartKind::Natal,
150 label: label.unwrap_or_else(|| "Service request".into()),
151 birth_data: birth,
152 config,
153 related_chart_id: None,
154 created_at_ms: 0,
155 };
156 match compose_with_options(&chart, offset_minutes, &[], &NatalOptions::default()) {
157 Ok(render) => ComputeResponse::Render { render },
158 Err(e) => ComputeResponse::Error {
159 message: format!("{}", e),
160 },
161 }
162 }
163 }
164}
165
166// =====================================================================
167// Client helper
168// =====================================================================
169
170/// Cliente async: abre el socket, envía un request, espera la response.
171/// Cierra la conexión al volver (no reusable; útil para CLI/tests).
172pub async fn request(
173 socket: &Path,
174 req: &ComputeRequest,
175) -> Result<ComputeResponse, ServiceError> {
176 let mut stream = UnixStream::connect(socket).await.map_err(|source| {
177 ServiceError::Connect {
178 path: socket.to_path_buf(),
179 source,
180 }
181 })?;
182 write_frame(&mut stream, req).await?;
183 read_frame(&mut stream).await
184}
185
186// =====================================================================
187// Framing
188// =====================================================================
189
190async fn write_frame<T: Serialize>(stream: &mut UnixStream, value: &T) -> Result<(), ServiceError> {
191 let bytes = postcard::to_allocvec(value)?;
192 let len = u32::try_from(bytes.len()).map_err(|_| ServiceError::FrameTooLarge(u32::MAX))?;
193 if len > MAX_FRAME_BYTES {
194 return Err(ServiceError::FrameTooLarge(len));
195 }
196 stream.write_u32_le(len).await?;
197 stream.write_all(&bytes).await?;
198 stream.flush().await?;
199 Ok(())
200}
201
202async fn read_frame<T: for<'de> Deserialize<'de>>(
203 stream: &mut UnixStream,
204) -> Result<T, ServiceError> {
205 let len = stream.read_u32_le().await?;
206 if len > MAX_FRAME_BYTES {
207 return Err(ServiceError::FrameTooLarge(len));
208 }
209 let mut buf = vec![0u8; len as usize];
210 stream.read_exact(&mut buf).await?;
211 let value = postcard::from_bytes(&buf)?;
212 Ok(value)
213}
214
215// =====================================================================
216// Spawn helper para uso desde el binario GUI
217// =====================================================================
218
219/// Spawn fire-and-forget: thread aparte con tokio runtime current_thread
220/// corriendo el server. Si la initialización falla, loggea warn y el
221/// thread termina. El binario GUI sigue funcionando standalone.
222pub fn spawn_service_thread(socket_path: PathBuf) {
223 std::thread::Builder::new()
224 .name("tahuantinsuyu-service".into())
225 .spawn(move || {
226 let rt = match tokio::runtime::Builder::new_current_thread()
227 .enable_io()
228 .build()
229 {
230 Ok(rt) => rt,
231 Err(e) => {
232 error!(?e, "no pude crear runtime para service thread");
233 return;
234 }
235 };
236 if let Err(e) = rt.block_on(serve(socket_path)) {
237 error!(?e, "service server terminó con error");
238 }
239 })
240 .map(|_| ())
241 .unwrap_or_else(|e| {
242 error!(?e, "no pude spawnear thread del service socket");
243 });
244}