tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Sutichaykuna

⎇ e2da24239e1e1fe440aa4497e734b9fa8b3457eb crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/service.rs Rust · 244 siq'ikuna · 8.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(tahuantinsuyu): fase 26 — data plane brahman real (service socket + CLI) e2da24239 · sergio ·

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}