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 · 2026-05-18T00:09:22Z (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
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");
+ });
+}