diff --git a/Cargo.lock b/Cargo.lock index e370d4c..cbb59c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10943,9 +10943,29 @@ version = "0.1.0" dependencies = [ "brahman-card", "brahman-sidecar", + "directories", + "postcard", + "serde", + "tahuantinsuyu-engine", + "tahuantinsuyu-model", + "thiserror 2.0.18", + "tokio", + "tracing", "ulid", ] +[[package]] +name = "tahuantinsuyu-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde_json", + "tahuantinsuyu-card", + "tahuantinsuyu-model", + "tokio", +] + [[package]] name = "tahuantinsuyu-engine" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 19ca9ed..1bd13f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,6 +170,7 @@ members = [ "crates/apps/lapaloma-phosphor-demo", "crates/apps/lapaloma-financial-demo", "crates/apps/tahuantinsuyu", + "crates/apps/tahuantinsuyu-cli", ] [workspace.package] diff --git a/crates/apps/tahuantinsuyu-cli/Cargo.toml b/crates/apps/tahuantinsuyu-cli/Cargo.toml new file mode 100644 index 0000000..9c8095e --- /dev/null +++ b/crates/apps/tahuantinsuyu-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tahuantinsuyu-cli" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Tahuantinsuyu — CLI cliente del service socket. Pide cómputos de cartas sin abrir la GUI." + +[dependencies] +tahuantinsuyu-card = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-card" } +tahuantinsuyu-model = { path = "../../modules/tahuantinsuyu/tahuantinsuyu-model" } +clap = { workspace = true } +tokio = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } + +[[bin]] +name = "tahuantinsuyu-cli" +path = "src/main.rs" diff --git a/crates/apps/tahuantinsuyu-cli/src/main.rs b/crates/apps/tahuantinsuyu-cli/src/main.rs new file mode 100644 index 0000000..e3051c2 --- /dev/null +++ b/crates/apps/tahuantinsuyu-cli/src/main.rs @@ -0,0 +1,164 @@ +//! `tahuantinsuyu-cli` — cliente del service socket de Tahuantinsuyu. +//! +//! Pide cómputos de cartas sin abrir la GUI. Útil para integraciones, +//! scripts y para verificar end-to-end que el data plane brahman está +//! sirviendo. Conecta al socket que la app GUI expone (default +//! `$XDG_CACHE_HOME/tahuantinsuyu/service.sock`). +//! +//! ## Comandos +//! +//! - `ping` — verifica que el server responde. +//! - `natal --year N --month M --day D --hour H --minute MIN +//! --tz-min TZ --lat LAT --lon LON [--alt ALT] [--label TEXT]` +//! — pide una carta natal y la imprime como JSON. +//! +//! ## Ejemplo +//! +//! ```bash +//! cargo run -p tahuantinsuyu-cli -- natal \ +//! --year 1987 --month 3 --day 14 \ +//! --hour 5 --minute 22 --tz-min -240 \ +//! --lat 10.4806 --lon -66.9036 \ +//! --label "Sergio" +//! ``` + +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; +use tahuantinsuyu_card::service::{self, ComputeRequest, ComputeResponse}; +use tahuantinsuyu_model::{StoredBirthData, StoredChartConfig}; + +#[derive(Parser)] +#[command( + name = "tahuantinsuyu-cli", + version, + about = "Cliente del service socket de Tahuantinsuyu." +)] +struct Cli { + /// Path al service socket. Default: el resuelto por + /// `service::default_service_socket()`. + #[arg(long, global = true)] + socket: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Health check — verifica que el server responde con Pong. + Ping, + /// Pide el cómputo de una carta natal e imprime el RenderModel + /// como JSON. + Natal { + #[arg(long)] + year: i32, + #[arg(long)] + month: u32, + #[arg(long)] + day: u32, + #[arg(long)] + hour: u32, + #[arg(long)] + minute: u32, + #[arg(long, default_value_t = 0.0)] + second: f64, + /// Offset de zona horaria del lugar de nacimiento, en minutos. + /// Ej: Argentina = -180, UTC = 0, Madrid = 60. + #[arg(long = "tz-min")] + tz_offset_minutes: i32, + #[arg(long)] + lat: f64, + #[arg(long)] + lon: f64, + #[arg(long, default_value_t = 0.0)] + alt: f64, + /// Etiqueta del chart para el title del RenderModel. + #[arg(long)] + label: Option, + /// Offset adicional en minutos sobre el instante natal (útil + /// para rectificación rápida sin guardar variantes). + #[arg(long, default_value_t = 0)] + offset_minutes: i64, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = cli + .socket + .unwrap_or_else(service::default_service_socket); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .context("crear tokio runtime")?; + + rt.block_on(async { + match cli.command { + Command::Ping => { + let response = service::request(&socket, &ComputeRequest::Ping) + .await + .with_context(|| format!("ping a {}", socket.display()))?; + match response { + ComputeResponse::Pong => { + println!("pong"); + Ok(()) + } + other => Err(anyhow!("respuesta inesperada al ping: {:?}", other)), + } + } + Command::Natal { + year, + month, + day, + hour, + minute, + second, + tz_offset_minutes, + lat, + lon, + alt, + label, + offset_minutes, + } => { + let request = ComputeRequest::Natal { + birth: StoredBirthData { + year, + month, + day, + hour, + minute, + second, + tz_offset_minutes, + latitude_deg: lat, + longitude_deg: lon, + altitude_m: alt, + time_certainty: Default::default(), + subject_name: label.clone(), + birthplace_label: None, + }, + config: StoredChartConfig::default(), + offset_minutes, + label, + }; + let response = service::request(&socket, &request) + .await + .with_context(|| format!("natal request a {}", socket.display()))?; + match response { + ComputeResponse::Render { render } => { + let json = serde_json::to_string_pretty(&render) + .context("serializar RenderModel a JSON")?; + println!("{}", json); + Ok(()) + } + ComputeResponse::Error { message } => { + Err(anyhow!("server reportó error: {}", message)) + } + other => Err(anyhow!("respuesta inesperada al natal: {:?}", other)), + } + } + } + }) +} diff --git a/crates/apps/tahuantinsuyu/src/main.rs b/crates/apps/tahuantinsuyu/src/main.rs index a0ca5a2..bff8bcb 100644 --- a/crates/apps/tahuantinsuyu/src/main.rs +++ b/crates/apps/tahuantinsuyu/src/main.rs @@ -44,6 +44,13 @@ const APP_TITLE: &str = "Tahuantinsuyu"; fn main() { // Sidecar brahman primero — si el Init está corriendo, nos presentamos. tahuantinsuyu_card::spawn_sidecar(); + // Service socket: thread separado escuchando ComputeRequest. Otros + // módulos brahman pueden conectar y pedir cómputos de cartas + // natales sin GUI. Si el bind falla (socket ya tomado, sin + // permisos), loggea warn y la app sigue corriendo standalone. + let service_socket = tahuantinsuyu_card::service::default_service_socket(); + eprintln!("[tahuantinsuyu] service socket → {}", service_socket.display()); + tahuantinsuyu_card::service::spawn_service_thread(service_socket); // DB en directorio de datos del usuario. let db_path = resolve_db_path(); diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml index 985c14b..2a52aa5 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/Cargo.toml @@ -3,9 +3,17 @@ name = "tahuantinsuyu-card" version = { workspace = true } edition = { workspace = true } license = { workspace = true } -description = "Tahuantinsuyu — Tarjeta de Presentación brahman + spawn del sidecar." +description = "Tahuantinsuyu — Tarjeta de Presentación brahman + spawn del sidecar + protocolo del service socket." [dependencies] brahman-card = { path = "../../../core/brahman-card" } brahman-sidecar = { path = "../../../shared/brahman-sidecar" } +tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" } +tahuantinsuyu-model = { path = "../tahuantinsuyu-model" } ulid = { workspace = true } +serde = { workspace = true } +postcard = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +directories = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs index e558fcf..e6565c4 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs @@ -8,6 +8,8 @@ #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] +pub mod service; + use std::collections::BTreeSet; use brahman_card::{ @@ -26,13 +28,17 @@ pub fn spawn_sidecar() { } /// Construye la Card. Expuesto público para tests + para shells que -/// quieran inspeccionar el manifiesto antes de spawnear. +/// quieran inspeccionar el manifiesto antes de spawnear. Anuncia el +/// path del service socket en `Card.service_socket` para que otros +/// módulos brahman, después de matchear via el broker, puedan conectar +/// directo al data plane. pub fn build_card() -> Card { Card { schema_version: CARD_SCHEMA_VERSION, id: Ulid::new(), lineage: None, label: LABEL.into(), + service_socket: Some(service::default_service_socket()), provides: BTreeSet::new(), requires: BTreeSet::new(), payload: Payload::Virtual, diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/service.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/service.rs new file mode 100644 index 0000000..e23c94f --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/service.rs @@ -0,0 +1,244 @@ +//! Service socket de Tahuantinsuyu — protocolo y server. +//! +//! La Card de Tahuantinsuyu declara desde fase 1 los flows +//! `chart-request` (input) y `chart-result` (output). Acá vive el +//! **data plane** real que los implementa: un Unix socket sobre el que +//! cualquier módulo brahman puede pedir un cómputo de carta y recibir +//! el RenderModel ya armado. +//! +//! ## Protocolo +//! +//! Frame: `u32 length` little-endian + `postcard`-serialized payload. +//! Misma forma que `brahman-handshake` para reducir sorpresas. +//! +//! ## Endpoints +//! +//! - `ComputeRequest::Natal { birth, config, offset_minutes }` → +//! `ComputeResponse::Render { render }` o `Error { message }`. +//! - `ComputeRequest::Ping` → `ComputeResponse::Pong`. +//! +//! El service no expone los overlays (transit / synastry / etc) por +//! ahora — son una pasada futura. Cubre el caso 80%: "necesito la +//! carta natal de estos datos". + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tahuantinsuyu_engine::{compose_with_options, NatalOptions, RenderModel}; +use tahuantinsuyu_model::{Chart, ChartId, ChartKind, ContactId, StoredBirthData, StoredChartConfig}; +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{debug, error, info, warn}; + +/// Path canónico del service socket. Usa `XDG_RUNTIME_DIR` si está +/// (por usuario, no persistente), sino cae a `/tmp/tahuantinsuyu.sock`. +pub fn default_service_socket() -> PathBuf { + if let Some(rt) = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu") { + // ProjectDirs no expone runtime_dir directo en todas las + // plataformas — usamos cache_dir como fallback estable. + let mut p = rt.cache_dir().to_path_buf(); + std::fs::create_dir_all(&p).ok(); + p.push("service.sock"); + return p; + } + PathBuf::from("/tmp/tahuantinsuyu.sock") +} + +// ===================================================================== +// Tipos del protocolo +// ===================================================================== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ComputeRequest { + /// Salud del server. Usá para verificar que el sidecar está vivo. + Ping, + /// Pide el cómputo de una carta natal pura (sin overlays). + Natal { + birth: StoredBirthData, + config: StoredChartConfig, + /// Offset en minutos sobre el instante natal — útil para + /// rectificación rápida sin guardar variantes. + #[serde(default)] + offset_minutes: i64, + /// Label opcional para que el render lo lleve en su title. + #[serde(default)] + label: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ComputeResponse { + Pong, + Render { render: RenderModel }, + Error { message: String }, +} + +// ===================================================================== +// Errores +// ===================================================================== + +#[derive(Debug, Error)] +pub enum ServiceError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("postcard: {0}")] + Postcard(#[from] postcard::Error), + #[error("frame demasiado grande: {0} bytes")] + FrameTooLarge(u32), + #[error("connect a {path}: {source}")] + Connect { + path: PathBuf, + source: std::io::Error, + }, +} + +/// Cap de tamaño de frame — defensivo contra peers malformados. +const MAX_FRAME_BYTES: u32 = 1024 * 1024; // 1 MiB + +// ===================================================================== +// Server +// ===================================================================== + +/// Arranca el server async sobre `socket_path`. Cada conexión nueva +/// procesa una secuencia de Request/Response hasta que el peer cierra. +pub async fn serve(socket_path: PathBuf) -> Result<(), ServiceError> { + // Si quedó un socket viejo del run anterior, lo borramos. + let _ = std::fs::remove_file(&socket_path); + + let listener = UnixListener::bind(&socket_path)?; + info!(socket = %socket_path.display(), "tahuantinsuyu service socket arriba"); + + loop { + let (stream, _addr) = listener.accept().await?; + tokio::spawn(async move { + if let Err(e) = serve_connection(stream).await { + warn!(?e, "connection terminó con error"); + } + }); + } +} + +async fn serve_connection(mut stream: UnixStream) -> Result<(), ServiceError> { + loop { + let request: ComputeRequest = match read_frame(&mut stream).await { + Ok(r) => r, + Err(ServiceError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + debug!("peer cerró"); + return Ok(()); + } + Err(e) => return Err(e), + }; + let response = handle(request); + write_frame(&mut stream, &response).await?; + } +} + +fn handle(req: ComputeRequest) -> ComputeResponse { + match req { + ComputeRequest::Ping => ComputeResponse::Pong, + ComputeRequest::Natal { + birth, + config, + offset_minutes, + label, + } => { + let chart = Chart { + id: ChartId::new(), + contact_id: ContactId::new(), + kind: ChartKind::Natal, + label: label.unwrap_or_else(|| "Service request".into()), + birth_data: birth, + config, + related_chart_id: None, + created_at_ms: 0, + }; + match compose_with_options(&chart, offset_minutes, &[], &NatalOptions::default()) { + Ok(render) => ComputeResponse::Render { render }, + Err(e) => ComputeResponse::Error { + message: format!("{}", e), + }, + } + } + } +} + +// ===================================================================== +// Client helper +// ===================================================================== + +/// Cliente async: abre el socket, envía un request, espera la response. +/// Cierra la conexión al volver (no reusable; útil para CLI/tests). +pub async fn request( + socket: &Path, + req: &ComputeRequest, +) -> Result { + let mut stream = UnixStream::connect(socket).await.map_err(|source| { + ServiceError::Connect { + path: socket.to_path_buf(), + source, + } + })?; + write_frame(&mut stream, req).await?; + read_frame(&mut stream).await +} + +// ===================================================================== +// Framing +// ===================================================================== + +async fn write_frame(stream: &mut UnixStream, value: &T) -> Result<(), ServiceError> { + let bytes = postcard::to_allocvec(value)?; + let len = u32::try_from(bytes.len()).map_err(|_| ServiceError::FrameTooLarge(u32::MAX))?; + if len > MAX_FRAME_BYTES { + return Err(ServiceError::FrameTooLarge(len)); + } + stream.write_u32_le(len).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +async fn read_frame Deserialize<'de>>( + stream: &mut UnixStream, +) -> Result { + let len = stream.read_u32_le().await?; + if len > MAX_FRAME_BYTES { + return Err(ServiceError::FrameTooLarge(len)); + } + let mut buf = vec![0u8; len as usize]; + stream.read_exact(&mut buf).await?; + let value = postcard::from_bytes(&buf)?; + Ok(value) +} + +// ===================================================================== +// Spawn helper para uso desde el binario GUI +// ===================================================================== + +/// Spawn fire-and-forget: thread aparte con tokio runtime current_thread +/// corriendo el server. Si la initialización falla, loggea warn y el +/// thread termina. El binario GUI sigue funcionando standalone. +pub fn spawn_service_thread(socket_path: PathBuf) { + std::thread::Builder::new() + .name("tahuantinsuyu-service".into()) + .spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + { + Ok(rt) => rt, + Err(e) => { + error!(?e, "no pude crear runtime para service thread"); + return; + } + }; + if let Err(e) = rt.block_on(serve(socket_path)) { + error!(?e, "service server terminó con error"); + } + }) + .map(|_| ()) + .unwrap_or_else(|e| { + error!(?e, "no pude spawnear thread del service socket"); + }); +}