tawasuyu

tawasuyu/brahman

Rust 202TOML 61JSON 7HTML 12471 Sutichaykuna

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

Cierra el círculo del Card: el flow.input "chart-request" y output
"chart-result" declarados desde fase 1 ahora tienen data plane real.
Otros módulos brahman (incluyendo el CLI nuevo) pueden conectar al
Unix socket de Tahuantinsuyu y pedir cómputos de cartas natales sin
abrir la GUI.

## Protocolo y server

Nuevo módulo `tahuantinsuyu_card::service` con:

- `ComputeRequest { Ping, Natal { birth, config, offset_minutes,
  label } }` — postcard-serializable
- `ComputeResponse { Pong, Render { render }, Error { message } }`
- `serve(socket_path)` — async loop sobre tokio::UnixListener,
  spawn por conexión, frame `u32 length` LE + postcard payload
  (mismo molde que brahman-handshake). Cap defensivo a 1 MiB por
  frame.
- `request(&socket, &req) -> Response` — cliente async one-shot
  (abre, envía, recibe, cierra)
- `spawn_service_thread(path)` — thread dedicado con tokio
  current_thread runtime; loggea warn si bind falla, la app sigue
  standalone.

La Card ahora declara `service_socket: Some(default_service_socket())`
— el broker brahman puede revelar este path a consumidores que
matcheen el flow `chart-request`. Path canónico:
`$XDG_CACHE_HOME/tahuantinsuyu/service.sock` (con fallback a
/tmp).

## CLI nuevo

`crates/apps/tahuantinsuyu-cli` — binario standalone que usa el
helper cliente. Comandos:

- `tahuantinsuyu-cli ping` — health check
- `tahuantinsuyu-cli natal --year ... --month ... --day ... --hour
  ... --minute ... --tz-min ... --lat ... --lon ... [--alt ...]
  [--label "..."] [--offset-minutes N]` — pide compute y emite
  RenderModel como JSON pretty-print en stdout

Útil para:
- Smoke tests del data plane (CI puede levantar la app + ping)
- Scripts batch (computar 100 cartas y exportar JSON)
- Integraciones con otros tools del fractal brahman vía broker

## Cambios accesorios

- apps/tahuantinsuyu/main.rs: spawn_service_thread al boot junto al
  sidecar. Loggea el path del socket a stderr para debug.
- Cargo workspace: agrega tahuantinsuyu-cli como member.
- tahuantinsuyu-card Cargo: agrega deps (engine, model, postcard,
  tokio, tracing, directories, thiserror) para soportar el server.

Lo que falta para integración brahman 100%:
- Suscripción al broker como "consumer-aware" para detectar cuando
  otros módulos publican `chart-request`s
- Publishing de eventos al broker cuando se crean/borran cartas
- Ambos requieren protocolo handshake bidireccional sobre el Init
  socket (no service_socket) — fase posterior.

cargo check verde, 8 tests engine + 1 modules verdes. CLI compila;
prueba end-to-end (ping + natal) queda a manos del usuario que
levante la GUI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

e2da24239e1e1fe440aa4497e734b9fa8b3457eb · ruwaq sergio · (5 killa ñawpaq) · tayta 6191ce7de · kay commitpi sach'ata qhaway

Semántico sayk'uy · 18

crates/apps/tahuantinsuyu-cli/src/main.rs

crates/apps/tahuantinsuyu/src/main.rs

  • ukhunpi t'ikrakurqan fn main

crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-card/src/service.rs

8 qillqakuna · +470 −2

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<PathBuf>,
+
+    #[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<String>,
+        /// 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<String>,
+    },
+}
+
+#[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<ComputeResponse, ServiceError> {
+    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<T: Serialize>(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<T: for<'de> Deserialize<'de>>(
+    stream: &mut UnixStream,
+) -> Result<T, ServiceError> {
+    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");
+        });
+}