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>
This commit is contained in:
Generated
+20
@@ -10943,9 +10943,29 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"brahman-card",
|
"brahman-card",
|
||||||
"brahman-sidecar",
|
"brahman-sidecar",
|
||||||
|
"directories",
|
||||||
|
"postcard",
|
||||||
|
"serde",
|
||||||
|
"tahuantinsuyu-engine",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
"ulid",
|
"ulid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tahuantinsuyu-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"serde_json",
|
||||||
|
"tahuantinsuyu-card",
|
||||||
|
"tahuantinsuyu-model",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tahuantinsuyu-engine"
|
name = "tahuantinsuyu-engine"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ members = [
|
|||||||
"crates/apps/lapaloma-phosphor-demo",
|
"crates/apps/lapaloma-phosphor-demo",
|
||||||
"crates/apps/lapaloma-financial-demo",
|
"crates/apps/lapaloma-financial-demo",
|
||||||
"crates/apps/tahuantinsuyu",
|
"crates/apps/tahuantinsuyu",
|
||||||
|
"crates/apps/tahuantinsuyu-cli",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -44,6 +44,13 @@ const APP_TITLE: &str = "Tahuantinsuyu";
|
|||||||
fn main() {
|
fn main() {
|
||||||
// Sidecar brahman primero — si el Init está corriendo, nos presentamos.
|
// Sidecar brahman primero — si el Init está corriendo, nos presentamos.
|
||||||
tahuantinsuyu_card::spawn_sidecar();
|
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.
|
// DB en directorio de datos del usuario.
|
||||||
let db_path = resolve_db_path();
|
let db_path = resolve_db_path();
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ name = "tahuantinsuyu-card"
|
|||||||
version = { workspace = true }
|
version = { workspace = true }
|
||||||
edition = { workspace = true }
|
edition = { workspace = true }
|
||||||
license = { 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]
|
[dependencies]
|
||||||
brahman-card = { path = "../../../core/brahman-card" }
|
brahman-card = { path = "../../../core/brahman-card" }
|
||||||
brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
|
brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
|
||||||
|
tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
|
||||||
|
tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
||||||
ulid = { workspace = true }
|
ulid = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
postcard = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
directories = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(rust_2018_idioms)]
|
#![warn(rust_2018_idioms)]
|
||||||
|
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use brahman_card::{
|
use brahman_card::{
|
||||||
@@ -26,13 +28,17 @@ pub fn spawn_sidecar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Construye la Card. Expuesto público para tests + para shells que
|
/// 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 {
|
pub fn build_card() -> Card {
|
||||||
Card {
|
Card {
|
||||||
schema_version: CARD_SCHEMA_VERSION,
|
schema_version: CARD_SCHEMA_VERSION,
|
||||||
id: Ulid::new(),
|
id: Ulid::new(),
|
||||||
lineage: None,
|
lineage: None,
|
||||||
label: LABEL.into(),
|
label: LABEL.into(),
|
||||||
|
service_socket: Some(service::default_service_socket()),
|
||||||
provides: BTreeSet::new(),
|
provides: BTreeSet::new(),
|
||||||
requires: BTreeSet::new(),
|
requires: BTreeSet::new(),
|
||||||
payload: Payload::Virtual,
|
payload: Payload::Virtual,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user