feat: chasqui standalone — descubrimiento + transporte P2P soberano (DHT, relay, NAT traversal) (front-door, git-dep al monorepo)

Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por
git-dep del monorepo gioser.git. cargo check pasa (8 crates, 0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:17:59 +00:00
commit 5d5e4a3ae4
80 changed files with 22318 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
# ARQUITECTURA.md — chasqui
> Descripción técnico-arquitectónica densa, optimizada para consumo por IA.
> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con la prosa de los READMEs.
```yaml
DOMINIO: chasqui
CUADRANTE: 02_ruway (HACER)
NOMBRE: quechua "mensajero del camino del Inca"
ADVERTENCIA_SEMÁNTICA:
El README/LEEME aspira a "broker de mensajería + bus tipado pub/sub".
El CÓDIGO ACTUAL implementa otra cosa: NO hay transporte de mensajes app↔app en tiempo real.
La aspiración pub/sub migró al dominio Ayni (chat P2P). Ver project_ayni_chat.md.
TESIS_REAL: dominio DUAL — (A) matcher determinista de tipos entre módulos + (B) inteligencia de datos semántica
TAMAÑO: 11 crates, ~8.7 KLoC. Brahman ≈4.3K · Nouser ≈4.4K
```
## Los dos subsistemas (no confundir)
```
SUBSISTEMA A — BRAHMAN CARD BROKER (plano de TIPOS / control)
Registry + matcher determinista de FLUJOS tipados entre Cards (módulos con interfaz WIT opcional).
Responde "¿quién produce el flujo que este consumer necesita?". NO mueve datos: hace matching.
Crates: chasqui-broker · card-handshake · card-sidecar · card-admin · chasqui-broker-explorer-llimphi
SUBSISTEMA B — NOUSER MONADS + EMBEDDINGS (plano de DATOS / semántica)
Escanea directorios → agrupa archivos en MÓNADAS (clusters semánticos) → enriquece con embeddings
→ permite búsqueda/atracción por centroide. Persiste en sled. UI consulta.
Crates: chasqui-core · chasqui-card · chasqui-nous · chasqui-nous-mock · chasqui-nous-real · chasqui-explorer-llimphi
NEXO entre A y B:
Los proveedores de embeddings (nous-mock/real) se REGISTRAN como Cards en Brahman.
Un consumidor que pide "embed-request:json" es matcheado por el broker al proveedor correcto según CONTEXTO.
=> B usa A para descubrir QUÉ proveedor de embeddings está vivo y debe ganar.
```
## SUBSISTEMA A — Brahman Broker
### Tipos núcleo (`chasqui-broker/src/lib.rs`, 995 LOC, lib pura sin IO)
```rust
enum MatchStrategy { Exact, Structural, ExactThenStructural }
// Exact = TypeRef idéntico (interface+package+name)
// Structural = mismo package+name, ignora interface (o mismo Primitive)
// ExactThenStructural = prefiere exact, cae a structural
struct BrokeredCard {
session: SessionId, // ULID del handshake
label: String, lifecycle: Lifecycle, priority: Priority,
inputs: Vec<Flow>, outputs: Vec<Flow>,
wit: Option<WitInterface>, // matching fino si el módulo es "consciente"
priority_contexts: BTreeMap<String, ContextBias>, // sesgos por contexto ("test"/"prod")
kind: CardKind /*Ente|Data*/, data: Option<DataFacet>, service_socket: Option<PathBuf>,
}
struct Match { consumer: Endpoint, producer: Endpoint, ty: TypeRef, via: MatchStrategy, pinned: bool }
struct ContextBias { pin_to: Option<String>, priority_offset: i16 }
struct Broker { cards: BTreeMap<SessionId, BrokeredCard>, config: BrokerConfig }
```
### Algoritmo `find_producer_for(consumer, input_name)`
```
1. PIN: si el input (o priority_contexts[ctx].pin_to) fija un label → buscar productor con ese label (type-agnóstico).
2. TYPE-SEARCH: filtrar outputs de otras Cards por compatibilidad según strategy.
3. RANK: ordenar por effective_priority(producer) DESC, luego label ASC (tie-break determinista).
4. → Match { ... via, pinned }.
PROPIEDADES: determinista (mismas Cards + mismo contexto ⇒ mismo resultado); O(n·m); stateless en rutas.
```
### Handshake e infra (protocolo nativo Rust↔Rust, NO WIT/WASM)
```
card-handshake (2942 LOC): Hello{card} → HelloAck{session_id: ULID} sobre Unix socket.
Frames length-prefixed + postcard. Sesión viva con Ping(~30s)/Farewell. Deriva TrustLevel (agnóstico vs WIT-consciente).
card-sidecar (565): thread tokio current_thread que mantiene la sesión. API await_provider(card,timeout), list_matches().
card-admin (225): Unix socket SEPARADO; emite StatusSnapshot JSON (sesiones+matches). Single-shot/conexión. bin `brahman-status`.
```
### INVARIANTES (A)
```
A-INV-1 El broker NO rutea datos; sólo computa matches de tipo bajo demanda. El data-plane lo abre cada módulo (service_socket).
A-INV-2 Matching determinista y reproducible: orden total por (priority efectiva, label).
A-INV-3 pin_to gana sobre type-search; priority_contexts[ctx] override estático. Contexto activo = BRAHMAN_BROKER_CONTEXT.
A-INV-4 El broker vive en memoria del Init. NO hay snapshot/recover al reboot (deuda → ver ASPIRA).
```
## SUBSISTEMA B — Nouser
### Mónada (`chasqui-card/src/lib.rs`, 709 LOC)
```rust
struct FileEntry { id: FileId/*ULID*/, path, content_hash: Option<[u8;32]>/*blake3*/, size, mtime_ms, extension }
enum Lens { Grid, Code, Gallery, Database, Markdown, Tree } // cómo se visualiza el cluster
struct MonadManifest {
schema_version: u16/*=1*/, id: MonadId/*ULID, ordenable por tiempo*/, lineage: Option<MonadId>,
label, summary, centroid: Vec<f32>, centroid_model: Option<String>/*"chasqui-pseudo-32d"|"real-fastembed-384d"*/,
path_hint: Option<String>/*dir padre canónico = identidad estable*/, keywords: Vec<String>,
cardinality: u32, entropy: f32/*[0,1] cohesión de extensiones*/, dominant_lens: Lens,
pins: BTreeSet<FileId>/*anclados, no migran*/, members: BTreeSet<FileId>,
created_at_ms, updated_at_ms, extensions: BTreeMap<String, Value>/*forward-compat*/,
}
```
### Pipeline determinista (`chasqui-core`, 2215 LOC)
```
scanner.rs recorre directorios → Vec<FileEntry>
cluster.rs by_directory: agrupa por (dir padre + extensión dominante) → MonadManifest
db.rs MonadDb: store en memoria + sled opcional
embed.rs embed(file) → [f32;32] L2-normalizado, DETERMINISTA sin LLM:
dims 0..8 blake3(extension) · 8..16 blake3(parent) · 16..24 blake3(stem) · 24..28 size(log) · 28..32 mtime(cíclico)
EMBED_DIM=32, MODEL_ID="chasqui-pseudo-32d"
CLI: nouser scan|show|json|daemon|attract
```
### Contrato Nous (`chasqui-nous`, 196 LOC) — proveedor de embeddings intercambiable
```
WIRE: JSON line-delimited sobre Unix socket, single-shot/conexión.
EmbedRequest{ kind: EmbedFile|EmbedText|Ping, payload: Value }
EmbedResponse{ embedding: Vec<f32>, model: String, elapsed_ms: u64 }
SOCKET: $XDG_RUNTIME_DIR/chasqui-nous-{provider}.sock (override $NOUSER_NOUS_SOCKET)
mock (chasqui-nous-mock): chasqui_core::embed, 32d determinista; Card priority_contexts["test"]={+1} → gana en test
real (chasqui-nous-real): fastembed+ONNX all-MiniLM-L6-v2, 384d; feature `embeddings`; cache sled;
Card priority_contexts["prod"]={+1} → gana en prod
=> El broker Brahman elige mock vs real según BRAHMAN_BROKER_CONTEXT. Mismo flow embed-request:json / embed-result:json.
```
### INVARIANTES (B)
```
B-INV-1 embed() es puro y determinista (hash+metadata) ⇒ misma carpeta ⇒ mismas Mónadas y centroides en toda máquina.
B-INV-2 path_hint es la identidad estable de una Mónada (sobrevive a cambios de miembros).
B-INV-3 El proveedor de embeddings es opaco/intercambiable tras el contrato Nous; el core no sabe si es mock o LLM.
```
## UIs Llimphi
```
chasqui-broker-explorer-llimphi (599): probe de salud; poll 5s await_provider_blocking →
estado Down | UpNoProvider | UpWithProvider(label,socket) + timeline de MatchEvent (últimos 50).
chasqui-explorer-llimphi (501): explorador de Mónadas; descubre el daemon nouser vía broker,
consulta Mónadas, filtra por centroide (búsqueda semántica), muestra cardinality/entropy/lens.
```
## Relaciones inter-dominio
```
agora : identidad Ed25519 — autores de mensajes en el futuro chat (vía Ayni), no en chasqui hoy.
minga : transporte P2P libp2p (BrahmanNet) — chasqui aún NO lo usa; el broker es LOCAL/LAN (Unix socket).
akasha : VFS content-addressed en wawa — no usado por chasqui hoy.
rimay : chasqui-explorer-llimphi → rimay-localize para búsqueda semántica sobre centroides.
shuma : chasqui-core → shuma-discern (clasificación determinista) — integración superficial, pendiente profundizar.
arje : chasqui-nous-real podría persistir embeddings en CAS de wawa — experimental.
ayni : HEREDA la aspiración pub/sub de chasqui. Ayni-sync usa trait Transporte (EnlaceMinga), NO el broker chasqui.
```
## Estado (2026-05-31)
### Hecho
- Subsistema A (Brahman): matching tipado determinista (Exact/Structural/ExactThenStructural) + context biases + handshake Unix-socket (card-handshake/sidecar) + observabilidad admin (`brahman-status`) + UI probe (`chasqui-broker-explorer-llimphi`).
- Subsistema B (Nouser): scanner + clustering `by_directory` + `MonadDb` (sled) + pseudo-embeddings 32d deterministas + real 384d ONNX gated por feature + contrato Nous (mock/real intercambiables) + UI explorer semántico + CLI `nouser`.
- Nexo A↔B: los proveedores de embeddings se registran como Cards; el broker elige mock vs real por `BRAHMAN_BROKER_CONTEXT`.
- Las dos UIs portadas a Llimphi (GPUI extinto) + menú principal/contextual (lotes 1 y 5).
### Pendiente
- Persistencia del broker: hoy vive en memoria del Init, sin snapshot/recover al reboot.
- Transporte remoto: Brahman es Unix-socket local; el matching de módulos remotos (card-net/libp2p) ya NO está bloqueado por NAT — `card-net` cablea relay+dcutr+autonat (verificado por el test `jalar_a_traves_de_un_relay` de khipu). Lo que falta es CABLEAR el discovery: el daemon Nouser no llama `announce_outputs()` al DHT ni hay `find_remote_providers(flow_type)` en `card-sidecar`.
- real-nous en producción: la feature `embeddings` arrastra ~200 MB de ONNX runtime (trade-off tamaño↔capacidad).
- Integración a fondo cross-dominio (rimay/shuma/arje) con nouser, aún superficial.
- La aspiración pub/sub original migró a Ayni (chat P2P); chasqui hoy NO transporta mensajes app↔app en tiempo real.
## Estado vs aspiración
```
DEUDA / ASPIRA_A:
#1 PERSISTENCIA del broker: hoy vive en memoria del Init, sin snapshot/recover al reboot.
#2 TRANSPORTE remoto: Brahman es Unix-socket local; falta CABLEAR discovery por DHT
(announce_outputs en el daemon Nouser + find_remote_providers en card-sidecar).
#3 NAT traversal: HECHO en card-net (relay+dcutr+autonat), heredado por minga/agora/
chasqui/khipu; ya no bloquea el uso remoto P2P.
#4 real-nous en producción: feature `embeddings` arrastra ~200MB de ONNX runtime — trade-off size↔capacidad.
#5 COHERENCIA cross-dominio: rimay/shuma/arje aún no integrados a fondo con nouser.
NORTE_ARQUITECTÓNICO:
Dos planos que convergen. PLANO DE TIPOS (Brahman): "decláme qué consumís y te encuentro quién lo produce,
determinista y observable, sin acoplar módulos". PLANO DE DATOS (Nouser): "dame una carpeta y te devuelvo su
estructura semántica latente (Mónadas) con embeddings intercambiables".
El destino es que el broker deje de ser local (Unix socket) y, sobre transporte minga/libp2p, haga discovery
tipado de pares REMOTOS — habilitando que Ayni (chat P2P) y otros descubran proveedores por tipo a través de la red,
no por config. Chasqui hoy es una joya quieta: matching + búsqueda + discovery local, lista para crecer a P2P.
```
---
**Síntesis de una línea para otra IA:** chasqui es un dominio dual — (A) un **broker de tipos** Brahman que
hace matching determinista y observable de flujos entre módulos (Cards) sin mover datos, y (B) **Nouser**, un motor de
inteligencia de datos que agrupa archivos en Mónadas semánticas con embeddings intercambiables (mock 32d determinista /
real 384d ONNX) descubiertos a través de (A) — cuya aspiración pub/sub original migró a Ayni y cuyo norte es elevar el
discovery tipado local a discovery P2P remoto sobre transporte minga/libp2p.
+41
View File
@@ -0,0 +1,41 @@
# chasqui
> `chasqui` (quechua: *mensajero del camino del inca*). Broker de mensajería + bus tipado.
Sistema nervioso del monorepo. Apps publican y se suscriben a topics tipados; el broker rutea y persiste. Backend `nous` con dos implementaciones: `mock` (in-process para tests) y `real` (TCP + binary). Cada mensaje lleva su schema, fail-closed si el receptor no lo conoce.
## Instalación
```sh
# arrancar el broker
cargo run --release -p chasqui-broker
# explorer (ver topics + mensajes en vivo)
cargo run --release -p chasqui-broker-explorer-llimphi
cargo run --release -p chasqui-explorer-llimphi
```
## Compatibilidad
- **Linux / macOS / Windows** — broker + clientes en Rust nativo.
- **Wawa** — broker corre como app del kernel (`apps/`).
- TCP localhost por default; sockets Unix opcionales.
## Crates
| Crate | Rol |
|---|---|
| [`chasqui-core`](chasqui-core/README.md) | Tipos: Topic, Message, Schema, Subscription. |
| [`chasqui-broker`](chasqui-broker/README.md) | Binario del broker. |
| [`chasqui-nous`](chasqui-nous/README.md) | Trait del transport. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | Transport in-process para tests. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Transport TCP/Unix binario. |
| [`chasqui-card`](chasqui-card/README.md) | Card escritorio (estado del broker). |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | UI: topics + suscriptores activos. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | UI: log de mensajes en vivo. |
## Consideraciones
- **Schema-first.** Sin schema declarado, ningún mensaje pasa.
- **Persistencia opt-in** por topic; los topics efímeros viven sólo en memoria.
- **No es Kafka.** Diseñado para el monorepo, no para volumen de producción interplanetaria.
+38
View File
@@ -0,0 +1,38 @@
# chasqui
> `chasqui` (Quechua: *messenger of the Inca road*). Message broker + typed bus.
Nervous system of the monorepo. Apps publish and subscribe to typed topics; the broker routes and persists. `nous` backend with two impls: `mock` (in-process for tests) and `real` (binary TCP). Every message carries its schema, fail-closed if the receiver doesn't know it.
## Install
```sh
cargo run --release -p chasqui-broker
cargo run --release -p chasqui-broker-explorer-llimphi
cargo run --release -p chasqui-explorer-llimphi
```
## Compatibility
- **Linux / macOS / Windows** — broker + clients in native Rust.
- **Wawa** — broker runs as a kernel app.
- TCP localhost by default; Unix sockets optional.
## Crates
| Crate | Role |
|---|---|
| [`chasqui-core`](chasqui-core/README.md) | Topic, Message, Schema, Subscription. |
| [`chasqui-broker`](chasqui-broker/README.md) | Broker binary. |
| [`chasqui-nous`](chasqui-nous/README.md) | Transport trait. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | In-process transport. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Binary TCP/Unix transport. |
| [`chasqui-card`](chasqui-card/README.md) | Desktop card. |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | Topics + active subscribers UI. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | Live message log UI. |
## Considerations
- **Schema-first.** No schema declared, no message through.
- **Persistence opt-in** per topic; ephemeral topics live in memory only.
- **Not Kafka.** Designed for the monorepo, not interplanetary production volume.
+40
View File
@@ -0,0 +1,40 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# chasqui
> `chasqui` (runa-simi: *Inka ñanninpa mensahero*). Mensaje broker + tipo bus.
Monorepupa sapan nervio. Aplikacionkuna tipasqa topics-pi publish + subscribe ruwanku; brokerqa rutéo + waqaychaq. `nous` backend, iskay implementaciones: `mock` (in-process tests-paq) + `real` (binario TCP). Sapanka mensaje schema-yuq, mana yachaqtin chaski-fail.
## Churay
```sh
cargo run --release -p chasqui-broker
cargo run --release -p chasqui-broker-explorer-llimphi
cargo run --release -p chasqui-explorer-llimphi
```
## Tinkuy
- **Linux / macOS / Windows** — broker + cliente Rust naturalwan.
- **Wawa** — broker kernel apps hina.
- TCP localhost default; Unix sockets opcional.
## Crateskuna
| Crate | Ima ruwan |
|---|---|
| [`chasqui-core`](chasqui-core/README.md) | Topic, Mensaje, Schema, Subscripción. |
| [`chasqui-broker`](chasqui-broker/README.md) | Broker binario. |
| [`chasqui-nous`](chasqui-nous/README.md) | Transporte trait. |
| [`chasqui-nous-mock`](chasqui-nous-mock/README.md) | In-process transporte. |
| [`chasqui-nous-real`](chasqui-nous-real/README.md) | Binario TCP/Unix transporte. |
| [`chasqui-card`](chasqui-card/README.md) | Escritorio card. |
| [`chasqui-broker-explorer-llimphi`](chasqui-broker-explorer-llimphi/README.md) | Topics + subscriptores UI. |
| [`chasqui-explorer-llimphi`](chasqui-explorer-llimphi/README.md) | Kawsaq mensaje log UI. |
## Yuyaykunaq
- **Schema-ñawpaq.** Mana schema mana mensaje.
- **Waqaychay opt-in** topic-pi; ephemeris topics ukhupi kawsanku.
- **Mana Kafka.** Monorepupaq, mana planetapaq.
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "card-admin"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — admin API: snapshot del estado del broker (sesiones + matches) por Unix socket, format JSON."
[dependencies]
chasqui-broker = { path = "../chasqui-broker" }
card-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
ulid = { workspace = true }
anyhow = { workspace = true }
[[example]]
name = "brahman-status"
path = "examples/brahman-status.rs"
@@ -0,0 +1,98 @@
//! `brahman-status` — CLI para inspeccionar el estado del Init.
//!
//! Conecta al socket admin (default `$XDG_RUNTIME_DIR/brahman-admin.sock`,
//! override con `$BRAHMAN_ADMIN_SOCKET`), recibe el snapshot, y lo imprime.
use card_admin::{client, transport};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let path = transport::default_socket_path();
let snap = client::query(&path).await?;
println!(
"Init: server={} protocol={} attached={}",
snap.server_version, snap.protocol_version, snap.init_attached
);
if let Some(ctx) = &snap.current_context {
println!("Context: {}", ctx);
}
println!();
println!("Sessions ({}):", snap.sessions.len());
if snap.sessions.is_empty() {
println!(" (ninguna)");
} else {
for s in &snap.sessions {
let conscious_marker = if s.wit.is_some() { " 🧠" } else { "" };
let kind_marker = match s.kind {
card_core::CardKind::Ente => "ente",
card_core::CardKind::Data => "data",
};
println!(
" [{}] {} {}{} lifecycle={:?} priority={:?}",
kind_marker, s.session, s.label, conscious_marker, s.lifecycle, s.priority
);
if let Some(sock) = &s.service_socket {
println!(" socket: {}", sock.display());
}
for r in &s.references {
println!(
" ref {:?}{} ({})",
r.kind, r.target_label, r.target_id
);
}
if let Some(data) = &s.data {
if !data.summary.is_empty() {
println!(" summary: {}", data.summary);
}
if data.member_count > 0 {
println!(
" members: {} (dispersion={:.2})",
data.member_count, data.dispersion
);
}
if !data.keywords.is_empty() {
println!(" keywords: {}", data.keywords.join(", "));
}
if !data.presentation_hint.is_empty() {
println!(" lens hint: {}", data.presentation_hint);
}
}
if let Some(wit) = &s.wit {
println!(" wit: {} / {}", wit.package, wit.world);
if !wit.imports.is_empty() {
println!(" imports: {}", wit.imports.join(", "));
}
if !wit.exports.is_empty() {
println!(" exports: {}", wit.exports.join(", "));
}
}
for f in &s.inputs {
println!(" in {}: {:?}", f.name, f.ty);
}
for f in &s.outputs {
println!(" out {}: {:?}", f.name, f.ty);
}
}
}
println!();
println!("Matches ({}):", snap.matches.len());
if snap.matches.is_empty() {
println!(" (ninguno)");
} else {
for m in &snap.matches {
let pin_marker = if m.pinned { "📌" } else { " " };
println!(
" {} {}.{}{}.{} via {:?}",
pin_marker,
m.consumer_label,
m.consumer.flow_name,
m.producer_label,
m.producer.flow_name,
m.via
);
}
}
Ok(())
}
+48
View File
@@ -0,0 +1,48 @@
//! Cliente admin: lee un `StatusSnapshot` desde un socket admin.
use std::path::Path;
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::net::UnixStream;
use crate::snapshot::StatusSnapshot;
#[derive(Debug, Error)]
pub enum AdminError {
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
#[error("respuesta vacía")]
Empty,
#[error("JSON inválido: {0}")]
Json(#[from] serde_json::Error),
}
/// Conecta al socket admin, lee la línea JSON y deserializa.
pub async fn query(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
let stream = UnixStream::connect(path).await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Err(AdminError::Empty);
}
let snapshot = serde_json::from_str(&line)?;
Ok(snapshot)
}
/// Variante sync de [`query`] para callers que no tienen runtime tokio
/// (típicamente: GUIs con su propio executor, como GPUI).
pub fn query_blocking(path: impl AsRef<Path>) -> Result<StatusSnapshot, AdminError> {
use std::io::{BufRead, BufReader as StdBufReader};
use std::os::unix::net::UnixStream as StdUnixStream;
let stream = StdUnixStream::connect(path)?;
let mut reader = StdBufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Err(AdminError::Empty);
}
let snapshot = serde_json::from_str(&line)?;
Ok(snapshot)
}
+23
View File
@@ -0,0 +1,23 @@
//! `brahman-admin` — observabilidad del broker.
//!
//! Expone un Unix socket separado (no se mezcla con el handshake) en el
//! que cada conexión recibe un `StatusSnapshot` JSON y se cierra. Es
//! single-shot por conexión: pensado para herramientas como
//! `brahman-status`, dashboards y health-checks.
//!
//! Wire format: una línea JSON por conexión, terminada en `\n`. Esto
//! hace trivial inspeccionar con `nc` o `socat` además del cliente
//! tipado de este crate.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod client;
pub mod server;
pub mod snapshot;
pub mod transport;
pub use snapshot::StatusSnapshot;
/// Versión del crate de admin.
pub const ADMIN_VERSION: &str = env!("CARGO_PKG_VERSION");
+110
View File
@@ -0,0 +1,110 @@
//! Servidor admin: emite un `StatusSnapshot` JSON por conexión y cierra.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use chasqui_broker::Broker;
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::Mutex;
use tracing::warn;
use crate::snapshot::StatusSnapshot;
/// Configuración del servidor admin.
#[derive(Debug, Clone, Default)]
pub struct AdminConfig {
/// `true` si el Init está atado al servidor que aloja este admin.
pub init_attached: bool,
/// Contexto operativo del broker, espejado en el snapshot.
pub current_context: Option<String>,
}
/// Servidor admin escuchando en un Unix socket.
pub struct AdminServer {
listener: UnixListener,
socket_path: PathBuf,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
}
impl AdminServer {
/// Crea el listener. Si `path` existe, lo elimina (asume socket stale).
pub fn bind(
path: impl Into<PathBuf>,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
) -> std::io::Result<Self> {
let socket_path = path.into();
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let listener = UnixListener::bind(&socket_path)?;
Ok(Self {
listener,
socket_path,
broker,
config,
})
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Loop de aceptación: cada conexión recibe un snapshot y se cierra.
pub async fn run(self) -> std::io::Result<()> {
loop {
let (stream, _addr) = self.listener.accept().await?;
let broker = self.broker.clone();
let config = self.config.clone();
tokio::spawn(async move {
if let Err(e) = handle_conn(stream, broker, config).await {
warn!(error = %e, "admin conn falló");
}
});
}
}
}
impl Drop for AdminServer {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar admin socket");
}
}
}
}
async fn handle_conn(
mut stream: UnixStream,
broker: Arc<Mutex<Broker>>,
config: AdminConfig,
) -> std::io::Result<()> {
let snapshot = build_snapshot(&broker, &config).await;
let mut json = serde_json::to_string(&snapshot)?;
json.push('\n');
stream.write_all(json.as_bytes()).await?;
stream.shutdown().await?;
Ok(())
}
async fn build_snapshot(broker: &Arc<Mutex<Broker>>, config: &AdminConfig) -> StatusSnapshot {
let b = broker.lock().await;
let sessions: Vec<_> = b.cards().cloned().collect();
let matches = b.all_matches();
StatusSnapshot {
server_version: crate::ADMIN_VERSION.to_string(),
protocol_version: card_core::PROTOCOL_VERSION.to_string(),
init_attached: config.init_attached,
current_context: config.current_context.clone(),
sessions,
matches,
}
}
@@ -0,0 +1,24 @@
//! Tipos del snapshot que el admin server emite.
use chasqui_broker::{BrokeredCard, Match};
use serde::{Deserialize, Serialize};
/// Snapshot completo del estado del Init en un instante.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusSnapshot {
/// Versión del crate del Init que respondió.
pub server_version: String,
/// Versión del protocolo brahman.
pub protocol_version: String,
/// `true` si el Init está atado al servidor.
pub init_attached: bool,
/// Contexto operativo activo del broker (p. ej. `"test"`, `"prod"`).
/// `None` si no hay contexto configurado — los biases per-contexto
/// declarados en las Cards quedan inactivos.
#[serde(default)]
pub current_context: Option<String>,
/// Cards actualmente registradas (sesiones vivas).
pub sessions: Vec<BrokeredCard>,
/// Matches consumer↔producer derivados del set actual.
pub matches: Vec<Match>,
}
@@ -0,0 +1,20 @@
//! Convenciones de transporte para el socket admin.
use std::path::PathBuf;
/// Variable de entorno que sobreescribe la ruta del socket admin.
pub const SOCKET_ENV: &str = "BRAHMAN_ADMIN_SOCKET";
/// Nombre del socket admin dentro del runtime dir.
pub const SOCKET_NAME: &str = "brahman-admin.sock";
/// Ruta canónica al socket admin del Init.
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join(SOCKET_NAME)
}
@@ -0,0 +1,37 @@
[package]
name = "card-handshake"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — handshake runtime Init↔módulo. Local sobre Unix socket; remoto sobre stream libp2p (card-net)."
[dependencies]
card-core = { workspace = true }
chasqui-broker = { path = "../chasqui-broker" }
card-net = { workspace = true }
blake3 = { workspace = true }
futures = { workspace = true }
notify = { workspace = true }
serde = { workspace = true }
postcard = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
[[example]]
name = "probe"
path = "examples/probe.rs"
[[example]]
name = "subscriber"
path = "examples/subscriber.rs"
@@ -0,0 +1,51 @@
//! probe — herramienta de diagnóstico del handshake.
//!
//! Conecta a un Init brahman vivo, hace handshake, un ping, y se va.
//! Ruta del socket: `$BRAHMAN_INIT_SOCKET` o el default
//! ([`card_handshake::transport::default_socket_path`]).
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-handshake --example probe
//! ```
use std::collections::BTreeSet;
use card_core::{Card, Payload, Supervision, CARD_SCHEMA_VERSION};
use card_handshake::{client::Client, transport};
use ulid::Ulid;
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: "brahman-probe".into(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
..Default::default()
};
let path = transport::default_socket_path();
println!("connecting to {}", path.display());
let mut client = Client::connect(&path, card).await?;
let info = client.server_info();
println!(
" HelloAck: session={} server={} protocol={} init_attached={}",
client.session(),
info.server_version,
info.protocol_version,
info.init_attached
);
let ts = client.ping().await?;
println!(" Pong: ts={}ms", ts);
client.farewell().await?;
println!(" Farewell OK");
Ok(())
}
@@ -0,0 +1,83 @@
//! `subscriber` — cliente brahman que loguea cada `MatchEvent` recibido.
//!
//! Declara una Card con un input `in` de tipo `json`. Cada vez que el
//! broker matchea (o desmatch) ese input contra un productor, imprime
//! una línea. Útil para visualizar la dinámica del broker en vivo.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-handshake --example subscriber [label]
//! ```
use std::collections::BTreeSet;
use std::time::Duration;
use card_core::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use card_handshake::{client::Client, transport};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let label = std::env::args()
.nth(1)
.unwrap_or_else(|| "subscriber".into());
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![],
},
..Default::default()
};
let path = transport::default_socket_path();
eprintln!("[{label}] connecting to {}", path.display());
let mut client = Client::connect(&path, card).await?;
eprintln!(
"[{label}] attached: session={} init={}",
client.session(),
client.server_info().init_attached
);
// Loop: espera hasta 25s por un MatchEvent. Si timeout, ping para
// mantener la conexión viva.
loop {
match client.await_event(Duration::from_secs(25)).await? {
Some(ev) => {
eprintln!(
"[{label}] {:?} {}{}.{} via={:?}{}",
ev.kind,
ev.consumer_flow,
if ev.producer_label.is_empty() {
"<none>"
} else {
&ev.producer_label
},
ev.producer_flow,
ev.via,
if ev.pinned { " 📌" } else { "" }
);
}
None => {
let _ts = client.ping().await?;
}
}
}
}
@@ -0,0 +1,312 @@
//! Cliente de handshake. Conecta a un Unix socket y mantiene la sesión.
use std::collections::VecDeque;
use std::path::Path;
use std::time::Duration;
use card_core::{Card, WitInterface, CARD_SCHEMA_VERSION};
use card_net::Keypair;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::UnixStream;
use crate::codec::{read_frame, write_frame};
use crate::identity::SessionCert;
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
use crate::signature::{sign_hello, SignatureError};
/// Errores del cliente.
#[derive(Debug, Error)]
pub enum ClientError {
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
/// El servidor respondió con un error explícito.
#[error("servidor: {0}")]
Server(#[source] HandshakeError),
/// El servidor envió un frame que no esperábamos en este punto del protocolo.
#[error("frame inesperado: {got}")]
UnexpectedFrame { got: &'static str },
/// La Card que el cliente intentó enviar no pasa su propia validación.
#[error("card inválida pre-envío: {0}")]
InvalidCard(String),
/// Firma del Hello falló al construirse (rara — sólo puede pasar
/// si la keypair pasada está en un estado inválido).
#[error("firma del Hello falló: {0}")]
Signature(#[from] SignatureError),
}
/// Cliente conectado y autenticado. Tras `connect` ya completó el handshake
/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
/// request/response se buferean en `pending_events` y se obtienen vía
/// [`Client::take_event`] o [`Client::await_event`].
///
/// Genérico sobre el transport (`AsyncRead + AsyncWrite + Unpin + Send`):
/// funciona indistintamente sobre `UnixStream` (path local) o sobre un
/// stream libp2p wrapped con `tokio_util::compat` (path remoto, vía
/// `card_handshake::network`).
#[derive(Debug)]
pub struct Client<S = UnixStream> {
stream: S,
session: SessionId,
server_info: HelloAck,
pending_events: VecDeque<MatchEvent>,
}
impl Client<UnixStream> {
/// Conecta como módulo agnóstico (sin WIT) sobre Unix socket.
/// Equivalente a `connect_with(path, card, None)`.
pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> {
Self::connect_with(path, card, None).await
}
/// Conecta al socket Unix enviando Hello con la Card dada y
/// opcionalmente una `WitInterface` ya extraída. Si `wit` es `Some`,
/// el server registra el módulo como "consciente".
pub async fn connect_with(
path: impl AsRef<Path>,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path).await?;
Self::connect_with_stream(stream, card, wit).await
}
}
impl<S> Client<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
/// Constructor genérico sobre un stream ya abierto, **sin firma**.
/// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica)
/// o tests in-memory. Para libp2p remoto usá
/// [`connect_with_stream_signed`](Self::connect_with_stream_signed) —
/// el server libp2p rechaza Hello sin firma.
pub async fn connect_with_stream(
stream: S,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, None, None).await
}
/// Igual que `connect_with_stream` pero firma el Hello con
/// `keypair`. Usar para conexiones libp2p donde el server exige
/// firma. La public key derivada de `keypair` debe coincidir con
/// el `peer_id` libp2p autenticado por Noise — típicamente la
/// keypair pasada a [`card_net::BrahmanNet::with_keypair`].
pub async fn connect_with_stream_signed(
stream: S,
card: Card,
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(keypair), None).await
}
/// Igual que `connect_with_stream_signed` pero además adjunta un
/// `SessionCert` que vincula la session keypair a una identity
/// master estable. El server, al recibir el cert, evalúa la
/// política de admisión contra el `master_peer_id` (no contra
/// el session peer_id) — permitiendo rotar la session sin perder
/// la identidad reconocida en allowlists remotas.
pub async fn connect_with_stream_signed_with_cert(
stream: S,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
identity_cert: SessionCert,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await
}
async fn connect_inner(
mut stream: S,
card: Card,
wit: Option<WitInterface>,
keypair: Option<&Keypair>,
identity_cert: Option<SessionCert>,
) -> Result<Self, ClientError> {
card.validate()
.map_err(|e| ClientError::InvalidCard(e.to_string()))?;
let wire_card = card_core::WireCard::from(card);
let signature = match keypair {
Some(kp) => Some(sign_hello(kp, &wire_card, &wit)?),
None => None,
};
let hello = Hello {
schema_version: CARD_SCHEMA_VERSION,
protocol_version: card_core::PROTOCOL_VERSION.to_string(),
card: wire_card,
wit,
signature,
identity_cert,
};
write_frame(&mut stream, &Frame::Hello(hello)).await?;
let frame = read_frame(&mut stream).await?;
let ack = match frame {
Frame::HelloAck(a) => a,
Frame::Error(e) => return Err(ClientError::Server(e)),
Frame::Hello(_) => return Err(ClientError::UnexpectedFrame { got: "Hello" }),
Frame::Ping(_) => return Err(ClientError::UnexpectedFrame { got: "Ping" }),
Frame::Pong(_) => return Err(ClientError::UnexpectedFrame { got: "Pong" }),
Frame::Farewell(_) => return Err(ClientError::UnexpectedFrame { got: "Farewell" }),
Frame::MatchEvent(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchEvent (pre-handshake)",
});
}
Frame::ListSessions(_) => {
return Err(ClientError::UnexpectedFrame {
got: "ListSessions (pre-handshake)",
});
}
Frame::SessionList(_) => {
return Err(ClientError::UnexpectedFrame {
got: "SessionList (pre-handshake)",
});
}
Frame::ListMatches(_) => {
return Err(ClientError::UnexpectedFrame {
got: "ListMatches (pre-handshake)",
});
}
Frame::MatchList(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchList (pre-handshake)",
});
}
};
Ok(Self {
stream,
session: ack.session,
server_info: ack,
pending_events: VecDeque::new(),
})
}
/// `SessionId` asignado por el servidor.
pub fn session(&self) -> SessionId {
self.session
}
/// Información del servidor recibida en el handshake.
pub fn server_info(&self) -> &HelloAck {
&self.server_info
}
/// Envía un Ping y devuelve el timestamp del servidor. Los frames
/// `MatchEvent` que lleguen mezclados se buferean en `pending_events`.
pub async fn ping(&mut self) -> Result<u64, ClientError> {
write_frame(
&mut self.stream,
&Frame::Ping(Ping {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::Pong(p) => return Ok(p.timestamp_ms),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => return Err(ClientError::UnexpectedFrame { got: "non-pong" }),
}
}
}
/// Saca un evento pendiente del buffer, sin bloquear ni leer del wire.
pub fn take_event(&mut self) -> Option<MatchEvent> {
self.pending_events.pop_front()
}
/// Espera un `MatchEvent` con timeout. Drena primero el buffer; si
/// está vacío, lee del wire hasta el timeout. Otros frames recibidos
/// (Pong huérfano, Error) cortan la espera con error.
pub async fn await_event(
&mut self,
timeout: Duration,
) -> Result<Option<MatchEvent>, ClientError> {
if let Some(ev) = self.pending_events.pop_front() {
return Ok(Some(ev));
}
match tokio::time::timeout(timeout, read_frame(&mut self.stream)).await {
Err(_) => Ok(None),
Ok(Err(e)) => Err(ClientError::Io(e)),
Ok(Ok(Frame::MatchEvent(ev))) => Ok(Some(ev)),
Ok(Ok(Frame::Error(e))) => Err(ClientError::Server(e)),
Ok(Ok(_)) => Err(ClientError::UnexpectedFrame {
got: "non-event en await_event",
}),
}
}
/// Pide al servidor el listado de sesiones activas. Pensado para
/// observadores (broker-explorer, CLIs de diagnóstico). Como
/// `ping`, los `MatchEvent` que lleguen intercalados se bufean
/// en `pending_events` y no rompen la respuesta.
pub async fn list_sessions(&mut self) -> Result<crate::messages::SessionList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListSessions(crate::messages::ListSessions {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::SessionList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-session-list",
});
}
}
}
}
/// Pide al servidor el listado de matches actuales del broker
/// (consumer↔producer pares con tipo y estrategia). Mismo patrón
/// de drenado de `MatchEvent`s intermedios.
pub async fn list_matches(&mut self) -> Result<crate::messages::MatchList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListMatches(crate::messages::ListMatches {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::MatchList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-match-list",
});
}
}
}
}
/// Cierre cooperativo. Consume el cliente.
pub async fn farewell(mut self) -> Result<(), ClientError> {
write_frame(
&mut self.stream,
&Frame::Farewell(Farewell {
session: self.session,
}),
)
.await?;
Ok(())
}
}
@@ -0,0 +1,72 @@
//! Codec de wire: frames length-prefixed con cuerpo postcard.
//!
//! Cada frame en el stream tiene la forma:
//! ```text
//! [4 bytes LE: longitud N] [N bytes: postcard(Frame)]
//! ```
//!
//! El `MAX_FRAME_BYTES` evita que un cliente malicioso/buggy reserve memoria
//! arbitraria al anunciar un length absurdo.
use std::io::{Error, ErrorKind, Result};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::messages::Frame;
/// Tamaño máximo de un frame antes de que el reader rechace la conexión.
/// 4 MiB cubre cualquier Card razonable con margen amplio.
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
/// Escribe un frame al stream.
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, frame: &Frame) -> Result<()> {
let bytes = postcard::to_allocvec(frame)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard encode: {e}")))?;
if bytes.len() > MAX_FRAME_BYTES {
return Err(Error::new(
ErrorKind::InvalidData,
format!("frame demasiado grande: {} bytes", bytes.len()),
));
}
let len = bytes.len() as u32;
w.write_all(&len.to_le_bytes()).await?;
w.write_all(&bytes).await?;
w.flush().await?;
Ok(())
}
/// Lee un frame del stream.
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Frame> {
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf).await?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_BYTES {
return Err(Error::new(
ErrorKind::InvalidData,
format!("frame anunciado demasiado grande: {len} bytes"),
));
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
postcard::from_bytes(&buf)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard decode: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::{Frame, HandshakeError};
#[tokio::test]
async fn frame_roundtrip() {
let frame = Frame::Error(HandshakeError::Rejected("test".into()));
let mut buf = Vec::new();
write_frame(&mut buf, &frame).await.unwrap();
let mut cursor = std::io::Cursor::new(buf);
let decoded = read_frame(&mut cursor).await.unwrap();
match decoded {
Frame::Error(HandshakeError::Rejected(s)) => assert_eq!(s, "test"),
_ => panic!("variant mismatch"),
}
}
}
@@ -0,0 +1,358 @@
//! Identidad multi-key del nodo: separación entre **identity** (master,
//! persistente forever) y **session** (keypair libp2p efímera, rotable).
//!
//! ## Problema que resuelve
//!
//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
//! keypair (por compromiso, por higiene, por cambio de hardware)
//! cambiaba el peer_id, lo que invalidaba todas las allowlists
//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
//! coordinar.
//!
//! ## Modelo
//!
//! Cada nodo tiene **dos** keypairs Ed25519:
//!
//! - **Identity** (master): persistente para siempre. Identifica al
//! nodo como entidad lógica. Su `peer_id` es lo que va en
//! allowlists/denylists remotas.
//! - **Session** (operacional): la que libp2p usa para Noise. Puede
//! rotarse libremente sin coordinar — el nodo emite un
//! [`SessionCert`] firmado con la identity que prueba "esta session
//! key pertenece a mí".
//!
//! ## Wire
//!
//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
//! server valida:
//! 1. La session key del cert == public key de `Hello.signature` ==
//! deriva al peer_id autenticado por Noise (consistencia interna).
//! 2. La firma del cert verifica con la master pubkey declarada.
//! 3. El cert no está expirado.
//! 4. La política (allowlist/denylist) se evalúa contra
//! `master.to_peer_id()`, NO contra el session peer_id.
//!
//! Sin cert, el server cae al modelo de Fase 3: policy contra session
//! peer_id (compat). Esto permite migración gradual.
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use card_net::{Keypair, PeerId, PublicKey};
use serde::{Deserialize, Serialize};
/// TTL recomendado para un session cert: 24 horas. Suficiente para
/// que un nodo "viva" un día sin re-emitir; corto enough para que
/// un cert robado no sirva por mucho. Operadores con políticas
/// estrictas pueden bajarlo; con uptime largo, subirlo.
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Identidad lógica del nodo. Wraps la master keypair y emite certs
/// de session firmados.
///
/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
/// Sólo se usa para firmar certs locales y para derivar
/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
/// session keypair.
#[derive(Clone)]
pub struct Identity {
master: Arc<Keypair>,
}
impl Identity {
/// Construye una Identity a partir de una keypair existente.
/// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
pub fn from_keypair(master: Keypair) -> Self {
Self {
master: Arc::new(master),
}
}
/// Variante para callers que ya tienen la keypair en `Arc`.
pub fn from_arc(master: Arc<Keypair>) -> Self {
Self { master }
}
/// PeerId derivado de la master pubkey. Ésta es la identidad
/// "lógica" estable del nodo — lo que va en allowlists/denylists.
pub fn master_peer_id(&self) -> PeerId {
self.master.public().to_peer_id()
}
/// Emite un [`SessionCert`] firmado: certifica que la session
/// keypair `session` pertenece a esta identity hasta `now + ttl`.
pub fn issue_session_cert(
&self,
session: &Keypair,
ttl: Duration,
) -> Result<SessionCert, CertError> {
let now_ms = now_unix_ms();
let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
let session_pubkey = session.public().encode_protobuf();
let master_pubkey = self.master.public().encode_protobuf();
let payload = sign_payload(&session_pubkey, expires_at_ms);
let signature = self
.master
.sign(&payload)
.map_err(|e| CertError::Sign(e.to_string()))?;
Ok(SessionCert {
version: SESSION_CERT_VERSION,
session_pubkey,
master_pubkey,
expires_at_ms,
signature,
})
}
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("master_peer_id", &self.master_peer_id())
.finish()
}
}
/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
/// el shape de `SessionCert`.
pub const SESSION_CERT_VERSION: u8 = 1;
/// Certificado firmado por la identity que vincula una session key
/// libp2p a la identidad master del nodo, con expiración.
///
/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
/// format canónico libp2p (`encode_protobuf`) — mismo encoding que
/// `HelloSignature.public_key`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionCert {
/// Versión del esquema (ver `SESSION_CERT_VERSION`).
pub version: u8,
/// Public key de la session libp2p (la que firma el Hello), en
/// format libp2p protobuf.
pub session_pubkey: Vec<u8>,
/// Public key de la master identity, en format libp2p protobuf.
/// El verificador deriva el `master_peer_id` desde acá.
pub master_pubkey: Vec<u8>,
/// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
/// cert no es válido y el nodo debe re-emitirse uno nuevo
/// (rotando o re-firmando la misma session).
pub expires_at_ms: u64,
/// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
pub signature: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum CertError {
#[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
UnknownVersion(u8),
#[error("decode master_pubkey: {0}")]
DecodeMaster(String),
#[error("decode session_pubkey: {0}")]
DecodeSession(String),
#[error("firma del cert inválida")]
InvalidSignature,
#[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
Expired { expires: u64, now: u64 },
#[error("session_pubkey del cert no coincide con la del Hello.signature")]
SessionMismatch,
#[error("error al firmar: {0}")]
Sign(String),
}
impl SessionCert {
/// Verifica el cert: versión, firma criptográfica, no expiración.
/// Devuelve el `(master_peer_id, session_peer_id)` derivados.
///
/// El caller debe además chequear que `session_peer_id` coincide
/// con el peer_id autenticado por Noise (lo verifica
/// [`verify_against_session`]).
pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
if self.version != SESSION_CERT_VERSION {
return Err(CertError::UnknownVersion(self.version));
}
let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
.map_err(|e| CertError::DecodeMaster(e.to_string()))?;
let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
.map_err(|e| CertError::DecodeSession(e.to_string()))?;
let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
if !master_pk.verify(&payload, &self.signature) {
return Err(CertError::InvalidSignature);
}
let now = now_unix_ms();
if now >= self.expires_at_ms {
return Err(CertError::Expired {
expires: self.expires_at_ms,
now,
});
}
Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
}
/// Verifica el cert Y exige que su `session_pubkey` matchee a
/// `expected_session_pubkey` (la que firmó el Hello). Esto
/// previene que un atacante reutilice un cert válido con una
/// session key distinta.
///
/// Devuelve el `master_peer_id` derivado, que es el que el server
/// debe usar para evaluar la política de admisión.
pub fn verify_against_session(
&self,
expected_session_pubkey: &[u8],
) -> Result<PeerId, CertError> {
if self.session_pubkey.as_slice() != expected_session_pubkey {
return Err(CertError::SessionMismatch);
}
let (master_peer, _session_peer) = self.verify()?;
Ok(master_peer)
}
}
/// Concat canónico de los campos firmados. Cualquier cambio aquí
/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
buf.push(SESSION_CERT_VERSION);
buf.extend_from_slice(b"sess");
buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
buf.extend_from_slice(session_pubkey);
buf.extend_from_slice(&expires_at_ms.to_le_bytes());
buf
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_and_verify_cert() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let (master_peer, session_peer) = cert.verify().unwrap();
assert_eq!(master_peer, id.master_peer_id());
assert_eq!(session_peer, session.public().to_peer_id());
}
#[test]
fn verify_against_session_admits_matching() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let session_pk = session.public().encode_protobuf();
let master_peer = cert.verify_against_session(&session_pk).unwrap();
assert_eq!(master_peer, id.master_peer_id());
}
#[test]
fn verify_against_session_rejects_mismatch() {
let master = Keypair::generate_ed25519();
let session_a = Keypair::generate_ed25519();
let session_b = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
.unwrap();
let other_pk = session_b.public().encode_protobuf();
let err = cert.verify_against_session(&other_pk).unwrap_err();
assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
}
#[test]
fn cert_with_zero_ttl_is_expired() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, Duration::from_secs(0))
.unwrap();
// Pequeña espera para asegurar que now_ms > expires_at_ms.
std::thread::sleep(Duration::from_millis(5));
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
}
#[test]
fn tampered_signature_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
if let Some(b) = cert.signature.last_mut() {
*b ^= 0x01;
}
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn tampered_expires_at_rejected() {
// Si alguien extiende el expires_at sin re-firmar, la firma
// no cuadra → InvalidSignature.
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn unknown_version_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.version = 99;
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
}
#[test]
fn rotated_session_with_same_master_yields_same_master_peer_id() {
// La propiedad fundamental: rotar la session key NO cambia el
// master_peer_id derivado del cert.
let master = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let original_master_peer = id.master_peer_id();
let session1 = Keypair::generate_ed25519();
let cert1 = id
.issue_session_cert(&session1, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert1, _) = cert1.verify().unwrap();
// Rotar: nueva session keypair, mismo master.
let session2 = Keypair::generate_ed25519();
let cert2 = id
.issue_session_cert(&session2, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert2, _) = cert2.verify().unwrap();
assert_eq!(master_from_cert1, original_master_peer);
assert_eq!(master_from_cert2, original_master_peer);
assert_eq!(
master_from_cert1, master_from_cert2,
"rotar session NO debe cambiar el master_peer_id"
);
}
}
@@ -0,0 +1,33 @@
//! `brahman-handshake` — protocolo runtime Init↔módulo sobre Unix socket.
//!
//! Implementa la versión concreta de `shared_wit/protocol.wit` (handshake +
//! lifecycle): un servidor que vive en el Init (o un Admin proxy) y clientes
//! que son los módulos Brahman. Cada conexión arranca con un `Hello` que
//! lleva una [`card_core::Card`]; el servidor valida la Card, deriva el
//! [`TrustLevel`], emite un `HelloAck` con `session-id` ULID, y a partir de
//! ahí acepta `Ping`/`Farewell`.
//!
//! Wire format: frames length-prefixed (4 bytes LE) con cuerpo
//! [`postcard`]-codificado. Compacto, rápido y reversible.
//!
//! Esto NO es la implementación WIT/WASM (que generaría wit-bindgen). Es la
//! implementación nativa Rust↔Rust que cubre el caso común antes de que los
//! módulos WASM consuman el mismo contrato vía ABI generada.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod codec;
pub mod identity;
pub mod messages;
pub mod server;
pub mod client;
pub mod network;
pub mod peer_policy;
pub mod signature;
pub mod transport;
pub use card_core::PROTOCOL_VERSION;
/// Versión del crate de handshake (independiente de `PROTOCOL_VERSION`).
pub const HANDSHAKE_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -0,0 +1,234 @@
//! Mensajes del protocolo de handshake.
//!
//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
use std::path::PathBuf;
use chasqui_broker::MatchStrategy;
use card_core::{TypeRef, WireCard, WitInterface};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
/// Identificador de sesión emitido por el servidor en `HelloAck`.
pub type SessionId = Ulid;
/// Saludo inicial del módulo. Lleva la Card en forma `WireCard`
/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor
/// la convierte a `Card` para uso interno. Opcionalmente, una
/// `WitInterface` ya extraída — si está presente, el módulo es
/// "consciente" y el server lo registra como `ResolvedCard::from_conscious`.
///
/// **Firma (Fase 3, trust remoto)**: el campo `signature` es
/// obligatorio para conexiones libp2p (donde el server exige que la
/// public key derive al `peer_id` autenticado por Noise) y opcional
/// para Unix socket (donde SO_PEERCRED del kernel ya provee
/// autenticación). La firma cubre los bytes postcard de
/// `(WireCard, Option<WitInterface>)` — ver
/// [`HelloSignature::sign_payload`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// Versión del schema de Card que el cliente sigue.
pub schema_version: u16,
/// Versión del protocolo handshake del cliente.
pub protocol_version: String,
/// Tarjeta de Presentación, proyectada al wire.
pub card: WireCard,
/// Interfaz WIT extraída por el cliente (típicamente con
/// `brahman-card-wit`). `None` si el módulo es agnóstico.
#[serde(default)]
pub wit: Option<WitInterface>,
/// Firma Ed25519 sobre `(card, wit)`. Requerida para conexiones
/// remotas (libp2p); opcional para Unix socket. Ver módulo
/// [`super::signature`] para construcción y verificación.
#[serde(default)]
pub signature: Option<HelloSignature>,
/// Cert opcional que vincula la session keypair (la que firma el
/// Hello) a una **identity master** estable. Si está presente,
/// la política de admisión se evalúa contra el `master_peer_id`
/// derivado del cert — no contra el session peer_id. Esto permite
/// rotar la session sin invalidar las allowlists remotas.
///
/// Ver [`super::identity::SessionCert`] para shape y semantics.
/// Si es `None`, fallback al modelo de Fase 3: la política
/// evalúa el session peer_id directamente.
#[serde(default)]
pub identity_cert: Option<crate::identity::SessionCert>,
}
/// Firma de un Hello. La `public_key` viaja en el format canónico
/// libp2p (protobuf) — el verificador la decodifica y compara su
/// `peer_id` derivado con la identidad libp2p autenticada por Noise.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HelloSignature {
/// Public key del firmante, encoded como `libp2p::identity::PublicKey::encode_protobuf()`.
pub public_key: Vec<u8>,
/// Bytes de la firma Ed25519 sobre el payload canonical.
pub signature: Vec<u8>,
}
/// Respuesta del servidor a un `Hello` aceptado.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloAck {
/// Versión del crate del servidor.
pub server_version: String,
/// Versión del protocolo soportada por el servidor.
pub protocol_version: String,
/// Identificador de sesión asignado.
pub session: SessionId,
/// `true` si el Init está vinculado al servidor; `false` si el servidor
/// corre standalone (modo degradado).
pub init_attached: bool,
}
/// Latido del cliente. El servidor responde con [`Pong`] llevando su reloj.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ping {
pub session: SessionId,
}
/// Respuesta a un `Ping` con timestamp del servidor (ms desde UNIX_EPOCH).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pong {
pub timestamp_ms: u64,
}
/// Cierre cooperativo de la sesión por parte del cliente.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Farewell {
pub session: SessionId,
}
/// Errores del protocolo emitidos por el servidor.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum HandshakeError {
#[error("protocolo incompatible: {0}")]
ProtocolMismatch(String),
#[error("card inválida: {0}")]
InvalidCard(String),
#[error("schema de card incompatible: cliente={client}, servidor={server}")]
SchemaMismatch { client: u16, server: u16 },
#[error("sin autorización: {0}")]
Unauthorized(String),
#[error("capacidad requerida no satisfecha: {0}")]
CapabilityUnmet(String),
#[error("rechazado: {0}")]
Rejected(String),
#[error("error interno: {0}")]
Internal(String),
}
/// Notificación push del server al consumer: un match disponible o perdido.
///
/// El server emite `Available` cuando un productor empieza a satisfacer un
/// `flow.input` del consumer (ya sea porque el productor acaba de
/// registrarse, o porque cambió el match anterior). Emite `Lost` cuando
/// el productor previo dejó de satisfacer el input (desregistro o
/// cambio de match).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchEvent {
pub kind: MatchEventKind,
/// Nombre del input del consumer al que aplica el evento.
pub consumer_flow: String,
/// Sesión y label del productor (en `Lost` puede ser nil/vacío).
pub producer_session: SessionId,
pub producer_label: String,
pub producer_flow: String,
/// Tipo del flujo matcheado.
pub ty: TypeRef,
/// Estrategia que ganó (relevante en `Available`).
pub via: MatchStrategy,
/// `true` si fue resuelto por `pin_to`.
pub pinned: bool,
/// Socket de servicio (data plane) que declaró el productor.
/// Si está presente, el consumer puede conectar directo sin
/// pasar por discovery adicional. `None` si el productor no
/// declaró service_socket en su Card.
#[serde(default)]
pub producer_service_socket: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MatchEventKind {
Available,
Lost,
}
/// Pedido de listado de sesiones activas registradas en el broker. La
/// `session` es el id propio del que pregunta — el server lo valida
/// contra la sesión actual de la conexión, mismo patrón que `Ping`.
///
/// Pensado para herramientas de observabilidad (broker-explorer y
/// CLIs de diagnóstico). No expone secrets: sólo metadata pública
/// que el módulo ya anunció en su `Hello`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSessions {
pub session: SessionId,
}
/// Una entrada en la respuesta a `ListSessions`. Slim por diseño —
/// el observer arma la UI con esto sin tener que abrir conexiones
/// adicionales por sesión.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub session: SessionId,
/// Label declarado en `WireCard.label` — el "nombre humano" del
/// módulo.
pub label: String,
/// Versión del schema de Card que el módulo declaró.
pub schema_version: u16,
/// Nombres de los `flow.output` que la Card declara producir.
pub outputs: Vec<String>,
/// Nombres de los `flow.input` que la Card declara consumir.
pub inputs: Vec<String>,
/// `true` si el módulo se anunció como "consciente" (trajo
/// `WitInterface` extraída en el Hello).
pub conscious: bool,
}
/// Respuesta a `ListSessions`. El orden no está garantizado — los
/// clientes que necesiten estabilidad pueden ordenar por `session`
/// (Ulid es ordenable temporal).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionList {
pub entries: Vec<SessionEntry>,
}
/// Pedido del listado de matches actuales del broker. La `session`
/// se valida igual que `ListSessions`. Si el server no tiene broker
/// configurado, devuelve la lista vacía (no es un error — refleja
/// que no hay matching activo).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMatches {
pub session: SessionId,
}
/// Respuesta a `ListMatches` con el snapshot de matches consumidor↔productor
/// actualmente computados por el broker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchList {
pub matches: Vec<chasqui_broker::Match>,
}
/// Frame único de wire — discriminada por variante. Cada conexión es un
/// stream de frames.
///
/// Direcciones:
/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`,
/// `ListMatches`.
/// - Server → Cliente: `HelloAck`, `Pong`, `Error`, `MatchEvent`,
/// `SessionList`, `MatchList`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Frame {
Hello(Hello),
HelloAck(HelloAck),
Ping(Ping),
Pong(Pong),
Farewell(Farewell),
Error(HandshakeError),
MatchEvent(MatchEvent),
ListSessions(ListSessions),
SessionList(SessionList),
ListMatches(ListMatches),
MatchList(MatchList),
}
@@ -0,0 +1,289 @@
//! Backend libp2p del handshake brahman: el mismo protocolo (Hello /
//! HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard
//! length-prefixed) ahora también viaja sobre streams libp2p de la
//! malla `brahman-net`.
//!
//! El servidor expone el bucle [`run_libp2p_accept_loop`] que acepta
//! streams del protocolo `BRAHMAN_HANDSHAKE_PROTOCOL` y los delega al
//! mismo `Server` que ya escucha por Unix socket — la `Session` es
//! genérica sobre el transporte, así que ambas vías comparten broker,
//! tablas de sesiones, push de MatchEvents, todo.
//!
//! El cliente se conecta vía [`connect_libp2p`]: abre un stream
//! libp2p hacia un `PeerId` ya conocido y arranca el handshake como
//! cualquier `Client`.
//!
//! Identidad: cada nodo libp2p tiene su `PeerId` (ed25519 derivado).
//! La identidad del Ente (Card.id ULID + futura firma) viaja en el
//! Hello, como en el path Unix. Trust remoto (verificación de firma
//! antes de aceptar el Hello) es Fase 3.
//!
//! Ejemplo (servidor — Arje):
//! ```ignore
//! let server = Arc::new(Server::bind("/run/brahman-init.sock", config)?);
//! let net = Arc::new(BrahmanNet::new()?);
//! net.listen("/ip4/0.0.0.0/tcp/4101".parse()?).await;
//!
//! tokio::spawn(card_handshake::network::run_libp2p_accept_loop(
//! server.clone(),
//! net.clone(),
//! ));
//! // Server::run sigue escuchando Unix en paralelo.
//! ```
//!
//! Ejemplo (cliente — sidecar de un Ente remoto):
//! ```ignore
//! let net = BrahmanNet::new()?;
//! net.dial(remote_multiaddr);
//! let mut client = card_handshake::network::connect_libp2p(
//! &net, peer_id, my_card, None,
//! ).await?;
//! client.ping().await?;
//! ```
use std::sync::Arc;
use card_core::{Card, TypeRef, WitInterface};
use card_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol};
use crate::identity::SessionCert;
use futures::StreamExt;
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
use tracing::{debug, warn};
use crate::client::{Client, ClientError};
use crate::server::Server;
/// Sub-protocolo del handshake brahman sobre streams libp2p.
pub const BRAHMAN_HANDSHAKE_PROTOCOL: StreamProtocol =
StreamProtocol::new("/brahman/handshake/1.0.0");
/// Tipo del stream que ve la lógica del handshake una vez convertido
/// del mundo `futures::AsyncRead/Write` (libp2p) al mundo
/// `tokio::io::AsyncRead/Write` (resto del crate).
pub type LibP2pHandshakeStream = Compat<Stream>;
/// Errores específicos del backend libp2p.
#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
#[error("abrir stream libp2p: {0}")]
OpenStream(#[from] OpenStreamError),
#[error("handshake: {0}")]
Handshake(#[from] ClientError),
#[error("aceptar stream libp2p: {0}")]
AcceptStream(String),
}
/// Loop de aceptación de streams libp2p del protocolo handshake.
/// Cada stream entrante se construye como `Session` reutilizando las
/// tablas compartidas del `Server`, así que conviven con sesiones
/// Unix indistinguibles.
///
/// Vive hasta que el control libp2p se cierre o el caller drop el
/// future. Errores por sesión se loggean (no tumban el loop).
pub async fn run_libp2p_accept_loop(
server: Arc<Server>,
net: Arc<BrahmanNet>,
) -> Result<(), NetworkError> {
let mut control = net.control.clone();
let mut incoming = control
.accept(BRAHMAN_HANDSHAKE_PROTOCOL)
.map_err(|e| NetworkError::AcceptStream(e.to_string()))?;
while let Some((peer, stream)) = incoming.next().await {
let server = server.clone();
// .compat() debe pasar al spawn ADENTRO; si lo hacemos afuera
// y capturamos `Compat<Stream>` en la closure, el future
// resultante hereda traits que dyn AsyncReadWrite no satisface
// (compatibility con thread-safety de tokio::spawn).
tokio::spawn(handle_libp2p_session(server, stream, peer));
}
Ok(())
}
async fn handle_libp2p_session(
server: Arc<Server>,
stream: Stream,
peer: PeerId,
) {
// session_from_libp2p_stream propaga el peer_id al `do_handshake`,
// que exige firma del Hello cuya public key derive a este peer.
let session = server.session_from_libp2p_stream(stream.compat(), peer);
if let Err(e) = session.handle().await {
warn!(
target: "card_handshake::network",
peer = %peer,
error = %e,
"sesión libp2p terminó con error"
);
}
}
/// Conecta como cliente a un Ente remoto vía libp2p y completa el
/// handshake **firmado** con `keypair`. Requiere que `net` ya tenga
/// conexión (o pueda dial-ar) al `peer`; típicamente el caller hace
/// `net.dial(multiaddr)` antes.
///
/// La `keypair` debe ser la misma que la del nodo libp2p (la que
/// pasaste a [`card_net::BrahmanNet::with_keypair`]). Si no coincide
/// con el `peer_id` autenticado por Noise, el server rechaza el Hello
/// con `Unauthorized`.
///
/// Devuelve un `Client` típico — los métodos `ping`, `await_event`,
/// `farewell` funcionan idéntico al path Unix. El stream subyacente
/// es libp2p convertido vía `tokio_util::compat`.
pub async fn connect_libp2p(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed(stream.compat(), card, wit, keypair).await?;
Ok(client)
}
/// Igual que `connect_libp2p` pero adjunta un `SessionCert` al Hello.
/// El server, al verificar el cert, evalúa la política de admisión
/// contra el `master_peer_id` derivado — no contra el `peer_id`
/// libp2p. Esto permite **rotar** la session keypair sin perder
/// reconocimiento en allowlists remotas.
///
/// El `keypair` debe ser la session libp2p (la que firma la conexión
/// Noise); el `cert` debe haber sido emitido por una identity master
/// para esa misma session pubkey (ver
/// [`crate::identity::Identity::issue_session_cert`]).
pub async fn connect_libp2p_with_cert(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
cert: SessionCert,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed_with_cert(
stream.compat(),
card,
wit,
session_keypair,
cert,
)
.await?;
Ok(client)
}
// =====================================================================
// Discovery remoto via DHT — Fase 2
// =====================================================================
//
// Cuando un Ente registra una Card con outputs en el Init local, el
// Init anuncia al DHT (`net.start_providing(key)`) bajo una key
// derivada de `(flow_name, TypeRef)`. Cualquier nodo conectado al
// mismo DHT puede consultar `find_remote_providers(flow_name, type)`
// y obtener la lista de `PeerId`s que dijeron proveer ese flow.
//
// La key es **estable y libre de colisiones** entre versiones del
// monorepo: usa blake3 sobre un canon textual `brahman-flow|{name}|{type_canon}`.
// Cambiar la canonicalización rompe el discovery cross-version, así
// que cualquier modificación requiere bump de versión documentado.
/// Prefijo de namespace para todas las keys DHT del subprotocolo
/// brahman. Discrimina contra otros usos del mismo DHT (sync minga,
/// futuros) — protege contra colisiones accidentales.
const FLOW_KEY_PREFIX: &str = "brahman-flow|v1|";
/// Canonicaliza un `TypeRef` a string estable. Cambios aquí rompen
/// la compatibilidad de discovery cross-version; bump documentado
/// en `FLOW_KEY_PREFIX` al modificar.
fn canonicalize_type(t: &TypeRef) -> String {
match t {
TypeRef::Primitive { name } => format!("prim:{}", name),
TypeRef::Wit {
package,
interface,
name,
} => format!(
"wit:{}#{}#{}",
package,
interface.as_deref().unwrap_or(""),
name
),
}
}
/// Deriva la key del DHT para un `(flow_name, type_ref)` específico.
/// blake3-32B determinístico — la misma tupla en cualquier máquina
/// produce la misma key.
pub fn flow_dht_key(flow_name: &str, type_ref: &TypeRef) -> [u8; 32] {
let canon = format!(
"{}{}|{}",
FLOW_KEY_PREFIX,
flow_name,
canonicalize_type(type_ref)
);
*blake3::hash(canon.as_bytes()).as_bytes()
}
/// Anuncia al DHT que este nodo provee cada output flow declarado
/// en `card`. Llamarlo tras `register_session` propaga la
/// disponibilidad a todos los peers que comparten DHT con éste.
///
/// Idempotente: re-anunciar la misma key actualiza el TTL del record
/// en el DHT. Best-effort: si `start_providing` falla por falta de
/// peers cercanos (DHT vacío), el record vive en el store local
/// hasta que llegue una conexión.
pub fn announce_outputs(net: &BrahmanNet, card: &Card) {
for flow in &card.flow.output {
let key = flow_dht_key(&flow.name, &flow.ty);
debug!(
target: "card_handshake::network",
flow = %flow.name,
"announce_output → DHT"
);
net.start_providing(&key);
}
}
/// Retira los anuncios DHT previos de [`announce_outputs`] para esta
/// `card`. Llamado desde `cleanup` cuando una sesión cierra (Farewell,
/// EOF, error). El record local se borra al instante; copias
/// replicadas en peers remotos expiran por TTL natural de kad.
pub fn withdraw_outputs(net: &BrahmanNet, card: &Card) {
for flow in &card.flow.output {
let key = flow_dht_key(&flow.name, &flow.ty);
debug!(
target: "card_handshake::network",
flow = %flow.name,
"withdraw_output → DHT (stop_providing)"
);
net.stop_providing(&key);
}
}
/// Consulta el DHT por peers que han anunciado proveer el flow
/// `(flow_name, type_ref)`. Devuelve la lista resuelta de `PeerId`s.
/// Lista vacía si nadie anuncia, si la query timeout-ea, o si el
/// DHT no ha encontrado providers.
///
/// Para cada `PeerId` devuelto, el caller puede luego dial-ar al
/// peer (a sus addrs conocidas vía Identify) y abrir un sub-handshake
/// remoto con [`connect_libp2p`].
pub async fn find_remote_providers(
net: &BrahmanNet,
flow_name: &str,
type_ref: &TypeRef,
) -> Vec<PeerId> {
let key = flow_dht_key(flow_name, type_ref);
net.find_providers(&key).await
}
@@ -0,0 +1,582 @@
//! Política de admisión de peers libp2p: allowlist + denylist con hot
//! reload opcional.
//!
//! Capa de política sobre el trust criptográfico de Fase 3. Combina:
//!
//! - **Denylist**: peers explícitamente baneados. Si está, deny gana.
//! - **Allowlist**: si está set, sólo los peers listados pasan.
//! Si no está set, modo abierto (todo peer Ed25519-válido pasa,
//! sujeto sólo a denylist).
//!
//! Sin denylist y sin allowlist → modo totalmente abierto (compat
//! con todo lo anterior). Con allowlist y denylist a la vez, el
//! orden de evaluación es: deny first → allow check → admit.
//!
//! Aplica únicamente al path libp2p — el path Unix usa SO_PEERCRED
//! del kernel para autenticación local, no PeerId.
//!
//! ## Hot reload
//!
//! Si la política se construyó con [`PeerPolicy::watch_files`], un
//! thread dedicado vigila los archivos de allow/deny vía `notify`.
//! Cualquier cambio (write, create, modify, remove) dispara una
//! recarga atómica con debounce de 250ms (los editores típicos
//! producen varios eventos por save).
//!
//! Errores de reload (parse fallido, archivo eliminado) se loggean
//! pero NO bajan la política existente — aceptamos la versión
//! anterior hasta que el archivo vuelva a parsearse limpio. Esto
//! evita que un error de tipeo deje al Init en modo inseguro.
//!
//! ## Formato del archivo
//!
//! Idéntico para allow y deny: PeerId base58 por línea, `#` para
//! comentarios (línea entera o inline), líneas vacías ignoradas.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use card_net::{BrahmanNet, PeerId};
use tracing::{debug, info, warn};
/// Política de admisión combinada (allow + deny). Clone barato (todos
/// los campos son Arc o referencias inmutables).
#[derive(Clone)]
pub struct PeerPolicy {
inner: Arc<RwLock<PolicyInner>>,
paths: Arc<PolicyPaths>,
/// `BrahmanNet` opcional asociado vía [`Self::attach_to_net`].
/// Si está set, cada cambio en la denylist se sincroniza con el
/// `block_list` behaviour del swarm — los peers baneados son
/// rechazados ANTES del Noise handshake. `RwLock<Option<...>>`
/// para que `attach_to_net` se pueda llamar después del
/// constructor (típico en ente-zero: primero arma la policy,
/// después el net, después attach).
net: Arc<RwLock<Option<Arc<BrahmanNet>>>>,
}
#[derive(Default)]
struct PolicyInner {
/// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto.
allow: Option<BTreeSet<PeerId>>,
/// Peers baneados. Vacío = sin denylist.
deny: BTreeSet<PeerId>,
}
#[derive(Default)]
struct PolicyPaths {
allow_path: Option<PathBuf>,
deny_path: Option<PathBuf>,
}
/// Decisión del gate de política para un peer dado.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
/// El peer es admitido (no está en deny y, si hay allow, está en allow).
Admit,
/// El peer está explícitamente en la denylist.
DeniedByDenylist,
/// Hay allowlist configurada y el peer no está en ella.
NotInAllowlist,
}
impl Decision {
pub fn is_admitted(self) -> bool {
matches!(self, Decision::Admit)
}
pub fn reason(self) -> &'static str {
match self {
Decision::Admit => "admit",
Decision::DeniedByDenylist => "explicitly denied",
Decision::NotInAllowlist => "not in allowlist",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyError {
#[error("leer política en {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("línea {line_no} de {path}: PeerId inválido '{value}'")]
InvalidPeerId {
path: PathBuf,
line_no: usize,
value: String,
},
}
impl PeerPolicy {
/// Política totalmente abierta: todo peer pasa. Útil como default
/// cuando no hay archivos configurados.
pub fn open() -> Self {
Self {
inner: Arc::new(RwLock::new(PolicyInner::default())),
paths: Arc::new(PolicyPaths::default()),
net: Arc::new(RwLock::new(None)),
}
}
/// Construye una política inline con sets explícitos. Sin paths
/// asociados, así que `reload` y `watch_files` son no-ops.
pub fn from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>) -> Self {
Self {
inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
paths: Arc::new(PolicyPaths::default()),
net: Arc::new(RwLock::new(None)),
}
}
/// Carga política desde archivos. Cada path es opcional: `None`
/// significa "esa lista no aplica" (allow=None ⇒ modo abierto;
/// deny=None ⇒ sin baneados). Asocia los paths internamente para
/// que `reload` y `watch_files` los re-lean.
pub fn from_files(
allow_path: Option<&Path>,
deny_path: Option<&Path>,
) -> Result<Self, PolicyError> {
let allow = match allow_path {
Some(p) => Some(parse_peer_set(p)?),
None => None,
};
let deny = match deny_path {
Some(p) => parse_peer_set(p)?,
None => BTreeSet::new(),
};
Ok(Self {
inner: Arc::new(RwLock::new(PolicyInner { allow, deny })),
paths: Arc::new(PolicyPaths {
allow_path: allow_path.map(Path::to_path_buf),
deny_path: deny_path.map(Path::to_path_buf),
}),
net: Arc::new(RwLock::new(None)),
})
}
/// Evalúa si `peer` puede registrarse. Toma read lock — barato,
/// concurrente, sin awaits.
pub fn evaluate(&self, peer: &PeerId) -> Decision {
let inner = match self.inner.read() {
Ok(g) => g,
Err(_) => {
// Lock envenenado: degrada a "deny por seguridad".
warn!("policy lock envenenado — deny por defecto");
return Decision::DeniedByDenylist;
}
};
if inner.deny.contains(peer) {
return Decision::DeniedByDenylist;
}
if let Some(allow) = &inner.allow {
if !allow.contains(peer) {
return Decision::NotInAllowlist;
}
}
Decision::Admit
}
/// Tamaño actual de cada lista, para logging. Tupla `(allow_count,
/// deny_count)`. `allow_count = None` significa "modo abierto"
/// (sin allowlist).
pub fn sizes(&self) -> (Option<usize>, usize) {
match self.inner.read() {
Ok(g) => (g.allow.as_ref().map(|s| s.len()), g.deny.len()),
Err(_) => (Some(0), 0),
}
}
/// Recarga atómica desde los paths asociados. Si un archivo
/// falla, la versión anterior persiste y el error se devuelve.
/// Esto evita que un typo en el archivo deje al Init en modo
/// inseguro.
///
/// Si hay un `BrahmanNet` attached vía [`Self::attach_to_net`],
/// el cambio de denylist se sincroniza con el `block_list` del
/// swarm: se calcula el diff (added/removed) y se aplican
/// `block_peer`/`unblock_peer` por cada cambio.
pub fn reload(&self) -> Result<(), PolicyError> {
let new_allow = match &self.paths.allow_path {
Some(p) => Some(parse_peer_set(p)?),
None => None,
};
let new_deny = match &self.paths.deny_path {
Some(p) => parse_peer_set(p)?,
None => BTreeSet::new(),
};
// Snapshot de la deny actual ANTES de mutar, para diff.
let prev_deny = self
.inner
.read()
.map(|g| g.deny.clone())
.unwrap_or_default();
if let Ok(mut inner) = self.inner.write() {
inner.allow = new_allow;
inner.deny = new_deny.clone();
}
self.sync_deny_to_swarm(&prev_deny, &new_deny);
Ok(())
}
/// Asocia esta política a un `BrahmanNet`. Sincroniza el snapshot
/// actual de la denylist con el `block_list` behaviour del swarm
/// (cada peer baneado se rechaza ANTES del Noise handshake), y
/// registra el net para re-sincronizarse en cada [`Self::reload`].
///
/// Si ya había un net attached, se reemplaza (caso esperado:
/// un Init no debería tener dos `BrahmanNet`s).
pub fn attach_to_net(&self, net: Arc<BrahmanNet>) {
// Sync inicial: bloquear todos los peers actualmente en deny.
if let Ok(inner) = self.inner.read() {
for peer in &inner.deny {
net.block_peer(*peer);
}
}
if let Ok(mut slot) = self.net.write() {
*slot = Some(net);
}
}
/// Calcula el diff entre `prev` y `new` y aplica
/// `block_peer`/`unblock_peer` al net asociado (si hay).
/// No-op si no hay net attached.
fn sync_deny_to_swarm(&self, prev: &BTreeSet<PeerId>, new: &BTreeSet<PeerId>) {
let net = match self.net.read() {
Ok(g) => match g.as_ref() {
Some(n) => n.clone(),
None => return,
},
Err(_) => return,
};
for added in new.difference(prev) {
net.block_peer(*added);
}
for removed in prev.difference(new) {
net.unblock_peer(*removed);
}
}
/// Arranca un thread que vigila los archivos asociados con
/// `notify` y llama [`Self::reload`] cuando cambian. Debounce
/// 250ms para coalescer múltiples eventos por save (los editores
/// hacen Create+Modify+más).
///
/// Devuelve un `JoinHandle` que el caller debe mantener vivo.
/// Drop del handle no detiene el thread (notify watcher es
/// sticky); para detener, terminar el proceso.
///
/// No-op si no hay paths asociados (devuelve un handle dummy
/// que termina inmediatamente).
pub fn spawn_watcher(&self) -> std::io::Result<std::thread::JoinHandle<()>> {
let allow_path = self.paths.allow_path.clone();
let deny_path = self.paths.deny_path.clone();
let policy = self.clone();
if allow_path.is_none() && deny_path.is_none() {
// Sin archivos a vigilar: spawn un thread que termina ya.
return std::thread::Builder::new()
.name("brahman-policy-watcher-noop".into())
.spawn(|| {});
}
std::thread::Builder::new()
.name("brahman-policy-watcher".into())
.spawn(move || {
run_watcher(policy, allow_path, deny_path);
})
}
}
impl std::fmt::Debug for PeerPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (allow, deny) = self.sizes();
f.debug_struct("PeerPolicy")
.field("allow", &allow)
.field("deny", &deny)
.field("allow_path", &self.paths.allow_path)
.field("deny_path", &self.paths.deny_path)
.finish()
}
}
fn parse_peer_set(path: &Path) -> Result<BTreeSet<PeerId>, PolicyError> {
let contents = std::fs::read_to_string(path).map_err(|e| PolicyError::Io {
path: path.to_path_buf(),
source: e,
})?;
let mut out = BTreeSet::new();
for (idx, raw) in contents.lines().enumerate() {
let line_no = idx + 1;
let trimmed = raw.split('#').next().unwrap_or("").trim();
if trimmed.is_empty() {
continue;
}
let peer = trimmed
.parse::<PeerId>()
.map_err(|_| PolicyError::InvalidPeerId {
path: path.to_path_buf(),
line_no,
value: trimmed.to_string(),
})?;
out.insert(peer);
}
Ok(out)
}
const DEBOUNCE_MS: u64 = 250;
fn run_watcher(
policy: PeerPolicy,
allow_path: Option<PathBuf>,
deny_path: Option<PathBuf>,
) {
use notify::{RecursiveMode, Watcher};
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
let mut watcher = match notify::recommended_watcher(move |res| {
let _ = tx.send(res);
}) {
Ok(w) => w,
Err(e) => {
warn!(?e, "notify watcher para policy no se pudo crear — hot reload deshabilitado");
return;
}
};
// Vigilamos los DIRECTORIOS de los archivos, no los archivos
// directos. Los editores típicos hacen rename-and-replace (escriben
// a tmp, rename al destino), lo que rompe el watch del archivo
// pero NO el del directorio. Trade-off: recibimos más eventos
// (cualquier archivo del dir), filtramos por path al procesar.
for p in [&allow_path, &deny_path].iter().filter_map(|x| x.as_ref()) {
if let Some(parent) = p.parent() {
if let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive) {
warn!(path = %parent.display(), ?e, "watch failed");
}
}
}
let debounce = Duration::from_millis(DEBOUNCE_MS);
let mut pending_at: Option<Instant> = None;
loop {
let timeout = match pending_at {
Some(at) => debounce.saturating_sub(at.elapsed()).max(Duration::from_millis(10)),
None => Duration::from_secs(60), // wakeup periódico, no esencial
};
match rx.recv_timeout(timeout) {
Ok(Ok(event)) => {
// Sólo nos interesan eventos sobre los paths exactos.
let touches_us = event.paths.iter().any(|p| {
Some(p) == allow_path.as_ref() || Some(p) == deny_path.as_ref()
});
if !touches_us {
continue;
}
debug!(?event.kind, "policy file event recibido — debounce");
pending_at = Some(Instant::now());
}
Ok(Err(e)) => {
warn!(?e, "notify error en policy watcher");
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if let Some(at) = pending_at {
if at.elapsed() >= debounce {
match policy.reload() {
Ok(()) => {
let (a, d) = policy.sizes();
info!(
allow = ?a,
deny = d,
"policy hot-reload completo"
);
}
Err(e) => {
warn!(?e, "policy hot-reload falló — manteniendo versión anterior");
}
}
pending_at = None;
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
warn!("policy watcher channel cerrado — terminando thread");
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use card_net::Keypair;
use tempfile::TempDir;
fn fresh_peer() -> PeerId {
Keypair::generate_ed25519().public().to_peer_id()
}
#[test]
fn open_admits_anyone() {
let p = PeerPolicy::open();
assert_eq!(p.evaluate(&fresh_peer()), Decision::Admit);
}
#[test]
fn allow_only_admits_listed() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let policy = PeerPolicy::from_sets(
Some([p1].into_iter().collect()),
BTreeSet::new(),
);
assert_eq!(policy.evaluate(&p1), Decision::Admit);
assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
}
#[test]
fn deny_overrides_open() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let policy = PeerPolicy::from_sets(None, [p1].into_iter().collect());
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
assert_eq!(policy.evaluate(&p2), Decision::Admit);
}
#[test]
fn deny_overrides_allow() {
// Conflicto explícito: p1 está en ambas. Deny gana.
let p1 = fresh_peer();
let policy = PeerPolicy::from_sets(
Some([p1].into_iter().collect()),
[p1].into_iter().collect(),
);
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
}
#[test]
fn from_files_with_both_lists() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let p3 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
let deny = tmp.path().join("deny.txt");
std::fs::write(&allow, format!("{}\n{}\n", p1, p2)).unwrap();
std::fs::write(&deny, format!("# baneado\n{}\n", p2)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), Some(&deny)).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::Admit);
assert_eq!(policy.evaluate(&p2), Decision::DeniedByDenylist); // deny gana
assert_eq!(policy.evaluate(&p3), Decision::NotInAllowlist);
}
#[test]
fn from_files_only_deny() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let tmp = TempDir::new().unwrap();
let deny = tmp.path().join("deny.txt");
std::fs::write(&deny, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(None, Some(&deny)).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist);
assert_eq!(policy.evaluate(&p2), Decision::Admit);
}
#[test]
fn reload_picks_up_changes() {
let p1 = fresh_peer();
let p2 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::Admit);
assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist);
// Mutar el archivo: ahora p2 está, p1 no.
std::fs::write(&allow, format!("{}\n", p2)).unwrap();
policy.reload().unwrap();
assert_eq!(policy.evaluate(&p1), Decision::NotInAllowlist);
assert_eq!(policy.evaluate(&p2), Decision::Admit);
}
#[test]
fn reload_failure_preserves_previous_state() {
let p1 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
assert_eq!(policy.evaluate(&p1), Decision::Admit);
// Romper el archivo con basura.
std::fs::write(&allow, "this-is-not-a-peer-id\n").unwrap();
let err = policy.reload();
assert!(err.is_err(), "reload con typo debe fallar");
// Estado anterior se mantiene.
assert_eq!(
policy.evaluate(&p1),
Decision::Admit,
"policy debe conservar la versión anterior tras fallo de reload"
);
}
#[test]
fn invalid_file_rejected_at_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.txt");
std::fs::write(&path, "not-a-peer-id\n").unwrap();
let err = PeerPolicy::from_files(Some(&path), None).unwrap_err();
assert!(matches!(err, PolicyError::InvalidPeerId { .. }));
}
#[test]
fn watcher_reloads_on_file_change() {
// Test integración del watcher: arma policy con file, spawn
// watcher, modifica el archivo, espera el debounce, verifica
// que la policy refleja el cambio.
let p1 = fresh_peer();
let p2 = fresh_peer();
let tmp = TempDir::new().unwrap();
let allow = tmp.path().join("allow.txt");
std::fs::write(&allow, format!("{}\n", p1)).unwrap();
let policy = PeerPolicy::from_files(Some(&allow), None).unwrap();
let _watcher = policy.spawn_watcher().unwrap();
// Le damos un instante al watcher para subscribirse al dir.
std::thread::sleep(Duration::from_millis(100));
// Mutamos el archivo: p2 reemplaza a p1.
std::fs::write(&allow, format!("{}\n", p2)).unwrap();
// Esperamos > debounce + margen.
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
if policy.evaluate(&p2) == Decision::Admit {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(
policy.evaluate(&p2),
Decision::Admit,
"watcher debería haber recargado la policy"
);
assert_eq!(
policy.evaluate(&p1),
Decision::NotInAllowlist,
"p1 debería haber salido tras el reload"
);
}
}
@@ -0,0 +1,855 @@
//! Servidor de handshake. Listener Unix socket → sesiones por conexión.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use chasqui_broker::{Broker, Endpoint};
use card_core::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION};
use card_net::{BrahmanNet, PeerId};
use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, warn};
use ulid::Ulid;
use crate::codec::{read_frame, write_frame};
use crate::messages::{
Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, MatchEventKind, Ping, Pong,
SessionId,
};
/// Tabla de sesiones vivas indexada por `SessionId`.
pub type SessionRegistry = Arc<Mutex<HashMap<SessionId, ResolvedCard>>>;
/// Broker compartido (opcional) que el servidor mantiene en sincronía con
/// el ciclo de vida de las sesiones.
pub type SharedBroker = Arc<Mutex<Broker>>;
/// Tabla de canales push por sesión: el server inyecta frames hacia el
/// cliente (p. ej. `MatchEvent`) sin requerir que el cliente haga request.
type SessionTxTable = Arc<Mutex<HashMap<SessionId, mpsc::Sender<Frame>>>>;
/// Por sesión, último match conocido por nombre de input. Se usa para
/// emitir diffs (Available/Lost) en lugar del estado completo.
type LastMatches = Arc<Mutex<HashMap<SessionId, HashMap<String, Endpoint>>>>;
/// Capacidad del canal push por sesión. Si se llena (cliente lento), los
/// eventos extra se descartan — el cliente puede re-consultar el estado.
const PUSH_CHANNEL_CAPACITY: usize = 32;
/// Configuración del servidor.
#[derive(Clone, Default)]
pub struct ServerConfig {
/// `true` si el Init está atado al servidor (se reporta en `HelloAck`).
pub init_attached: bool,
/// Broker compartido. Si está presente, el servidor llama
/// `register` tras un Hello aceptado y `unregister` al cerrar la
/// sesión (Farewell o EOF). Si es `None`, el broker no se usa.
pub broker: Option<SharedBroker>,
/// Capa P2P compartida. Si está presente, cada Card registrada
/// con outputs se anuncia automáticamente al DHT vía
/// [`card_handshake::network::announce_outputs`], permitiendo
/// que un consumer remoto los descubra con
/// [`card_handshake::network::find_remote_providers`]. Si es
/// `None`, el server queda "ciego al DHT" — sólo matchea sesiones
/// locales (lo cual es correcto cuando no hay conectividad o no
/// se desea exponer al exterior).
pub net: Option<Arc<BrahmanNet>>,
/// Política de admisión de peers libp2p (allow + deny + hot
/// reload opcional). Si está presente, el trust gate del path
/// libp2p evalúa cada `peer_id` (ya autenticado por Noise)
/// contra esta política. `None` → modo totalmente abierto
/// (cualquier peer Ed25519-válido pasa). El path Unix la ignora.
pub policy: Option<crate::peer_policy::PeerPolicy>,
}
// Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm
// no es Debug). Sólo loggeamos los campos relevantes para tracing.
impl std::fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerConfig")
.field("init_attached", &self.init_attached)
.field("broker", &self.broker.as_ref().map(|_| "<broker>"))
.field("net", &self.net.as_ref().map(|_| "<net>"))
.field("policy", &self.policy.as_ref().map(|p| p.sizes()))
.finish()
}
}
/// Servidor de handshake escuchando en un Unix socket.
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
}
impl Server {
/// Crea el listener en `path`. Si el archivo existe, lo elimina (asume
/// que es un socket stale de una sesión previa).
pub fn bind(path: impl Into<PathBuf>, config: ServerConfig) -> std::io::Result<Self> {
let socket_path = path.into();
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let listener = UnixListener::bind(&socket_path)?;
Ok(Self {
listener,
socket_path,
sessions: Arc::new(Mutex::new(HashMap::new())),
push_table: Arc::new(Mutex::new(HashMap::new())),
last_matches: Arc::new(Mutex::new(HashMap::new())),
config,
})
}
/// Devuelve la ruta del socket (útil para clientes en el mismo proceso).
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Vista compartida del registro de sesiones — útil para el Init/Admin
/// para inspeccionar quién está conectado.
pub fn sessions(&self) -> SessionRegistry {
self.sessions.clone()
}
/// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`.
/// No corre el handler — eso es responsabilidad del llamante.
/// Path Unix → `expected_peer = None` (firma del Hello opcional;
/// SO_PEERCRED del kernel ya autentica al cliente).
pub async fn accept_one(&self) -> std::io::Result<Session<UnixStream>> {
let (stream, _addr) = self.listener.accept().await?;
Ok(self.session_from_stream(stream))
}
/// Construye una `Session` a partir de un stream arbitrario que
/// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Path
/// agnóstico al transport (Unix, in-memory, etc.) — `expected_peer`
/// queda en `None`, así que la firma del Hello es opcional.
pub fn session_from_stream<S>(&self, stream: S) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
self.session_from_stream_inner(stream, None)
}
/// Variante para conexiones libp2p: el `peer_id` viene autenticado
/// por Noise. La sesión exige firma del Hello cuya public key
/// derive a este `peer_id` exacto. Ver
/// [`super::network::run_libp2p_accept_loop`].
pub fn session_from_libp2p_stream<S>(
&self,
stream: S,
peer: PeerId,
) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
self.session_from_stream_inner(stream, Some(peer))
}
fn session_from_stream_inner<S>(
&self,
stream: S,
expected_peer: Option<PeerId>,
) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
Session {
stream,
sessions: self.sessions.clone(),
push_table: self.push_table.clone(),
last_matches: self.last_matches.clone(),
config: self.config.clone(),
expected_peer,
}
}
/// Loop de aceptación: cada conexión se despacha en una task separada.
/// Vive hasta que el listener falle o el caller drop el future.
pub async fn run(self) -> std::io::Result<()> {
loop {
let session = self.accept_one().await?;
tokio::spawn(async move {
if let Err(e) = session.handle().await {
warn!(error = %e, "session terminó con error");
}
});
}
}
}
impl Drop for Server {
fn drop(&mut self) {
// Limpieza best-effort del socket. Si falla, log y seguir.
if let Err(e) = std::fs::remove_file(&self.socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar socket");
}
}
}
}
/// Conexión individual aceptada por el servidor. Genérica sobre el
/// transport — funciona indistinguiblemente sobre `UnixStream` (modo
/// local), libp2p stream wrapped con `tokio_util::compat`, in-memory
/// duplex (tests), etc.
pub struct Session<S> {
stream: S,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
/// Si está set, el path es libp2p y `do_handshake` exige firma
/// del Hello cuya public key derive a este `peer_id`. Si es
/// `None`, el path es Unix/in-memory y la firma es opcional
/// (pero si está, se verifica anyway por defensa en profundidad).
expected_peer: Option<PeerId>,
}
impl<S> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
/// Procesa la conexión hasta `Farewell` o EOF.
///
/// Estructura: handshake (sobre el stream entero) → split en
/// halves (read + write) → reader loop principal + writer task
/// que drena el push channel. Garantiza cleanup (sessions + broker
/// + canales) sin importar la rama de salida.
///
/// El split es necesario para soportar streams `!Sync` como
/// `libp2p::Stream`: `tokio::select!` sobre `&mut self.stream`
/// requeriría `S: Sync`. Con `tokio::io::split` cada mitad va a
/// su propia task, eliminando el requirement y permitiendo que
/// la misma `Session` sirva indistinta sobre Unix socket o
/// stream libp2p remoto.
pub async fn handle(self) -> std::io::Result<()> {
let Self {
mut stream,
sessions,
push_table,
last_matches,
config,
expected_peer,
} = self;
let session_id = match do_handshake(&mut stream, &config, &sessions, expected_peer).await?
{
Some(id) => id,
None => return Ok(()), // Hello rechazado, no se registró nada
};
let result = run_post_handshake(
stream,
session_id,
sessions.clone(),
push_table.clone(),
last_matches.clone(),
config.clone(),
)
.await;
cleanup(
session_id,
&sessions,
&push_table,
&last_matches,
&config,
)
.await;
result
}
}
// ============================================================================
// Free functions (post-refactor): la lógica del post-handshake corre sobre
// halves del stream; no necesita más `&mut Session<S>` y por eso vive afuera.
// ============================================================================
async fn run_post_handshake<S>(
stream: S,
session_id: SessionId,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
) -> std::io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
// Canal por donde el server inyecta frames push (MatchEvent, etc.).
let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY);
push_table.lock().await.insert(session_id, tx);
// Tras registrar el canal, recomputar matches y emitir diffs.
broadcast_match_diffs(&push_table, &last_matches, &config).await;
// Split: reader en el hilo principal, writer compartido bajo Mutex
// entre la writer task (push channel) y el handler de inbound
// (que escribe Pong/Error). Mutex serializa writes; es OK porque
// la frecuencia de writes por sesión es baja.
let (mut reader, writer) = split(stream);
let writer = Arc::new(Mutex::new(writer));
// Writer task: drena el push channel.
let writer_for_push = writer.clone();
let writer_task = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let mut w = writer_for_push.lock().await;
if write_frame(&mut *w, &frame).await.is_err() {
break;
}
}
});
// Reader loop principal.
let broker_for_loop = config.broker.clone();
let result: std::io::Result<()> = loop {
match read_frame(&mut reader).await {
Ok(frame) => {
match handle_inbound_frame(
session_id,
frame,
&writer,
&sessions,
broker_for_loop.as_ref(),
)
.await
{
Ok(true) => continue,
Ok(false) => break Ok(()),
Err(e) => break Err(e),
}
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!(session = %session_id, "cliente cerró sin Farewell");
break Ok(());
}
Err(e) => break Err(e),
}
};
// Cerrar writer: drop nuestro Arc y abortar la task. La task
// saldrá igual cuando rx se cierre por drop del último Sender,
// pero abortarla es más rápido que esperar a que próximo recv()
// observe el cierre.
drop(writer);
writer_task.abort();
let _ = writer_task.await;
result
}
async fn handle_inbound_frame<S>(
session_id: SessionId,
frame: Frame,
writer: &Arc<Mutex<WriteHalf<S>>>,
sessions: &SessionRegistry,
broker_for_match: Option<&SharedBroker>,
) -> std::io::Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
match frame {
Frame::Ping(Ping { session }) if session == session_id => {
let pong = Pong {
timestamp_ms: now_ms(),
};
let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::Pong(pong)).await?;
Ok(true)
}
Frame::Ping(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::Farewell(Farewell { session }) if session == session_id => Ok(false),
Frame::Farewell(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::ListSessions(crate::messages::ListSessions { session })
if session == session_id =>
{
let list = build_session_list(sessions).await;
let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::SessionList(list)).await?;
Ok(true)
}
Frame::ListSessions(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::ListMatches(crate::messages::ListMatches { session })
if session == session_id =>
{
let matches = match &broker_for_match {
Some(b) => b.lock().await.all_matches(),
None => Vec::new(),
};
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::MatchList(crate::messages::MatchList { matches }),
)
.await?;
Ok(true)
}
Frame::ListMatches(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
_ => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Rejected(
"frame inesperado tras handshake".into(),
)),
)
.await?;
Ok(true)
}
}
}
/// Snapshot read-only de la `SessionRegistry` proyectado a la forma
/// de wire para el frame `SessionList`. Suelta el lock antes de
/// retornar para que el writer del frame no contenga el mutex.
async fn build_session_list(sessions: &SessionRegistry) -> crate::messages::SessionList {
let table = sessions.lock().await;
let entries = table
.iter()
.map(|(id, resolved)| crate::messages::SessionEntry {
session: *id,
label: resolved.card.label.clone(),
schema_version: resolved.card.schema_version,
outputs: resolved
.card
.flow
.output
.iter()
.map(|f| f.name.clone())
.collect(),
inputs: resolved
.card
.flow
.input
.iter()
.map(|f| f.name.clone())
.collect(),
conscious: resolved.wit.is_some(),
})
.collect();
crate::messages::SessionList { entries }
}
/// Limpieza atómica de las vistas registradas + (si net activo) retiro
/// de anuncios DHT de los outputs de la Card. Se ejecuta tanto si la
/// sesión cierra por Farewell, EOF, o error. Tras desregistrar, emite
/// diffs a las sesiones que perdieron el match contra ésta.
async fn cleanup(
session_id: SessionId,
sessions: &SessionRegistry,
push_table: &SessionTxTable,
last_matches: &LastMatches,
config: &ServerConfig,
) {
// Tomamos la Card ANTES de borrarla — si net está configurado
// necesitamos sus outputs para llamar withdraw_outputs. `remove`
// devuelve el valor extraído.
let removed_card = sessions.lock().await.remove(&session_id);
push_table.lock().await.remove(&session_id);
last_matches.lock().await.remove(&session_id);
if let Some(broker) = &config.broker {
broker.lock().await.unregister(session_id);
}
if let (Some(net), Some(resolved)) = (&config.net, removed_card) {
crate::network::withdraw_outputs(net, &resolved.card);
}
broadcast_match_diffs(push_table, last_matches, config).await;
}
/// Recomputa los matches para todas las sesiones registradas y empuja
/// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron
/// respecto al último estado conocido.
async fn broadcast_match_diffs(
push_table: &SessionTxTable,
last_matches: &LastMatches,
config: &ServerConfig,
) {
let broker = match &config.broker {
Some(b) => b,
None => return,
};
let b = broker.lock().await;
let push_table = push_table.lock().await;
let mut last = last_matches.lock().await;
debug!(
target: "card_handshake::broadcast",
cards = b.len(),
push_subscribers = push_table.len(),
"broadcast_match_diffs"
);
let cards: Vec<_> = b.cards().cloned().collect();
for cons in &cards {
let cons_session = cons.session;
let tx = match push_table.get(&cons_session) {
Some(tx) => tx,
None => continue,
};
let cons_last = last.entry(cons_session).or_default();
for input in &cons.inputs {
let new_match = b.find_producer_for(cons_session, &input.name);
let new_endpoint = new_match.as_ref().map(|m| m.producer.clone());
let old_endpoint = cons_last.get(&input.name).cloned();
if old_endpoint == new_endpoint {
continue;
}
if let Some(m) = &new_match {
let producer_service_socket = b
.cards()
.find(|c| c.session == m.producer.session)
.and_then(|c| c.service_socket.clone());
let event = MatchEvent {
kind: MatchEventKind::Available,
consumer_flow: input.name.clone(),
producer_session: m.producer.session,
producer_label: m.producer_label.clone(),
producer_flow: m.producer.flow_name.clone(),
ty: m.ty.clone(),
via: m.via,
pinned: m.pinned,
producer_service_socket,
};
let send_res = tx.try_send(Frame::MatchEvent(event));
debug!(
target: "card_handshake::broadcast",
consumer = %cons_session,
flow = %input.name,
producer = %m.producer_label,
result = ?send_res.as_ref().map(|_| "ok").unwrap_or_else(|e| match e {
tokio::sync::mpsc::error::TrySendError::Full(_) => "full",
tokio::sync::mpsc::error::TrySendError::Closed(_) => "closed",
}),
"Available pushed"
);
} else {
let event = MatchEvent {
kind: MatchEventKind::Lost,
consumer_flow: input.name.clone(),
producer_session: Ulid::nil(),
producer_label: String::new(),
producer_flow: String::new(),
ty: input.ty.clone(),
via: chasqui_broker::MatchStrategy::Exact,
pinned: false,
producer_service_socket: None,
};
let _ = tx.try_send(Frame::MatchEvent(event));
}
if let Some(ep) = new_endpoint {
cons_last.insert(input.name.clone(), ep);
} else {
cons_last.remove(&input.name);
}
}
}
}
/// Lee el Hello, valida (incluyendo firma cuando aplica), registra la
/// sesión y emite HelloAck.
async fn do_handshake<S>(
stream: &mut S,
config: &ServerConfig,
sessions: &SessionRegistry,
expected_peer: Option<PeerId>,
) -> std::io::Result<Option<SessionId>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let frame = read_frame(stream).await?;
let hello = match frame {
Frame::Hello(h) => h,
_ => {
write_frame(
stream,
&Frame::Error(HandshakeError::Rejected(
"primer frame debe ser Hello".into(),
)),
)
.await?;
return Ok(None);
}
};
if let Some(err) = validate_hello(&hello) {
write_frame(stream, &Frame::Error(err)).await?;
return Ok(None);
}
// Identity cert (multi-key identity, opcional): si el cliente
// adjuntó cert, la "identidad lógica" del peer es el master
// derivado del cert (estable across rotaciones), no el session
// peer_id (efímero). Sin cert, fallback al modelo de Fase 3
// (logical = session). Esto permite migración gradual y
// backwards compatibility con clientes que no usan identity.
let logical_peer = if let (Some(session_peer), Some(cert)) =
(expected_peer, &hello.identity_cert)
{
let session_pk_bytes: &[u8] = match &hello.signature {
Some(sig) => &sig.public_key,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello con identity_cert requiere también signature".into(),
)),
)
.await?;
return Ok(None);
}
};
match cert.verify_against_session(session_pk_bytes) {
Ok(master_peer) => {
debug!(
session = %session_peer,
master = %master_peer,
"identity cert válido — policy se evalúa contra master_peer"
);
Some(master_peer)
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"identity cert inválido: {e}"
))),
)
.await?;
debug!(peer = %session_peer, error = %e, "cert rechazado");
return Ok(None);
}
}
} else {
expected_peer
};
// Policy gate (path libp2p): si está configurada, el peer
// autenticado debe pasar la política (deny first, luego allow).
// El peer evaluado es `logical_peer`: master si hay cert,
// session si no. Se chequea ANTES de la firma porque es
// comparación O(log n) sin crypto. La política no se aplica
// al path Unix (autenticación por SO_PEERCRED, no por PeerId).
if let (Some(peer), Some(policy)) = (logical_peer, &config.policy) {
let decision = policy.evaluate(&peer);
if !decision.is_admitted() {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"peer {peer}: {}",
decision.reason()
))),
)
.await?;
debug!(peer = %peer, reason = decision.reason(), "rechazado por policy");
return Ok(None);
}
}
// Trust gate: en path libp2p (expected_peer = Some), exigir
// firma cuya public key derive al peer autenticado por Noise.
// En path Unix (expected_peer = None), si la firma viene se
// verifica anyway por defensa en profundidad — no es un error
// que esté ahí, pero si está debe ser válida.
if let Some(peer) = expected_peer {
let sig = match &hello.signature {
Some(s) => s,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello sin firma en conexión remota libp2p".into(),
)),
)
.await?;
return Ok(None);
}
};
if let Err(e) = crate::signature::verify_hello(sig, &hello.card, &hello.wit, peer) {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!("firma inválida: {e}"))),
)
.await?;
debug!(peer = %peer, error = %e, "firma rechazada");
return Ok(None);
}
} else if let Some(sig) = &hello.signature {
// Firma presente en path local: no exigida pero verificada.
// Si está y no valida, es un signo de Hello mal-construido y
// rechazamos por seguridad.
// Para Unix no tenemos peer_id contra el cual comparar; se
// verifica sólo la consistencia interna (firma sobre payload
// con la public_key declarada).
match card_net::PublicKey::try_decode_protobuf(&sig.public_key) {
Ok(pk) => {
let payload = match postcard::to_allocvec(&(
crate::signature::SIGNATURE_VERSION,
&hello.card,
&hello.wit,
)) {
Ok(b) => b,
Err(_) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Internal(
"no pude codificar payload para verificar firma".into(),
)),
)
.await?;
return Ok(None);
}
};
if !pk.verify(&payload, &sig.signature) {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"firma del Hello presente pero inválida".into(),
)),
)
.await?;
return Ok(None);
}
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"public_key inválida en firma: {e}"
))),
)
.await?;
return Ok(None);
}
}
}
let session_id = Ulid::new();
let card: Card = hello.card.into();
register_session(session_id, card, hello.wit, config, sessions).await;
let ack = HelloAck {
server_version: crate::HANDSHAKE_VERSION.to_string(),
protocol_version: card_core::PROTOCOL_VERSION.to_string(),
session: session_id,
init_attached: config.init_attached,
};
write_frame(stream, &Frame::HelloAck(ack)).await?;
debug!(session = %session_id, "handshake completado");
Ok(Some(session_id))
}
async fn register_session(
session_id: SessionId,
card: Card,
wit: Option<WitInterface>,
config: &ServerConfig,
sessions: &SessionRegistry,
) {
if let Some(broker) = &config.broker {
broker
.lock()
.await
.register(session_id, &card, wit.clone());
}
// Si el server tiene net configurado, anunciar los outputs al
// DHT para que peers remotos puedan descubrirlos. Idempotente
// y best-effort — fallos de Kad no propagan al handshake.
if let Some(net) = &config.net {
crate::network::announce_outputs(net, &card);
}
let resolved = match wit {
Some(w) => ResolvedCard::from_conscious(card, w),
None => ResolvedCard::from_agnostic(card),
};
sessions.lock().await.insert(session_id, resolved);
}
fn validate_hello(hello: &Hello) -> Option<HandshakeError> {
if hello.schema_version != CARD_SCHEMA_VERSION {
return Some(HandshakeError::SchemaMismatch {
client: hello.schema_version,
server: CARD_SCHEMA_VERSION,
});
}
if hello.protocol_version != card_core::PROTOCOL_VERSION {
return Some(HandshakeError::ProtocolMismatch(format!(
"cliente={}, servidor={}",
hello.protocol_version,
card_core::PROTOCOL_VERSION
)));
}
let as_card: Card = Card::from(hello.card.clone());
if let Err(e) = as_card.validate() {
return Some(HandshakeError::InvalidCard(e.to_string()));
}
None
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
@@ -0,0 +1,155 @@
//! Firma y verificación del payload del `Hello` para trust remoto.
//!
//! Usa la identidad Ed25519 de libp2p (la misma keypair que el peer
//! presenta al swarm vía Noise). Esto ancla la identidad criptográfica
//! del Ente a la identidad de transporte: si Noise autenticó al
//! `peer_id` X, sólo X puede firmar Cards válidas para esa conexión.
//!
//! ## Payload firmado
//!
//! Bytes postcard de la tupla `(WireCard, Option<WitInterface>)`. Se
//! eligió postcard porque ya es el wire format del resto del protocolo:
//! mismo determinismo, sin convertir a otro format sólo para firmar.
//!
//! Cualquier campo que entre al payload firmado en el futuro debe
//! añadirse al final de la tupla (postcard es position-dependent), o
//! bumpearse el [`SIGNATURE_VERSION`] para distinguir esquemas.
use card_core::{WireCard, WitInterface};
use card_net::{Keypair, PeerId, PublicKey};
use crate::messages::HelloSignature;
/// Versión del esquema de payload firmado. Si cambia el shape de
/// `(WireCard, Option<WitInterface>)` o cómo se serializa, bump este
/// número y el verificador rechaza firmas antiguas.
pub const SIGNATURE_VERSION: u8 = 1;
/// Errores de verificación de firma.
#[derive(Debug, thiserror::Error)]
pub enum SignatureError {
#[error("public_key inválida (libp2p decode protobuf): {0}")]
DecodeKey(String),
#[error("encode del payload falló: {0}")]
EncodePayload(String),
#[error("firma rechazada: bytes inválidos para la public_key")]
Invalid,
#[error("peer_id de la firma ({signer}) no coincide con el peer libp2p autenticado ({expected})")]
PeerMismatch { signer: PeerId, expected: PeerId },
#[error("firma del Hello faltante (requerida para conexión remota libp2p)")]
Missing,
#[error("firma del Hello inesperada en path local sin trust remoto")]
Unexpected,
}
/// Construye los bytes canónicos a firmar/verificar para un Hello.
/// Postcard determinístico de `(version, WireCard, Option<WitInterface>)`.
fn payload_bytes(card: &WireCard, wit: &Option<WitInterface>) -> Result<Vec<u8>, SignatureError> {
let tup = (SIGNATURE_VERSION, card, wit);
postcard::to_allocvec(&tup).map_err(|e| SignatureError::EncodePayload(e.to_string()))
}
/// Firma `(card, wit)` con la `keypair`. La public key derivada de
/// `keypair` debe coincidir con la identidad libp2p del peer cuando
/// el verificador la chequee.
pub fn sign_hello(
keypair: &Keypair,
card: &WireCard,
wit: &Option<WitInterface>,
) -> Result<HelloSignature, SignatureError> {
let bytes = payload_bytes(card, wit)?;
let signature_bytes = keypair
.sign(&bytes)
.map_err(|e| SignatureError::EncodePayload(e.to_string()))?;
Ok(HelloSignature {
public_key: keypair.public().encode_protobuf(),
signature: signature_bytes,
})
}
/// Verifica que `sig` es una firma válida sobre `(card, wit)` y que
/// la public key declarada coincide con `expected_peer` (la identidad
/// libp2p autenticada por Noise).
///
/// Devuelve `Ok(())` si todo cuadra; si no, el error concreto.
pub fn verify_hello(
sig: &HelloSignature,
card: &WireCard,
wit: &Option<WitInterface>,
expected_peer: PeerId,
) -> Result<(), SignatureError> {
let public_key = PublicKey::try_decode_protobuf(&sig.public_key)
.map_err(|e| SignatureError::DecodeKey(e.to_string()))?;
let signer_peer = public_key.to_peer_id();
if signer_peer != expected_peer {
return Err(SignatureError::PeerMismatch {
signer: signer_peer,
expected: expected_peer,
});
}
let bytes = payload_bytes(card, wit)?;
if !public_key.verify(&bytes, &sig.signature) {
return Err(SignatureError::Invalid);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use card_core::Card;
fn sample_card() -> WireCard {
Card::new("test.signed").into()
}
#[test]
fn sign_then_verify_roundtrip() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let card = sample_card();
let wit = None;
let sig = sign_hello(&kp, &card, &wit).unwrap();
verify_hello(&sig, &card, &wit, peer).expect("firma propia debe verificar");
}
#[test]
fn verify_rejects_wrong_peer() {
let kp = Keypair::generate_ed25519();
let other = Keypair::generate_ed25519().public().to_peer_id();
let card = sample_card();
let wit = None;
let sig = sign_hello(&kp, &card, &wit).unwrap();
let err = verify_hello(&sig, &card, &wit, other).unwrap_err();
assert!(matches!(err, SignatureError::PeerMismatch { .. }), "got {err:?}");
}
#[test]
fn verify_rejects_tampered_card() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let original = sample_card();
let wit = None;
let sig = sign_hello(&kp, &original, &wit).unwrap();
// Verificamos contra una Card distinta (mismo shape, distinto label).
let tampered: WireCard = Card::new("test.tampered").into();
let err = verify_hello(&sig, &tampered, &wit, peer).unwrap_err();
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
}
#[test]
fn verify_rejects_corrupted_signature() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let card = sample_card();
let wit = None;
let mut sig = sign_hello(&kp, &card, &wit).unwrap();
// Flip un bit de la firma.
if let Some(b) = sig.signature.last_mut() {
*b ^= 0x01;
}
let err = verify_hello(&sig, &card, &wit, peer).unwrap_err();
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
}
}
@@ -0,0 +1,52 @@
//! Convenciones de transporte: dónde vive el socket del Init.
//!
//! Resolución del path canónico:
//! 1. Variable de entorno [`SOCKET_ENV`] si está definida (override
//! explícito, prioridad máxima).
//! 2. `$XDG_RUNTIME_DIR/brahman-init.sock` (sesión usuario).
//! 3. `$TMPDIR/brahman-init.sock` (fallback portable).
use std::path::PathBuf;
/// Variable de entorno que sobreescribe la ruta del socket del Init.
pub const SOCKET_ENV: &str = "BRAHMAN_INIT_SOCKET";
/// Nombre del socket dentro del runtime dir.
pub const SOCKET_NAME: &str = "brahman-init.sock";
/// Ruta canónica al socket del Init brahman.
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join(SOCKET_NAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_override_wins() {
// Nota: estos tests modifican entorno del proceso. `cargo test`
// los corre paralelos por defecto pero usamos un nombre de var
// único y restablecemos al final.
let key = "BRAHMAN_INIT_SOCKET_TEST_OVERRIDE";
// SAFETY: sólo escribimos una variable local al test; sin
// contaminar SOCKET_ENV.
std::env::set_var(key, "/tmp/explicit.sock");
let saved = std::env::var(SOCKET_ENV).ok();
std::env::set_var(SOCKET_ENV, "/tmp/explicit.sock");
let p = default_socket_path();
assert_eq!(p, PathBuf::from("/tmp/explicit.sock"));
// Restaurar
match saved {
Some(v) => std::env::set_var(SOCKET_ENV, v),
None => std::env::remove_var(SOCKET_ENV),
}
std::env::remove_var(key);
}
}
@@ -0,0 +1,480 @@
//! Tests de integración: levanta server + client en el mismo proceso,
//! ejercita el round-trip completo del protocolo.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use chasqui_broker::{Broker, BrokerConfig};
use card_core::{
Card, CgroupSpec, Flow, Flows, NamespaceSet, Payload, ResourceLimits, SomaSpec, Supervision,
TypeRef, CARD_SCHEMA_VERSION,
};
use card_handshake::{
client::{Client, ClientError},
codec::{read_frame, write_frame},
messages::{Frame, HandshakeError, Hello, Ping},
server::{Server, ServerConfig},
};
use tokio::net::UnixStream;
use tokio::sync::Mutex;
use ulid::Ulid;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
soma: SomaSpec {
cgroup: CgroupSpec {
path: "ente.slice/test".into(),
cpu_weight: None,
io_weight: None,
},
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
..Default::default()
}
}
/// Genera una ruta de socket única bajo TMPDIR. No la creamos —
/// el server la creará al hacer bind.
fn sock_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"brahman-test-{}-{}-{}.sock",
name,
std::process::id(),
Ulid::new()
))
}
#[tokio::test]
async fn full_handshake_roundtrip() {
let path = sock_path("happy");
let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, policy: None }).unwrap();
let session_handle = tokio::spawn({
async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
}
});
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
assert!(client.server_info().init_attached);
assert_eq!(
client.server_info().protocol_version,
card_core::PROTOCOL_VERSION
);
let mut last = 0u64;
for _ in 0..3 {
let ts = client.ping().await.unwrap();
assert!(ts >= last);
last = ts;
tokio::time::sleep(Duration::from_millis(2)).await;
}
client.farewell().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after farewell")
.unwrap();
}
#[tokio::test]
async fn list_sessions_returns_currently_registered() {
// Levantamos un server con broker (requerido para que el registro
// pase por el path real) y conectamos 3 clientes. El último pide
// ListSessions y debe ver a los 2 anteriores + a sí mismo.
let path = sock_path("listsess");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: true,
broker: Some(broker),
net: None,
policy: None,
},
)
.unwrap();
// Una task accept loop genérica para los 3 clientes.
let server_handle = tokio::spawn(async move {
for _ in 0..3 {
let session = server.accept_one().await.unwrap();
tokio::spawn(async move {
let _ = session.handle().await;
});
}
// Mantener el server vivo para que las sesiones puedan
// mantenerse abiertas mientras el observer pregunta.
std::future::pending::<()>().await;
});
let mut alpha = Client::connect(&path, sample_card("producer-alpha"))
.await
.unwrap();
let mut beta = Client::connect(&path, sample_card("producer-beta"))
.await
.unwrap();
// observer es el que va a preguntar.
let mut observer = Client::connect(&path, sample_card("observer"))
.await
.unwrap();
let list = observer.list_sessions().await.unwrap();
assert_eq!(list.entries.len(), 3, "deberían verse 3 sesiones activas");
let labels: BTreeSet<&str> = list.entries.iter().map(|e| e.label.as_str()).collect();
assert!(labels.contains("producer-alpha"));
assert!(labels.contains("producer-beta"));
assert!(labels.contains("observer"));
// schema_version + conscious sanity en la propia entry del observer.
let me = list
.entries
.iter()
.find(|e| e.label == "observer")
.unwrap();
assert_eq!(me.schema_version, card_core::CARD_SCHEMA_VERSION);
assert!(!me.conscious, "observer no envió WIT — debería ser agnostic");
alpha.farewell().await.unwrap();
beta.farewell().await.unwrap();
observer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn rejects_invalid_card_client_side() {
let path = sock_path("invalid");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
// No esperamos que el server complete: el cliente corta antes.
let _ = tokio::time::timeout(Duration::from_secs(1), async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
})
.await;
});
let mut bad = sample_card("placeholder");
bad.label = String::new();
let err = Client::connect(&path, bad).await.unwrap_err();
assert!(matches!(err, ClientError::InvalidCard(_)));
session_handle.abort();
}
#[tokio::test]
async fn server_rejects_protocol_mismatch() {
let path = sock_path("mismatch");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
let mut stream = UnixStream::connect(&path).await.unwrap();
let hello = Hello {
schema_version: CARD_SCHEMA_VERSION,
protocol_version: "999.0.0".into(),
card: sample_card("future-module").into(),
wit: None,
signature: None,
identity_cert: None,
};
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
match read_frame(&mut stream).await.unwrap() {
Frame::Error(HandshakeError::ProtocolMismatch(_)) => {}
other => panic!("esperado ProtocolMismatch, got {other:?}"),
}
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after rejecting")
.unwrap();
}
// =====================================================================
// Integración handshake ↔ broker
// =====================================================================
fn card_with_flows(label: &str, input: Vec<Flow>, output: Vec<Flow>) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
soma: SomaSpec {
cgroup: CgroupSpec {
path: "ente.slice/test".into(),
cpu_weight: None,
io_weight: None,
},
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
flow: Flows { input, output },
..Default::default()
}
}
fn flow(name: &str, ty: TypeRef) -> Flow {
Flow {
name: name.into(),
ty,
pin_to: None,
}
}
/// Espera hasta que `broker.len() >= n` o timeout.
async fn wait_for_broker_len(broker: &Arc<Mutex<Broker>>, n: usize) {
for _ in 0..50 {
if broker.lock().await.len() >= n {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("broker no alcanzó {n} entradas en 500ms");
}
#[tokio::test]
async fn broker_registers_and_unregisters_with_session() {
let path = sock_path("broker-lifecycle");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
let session_id = client.session();
// Tras el handshake, la Card debe estar registrada en el broker.
wait_for_broker_len(&broker, 1).await;
{
let b = broker.lock().await;
assert_eq!(b.len(), 1);
assert!(b.sessions().any(|s| s == session_id));
}
client.farewell().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server colgó tras farewell")
.unwrap();
// Tras el cleanup, el broker queda vacío.
{
let b = broker.lock().await;
assert_eq!(b.len(), 0);
}
}
#[tokio::test]
async fn broker_matches_two_live_modules() {
let path = sock_path("broker-match");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
// Server loop: usa la API run() para manejar accept+spawn.
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
// Productor: emite "out" tipo string.
let producer_card = card_with_flows(
"dht",
vec![],
vec![flow(
"out",
TypeRef::Primitive {
name: "string".into(),
},
)],
);
let mut producer = Client::connect(&path, producer_card).await.unwrap();
wait_for_broker_len(&broker, 1).await;
// Consumidor: pide "in" tipo string.
let consumer_card = card_with_flows(
"ui",
vec![flow(
"in",
TypeRef::Primitive {
name: "string".into(),
},
)],
vec![],
);
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
wait_for_broker_len(&broker, 2).await;
// El broker debe encontrar el match consumer.in ← producer.out.
let m = {
let b = broker.lock().await;
b.find_producer_for(consumer.session(), "in")
}
.expect("broker no encontró match");
assert_eq!(m.consumer_label, "ui");
assert_eq!(m.producer_label, "dht");
assert_eq!(m.producer.flow_name, "out");
// Cuando el productor se va, el match desaparece.
producer.farewell().await.unwrap();
for _ in 0..50 {
if broker.lock().await.len() < 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
{
let b = broker.lock().await;
assert!(b.find_producer_for(consumer.session(), "in").is_none());
}
consumer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn match_event_pushed_on_producer_arrival() {
use card_handshake::messages::MatchEventKind;
let path = sock_path("push-match");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
// El consumidor llega primero — sin productor, no hay match aún.
let consumer_card = card_with_flows(
"ui",
vec![flow(
"in",
TypeRef::Primitive {
name: "json".into(),
},
)],
vec![],
);
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
// No debería haber evento todavía.
let no_event = consumer
.await_event(Duration::from_millis(100))
.await
.unwrap();
assert!(no_event.is_none(), "evento inesperado: {no_event:?}");
// Llega el productor → consumer debe recibir Available.
let producer_card = card_with_flows(
"dht",
vec![],
vec![flow(
"out",
TypeRef::Primitive {
name: "json".into(),
},
)],
);
let mut producer = Client::connect(&path, producer_card).await.unwrap();
let ev = consumer
.await_event(Duration::from_secs(2))
.await
.unwrap()
.expect("Available no llegó");
assert_eq!(ev.kind, MatchEventKind::Available);
assert_eq!(ev.consumer_flow, "in");
assert_eq!(ev.producer_label, "dht");
assert_eq!(ev.producer_flow, "out");
// El productor se va → consumer debe recibir Lost.
producer.farewell().await.unwrap();
let ev = consumer
.await_event(Duration::from_secs(2))
.await
.unwrap()
.expect("Lost no llegó");
assert_eq!(ev.kind, MatchEventKind::Lost);
assert_eq!(ev.consumer_flow, "in");
consumer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn ping_before_hello_rejected() {
let path = sock_path("ping-no-hello");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
// Conectamos y mandamos un Ping sin haber saludado.
let mut stream = UnixStream::connect(&path).await.unwrap();
write_frame(
&mut stream,
&Frame::Ping(Ping {
session: Ulid::new(),
}),
)
.await
.unwrap();
match read_frame(&mut stream).await.unwrap() {
Frame::Error(HandshakeError::Rejected(_)) => {}
other => panic!("esperado Rejected, got {other:?}"),
}
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after rejecting")
.unwrap();
}
@@ -0,0 +1,340 @@
//! Test E2E de Fase 2: discovery remoto vía DHT.
//!
//! Pipeline:
//! 1. **Provider node (A)**: arma server con `BrahmanNet` configurado;
//! listen TCP; un cliente local registra una Card con un output
//! flow. El server llama `announce_outputs` automáticamente, lo
//! que hace `start_providing` en el DHT bajo la key derivada del
//! flow.
//! 2. **Consumer node (B)**: arma su propio `BrahmanNet`; dial-ea al
//! multiaddr del provider para que ambos se conozcan vía Identify
//! (esto popula sus respectivos routing tables de Kademlia).
//! 3. **B llama `find_remote_providers(flow_name, type)`**: la query
//! DHT propaga vía Kad, y eventually el provider responde con su
//! `PeerId`.
//! 4. **Verificación**: el `PeerId` que B descubre coincide con el
//! de A.
//!
//! Notas:
//! - Kademlia replication factor por defecto es 20; con 2 nodos no
//! hay propagación material — A es el único provider, B llega a A
//! vía la conexión directa establecida en step 2 y obtiene el record
//! del store local de A.
//! - El test usa flow `monad-list:json` por familiaridad (es el flow
//! real que `chasqui daemon` declara). Sirve también como prueba de
//! que el sistema completo (daemon + DHT) funcionaría con cero
//! cambios en la Card.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use chasqui_broker::{Broker, BrokerConfig};
use card_core::{
ulid::Ulid, Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use card_handshake::network::{find_remote_providers, run_libp2p_accept_loop};
use card_handshake::server::{Server, ServerConfig};
use card_net::{BrahmanNet, Multiaddr, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![Flow {
name: flow_name.into(),
ty: TypeRef::Primitive {
name: type_name.into(),
},
pin_to: None,
}],
},
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_finds_remote_provider() {
// ---- Node A (provider): server + libp2p net + Card con output ----
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker.clone()),
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
policy: None,
},
)
.unwrap(),
);
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let a_addr = a_net.listen(listen_addr).await;
let mut a_full_addr = a_addr.clone();
a_full_addr.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
// Unix accept loop: necesario para que Client::connect al socket
// local no cuelgue (Server no se auto-accepta; el caller arma el
// loop). Cada session entrante corre en su propia task.
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// Registrar la Card local en A con un flow output.
let card = provider_card("test.engine_remote", "monad-list", "json");
let mut local_client = card_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
// ---- Node B (consumer): otro net que dial-a a A ----
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full_addr.clone());
// Esperar a que la conexión se establezca y Identify popule el
// routing table de Kad. En localhost con 2 peers, ~250ms es de
// sobra; sumamos margen para CI.
tokio::time::sleep(Duration::from_millis(500)).await;
// ---- Discovery: B busca providers de "monad-list:json" ----
let providers = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
providers.contains(&a_peer),
"B debería descubrir a A vía DHT. Encontrados: {:?}, esperado: {}",
providers,
a_peer
);
// Sanidad: el cliente local sigue vivo durante todo el test (lo
// que mantiene la Card registrada y por tanto el record DHT vivo).
local_client.farewell().await.ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_negative_unknown_flow() {
// Mismo setup que el test happy-path, pero B busca un flow que A
// NO ofrece. Debe devolver lista vacía dentro del timeout
// razonable (no colgarse).
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
// Unix accept loop: necesario para que Client::connect al socket
// local no cuelgue (Server no se auto-accepta; el caller arma el
// loop). Cada session entrante corre en su propia task.
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
let card = provider_card("test.engine_other", "monad-list", "json");
let mut local = card_handshake::client::Client::connect(&a_unix, card)
.await
.unwrap();
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full);
tokio::time::sleep(Duration::from_millis(500)).await;
// Buscamos un flow que NADIE anunció.
let providers = find_remote_providers(
&b_net,
"flow-que-no-existe",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
providers.is_empty(),
"no debería haber providers para un flow inexistente, got: {:?}",
providers
);
local.farewell().await.ok();
}
/// stop_providing test: A registra Card con flow X, B descubre a A.
/// El cliente local de A hace farewell → cleanup llama
/// withdraw_outputs → A se quita del provider local store. Una nueva
/// query desde B (que rutea por A, único peer en el DHT) ya no debe
/// listarlo.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_withdraws_on_session_cleanup() {
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let sessions = a_server.sessions();
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// Card con un flow output anunciable.
let card = provider_card("test.withdraws", "monad-list", "json");
let local = card_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full);
tokio::time::sleep(Duration::from_millis(500)).await;
// Confirmación previa: A es discoverable.
let before = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
before.contains(&a_peer),
"antes del farewell A debería ser discoverable. got: {:?}",
before
);
// Farewell del cliente local → server.cleanup → withdraw_outputs.
local.farewell().await.ok();
// Esperamos a que la sesión salga del registro de A (señal de
// que cleanup completó).
let mut waited = 0;
while !sessions.lock().await.is_empty() && waited < 50 {
tokio::time::sleep(Duration::from_millis(20)).await;
waited += 1;
}
assert!(
sessions.lock().await.is_empty(),
"sesión debería estar removida tras farewell"
);
// Pequeño margen extra para que el Command::StopProviding lo
// procese el swarm task (no es await-able desde fuera).
tokio::time::sleep(Duration::from_millis(100)).await;
// Nueva query: A ya no debería listarse como provider.
let after = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
!after.contains(&a_peer),
"tras farewell + withdraw_outputs, A NO debería ser discoverable. got: {:?}",
after
);
}
@@ -0,0 +1,525 @@
//! Test E2E: handshake brahman remoto sobre libp2p stream.
//!
//! Pipeline:
//! 1. Server: bind Unix socket (necesario aunque no lo use el cliente);
//! crear `BrahmanNet` y escuchar en `/ip4/127.0.0.1/tcp/0`;
//! montar `run_libp2p_accept_loop`.
//! 2. Client: crear su propio `BrahmanNet`; dial al multiaddr del
//! server; `connect_libp2p` con su Card; `ping`; `farewell`.
//! 3. Verificar: el server registró la sesión; sessions.len() == 1
//! durante la sesión, == 0 después del farewell.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use chasqui_broker::{Broker, BrokerConfig};
use card_core::{
ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
CARD_SCHEMA_VERSION,
};
use card_handshake::identity::{Identity, DEFAULT_SESSION_TTL};
use card_handshake::network::{connect_libp2p, connect_libp2p_with_cert, run_libp2p_accept_loop};
use card_handshake::peer_policy::PeerPolicy;
use card_handshake::server::{Server, ServerConfig};
use card_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::default(),
priority: Priority::default(),
kind: CardKind::Ente,
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_roundtrip() {
// ---- Server side ----
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer_id = server_net.peer_id;
// Listen on a random TCP port.
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual_addr = server_net.listen(listen_addr).await;
// Inject the libp2p PeerId into the multiaddr so the client knows
// who to dial.
let mut full_addr = actual_addr.clone();
full_addr.push(Protocol::P2p(server_peer_id));
// Spawn the libp2p accept loop.
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Client side ----
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full_addr.clone());
// Pequeña espera para que el dial conecte. En un entorno real el
// caller usaría un mecanismo de barrier, pero para tests un sleep
// corto es suficiente y deterministic en localhost.
tokio::time::sleep(Duration::from_millis(200)).await;
let card = sample_card("test.remote_ente");
let client_kp = client_net.keypair();
let mut client = connect_libp2p(&client_net, server_peer_id, card, None, &client_kp)
.await
.expect("handshake remoto debería completar");
// Verificación: el server vio la sesión.
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "una sesión registrada");
let resolved = s.values().next().unwrap();
assert_eq!(resolved.card.label, "test.remote_ente");
}
// Ping roundtrip.
let ts = client.ping().await.expect("ping debería responder");
assert!(ts > 0, "timestamp del Pong > 0");
// Farewell limpio.
client.farewell().await.expect("farewell debería completar");
// Tras el farewell, el cleanup remueve la sesión.
// Damos un tick para que el handler procese el frame.
tokio::time::sleep(Duration::from_millis(100)).await;
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "sesión removida tras farewell");
}
// peer_id no usado aquí, pero validamos que la API existe.
let _ = PeerId::random();
}
/// Fase 3 negativo: el cliente intenta firmar el Hello con una keypair
/// distinta a la del peer libp2p. El server (que verifica que la
/// public key del Hello derive al peer_id autenticado por Noise) debe
/// rechazar con `Unauthorized`.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_rejects_mismatched_signing_key() {
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: None,
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual = server_net.listen(listen_addr).await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full);
tokio::time::sleep(Duration::from_millis(200)).await;
// Keypair fraudulenta: NO es la del client_net.
let evil_keypair = Keypair::generate_ed25519();
let card = sample_card("test.evil");
let result = connect_libp2p(&client_net, server_peer, card, None, &evil_keypair).await;
assert!(
result.is_err(),
"handshake con keypair fraudulenta debe fallar"
);
// Sanidad: ninguna sesión registrada.
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "no debería haber sesión registrada");
}
/// Allowlist gate: A configura `allowlist = [client_authorized_peer]`.
/// Un cliente con peer_id en la lista pasa el handshake; otro con
/// peer_id distinto es rechazado con `Unauthorized` ANTES de la
/// verificación de firma (la allowlist se chequea primero, es más
/// barata).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_allowlist_admits_listed_rejects_others() {
// Pre-generamos las dos identidades cliente para que A pueda
// construir la allowlist conociendo cuál es la "permitida".
let allowed_kp = Keypair::generate_ed25519();
let allowed_peer = allowed_kp.public().to_peer_id();
let denied_kp = Keypair::generate_ed25519();
// (denied_peer no se necesita para la lista — sólo para clarity)
let _ = denied_kp.public().to_peer_id();
// ---- Server con allowlist activa ----
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(PeerPolicy::from_sets(
Some([allowed_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let actual = server_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Cliente PERMITIDO ----
let allowed_net = BrahmanNet::with_keypair(allowed_kp.clone()).unwrap();
allowed_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_ok = sample_card("test.allowed");
let mut allowed_client = connect_libp2p(&allowed_net, server_peer, card_ok, None, &allowed_kp)
.await
.expect("peer en allowlist debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "sesión del peer permitido registrada");
}
allowed_client.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- Cliente DENEGADO ----
let denied_net = BrahmanNet::with_keypair(denied_kp.clone()).unwrap();
denied_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_no = sample_card("test.denied");
let result = connect_libp2p(&denied_net, server_peer, card_no, None, &denied_kp).await;
assert!(
result.is_err(),
"peer fuera de allowlist debe ser rechazado, got: {:?}",
result.is_ok()
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "ninguna sesión adicional registrada tras intento denegado");
}
}
/// Denylist gate: A configura `policy` con un peer en la denylist.
/// Modo abierto para todo lo demás (sin allowlist), pero el peer
/// baneado es rechazado aún teniendo Ed25519 válida y peer_id que
/// derivaría limpio del Noise handshake.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_denylist_blocks_listed_peer() {
let banned_kp = Keypair::generate_ed25519();
let banned_peer = banned_kp.public().to_peer_id();
let other_kp = Keypair::generate_ed25519();
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(PeerPolicy::from_sets(
None, // sin allowlist (abierto)
[banned_peer].into_iter().collect(),
)),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let actual = server_net
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
.await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// Cliente baneado: connect debe fallar.
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
banned_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_x = sample_card("test.banned");
let result = connect_libp2p(&banned_net, server_peer, card_x, None, &banned_kp).await;
assert!(
result.is_err(),
"peer en denylist debe ser rechazado, got Ok"
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "el peer baneado no debería tener sesión");
}
// Cliente no-baneado pasa.
let other_net = BrahmanNet::with_keypair(other_kp.clone()).unwrap();
other_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_ok = sample_card("test.other");
let mut other_client = connect_libp2p(&other_net, server_peer, card_ok, None, &other_kp)
.await
.expect("peer fuera de denylist debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "sesión del peer no-baneado registrada");
}
other_client.farewell().await.ok();
}
/// Swarm-level deny via `PeerPolicy::attach_to_net`: cuando la deny
/// se aplica al swarm vía `block_list`, el peer baneado es rechazado
/// en el dial — la conexión TCP/Noise nunca completa, así que el
/// cliente nunca llega siquiera a mandar el Hello. Más eficiente que
/// el handshake-level deny.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn swarm_level_deny_blocks_before_noise() {
let banned_kp = Keypair::generate_ed25519();
let banned_peer = banned_kp.public().to_peer_id();
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let policy = card_handshake::peer_policy::PeerPolicy::from_sets(
None,
[banned_peer].into_iter().collect(),
);
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(policy.clone()),
},
)
.unwrap(),
);
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
// ATTACH: la deny se proyecta al swarm. Es lo nuevo de este
// commit — sin esta llamada, el deny seguiría aplicando sólo
// al nivel de handshake brahman (lo que también funciona pero
// gasta un round-trip Noise).
policy.attach_to_net(server_net.clone());
let actual = server_net
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
.await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// Cliente baneado intenta dial + handshake. Con swarm-level
// deny, la conexión libp2p ni siquiera completa: `connect_libp2p`
// falla con error de open_stream (peer inalcanzable / connection
// refused) en lugar del Unauthorized del handshake-level path.
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
banned_net.dial(full.clone());
let card = sample_card("test.swarm_banned");
// Timeout corto: si el block falla, el handshake completaría
// rápido en localhost. Si funciona, debería fallar el dial casi
// instantáneo o colgarse hasta el timeout.
let result = tokio::time::timeout(
Duration::from_secs(3),
connect_libp2p(&banned_net, server_peer, card, None, &banned_kp),
)
.await;
match result {
Ok(Ok(_)) => panic!("peer baneado a nivel swarm NO debería completar handshake"),
Ok(Err(e)) => {
// Esperado: error de transporte/stream, no de handshake.
tracing::info!(error = %e, "swarm-level deny rechazó como esperado");
}
Err(_) => {
// También aceptable: timeout porque el dial nunca completa.
tracing::info!("swarm-level deny → connect timeout (también OK)");
}
}
}
/// Multi-key identity: la propiedad fundamental que cierra el
/// proyecto. El cliente B tiene una identity master estable; el
/// server A le permite el master_peer en allowlist. B se conecta con
/// **session1**; pasa. B "rota": genera **session2** distinta, emite
/// un nuevo cert con la misma identity, se conecta de nuevo. Pasa
/// también — sin que A toque su allowlist.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn identity_cert_allows_session_rotation_without_policy_change() {
// Master de B (estable, persistente).
let master_kp = Keypair::generate_ed25519();
let master_peer = master_kp.public().to_peer_id();
let identity = Identity::from_keypair(master_kp);
// A configura policy: allowlist con master_peer (NO sessions).
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(PeerPolicy::from_sets(
Some([master_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let actual = server_net
.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap())
.await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Conexión 1: session1 ----
let session1_kp = Keypair::generate_ed25519();
let cert1 = identity
.issue_session_cert(&session1_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net1 = BrahmanNet::with_keypair(session1_kp.clone()).unwrap();
net1.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client1 = connect_libp2p_with_cert(
&net1,
server_peer,
sample_card("test.session1"),
None,
&session1_kp,
cert1,
)
.await
.expect("session1 con cert válido del master allowlisted debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session1 registrada");
}
client1.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- ROTACIÓN: session2 distinta, mismo master ----
let session2_kp = Keypair::generate_ed25519();
assert_ne!(
session1_kp.public().to_peer_id(),
session2_kp.public().to_peer_id(),
"test inválido si las sessions son iguales"
);
let cert2 = identity
.issue_session_cert(&session2_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net2 = BrahmanNet::with_keypair(session2_kp.clone()).unwrap();
net2.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client2 = connect_libp2p_with_cert(
&net2,
server_peer,
sample_card("test.session2"),
None,
&session2_kp,
cert2,
)
.await
.expect(
"session2 (rotada) con cert del MISMO master debe pasar sin tocar allowlist",
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session2 registrada");
}
client2.farewell().await.ok();
// Sanity: una session sin cert (path Fase 3) cuyo session_peer_id
// NO está en la allowlist (porque la allowlist tiene master, no
// sessions) DEBE ser rechazada.
let session_other = Keypair::generate_ed25519();
let net_other = BrahmanNet::with_keypair(session_other.clone()).unwrap();
net_other.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let result = connect_libp2p(
&net_other,
server_peer,
sample_card("test.no_cert"),
None,
&session_other,
)
.await;
assert!(
result.is_err(),
"sin cert, session_peer_id (no listado) debe ser rechazado"
);
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "card-sidecar"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — sidecar reusable: thread + tokio runtime que mantiene viva la sesión de un módulo contra el Init."
[dependencies]
card-core = { workspace = true }
card-handshake = { path = "../card-handshake" }
# Discovery remoto por DHT: el resolver local-first/remote-fallback expone
# `&BrahmanNet` y `PeerId` en su API pública.
card-net = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tracing-subscriber = { workspace = true }
card-wit = { workspace = true }
chasqui-broker = { path = "../chasqui-broker" }
tempfile = { workspace = true }
[[example]]
name = "presence"
path = "examples/presence.rs"
[[example]]
name = "presence-conscious"
path = "examples/presence-conscious.rs"
@@ -0,0 +1,99 @@
//! `presence-conscious` — módulo brahman que se presenta con su WIT.
//!
//! Variante de [`presence`] que toma un path a un archivo `.wit` (default
//! `shared_wit/protocol.wit` resuelto desde el cwd) y lo parsea con
//! `brahman-card-wit` antes de spawnear el sidecar. Demuestra el flujo
//! "módulo consciente": Hello incluye `WitInterface`, el server lo
//! registra como `ResolvedCard::from_conscious`, y aparece con marker
//! 🧠 en `brahman-status`.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-sidecar --example presence-conscious -- mi-modulo [path/al.wit]
//! ```
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::time::Duration;
use card_core::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use card_sidecar::{spawn_with_handle, SidecarConfig};
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let mut args = std::env::args().skip(1);
let label = args.next().unwrap_or_else(|| "conscious-default".into());
let wit_path: PathBuf = args
.next()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("shared_wit/protocol.wit"));
let wit = match card_wit::parse_wit_file(&wit_path) {
Ok(worlds) => match worlds.into_iter().next() {
Some(w) => {
eprintln!(
"[{label}] cargado wit: {} / {}",
w.package, w.world
);
Some(w)
}
None => {
eprintln!("[{label}] {} no declara worlds", wit_path.display());
None
}
},
Err(e) => {
eprintln!("[{label}] falló parse de {}: {e}", wit_path.display());
None
}
};
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![Flow {
name: "out".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
},
..Default::default()
};
let config = SidecarConfig {
card,
wit,
ping_interval: Duration::from_secs(5),
};
let _handle = spawn_with_handle(config);
eprintln!("[{label}] sidecar lanzado, durmiendo (Ctrl-C para salir)");
std::thread::park();
}
@@ -0,0 +1,70 @@
//! `presence` — módulo brahman dummy para pruebas y demos.
//!
//! Declara una Card mínima con label tomado del primer argumento (default
//! `presence-default`) y mantiene la sesión viva hasta SIGTERM/SIGINT.
//! Útil para poblar el broker con sesiones de prueba.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-sidecar --example presence -- mi-modulo
//! ```
use std::collections::BTreeSet;
use std::time::Duration;
use card_core::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use card_sidecar::{spawn_with_handle, SidecarConfig};
fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let label = std::env::args()
.nth(1)
.unwrap_or_else(|| "presence-default".into());
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![Flow {
name: "out".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
},
..Default::default()
};
let _handle = spawn_with_handle(SidecarConfig {
card,
wit: None,
ping_interval: Duration::from_secs(5),
});
eprintln!("presence({label}): sidecar lanzado, durmiendo (Ctrl-C para salir)");
std::thread::park();
}
@@ -0,0 +1,453 @@
//! `brahman-sidecar::discovery` — API reusable para que un módulo
//! consumer encuentre proveedores vivos vía broker, sin hardcodear
//! sockets ni reimplementar el patrón a mano.
//!
//! Es la generalización de `discover_producer_socket` del CLI
//! `chasqui attract --remote`: declarás el `TypeRef` que querés
//! consumir y el broker te empuja un `MatchEvent::Available` con el
//! `producer_service_socket` del primer proveedor matched.
//!
//! Pipeline:
//! 1. `build_consumer_card(label, flow_name, type_name)` arma una
//! Card mínima (Ente, Oneshot, Virtual) con un input flow.
//! 2. `await_provider(card, timeout)` se conecta al brahman-init,
//! espera hasta `timeout` por `MatchEvent::Available`, devuelve
//! el socket del proveedor electo, y envía Farewell.
//! 3. Para mundos blocking (CLIs, tests, std-thread loops) hay
//! `await_provider_blocking` que arma su propio runtime
//! `current_thread`.
//!
//! Quién elige al proveedor es el broker, no este módulo. Si el
//! broker tiene `priority_contexts` activo, podés cambiar de
//! proveedor sin tocar el consumer; el matching dinámico se respeta.
use std::path::PathBuf;
use std::time::{Duration, Instant};
use card_core::{
Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef, WitInterface,
};
use card_net::{BrahmanNet, PeerId};
use card_handshake::client::{Client, ClientError};
use card_handshake::messages::MatchEventKind;
use card_handshake::network::{connect_libp2p, LibP2pHandshakeStream, NetworkError};
use card_handshake::transport;
use tracing::warn;
#[derive(Debug, thiserror::Error)]
pub enum ConsumerError {
#[error("no se pudo conectar al init en {socket}: {source}")]
Connect {
socket: PathBuf,
#[source]
source: ClientError,
},
#[error("error en cliente brahman: {0}")]
Client(#[from] ClientError),
#[error("timeout {timeout:?} sin proveedor disponible para flow '{flow}' (type '{type_ref}')")]
NoProvider {
flow: String,
type_ref: String,
timeout: Duration,
},
#[error("no se pudo crear runtime tokio: {0}")]
Runtime(String),
#[error("la Card consumer no declara ningún input flow para resolver")]
NoInputFlow,
#[error(
"el DHT no anuncia proveedores para flow '{flow}' (type '{type_ref}')"
)]
NoRemoteProviders { flow: String, type_ref: String },
#[error(
"todos los {} peers remotos fallaron al conectar; último error: {source}",
peers.len()
)]
AllPeersFailed {
peers: Vec<PeerId>,
#[source]
source: NetworkError,
},
}
/// Construye una Card mínima de consumer que declara un input flow
/// con el `TypeRef::Primitive { name }` solicitado. Usá esto para
/// el caso común; si necesitás algo más rico (output flows,
/// permissions, references) construí la Card a mano y pasala a
/// [`await_provider`] directamente.
pub fn build_consumer_card(
consumer_label: impl Into<String>,
flow_name: impl Into<String>,
type_name: impl Into<String>,
) -> Card {
Card {
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Oneshot,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![Flow {
name: flow_name.into(),
ty: TypeRef::Primitive {
name: type_name.into(),
},
pin_to: None,
}],
output: vec![],
},
..Card::new(consumer_label)
}
}
/// Conecta al brahman-init, registra `consumer_card`, espera el
/// primer `MatchEvent::Available` y devuelve el `producer_service_socket`
/// que el broker emitió. Cierra la sesión con Farewell antes de
/// retornar (best-effort).
///
/// La `consumer_card` debe declarar al menos un `flow.input`; si no,
/// el broker no puede hacer matching y el await siempre dará timeout.
pub async fn await_provider(
consumer_card: Card,
timeout: Duration,
) -> Result<PathBuf, ConsumerError> {
let init_path = transport::default_socket_path();
// Capturamos descriptor para el mensaje de error antes de mover
// la card al cliente.
let (flow_name, type_ref_name) = describe_first_input(&consumer_card);
let mut client = Client::connect(&init_path, consumer_card)
.await
.map_err(|source| ConsumerError::Connect {
socket: init_path.clone(),
source,
})?;
let deadline = Instant::now() + timeout;
let socket = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break None;
}
match client.await_event(remaining).await? {
Some(ev) if ev.kind == MatchEventKind::Available => {
break ev.producer_service_socket;
}
Some(_) => continue, // Lost u otros: seguir esperando hasta el deadline
None => break None,
}
};
let _ = client.farewell().await; // best-effort cleanup
socket.ok_or(ConsumerError::NoProvider {
flow: flow_name,
type_ref: type_ref_name,
timeout,
})
}
/// Wrapper bloqueante de [`await_provider`]. Crea un runtime tokio
/// `current_thread` efímero y bloquea el thread llamador. Útil para
/// CLIs, tests y módulos std-thread (p. ej. el frontend GPUI antes
/// de tener su propio runtime async).
pub fn await_provider_blocking(
consumer_card: Card,
timeout: Duration,
) -> Result<PathBuf, ConsumerError> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
rt.block_on(await_provider(consumer_card, timeout))
}
// =====================================================================
// Discovery local-first / remoto (DHT) — capa de consumidor
// =====================================================================
/// Discovery DHT crudo, reexportado desde `card-handshake` para que un
/// consumidor que ya usa `card-sidecar` pueda buscar proveedores remotos
/// por `(flow_name, TypeRef)` sin alcanzar las internas del handshake.
pub use card_handshake::network::find_remote_providers;
/// Dónde vive el proveedor elegido para un flow.
///
/// El broker local (vía `await_provider`) entrega un socket Unix listo
/// para consumir; el DHT remoto entrega `PeerId`s que el caller debe
/// dial-ar y sub-handshakear con `card_handshake::network::connect_libp2p`.
#[derive(Debug, Clone)]
pub enum ProviderLocation {
/// Proveedor local: socket de servicio resuelto por el broker del init.
Local(PathBuf),
/// Proveedores remotos anunciados en el DHT (peers a dial-ar).
Remote(Vec<PeerId>),
}
/// Resuelve un proveedor para el **primer input flow** de `consumer_card`,
/// **local primero, DHT remoto como fallback**.
///
/// 1. Pregunta al broker del init local (`await_provider`) hasta
/// `local_timeout`. Si hay match → [`ProviderLocation::Local`].
/// 2. Si no hay proveedor local (timeout) ni init local alcanzable
/// (socket ausente), consulta el DHT vía `net` y devuelve
/// [`ProviderLocation::Remote`] con los `PeerId`s que anuncian el flow
/// (lista posiblemente vacía si nadie lo provee en la malla).
///
/// Esto es el cableado que faltaba entre el discovery local (broker) y el
/// discovery remoto (DHT de `card-net`, ya cableado en el handshake): un
/// consumidor pide "un proveedor para el tipo X" y obtiene el más cercano
/// disponible sin saber a priori si vive en esta máquina o en la red.
pub async fn resolve_provider(
consumer_card: Card,
net: &BrahmanNet,
local_timeout: Duration,
) -> Result<ProviderLocation, ConsumerError> {
// Necesitamos el flow a resolver antes de mover la card al broker.
let (flow_name, ty) = match consumer_card.flow.input.first() {
Some(f) => (f.name.clone(), f.ty.clone()),
None => return Err(ConsumerError::NoInputFlow),
};
// 1) Local primero.
match await_provider(consumer_card, local_timeout).await {
Ok(socket) => return Ok(ProviderLocation::Local(socket)),
// Sin proveedor local (timeout) o sin init local alcanzable:
// caemos al DHT. Cualquier otro error sí se propaga.
Err(ConsumerError::NoProvider { .. }) | Err(ConsumerError::Connect { .. }) => {}
Err(other) => return Err(other),
}
// 2) Fallback remoto por DHT.
let peers = find_remote_providers(net, &flow_name, &ty).await;
Ok(ProviderLocation::Remote(peers))
}
/// Consume una `Card` a través de un proveedor remoto descubierto por DHT.
///
/// Recorre `peers` en orden, intentando `connect_libp2p` con cada uno hasta
/// que uno establezca handshake. La sesión devuelta es un `Client` idéntico
/// al del path Unix: `ping`, `await_event`, `farewell` funcionan igual; el
/// stream subyacente es libp2p convertido vía `tokio_util::compat`.
///
/// La keypair que se usa para firmar el handshake es la del propio `net`
/// (`BrahmanNet::keypair`) — la misma que autentica la conexión Noise, así
/// que el `peer_id` del Hello coincide con el del transporte y el server
/// no rechaza por `Unauthorized`.
///
/// Errores:
/// * [`ConsumerError::NoRemoteProviders`] si `peers` está vacío.
/// * [`ConsumerError::AllPeersFailed`] si ningún peer aceptó el handshake;
/// el `source` es el último `NetworkError` observado.
///
/// Atajo de uso típico tras `resolve_provider`:
/// ```ignore
/// match resolve_provider(card.clone(), &net, timeout).await? {
/// ProviderLocation::Local(socket) => {
/// Client::connect_with(&socket, card, wit).await?
/// }
/// ProviderLocation::Remote(peers) => {
/// consume_remote(&net, card, wit, peers).await?
/// }
/// };
/// ```
pub async fn consume_remote(
net: &BrahmanNet,
consumer_card: Card,
wit: Option<WitInterface>,
peers: Vec<PeerId>,
) -> Result<Client<LibP2pHandshakeStream>, ConsumerError> {
if peers.is_empty() {
let (flow, type_ref) = describe_first_input(&consumer_card);
return Err(ConsumerError::NoRemoteProviders { flow, type_ref });
}
let keypair = net.keypair();
let mut last_error: Option<NetworkError> = None;
for peer in &peers {
match connect_libp2p(net, *peer, consumer_card.clone(), wit.clone(), &keypair).await {
Ok(client) => return Ok(client),
Err(e) => {
warn!(
target: "card_sidecar",
peer = %peer,
error = %e,
"dial al peer remoto falló, intento siguiente",
);
last_error = Some(e);
}
}
}
Err(ConsumerError::AllPeersFailed {
peers,
source: last_error.expect("el loop garantiza al menos un error si peers no era vacío"),
})
}
/// Conecta al brahman-init con una Card observer (sin inputs ni
/// outputs) y pide la lista de sesiones activas. Útil para
/// herramientas de observabilidad (broker-explorer, CLIs).
///
/// El observer se identifica con `observer_label`. La sesión se
/// cierra con Farewell antes de retornar (best-effort).
pub async fn list_sessions(
observer_label: impl Into<String>,
) -> Result<card_handshake::messages::SessionList, ConsumerError> {
let init_path = transport::default_socket_path();
// Card mínima sin flow.input/output: el observer no participa en
// matching, sólo establece sesión para poder consultar.
let card = Card {
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Oneshot,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![],
},
..Card::new(observer_label)
};
let mut client = Client::connect(&init_path, card)
.await
.map_err(|source| ConsumerError::Connect {
socket: init_path.clone(),
source,
})?;
let list = client.list_sessions().await?;
let _ = client.farewell().await;
Ok(list)
}
/// Wrapper bloqueante de [`list_sessions`]. Idéntico patrón a
/// `await_provider_blocking`: runtime current_thread efímero.
pub fn list_sessions_blocking(
observer_label: impl Into<String>,
) -> Result<card_handshake::messages::SessionList, ConsumerError> {
let label = observer_label.into();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
rt.block_on(list_sessions(label))
}
/// Análogo a `list_sessions` pero pide los matches activos del
/// broker. La Card observer es la misma forma minimalista (sin
/// flow.input/output) — el endpoint no requiere participar en
/// matching.
pub async fn list_matches(
observer_label: impl Into<String>,
) -> Result<card_handshake::messages::MatchList, ConsumerError> {
let init_path = transport::default_socket_path();
let card = Card {
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Oneshot,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![],
},
..Card::new(observer_label)
};
let mut client = Client::connect(&init_path, card)
.await
.map_err(|source| ConsumerError::Connect {
socket: init_path.clone(),
source,
})?;
let list = client.list_matches().await?;
let _ = client.farewell().await;
Ok(list)
}
/// Wrapper bloqueante de [`list_matches`].
pub fn list_matches_blocking(
observer_label: impl Into<String>,
) -> Result<card_handshake::messages::MatchList, ConsumerError> {
let label = observer_label.into();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| ConsumerError::Runtime(e.to_string()))?;
rt.block_on(list_matches(label))
}
fn describe_first_input(card: &Card) -> (String, String) {
match card.flow.input.first() {
Some(flow) => {
let type_name = match &flow.ty {
TypeRef::Primitive { name } => name.clone(),
TypeRef::Wit { package, name, .. } => format!("{package}#{name}"),
};
(flow.name.clone(), type_name)
}
None => ("(sin input)".into(), "(sin tipo)".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use card_core::ulid::Ulid;
#[test]
fn builder_sets_input_flow_with_primitive_type() {
let c = build_consumer_card("chasqui.cli", "embed-result", "json");
assert_eq!(c.label, "chasqui.cli");
assert_eq!(c.kind, CardKind::Ente);
assert!(matches!(c.lifecycle, Lifecycle::Oneshot));
assert!(matches!(c.supervision, Supervision::OneShot));
assert_eq!(c.flow.input.len(), 1);
let f = &c.flow.input[0];
assert_eq!(f.name, "embed-result");
match &f.ty {
TypeRef::Primitive { name } => assert_eq!(name, "json"),
_ => panic!("expected primitive type"),
}
assert!(c.flow.output.is_empty());
// El builder asigna un id real (no nil) — fundamental para que
// el broker no colisione con otros consumers.
assert!(c.id != Ulid::nil(), "consumer card id no debe ser nil");
}
#[test]
fn builder_assigns_distinct_ids_per_call() {
let a = build_consumer_card("a", "f", "t");
let b = build_consumer_card("a", "f", "t");
assert_ne!(a.id, b.id, "cada Card debería tener id propio");
}
#[test]
fn describe_falls_back_when_no_input_flow() {
let mut c = build_consumer_card("x", "f", "t");
c.flow.input.clear();
let (flow, ty) = describe_first_input(&c);
assert_eq!(flow, "(sin input)");
assert_eq!(ty, "(sin tipo)");
}
#[test]
fn describe_formats_wit_type() {
let mut c = build_consumer_card("x", "f", "t");
c.flow.input[0].ty = TypeRef::Wit {
package: "brahman:dht".into(),
interface: None,
name: "entity-result".into(),
};
let (_, ty) = describe_first_input(&c);
assert_eq!(ty, "brahman:dht#entity-result");
}
}
+255
View File
@@ -0,0 +1,255 @@
//! `brahman-sidecar` — boilerplate del cliente brahman extraído.
//!
//! Cualquier módulo que quiera presentarse al Init brahman pero que tenga
//! su propio runtime (GPUI, current_thread tokio, std-thread loop, etc.)
//! puede llamar [`spawn`] con su [`card_core::Card`]. Eso arma un
//! thread aparte con un runtime tokio current_thread, conecta al Init,
//! y mantiene la sesión viva con pings periódicos.
//!
//! Si el Init no está disponible, el thread loggea y termina — el módulo
//! sigue funcionando standalone.
//!
//! Errores de conexión / ping se loggean vía `tracing::warn!`. Si querés
//! capturar la salida del thread (por ejemplo para test), usá
//! [`spawn_with_handle`] que devuelve un `JoinHandle`.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod discovery;
pub use discovery::{
await_provider, await_provider_blocking, build_consumer_card, consume_remote, list_matches,
list_matches_blocking, list_sessions, list_sessions_blocking, resolve_provider, ConsumerError,
ProviderLocation,
};
use std::collections::HashMap;
use std::sync::{mpsc, Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use card_core::{ulid::Ulid, Card, WitInterface};
use card_handshake::{client::Client, transport};
use tokio::task::AbortHandle;
use tracing::{info, warn};
/// Período entre pings al Init.
pub const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(30);
/// Configuración del sidecar.
#[derive(Debug, Clone)]
pub struct SidecarConfig {
/// Card que se presenta al Init.
pub card: Card,
/// WIT interface opcional. Si es `Some`, el módulo se registra como
/// "consciente" (`ResolvedCard::from_conscious`).
pub wit: Option<WitInterface>,
/// Período entre pings.
pub ping_interval: Duration,
}
impl SidecarConfig {
/// Configuración agnóstica con defaults razonables (sin WIT, ping 30s).
pub fn new(card: Card) -> Self {
Self {
card,
wit: None,
ping_interval: DEFAULT_PING_INTERVAL,
}
}
/// Configuración consciente con WIT extraída.
pub fn with_wit(mut self, wit: WitInterface) -> Self {
self.wit = Some(wit);
self
}
}
/// Spawn fire-and-forget agnóstico. Para módulos conscientes usá
/// [`spawn_conscious`] o [`spawn_with_handle`] con un `SidecarConfig`
/// personalizado.
pub fn spawn(card: Card) {
if let Err(e) = spawn_with_handle(SidecarConfig::new(card)) {
warn!(error = %e, "no se pudo spawnear el sidecar brahman");
}
}
/// Spawn fire-and-forget con WIT. Idéntico a [`spawn`] pero el módulo se
/// registra como consciente en el broker.
pub fn spawn_conscious(card: Card, wit: WitInterface) {
if let Err(e) = spawn_with_handle(SidecarConfig::new(card).with_wit(wit)) {
warn!(error = %e, "no se pudo spawnear el sidecar brahman");
}
}
/// Spawn devolviendo el `JoinHandle` para tests o cleanup explícito.
pub fn spawn_with_handle(config: SidecarConfig) -> std::io::Result<JoinHandle<()>> {
std::thread::Builder::new()
.name("brahman-sidecar".into())
.spawn(move || run_thread(config))
}
// =====================================================================
// SidecarPool — un solo runtime tokio compartido por N sesiones
// =====================================================================
/// Pool consolidado: un único thread con un runtime tokio
/// `current_thread` que hostea N tasks de sidecar simultáneas.
///
/// Para módulos con muchas sesiones (p. ej. `chasqui daemon` publicando
/// 50+ Mónadas), evita el costo de tener un thread+runtime por cada
/// sesión.
///
/// **API**:
/// - `SidecarPool::new()` crea el pool (spawn del thread runtime).
/// - `pool.spawn(card)` añade una sesión sin WIT.
/// - `pool.spawn_conscious(card, wit)` añade una sesión con WIT.
/// - `pool.spawn_with_config(config)` para configuración custom.
///
/// El pool se mantiene vivo mientras exista. Si el `SidecarPool`
/// se dropea, el thread interno termina y todas las sesiones cierran.
pub struct SidecarPool {
handle: tokio::runtime::Handle,
/// Sesiones vivas indexadas por `Card.id`. Permite que un nuevo
/// `spawn` con el mismo id aborte la sesión previa — útil cuando
/// un módulo (p. ej. `chasqui daemon`) re-publica una Mónada cuya
/// composición cambió.
sessions: Arc<Mutex<HashMap<Ulid, AbortHandle>>>,
_thread: JoinHandle<()>,
}
impl SidecarPool {
/// Crea un pool nuevo. Bloquea hasta que el runtime esté listo.
pub fn new() -> std::io::Result<Self> {
let (handle_tx, handle_rx) = mpsc::sync_channel::<tokio::runtime::Handle>(0);
let thread = std::thread::Builder::new()
.name("brahman-sidecar-pool".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
warn!(error = %e, "tokio runtime falló — pool muerto");
return;
}
};
if handle_tx.send(rt.handle().clone()).is_err() {
return;
}
// Mantenemos el runtime vivo mientras existan tasks.
rt.block_on(std::future::pending::<()>());
})?;
let handle = handle_rx
.recv()
.map_err(|_| std::io::Error::other("pool runtime no respondió"))?;
Ok(Self {
handle,
sessions: Arc::new(Mutex::new(HashMap::new())),
_thread: thread,
})
}
/// Añade una sesión agnóstica al pool (sin WIT).
pub fn spawn(&self, card: Card) {
self.spawn_with_config(SidecarConfig::new(card));
}
/// Añade una sesión consciente (con WIT) al pool.
pub fn spawn_conscious(&self, card: Card, wit: WitInterface) {
self.spawn_with_config(SidecarConfig::new(card).with_wit(wit));
}
/// Añade una sesión con configuración custom.
///
/// Si ya existía una sesión para el mismo `Card.id`, la previa
/// se aborta antes de spawnear la nueva. Esto hace `spawn`
/// idempotente respecto al id: re-publicar una Mónada cuya
/// composición cambió "refresca" la sesión en el broker.
pub fn spawn_with_config(&self, config: SidecarConfig) {
let card_id = config.card.id;
let join = self.handle.spawn(run_client(config));
let abort = join.abort_handle();
if let Ok(mut sessions) = self.sessions.lock() {
if let Some(prev) = sessions.insert(card_id, abort) {
prev.abort();
}
}
}
/// Cierra explícitamente la sesión asociada a `card_id`. No-op si
/// no había sesión registrada.
pub fn drop_session(&self, card_id: Ulid) {
if let Ok(mut sessions) = self.sessions.lock() {
if let Some(abort) = sessions.remove(&card_id) {
abort.abort();
}
}
}
/// Cantidad actual de sesiones vivas (estimada — puede haber
/// drift transitorio entre abort y limpieza).
pub fn live_sessions(&self) -> usize {
self.sessions.lock().map(|s| s.len()).unwrap_or(0)
}
}
impl Default for SidecarPool {
fn default() -> Self {
Self::new().expect("falló SidecarPool::new")
}
}
fn run_thread(config: SidecarConfig) {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(e) => {
warn!(error = %e, "tokio runtime falló");
return;
}
};
rt.block_on(run_client(config));
}
/// Bucle async del sidecar. Público para que `SidecarPool` lo use vía
/// `handle.spawn(run_client(...))` desde código externo al runtime.
pub async fn run_client(config: SidecarConfig) {
let path = transport::default_socket_path();
let conscious = config.wit.is_some();
let mut client = match Client::connect_with(&path, config.card, config.wit).await {
Ok(c) => {
info!(
target: "card_sidecar",
session = %c.session(),
init_attached = c.server_info().init_attached,
server = %c.server_info().server_version,
conscious,
"attached"
);
c
}
Err(e) => {
warn!(
target: "card_sidecar",
error = %e,
socket = %path.display(),
"no conectado"
);
return;
}
};
loop {
tokio::time::sleep(config.ping_interval).await;
if let Err(e) = client.ping().await {
warn!(target: "card_sidecar", error = %e, "ping falló — terminando sidecar");
return;
}
}
}
@@ -0,0 +1,256 @@
//! Integración del resolver `card_sidecar::discovery::resolve_provider`:
//! prueba el camino **local-first / remote-fallback**. Sin init local
//! alcanzable (socket bogus), el resolver debe caer al DHT y descubrir un
//! proveedor remoto que anunció su output flow.
//!
//! Reusa el harness de `card-handshake/tests/network_discovery.rs`: un Node A
//! con Server + `BrahmanNet` + Card-con-output (el Server llama
//! `announce_outputs` al registrar → `start_providing` en el DHT), y un Node B
//! que dial-a a A y resuelve vía el DHT compartido.
use std::sync::Arc;
use std::time::Duration;
use card_core::{
Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
};
use card_handshake::network::run_libp2p_accept_loop;
use card_handshake::server::{Server, ServerConfig};
use card_net::{BrahmanNet, Multiaddr, Protocol};
use card_sidecar::discovery::{
build_consumer_card, consume_remote, resolve_provider, ConsumerError, ProviderLocation,
};
use chasqui_broker::{Broker, BrokerConfig};
use tempfile::TempDir;
use tokio::sync::Mutex;
/// Card de proveedor con un único output flow `(flow_name, type_name)`.
fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
Card {
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![Flow {
name: flow_name.into(),
ty: TypeRef::Primitive {
name: type_name.into(),
},
pin_to: None,
}],
},
..Card::new(label)
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn resolve_provider_cae_a_dht_cuando_no_hay_local() {
// Forzar miss local: que `await_provider` falle rápido por socket ausente
// (nextest aísla cada test en su propio proceso, así que la env no se filtra).
std::env::set_var("BRAHMAN_INIT_SOCKET", "/nonexistent/brahman-init-test.sock");
// ---- Node A: Server + net + Card con output (anuncia al DHT) ----
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker.clone()),
net: Some(a_net.clone()), // ← el Server anuncia los outputs al DHT
policy: None,
},
)
.unwrap(),
);
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let a_addr = a_net.listen(listen_addr).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// A registra una Card con output "monad-list":json → start_providing.
let card = provider_card("test.engine_remote", "monad-list", "json");
let mut local_client = card_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
// ---- Node B: net que dial-a a A y comparte DHT ----
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full.clone());
// Margen para que Identify popule la routing table de Kad.
tokio::time::sleep(Duration::from_millis(500)).await;
// resolve_provider: sin init local (socket bogus) → fallback al DHT.
let consumer = build_consumer_card("test.consumer", "monad-list", "json");
let loc = resolve_provider(consumer, &b_net, Duration::from_millis(200))
.await
.expect("resolve_provider no debe errar");
match loc {
ProviderLocation::Remote(peers) => assert!(
peers.contains(&a_peer),
"el fallback DHT debería descubrir a A; encontrados: {:?}, esperado: {}",
peers,
a_peer
),
ProviderLocation::Local(s) => {
panic!("esperaba Remote (no hay init local), obtuve Local({:?})", s)
}
}
local_client.farewell().await.ok();
}
/// End-to-end del follow-up de Nivel 3 (CIERRE §3.2): un nodo descubre por
/// DHT a un proveedor remoto y CONSUME — abre handshake firmado por libp2p,
/// pinea y se despide — sin un init local de por medio. Es el patrón de
/// `jalar_a_traves_de_un_relay` aplicado al consumer-side de chasqui.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn consume_remote_abre_handshake_firmado_via_libp2p() {
// Mismo truco que el test de arriba: forzar miss del init local para
// que el resolver caiga directo al DHT.
std::env::set_var(
"BRAHMAN_INIT_SOCKET",
"/nonexistent/brahman-init-consume.sock",
);
// ---- Node A: Server + net; al registrar la Card con output, el
// Server llama `announce_outputs` y publica la key
// `(monad-list, json)` en el DHT.
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker.clone()),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let a_addr = a_net.listen(listen_addr).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
// A acepta tanto las sesiones libp2p (handshake firmado entrante) como
// las locales por socket Unix (necesarias para registrar la Card que
// dispara el `announce_outputs`).
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
let provider = provider_card("test.engine_remote_consume", "monad-list", "json");
let mut local_provider_client = card_handshake::client::Client::connect(&a_unix, provider)
.await
.expect("registro local del provider en A");
// ---- Node B: net independiente; dial-a A para compartir DHT.
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full.clone());
tokio::time::sleep(Duration::from_millis(500)).await;
// Descubrir.
let consumer = build_consumer_card("test.consumer_remote", "monad-list", "json");
let loc = resolve_provider(consumer.clone(), &b_net, Duration::from_millis(200))
.await
.expect("resolve_provider no debe errar");
let peers = match loc {
ProviderLocation::Remote(peers) => {
assert!(
peers.contains(&a_peer),
"el fallback DHT debería descubrir a A; encontrados: {:?}, esperado: {}",
peers,
a_peer
);
peers
}
ProviderLocation::Local(s) => {
panic!("esperaba Remote (no hay init local), obtuve Local({:?})", s)
}
};
// Consumir: abre handshake firmado por libp2p contra A y pinea.
let mut remote = consume_remote(&b_net, consumer, None, peers)
.await
.expect("consume_remote debe abrir handshake firmado contra A");
remote
.ping()
.await
.expect("ping libp2p sobre la sesión consumer-remoto");
// Cierre limpio de ambas sesiones.
remote.farewell().await.ok();
local_provider_client.farewell().await.ok();
}
/// Si el DHT no anuncia ningún proveedor para el flow pedido, `consume_remote`
/// no debe colgar ni intentar nada — debe fallar inmediato con
/// `NoRemoteProviders`. Este test no necesita Net A ni B, sólo un net solo y
/// una lista vacía de peers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn consume_remote_falla_inmediato_si_no_hay_peers() {
let net = BrahmanNet::new().unwrap();
let consumer = build_consumer_card("test.consumer_solo", "flow-fantasma", "json");
let err = consume_remote(&net, consumer, None, vec![])
.await
.expect_err("vector vacío debe traducirse a NoRemoteProviders");
match err {
ConsumerError::NoRemoteProviders { flow, type_ref } => {
assert_eq!(flow, "flow-fantasma");
assert_eq!(type_ref, "json");
}
otro => panic!("esperaba NoRemoteProviders, obtuve {otro:?}"),
}
}
@@ -0,0 +1,28 @@
[package]
name = "chasqui-broker-explorer-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Probe Llimphi del broker brahman: conecta cada N segundos vía await_provider_blocking con un Card observer agnóstico, reporta 3 estados (down / up sin provider / up con provider) y trackea la diff de matches entre ticks. Reemplazo del `chasqui-broker-explorer` GPUI."
[dependencies]
chasqui-broker = { path = "../chasqui-broker" }
card-core = { workspace = true }
card-handshake = { path = "../card-handshake" }
card-sidecar = { path = "../card-sidecar" }
ulid = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-app-header = { workspace = true }
llimphi-widget-banner = { workspace = true }
llimphi-widget-stat-card = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
[[bin]]
name = "chasqui-broker-explorer-llimphi"
path = "src/main.rs"
@@ -0,0 +1,16 @@
# chasqui-broker-explorer-llimphi
> UI Llimphi: topics + suscriptores activos del broker de [chasqui](../README.md).
Lista los topics actuales, cuántos suscriptores tiene cada uno, throughput por topic, persistencia activa o no. Útil para diagnosticar el sistema.
## Uso
```sh
cargo run --release -p chasqui-broker-explorer-llimphi
```
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
- [`llimphi-ui`](../../llimphi/) + widgets `list`, `tabs`
@@ -0,0 +1,16 @@
# chasqui-broker-explorer-llimphi
> Llimphi UI: topics + active subscribers of [chasqui](../README.md)'s broker.
Lists current topics, how many subscribers each has, throughput per topic, persistence active or not. Useful for diagnosing the system.
## Usage
```sh
cargo run --release -p chasqui-broker-explorer-llimphi
```
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
- [`llimphi-ui`](../../llimphi/) + widgets `list`, `tabs`
@@ -0,0 +1,868 @@
//! `chasqui-broker-explorer-llimphi` — probe Llimphi del broker
//! brahman.
//!
//! Cada [`POLL_INTERVAL`] arma un Card observer agnóstico y lo manda
//! al broker via `card_sidecar::await_provider_blocking` (que
//! internamente abre tokio runtime + Unix socket + handshake).
//! Reporta 3 estados:
//!
//! - **Down**: connect failed (broker no escucha en el socket).
//! - **Up sin provider**: connect OK, pero el broker no encontró
//! productor para el flow probado dentro del timeout.
//! - **Up con provider**: connect OK + el broker matcheó algo →
//! muestra el `producer_service_socket` recibido.
//!
//! Configuración via env:
//! - `BRAHMAN_INIT_SOCKET` — path del socket del broker (default
//! resuelto por `card_handshake::transport`).
//! - `BRAHMAN_BROKER_PROBE_FLOW` — nombre del flow probe (default
//! `broker-health`).
//! - `BRAHMAN_BROKER_PROBE_TYPE` — type name del flow probe
//! (default `ping`).
//!
//! Usá un type name probable (ej. `monad-list:json`,
//! `event-log:tail`) para detectar productores específicos del
//! ecosistema.
#![forbid(unsafe_code)]
use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use card_handshake::messages::SessionList;
use card_handshake::transport;
use card_sidecar::{
await_provider_blocking, build_consumer_card, list_matches_blocking, list_sessions_blocking,
ConsumerError,
};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_motion::{animate, motion, Tween};
use llimphi_widget_app_header::{app_header, AppHeaderPalette};
use llimphi_widget_banner::{banner_view, BannerKind};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{
menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
DEFAULT_HEIGHT as MENU_H,
};
use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
use ulid::Ulid;
use app_bus::{AppMenu, Menu, MenuItem};
const POLL_INTERVAL: Duration = Duration::from_secs(5);
const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
/// Cap del buffer del timeline. Mantenemos las últimas N entries —
/// más viejo se descarta. 50 cubre ~4 minutos de actividad densa.
const TIMELINE_CAP: usize = 50;
#[derive(Clone, Debug)]
enum ProbeState {
Pending,
Down { reason: String },
UpNoProvider { flow: String },
UpWithProvider { flow: String, producer_socket: PathBuf },
}
#[derive(Clone, Debug)]
struct TimelineEntry {
at: std::time::SystemTime,
kind: card_handshake::messages::MatchEventKind,
consumer_label: String,
consumer_flow: String,
producer_label: String,
producer_flow: String,
via: chasqui_broker::MatchStrategy,
pinned: bool,
}
type MatchKey = (Ulid, String, Ulid, String);
struct Model {
theme: Theme,
socket_path: PathBuf,
flow: String,
type_name: String,
state: ProbeState,
last_probe_ms: u64,
sessions: Option<SessionList>,
last_match_keys: HashSet<MatchKey>,
timeline: VecDeque<TimelineEntry>,
/// Barra de menú principal: índice del menú raíz abierto (`None`
/// cerrado).
menu_open: Option<usize>,
/// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna).
menu_active: usize,
/// Animación de aparición del dropdown.
menu_anim: Tween<f32>,
/// Entry del timeline seleccionada (índice en `timeline`). `None`
/// si ninguna. La selección sólo habilita el menú contextual; el
/// probe es de sólo lectura.
selected: Option<usize>,
/// Menú contextual sobre una entry: `(idx, x, y)` ancla en ventana.
/// `None` cerrado.
context_menu: Option<(usize, f32, f32)>,
}
#[derive(Clone)]
enum Msg {
Tick,
ProbeResult {
state: ProbeState,
sessions: Option<SessionList>,
matches: Option<card_handshake::messages::MatchList>,
elapsed_ms: u64,
},
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Navega la fila activa del dropdown (+1/-1).
MenuNav(i32),
/// Ejecuta el comando de la fila activa (Enter).
MenuActivate,
/// No-op: sólo fuerza re-render durante la animación del dropdown.
MenuTick,
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Cicla el tema entre presets.
CycleTheme,
/// Selecciona una entry del timeline por índice (resalta).
SelectEntry(usize),
/// Right-click en la raíz → abre el menú contextual anclado en
/// `(x, y)` de ventana sobre la entry seleccionada. Sin selección
/// es no-op.
ContextMenuOpen(f32, f32),
/// Limpia el timeline acumulado.
ClearTimeline,
}
struct Explorer;
impl App for Explorer {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Brahman Broker — Probe"
}
fn initial_size() -> (u32, u32) {
(720, 480)
}
fn init(handle: &Handle<Msg>) -> Model {
let flow = std::env::var("BRAHMAN_BROKER_PROBE_FLOW")
.unwrap_or_else(|_| "broker-health".to_string());
let type_name = std::env::var("BRAHMAN_BROKER_PROBE_TYPE")
.unwrap_or_else(|_| "ping".to_string());
handle.dispatch(Msg::Tick);
handle.spawn_periodic(POLL_INTERVAL, || Msg::Tick);
Model {
theme: Theme::dark(),
socket_path: transport::default_socket_path(),
flow,
type_name,
state: ProbeState::Pending,
last_probe_ms: 0,
sessions: None,
last_match_keys: HashSet::new(),
timeline: VecDeque::new(),
menu_open: None,
menu_active: usize::MAX,
menu_anim: Tween::idle(1.0),
selected: None,
context_menu: None,
}
}
fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
if event.state != KeyState::Pressed {
return None;
}
if let Some(mi) = model.menu_open {
let n = app_menu().menus.len().max(1);
return match &event.key {
Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
_ => None,
};
}
None
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::Tick => {
let flow = m.flow.clone();
let type_name = m.type_name.clone();
handle.spawn(move || run_probe(flow, type_name));
}
Msg::ProbeResult { state, sessions, matches, elapsed_ms } => {
m.state = state;
m.sessions = sessions;
if let Some(list) = matches {
m.diff_matches_into_timeline(&list);
}
m.last_probe_ms = elapsed_ms;
// Si la selección quedó fuera de rango tras el refresh
// del timeline, la descartamos.
if m.selected.map(|i| i >= m.timeline.len()).unwrap_or(false) {
m.selected = None;
m.context_menu = None;
}
}
Msg::MenuOpen(which) => {
m.menu_open = which;
// Abrir un menú raíz cierra cualquier contextual.
m.context_menu = None;
m.menu_active = usize::MAX;
if which.is_some() {
m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
animate(handle, motion::FAST, || Msg::MenuTick);
}
}
Msg::MenuNav(dir) => {
if let Some(mi) = m.menu_open {
let menu = app_menu();
m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir);
}
}
Msg::MenuActivate => {
if let Some(mi) = m.menu_open {
let menu = app_menu();
if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) {
m.menu_open = None;
return handle_menu_command(m, &cmd, handle);
}
}
}
Msg::MenuTick => {}
Msg::CloseMenus => {
m.menu_open = None;
m.menu_active = usize::MAX;
m.context_menu = None;
}
Msg::MenuCommand(cmd) => {
m.menu_open = None;
m.menu_active = usize::MAX;
return handle_menu_command(m, &cmd, handle);
}
Msg::CycleTheme => {
m.theme = Theme::next_after(m.theme.name);
}
Msg::SelectEntry(i) => {
m.selected = Some(i);
m.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
// Sólo si hay una entry seleccionada válida.
if let Some(i) = m.selected.filter(|i| *i < m.timeline.len()) {
m.menu_open = None;
m.context_menu = Some((i, x, y));
}
}
Msg::ClearTimeline => {
m.timeline.clear();
m.selected = None;
m.context_menu = None;
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model, theme));
let header_palette = AppHeaderPalette::from_theme(theme);
let stat_palette = StatCardPalette::from_theme(theme);
// Acentos por estado.
let accent_up = Color::from_rgba8(0xa3, 0xbe, 0x8c, 0xff);
let accent_partial = Color::from_rgba8(0xeb, 0xcb, 0x8b, 0xff);
let accent_down = Color::from_rgba8(0xbf, 0x61, 0x6a, 0xff);
let accent_pending = Color::from_rgba8(0x6a, 0x72, 0x80, 0xff);
let header_text = format!(
"Probe: {} · flow: {}/{} · reload {} ms",
model.socket_path.display(),
model.flow,
model.type_name,
model.last_probe_ms,
);
let header = app_header::<Msg>(header_text, vec![], &header_palette);
let mut body_children: Vec<View<Msg>> = Vec::new();
// Banner permanente con el estado actual.
match &model.state {
ProbeState::Pending => {}
ProbeState::Down { reason } => body_children.push(banner_view::<Msg>(
BannerKind::Error,
format!("Broker DOWN — {reason}"),
)),
ProbeState::UpNoProvider { .. } => body_children.push(banner_view::<Msg>(
BannerKind::Warning,
"Broker UP, sin provider para el flow".to_string(),
)),
ProbeState::UpWithProvider { .. } => body_children.push(banner_view::<Msg>(
BannerKind::Success,
"Broker UP, provider matcheado".to_string(),
)),
}
// State card.
let (state_accent, state_value, state_descr) = state_card_params(
&model.state,
accent_up,
accent_partial,
accent_down,
accent_pending,
);
body_children.push(stat_card_view::<Msg>(
"Estado",
state_value,
&state_descr,
state_accent,
&[],
&stat_palette,
));
// Sessions card.
let sessions_items: Vec<String> = model
.sessions
.as_ref()
.map(|list| {
let mut entries: Vec<_> = list.entries.iter().collect();
entries.sort_by_key(|e| e.session);
entries
.iter()
.map(|e| {
format!(
"{} · in:[{}] out:[{}]{}",
e.label,
e.inputs.join(","),
e.outputs.join(","),
if e.conscious { " (wit)" } else { "" }
)
})
.collect()
})
.unwrap_or_default();
let sessions_count_value = model
.sessions
.as_ref()
.map(|l| l.entries.len().to_string())
.unwrap_or_else(|| "".into());
let sessions_descr = match &model.sessions {
None => "lista no disponible (broker DOWN o pendiente)".to_string(),
Some(l) if l.entries.is_empty() => {
"sin sesiones registradas en el broker".into()
}
Some(_) => "labels visibles + flows in/out · (wit) = consciente".into(),
};
body_children.push(stat_card_view::<Msg>(
"Sesiones activas",
sessions_count_value,
&sessions_descr,
accent_up,
&sessions_items,
&stat_palette,
));
// Timeline card de cabecera (contador + ayuda); las entries
// van debajo como filas seleccionables (click selecciona,
// right-click en la raíz abre el contextual).
let timeline_value = model.timeline.len().to_string();
let timeline_descr = if model.timeline.is_empty() {
"esperando primer match…".to_string()
} else {
"click selecciona · right-click = menú · cap 50 entries".to_string()
};
body_children.push(stat_card_view::<Msg>(
"Timeline de matches",
timeline_value,
&timeline_descr,
accent_partial,
&[],
&stat_palette,
));
for (i, e) in model.timeline.iter().take(20).enumerate() {
let selected = model.selected == Some(i);
let mut row = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.text_aligned(
format_timeline_entry(e),
11.0,
theme.fg_text,
Alignment::Start,
)
.on_click(Msg::SelectEntry(i));
if selected {
row = row.fill(theme.bg_selected);
}
body_children.push(row);
}
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(12.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(body_children);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
// Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el
// menú contextual sobre la entry seleccionada.
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, header, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
// El menú contextual de la entry tiene prioridad si está abierto.
if let Some((idx, x, y)) = model.context_menu {
let label = model
.timeline
.get(idx)
.map(format_timeline_entry)
.unwrap_or_else(|| "Entry".to_string());
let viewport = viewport_of(model);
// Acciones reales del probe (sólo lectura): refrescar el
// probe y limpiar el timeline. No inventamos edición.
let items = vec![
ContextMenuItem::action("Refrescar probe"),
ContextMenuItem::action("Limpiar timeline"),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> =
Arc::new(move |i: usize| match i {
0 => Msg::Tick,
_ => Msg::ClearTimeline,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport,
header: Some(label),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
// Si no, el dropdown del menú principal.
let menu = app_menu();
menubar_overlay_animated(
&menubar_spec(&menu, model, &model.theme),
model.menu_active,
model.menu_anim.value(),
)
}
}
/// Viewport para clampear overlays: el probe no trackea el tamaño de
/// ventana, así que usamos `initial_size()`.
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = Explorer::initial_size();
(w as f32, h as f32)
}
/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
fn menubar_spec<'a>(
menu: &'a AppMenu,
model: &Model,
theme: &'a Theme,
) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// El menú principal del probe. Archivo / Ver / Ayuda — sólo comandos
/// que mapean a acciones reales (refrescar probe, limpiar timeline,
/// reconectar, tema). Sin "Editar": el probe no tiene campos de texto
/// editables.
fn app_menu() -> AppMenu {
AppMenu::new()
.menu(
Menu::new("Archivo")
.item(MenuItem::new("Refrescar probe", "file.refresh").shortcut("Ctrl+R"))
.item(MenuItem::new("Limpiar timeline", "file.clear"))
.item(MenuItem::new("Salir", "file.quit").shortcut("Ctrl+Q").separated()),
)
.menu(
Menu::new("Ver")
.item(MenuItem::new("Reconectar", "view.reconnect"))
.item(MenuItem::new("Cambiar tema", "view.theme").separated()),
)
.menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
}
/// Traduce un command id del menú principal al `Msg`/efecto real.
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
match cmd {
// Reconectar == re-disparar el probe; el ProbeResult refresca
// estado/sesiones/matches.
"file.refresh" | "view.reconnect" => {
handle.dispatch(Msg::Tick);
model
}
"file.clear" => {
handle.dispatch(Msg::ClearTimeline);
model
}
"file.quit" => std::process::exit(0),
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => model,
}
}
impl Model {
fn diff_matches_into_timeline(
&mut self,
list: &card_handshake::messages::MatchList,
) {
let (new_entries, new_keys) = diff_matches(&self.last_match_keys, list);
for entry in new_entries {
self.push_timeline(entry);
}
self.last_match_keys = new_keys;
}
fn push_timeline(&mut self, entry: TimelineEntry) {
self.timeline.push_front(entry);
while self.timeline.len() > TIMELINE_CAP {
self.timeline.pop_back();
}
}
}
fn state_card_params(
state: &ProbeState,
accent_up: Color,
accent_partial: Color,
accent_down: Color,
accent_pending: Color,
) -> (Color, String, String) {
match state {
ProbeState::Pending => (
accent_pending,
"PENDING".into(),
"esperando primer probe…".into(),
),
ProbeState::Down { reason } => (
accent_down,
"DOWN".into(),
format!("connect failed: {reason}"),
),
ProbeState::UpNoProvider { flow } => (
accent_partial,
"UP / NO PROVIDER".into(),
format!("broker reachable; sin productor para flow `{flow}`"),
),
ProbeState::UpWithProvider { flow, producer_socket } => (
accent_up,
"UP / PROVIDER".into(),
format!(
"flow `{flow}` matcheado en producer socket: {}",
producer_socket.display()
),
),
}
}
fn run_probe(flow: String, type_name: String) -> Msg {
let started = Instant::now();
let card = build_consumer_card("brahman-broker-explorer-llimphi", flow.clone(), type_name);
let result = await_provider_blocking(card, PROBE_TIMEOUT);
let new_state = match result {
Ok(socket) => ProbeState::UpWithProvider {
flow: flow.clone(),
producer_socket: socket,
},
Err(ConsumerError::NoProvider { .. }) => ProbeState::UpNoProvider { flow: flow.clone() },
Err(e) => ProbeState::Down {
reason: e.to_string(),
},
};
let (sessions, matches) = match &new_state {
ProbeState::Down { .. } | ProbeState::Pending => (None, None),
_ => (
list_sessions_blocking("brahman-broker-explorer-llimphi").ok(),
list_matches_blocking("brahman-broker-explorer-llimphi").ok(),
),
};
Msg::ProbeResult {
state: new_state,
sessions,
matches,
elapsed_ms: started.elapsed().as_millis() as u64,
}
}
/// Diff puro entre snapshots de matches. Devuelve la lista de
/// entries nuevas (Available + Lost) en orden Available-primero, y
/// el set actualizado de keys.
fn diff_matches(
last_keys: &HashSet<MatchKey>,
list: &card_handshake::messages::MatchList,
) -> (Vec<TimelineEntry>, HashSet<MatchKey>) {
use card_handshake::messages::MatchEventKind;
let now = std::time::SystemTime::now();
let current_keys: HashSet<MatchKey> = list
.matches
.iter()
.map(|m| {
(
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
)
})
.collect();
let mut entries = Vec::new();
for m in &list.matches {
let key = (
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
);
if !last_keys.contains(&key) {
entries.push(TimelineEntry {
at: now,
kind: MatchEventKind::Available,
consumer_label: m.consumer_label.clone(),
consumer_flow: m.consumer.flow_name.clone(),
producer_label: m.producer_label.clone(),
producer_flow: m.producer.flow_name.clone(),
via: m.via,
pinned: m.pinned,
});
}
}
for key in last_keys.iter() {
if !current_keys.contains(key) {
entries.push(TimelineEntry {
at: now,
kind: MatchEventKind::Lost,
consumer_label: String::new(),
consumer_flow: key.1.clone(),
producer_label: String::new(),
producer_flow: key.3.clone(),
via: chasqui_broker::MatchStrategy::Exact,
pinned: false,
});
}
}
(entries, current_keys)
}
fn format_timeline_entry(e: &TimelineEntry) -> String {
use card_handshake::messages::MatchEventKind;
let secs_today = e
.at
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() % 86_400)
.unwrap_or(0);
let h = secs_today / 3600;
let m = (secs_today % 3600) / 60;
let s = secs_today % 60;
let kind = match e.kind {
MatchEventKind::Available => "+",
MatchEventKind::Lost => "-",
};
let pinned = if e.pinned { " (pinned)" } else { "" };
match e.kind {
MatchEventKind::Available => format!(
"{:02}:{:02}:{:02} {} {}.{}{}.{} [{:?}]{}",
h,
m,
s,
kind,
e.consumer_label,
e.consumer_flow,
e.producer_label,
e.producer_flow,
e.via,
pinned,
),
MatchEventKind::Lost => format!(
"{:02}:{:02}:{:02} {} ?.{} ← ?.{} (lost)",
h, m, s, kind, e.consumer_flow, e.producer_flow,
),
}
}
fn main() {
llimphi_ui::run::<Explorer>();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pending_is_default_state_at_boot() {
let s = ProbeState::Pending;
assert!(matches!(s, ProbeState::Pending));
}
#[test]
fn poll_and_probe_constants_are_sane() {
assert!(PROBE_TIMEOUT < POLL_INTERVAL);
assert!(POLL_INTERVAL >= Duration::from_secs(2));
}
fn synth_match(
consumer_label: &str,
consumer_flow: &str,
producer_label: &str,
producer_flow: &str,
) -> chasqui_broker::Match {
use card_core::TypeRef;
use chasqui_broker::{Endpoint, Match, MatchStrategy};
Match {
consumer: Endpoint {
session: Ulid::new(),
flow_name: consumer_flow.into(),
},
consumer_label: consumer_label.into(),
producer: Endpoint {
session: Ulid::new(),
flow_name: producer_flow.into(),
},
producer_label: producer_label.into(),
ty: TypeRef::Primitive { name: "json".into() },
via: MatchStrategy::Exact,
pinned: false,
}
}
#[test]
fn diff_matches_first_snapshot_marks_everything_available() {
use card_handshake::messages::{MatchEventKind, MatchList};
let list = MatchList {
matches: vec![
synth_match("a", "x", "b", "x"),
synth_match("c", "y", "d", "y"),
],
};
let last = HashSet::new();
let (entries, keys) = diff_matches(&last, &list);
assert_eq!(entries.len(), 2);
assert!(entries
.iter()
.all(|e| matches!(e.kind, MatchEventKind::Available)));
assert_eq!(keys.len(), 2);
}
#[test]
fn diff_matches_emits_lost_when_match_disappears() {
use card_handshake::messages::{MatchEventKind, MatchList};
let m = synth_match("a", "x", "b", "x");
let prev_key = (
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
);
let last: HashSet<_> = std::iter::once(prev_key).collect();
let list = MatchList { matches: vec![] };
let (entries, keys) = diff_matches(&last, &list);
assert_eq!(entries.len(), 1);
assert!(matches!(entries[0].kind, MatchEventKind::Lost));
assert_eq!(entries[0].consumer_flow, "x");
assert_eq!(entries[0].producer_flow, "x");
assert!(keys.is_empty());
}
#[test]
fn diff_matches_no_change_emits_nothing() {
use card_handshake::messages::MatchList;
let m = synth_match("a", "x", "b", "x");
let key = (
m.consumer.session,
m.consumer.flow_name.clone(),
m.producer.session,
m.producer.flow_name.clone(),
);
let last: HashSet<_> = std::iter::once(key.clone()).collect();
let list = MatchList {
matches: vec![m.clone()],
};
let (entries, keys) = diff_matches(&last, &list);
assert!(entries.is_empty(), "match unchanged → no events");
assert_eq!(keys.len(), 1);
assert!(keys.contains(&key));
}
}
@@ -0,0 +1,15 @@
[package]
name = "chasqui-broker"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — broker de tipos: empareja productores↔consumidores por TypeRef con matching híbrido (exact + structural) y prioridad."
[dependencies]
card-core = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
+16
View File
@@ -0,0 +1,16 @@
# chasqui-broker
> Binario del broker de [chasqui](../README.md).
Loop async sobre `tokio` que rutea mensajes entre topics. TCP/Unix configurables. Persistencia opt-in por topic en `$XDG_DATA_HOME/chasqui/`.
## Uso
```sh
cargo run --release -p chasqui-broker -- --listen 127.0.0.1:7711
```
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
- `tokio`, `clap`
+16
View File
@@ -0,0 +1,16 @@
# chasqui-broker
> Broker binary of [chasqui](../README.md).
Async loop on `tokio` that routes messages between topics. Configurable TCP/Unix. Opt-in persistence per topic at `$XDG_DATA_HOME/chasqui/`.
## Usage
```sh
cargo run --release -p chasqui-broker -- --listen 127.0.0.1:7711
```
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
- `tokio`, `clap`
+995
View File
@@ -0,0 +1,995 @@
//! `brahman-broker` — empareja productores y consumidores por tipo de flujo.
//!
//! El broker indexa [`card_core::Card`]s registradas por `SessionId` y,
//! para cada `flow.input` de un consumidor, busca el `flow.output`
//! compatible de mejor calidad entre los demás. Tres ejes:
//!
//! 1. **Estrategia de matching** ([`MatchStrategy`]):
//! - `Exact`: igualdad estricta de [`card_core::TypeRef`].
//! - `Structural`: misma forma (mismo `package` + `name` para Wit;
//! ignora `interface`).
//! - `ExactThenStructural`: prefiere exact; cae en structural si no hay.
//!
//! 2. **Override `pin_to`**: si el consumidor declara `pin_to = "label"`,
//! el broker prefiere productores cuya Card tenga ese `label` (siempre
//! que el tipo siga matcheando). Si la pista no resuelve, cae en
//! matching por tipo normal.
//!
//! 3. **Prioridad**: empate de tipo se resuelve por
//! [`card_core::Priority`] del productor (mayor gana). Empate de
//! prioridad se resuelve lexicográficamente por `label` (estable y
//! determinista).
//!
//! El broker es **stateless w.r.t. routes**: cada `find_producer_for` o
//! `all_matches` se calcula bajo demanda. La única persistencia es el
//! índice de Cards registradas. Esto permite re-evaluar matches cuando
//! cambia el set sin invalidar caches.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::BTreeMap;
use std::path::PathBuf;
use card_core::{
Card, CardKind, CardReference, ContextBias, DataFacet, Flow, Lifecycle, Priority, TypeRef,
WitInterface,
};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
/// Identificador de sesión emitido por el handshake. Idéntico al usado por
/// `brahman-handshake` (no es un re-export para evitar la dependencia).
pub type SessionId = Ulid;
/// Estrategia de matching de tipos.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MatchStrategy {
/// Igualdad estricta de `TypeRef`.
Exact,
/// Misma forma: para `Wit`, mismo `package` + `name`; para
/// `Primitive`, mismo `name`.
Structural,
/// Híbrido: intenta `Exact` primero; si no matchea, `Structural`.
/// Reporta cuál estrategia ganó en [`Match::via`].
#[default]
ExactThenStructural,
}
/// Configuración del broker.
#[derive(Debug, Clone, Default)]
pub struct BrokerConfig {
pub strategy: MatchStrategy,
/// Contexto operativo activo. Si una Card declara un
/// `priority_contexts.<this>`, ese bias se aplica durante el match.
/// `None` = sin biases per-contexto, sólo se usa lo estático.
pub current_context: Option<String>,
}
/// Vista mínima de una Card que el broker necesita.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokeredCard {
pub session: SessionId,
pub label: String,
pub lifecycle: Lifecycle,
pub priority: Priority,
pub inputs: Vec<Flow>,
pub outputs: Vec<Flow>,
/// Interfaz WIT extraída si el módulo es "consciente"; `None` si agnóstico.
pub wit: Option<WitInterface>,
/// Biases per-contexto, propagados desde `Card.priority_contexts`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub priority_contexts: BTreeMap<String, ContextBias>,
/// Naturaleza de la entidad. Diferencia procesos (Ente) de
/// agrupaciones de datos (Data — p. ej. Mónadas Nouser).
#[serde(default)]
pub kind: CardKind,
/// Faceta de datos cuando `kind != Ente`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<DataFacet>,
/// Socket de servicio (data plane) si lo declara la Card.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_socket: Option<PathBuf>,
/// Referencias a otras Cards (relaciones declaradas por esta Card).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<CardReference>,
}
impl BrokeredCard {
fn from_card(session: SessionId, card: &Card, wit: Option<WitInterface>) -> Self {
Self {
session,
label: card.label.clone(),
lifecycle: card.lifecycle,
priority: card.priority,
inputs: card.flow.input.clone(),
outputs: card.flow.output.clone(),
wit,
priority_contexts: card.priority_contexts.clone(),
kind: card.kind,
data: card.data.clone(),
service_socket: card.service_socket.clone(),
references: card.references.clone(),
}
}
}
/// Punto extremo de un flujo: qué sesión + nombre del flow dentro de su Card.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Endpoint {
pub session: SessionId,
pub flow_name: String,
}
/// Match concreto entre un consumidor y un productor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Match {
pub consumer: Endpoint,
pub consumer_label: String,
pub producer: Endpoint,
pub producer_label: String,
/// Tipo del flow (lado consumidor — lado productor coincide en
/// estrategia Exact, puede diferir en `interface` en Structural).
pub ty: TypeRef,
/// Estrategia que efectivamente matcheó.
pub via: MatchStrategy,
/// `true` si el match fue resuelto por `pin_to` y no por type-search.
pub pinned: bool,
}
// =====================================================================
// Broker
// =====================================================================
/// El broker. Registra Cards por SessionId, computa matches bajo demanda.
#[derive(Debug, Clone, Default)]
pub struct Broker {
cards: BTreeMap<SessionId, BrokeredCard>,
config: BrokerConfig,
}
impl Broker {
pub fn new(config: BrokerConfig) -> Self {
Self {
cards: BTreeMap::new(),
config,
}
}
/// Registra una Card con su WIT opcional. Devuelve `Some(prev)` si
/// reemplazó una existente. Pasar `None` en `wit` indica módulo
/// agnóstico (sin contrato WIT extraído).
pub fn register(
&mut self,
session: SessionId,
card: &Card,
wit: Option<WitInterface>,
) -> Option<BrokeredCard> {
self.cards
.insert(session, BrokeredCard::from_card(session, card, wit))
}
/// Quita una Card por sesión.
pub fn unregister(&mut self, session: SessionId) -> Option<BrokeredCard> {
self.cards.remove(&session)
}
/// Cardinalidad del registro.
pub fn len(&self) -> usize {
self.cards.len()
}
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
/// Iterador sobre las sesiones registradas.
pub fn sessions(&self) -> impl Iterator<Item = SessionId> + '_ {
self.cards.keys().copied()
}
/// Iterador sobre las Cards registradas (vista compartida).
pub fn cards(&self) -> impl Iterator<Item = &BrokeredCard> + '_ {
self.cards.values()
}
/// Busca el mejor productor para un input específico de un consumidor.
///
/// Algoritmo:
/// 1. Resuelve el flow input en el consumidor.
/// 2. Si tiene `pin_to`, prefiere productores con ese `label` que
/// matcheen el tipo (cualquier estrategia configurada).
/// 3. Si no hay pin_to o la pista falló, escanea todos los outputs
/// de las otras Cards. Filtra por compatibilidad de tipo.
/// 4. Ordena por (priority desc, label asc) y devuelve el primero.
pub fn find_producer_for(&self, consumer: SessionId, input_name: &str) -> Option<Match> {
let cons = self.cards.get(&consumer)?;
let input = cons.inputs.iter().find(|f| f.name == input_name)?;
// pin_to efectivo: bias del contexto activo (si la Card declara
// override consumer-side) > pin_to estático del Flow.
let context_pin = self
.context_bias(cons)
.and_then(|b| b.pin_to.as_deref());
let effective_pin = context_pin.or(input.pin_to.as_deref());
if let Some(pin) = effective_pin {
for prod in self.cards.values() {
if prod.session == consumer || prod.label != pin {
continue;
}
for out in &prod.outputs {
if let Some(via) = self.types_match(&input.ty, &out.ty) {
return Some(self.make_match(cons, prod, input, out, via, true));
}
}
}
// Fall through: pin no resuelto, type-search general.
}
let mut candidates: Vec<(&BrokeredCard, &Flow, MatchStrategy)> = Vec::new();
for prod in self.cards.values() {
if prod.session == consumer {
continue;
}
for out in &prod.outputs {
if let Some(via) = self.types_match(&input.ty, &out.ty) {
candidates.push((prod, out, via));
}
}
}
// Sort por (effective priority desc, label asc). El bias del
// contexto puede subir o bajar la priority del productor.
candidates.sort_by(|(a, _, _), (b, _, _)| {
self.effective_priority(b)
.cmp(&self.effective_priority(a))
.then_with(|| a.label.cmp(&b.label))
});
let (prod, out, via) = candidates.into_iter().next()?;
Some(self.make_match(cons, prod, input, out, via, false))
}
/// Devuelve el `ContextBias` que aplica a este Card en el contexto
/// activo (si lo hay).
fn context_bias<'a>(&self, card: &'a BrokeredCard) -> Option<&'a ContextBias> {
self.config
.current_context
.as_ref()
.and_then(|ctx| card.priority_contexts.get(ctx))
}
/// Priority efectiva del Card como productor, considerando el bias
/// del contexto activo. El offset se clampa a `[Low=0, Critical=3]`.
fn effective_priority(&self, card: &BrokeredCard) -> i16 {
let base = priority_value(card.priority);
let offset = self
.context_bias(card)
.map(|b| b.priority_offset as i16)
.unwrap_or(0);
(base + offset).clamp(0, 3)
}
/// Calcula todos los matches consumer→producer en el set actual.
/// Útil para introspección o para que el Admin emita rutas en lote.
pub fn all_matches(&self) -> Vec<Match> {
let mut out = Vec::new();
for cons in self.cards.values() {
for input in &cons.inputs {
if let Some(m) = self.find_producer_for(cons.session, &input.name) {
out.push(m);
}
}
}
out
}
fn types_match(&self, consumer_ty: &TypeRef, producer_ty: &TypeRef) -> Option<MatchStrategy> {
match self.config.strategy {
MatchStrategy::Exact => exact_match(consumer_ty, producer_ty).then_some(MatchStrategy::Exact),
MatchStrategy::Structural => {
structural_match(consumer_ty, producer_ty).then_some(MatchStrategy::Structural)
}
MatchStrategy::ExactThenStructural => {
if exact_match(consumer_ty, producer_ty) {
Some(MatchStrategy::Exact)
} else if structural_match(consumer_ty, producer_ty) {
Some(MatchStrategy::Structural)
} else {
None
}
}
}
}
fn make_match(
&self,
cons: &BrokeredCard,
prod: &BrokeredCard,
input: &Flow,
output: &Flow,
via: MatchStrategy,
pinned: bool,
) -> Match {
Match {
consumer: Endpoint {
session: cons.session,
flow_name: input.name.clone(),
},
consumer_label: cons.label.clone(),
producer: Endpoint {
session: prod.session,
flow_name: output.name.clone(),
},
producer_label: prod.label.clone(),
ty: input.ty.clone(),
via,
pinned,
}
}
}
// =====================================================================
// Predicados de matching (libres, testeables aislados)
// =====================================================================
fn priority_value(p: Priority) -> i16 {
match p {
Priority::Low => 0,
Priority::Normal => 1,
Priority::High => 2,
Priority::Critical => 3,
}
}
fn exact_match(a: &TypeRef, b: &TypeRef) -> bool {
a == b
}
fn structural_match(a: &TypeRef, b: &TypeRef) -> bool {
match (a, b) {
(TypeRef::Primitive { name: na }, TypeRef::Primitive { name: nb }) => na == nb,
(
TypeRef::Wit {
package: pa, name: na, ..
},
TypeRef::Wit {
package: pb, name: nb, ..
},
) => pa == pb && na == nb,
_ => false,
}
}
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
use card_core::{Card, Flows, Payload, Supervision, CARD_SCHEMA_VERSION};
fn card(label: &str, priority: Priority, flows: Flows) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
priority,
flow: flows,
..Default::default()
}
}
fn prim(name: &str) -> TypeRef {
TypeRef::Primitive { name: name.into() }
}
fn wit(pkg: &str, iface: Option<&str>, name: &str) -> TypeRef {
TypeRef::Wit {
package: pkg.into(),
interface: iface.map(|s| s.into()),
name: name.into(),
}
}
fn flow(name: &str, ty: TypeRef, pin: Option<&str>) -> Flow {
Flow {
name: name.into(),
ty,
pin_to: pin.map(|s| s.into()),
}
}
#[test]
fn exact_match_same_typeref() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::Exact,
current_context: None,
});
let producer = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("results", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("query", prim("string"), None)],
output: vec![],
},
);
let s_prod = Ulid::new();
let s_cons = Ulid::new();
b.register(s_prod, &producer, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "query").expect("match");
assert_eq!(m.producer_label, "dht");
assert_eq!(m.via, MatchStrategy::Exact);
assert!(!m.pinned);
}
#[test]
fn structural_ignores_interface() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::Structural,
current_context: None,
});
let producer = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow(
"in",
wit("brahman:dht", Some("v2"), "entity-result"),
None,
)],
output: vec![],
},
);
let s_prod = Ulid::new();
let s_cons = Ulid::new();
b.register(s_prod, &producer, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.via, MatchStrategy::Structural);
}
#[test]
fn exact_strategy_rejects_interface_mismatch() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::Exact,
current_context: None,
});
let producer = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow(
"in",
wit("brahman:dht", Some("v2"), "entity-result"),
None,
)],
output: vec![],
},
);
b.register(Ulid::new(), &producer, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
assert!(b.find_producer_for(s_cons, "in").is_none());
}
#[test]
fn exact_then_structural_prefers_exact() {
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::ExactThenStructural,
current_context: None,
});
// Productor 1: match estructural (interface diferente)
let p_struct = card(
"dht-cache",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v2"), "entity-result"),
None,
)],
},
);
// Productor 2: match exact (interface igual)
let p_exact = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow(
"out",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow(
"in",
wit("brahman:dht", Some("v1"), "entity-result"),
None,
)],
output: vec![],
},
);
b.register(Ulid::new(), &p_struct, None);
b.register(Ulid::new(), &p_exact, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
// El exact gana incluso si tiene priority igual: por estrategia.
assert_eq!(m.producer_label, "dht");
assert_eq!(m.via, MatchStrategy::Exact);
}
#[test]
fn pin_to_overrides_type_search() {
let mut b = Broker::new(BrokerConfig::default());
// Dos productores que producen el mismo tipo.
let p1 = card(
"dht-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let p2 = card(
"dht-test",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), Some("dht-test"))],
output: vec![],
},
);
b.register(Ulid::new(), &p1, None);
b.register(Ulid::new(), &p2, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "dht-test");
assert!(m.pinned);
}
#[test]
fn pin_to_unresolvable_falls_back_to_type_match() {
let mut b = Broker::new(BrokerConfig::default());
let p = card(
"real-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), Some("nonexistent"))],
output: vec![],
},
);
b.register(Ulid::new(), &p, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "real-dht");
assert!(!m.pinned);
}
#[test]
fn priority_breaks_ties() {
let mut b = Broker::new(BrokerConfig::default());
let p_low = card(
"z-dht",
Priority::Low,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let p_high = card(
"a-dht",
Priority::High,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
b.register(Ulid::new(), &p_low, None);
b.register(Ulid::new(), &p_high, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "a-dht"); // priority High > Low
}
#[test]
fn label_alpha_breaks_priority_ties() {
let mut b = Broker::new(BrokerConfig::default());
let p1 = card(
"z-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let p2 = card(
"a-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
b.register(Ulid::new(), &p1, None);
b.register(Ulid::new(), &p2, None);
let s_cons = Ulid::new();
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").expect("match");
assert_eq!(m.producer_label, "a-dht"); // alfabético gana
}
#[test]
fn unregister_removes_producer() {
let mut b = Broker::new(BrokerConfig::default());
let p = card(
"dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
let s_p = Ulid::new();
b.register(s_p, &p, None);
let s_c = Ulid::new();
b.register(s_c, &consumer, None);
assert!(b.find_producer_for(s_c, "in").is_some());
b.unregister(s_p);
assert!(b.find_producer_for(s_c, "in").is_none());
}
#[test]
fn no_self_loops() {
let mut b = Broker::new(BrokerConfig::default());
let same = card(
"echo",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![flow("out", prim("string"), None)],
},
);
let s = Ulid::new();
b.register(s, &same, None);
// Solo una Card registrada — no hay otra que produzca string.
assert!(b.find_producer_for(s, "in").is_none());
}
#[test]
fn all_matches_lists_pairs() {
let mut b = Broker::new(BrokerConfig::default());
let dht = card(
"dht",
Priority::Normal,
Flows {
input: vec![flow("query", prim("string"), None)],
output: vec![flow("results", prim("bytes"), None)],
},
);
let ui = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("data", prim("bytes"), None)],
output: vec![flow("user-input", prim("string"), None)],
},
);
b.register(Ulid::new(), &dht, None);
b.register(Ulid::new(), &ui, None);
let matches = b.all_matches();
assert_eq!(matches.len(), 2);
// dht.query ← ui.user-input y ui.data ← dht.results
let pairs: Vec<_> = matches
.iter()
.map(|m| (m.consumer_label.as_str(), m.producer_label.as_str()))
.collect();
assert!(pairs.contains(&("dht", "ui")));
assert!(pairs.contains(&("ui", "dht")));
}
// ===========================================================
// Priority contexts
// ===========================================================
#[test]
fn context_priority_offset_lifts_producer_above_alphabetic_winner() {
// Sin contexto, "a-prod" gana contra "b-prod" (alfabético).
// En contexto "test", b-prod tiene offset +1 → debería ganar.
let mut a_prod = card(
"a-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
a_prod.priority_contexts = std::collections::BTreeMap::new(); // explícito vacío
let mut b_prod = card(
"b-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
b_prod.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: None,
priority_offset: 1,
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
let s_cons = Ulid::new();
// Caso 1: sin contexto → a-prod gana (alfabético).
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: None,
});
b.register(Ulid::new(), &a_prod, None);
b.register(Ulid::new(), &b_prod, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "a-prod");
// Caso 2: contexto "test" → b-prod gana por offset +1.
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("test".into()),
});
b.register(Ulid::new(), &a_prod, None);
b.register(Ulid::new(), &b_prod, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "b-prod");
}
#[test]
fn context_pin_to_overrides_static_pin() {
// Consumer pinea estático a "real-dht", pero en contexto "test"
// declara override a "mock-dht".
let real = card(
"real-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let mock = card(
"mock-dht",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let mut consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), Some("real-dht"))],
output: vec![],
},
);
consumer.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: Some("mock-dht".into()),
priority_offset: 0,
},
);
let s_cons = Ulid::new();
// Caso 1: sin contexto → static pin gana ("real-dht").
let mut b = Broker::new(BrokerConfig::default());
b.register(Ulid::new(), &real, None);
b.register(Ulid::new(), &mock, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "real-dht");
assert!(m.pinned);
// Caso 2: contexto "test" → context override gana ("mock-dht").
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("test".into()),
});
b.register(Ulid::new(), &real, None);
b.register(Ulid::new(), &mock, None);
b.register(s_cons, &consumer, None);
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "mock-dht");
assert!(m.pinned);
}
#[test]
fn unknown_context_no_op() {
// Si la Card declara biases para "test" pero el broker está en
// "prod", los biases no aplican.
let mut b_prod = card(
"b-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
b_prod.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: None,
priority_offset: 5,
},
);
let a_prod = card(
"a-prod",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
let consumer = card(
"ui",
Priority::Normal,
Flows {
input: vec![flow("in", prim("string"), None)],
output: vec![],
},
);
let mut b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("prod".into()),
});
let s_cons = Ulid::new();
b.register(Ulid::new(), &a_prod, None);
b.register(Ulid::new(), &b_prod, None);
b.register(s_cons, &consumer, None);
// En contexto "prod" sin biases declarados, gana por alfabético.
let m = b.find_producer_for(s_cons, "in").unwrap();
assert_eq!(m.producer_label, "a-prod");
}
#[test]
fn priority_offset_clamps_to_critical() {
// Offset enorme no debe hacer overflow ni saltar fuera del rango.
let mut prod = card(
"p",
Priority::Normal,
Flows {
input: vec![],
output: vec![flow("out", prim("string"), None)],
},
);
prod.priority_contexts.insert(
"x".into(),
ContextBias {
pin_to: None,
priority_offset: 100,
},
);
let b = Broker::new(BrokerConfig {
strategy: MatchStrategy::default(),
current_context: Some("x".into()),
});
let bc = BrokeredCard::from_card(Ulid::new(), &prod, None);
// effective_priority debe estar clampada a 3 (Critical), no 101.
assert_eq!(b.effective_priority(&bc), 3);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "chasqui-card"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de card-core pero para datos."
[dependencies]
card-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
+10
View File
@@ -0,0 +1,10 @@
# chasqui-card
> Card escritorio (estado del broker) de [chasqui](../README.md).
Stat-card que muestra: broker activo (sí/no), topics activos, mensajes/s. Refresca cada 2s. Usa [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md).
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous`](../chasqui-nous/README.md)
- [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md)
+10
View File
@@ -0,0 +1,10 @@
# chasqui-card
> Desktop card (broker status) of [chasqui](../README.md).
Stat-card showing: broker alive (yes/no), active topics, messages/sec. Refreshes every 2s. Uses [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md).
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous`](../chasqui-nous/README.md)
- [`llimphi-widget-stat-card`](../../llimphi/widgets/stat-card/README.md)
+431
View File
@@ -0,0 +1,431 @@
//! `chasqui-card` — manifiesto de Mónada.
//!
//! Una **Mónada** es una agrupación semántica de archivos: el archivo
//! físico no se mueve, pero su pertenencia se modela por un objeto
//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
//! visualización. La idea hereda el espíritu de la Tarjeta de
//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
//! tipado, validado y serializable que define qué es la entidad y
//! cómo el sistema debe interactuar con ella.
//!
//! Diferencia con `brahman-card::Card`:
//!
//! | brahman::Card | chasqui::MonadManifest |
//! |-------------------------------------|-------------------------------|
//! | Describe una **entidad runtime** | Describe una **agrupación** |
//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás |
//! | Vive durante una sesión | Vive en una DB persistente |
//! | Fluye por handshake/postcard | Fluye por queries del backend |
//!
//! Este crate sólo define los tipos. La lógica de scan, cluster,
//! attraction vive en `chasqui-core`.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ulid::Ulid;
// Re-export para consumidores
pub use ::ulid;
pub mod query;
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
pub const MONAD_SCHEMA_VERSION: u16 = 1;
/// Identificador opaco de un archivo registrado en la DB.
pub type FileId = Ulid;
/// Identificador opaco de una Mónada.
pub type MonadId = Ulid;
// =====================================================================
// FileEntry — el archivo como dato indexado
// =====================================================================
/// Registro físico de un archivo en la DB. Es la unidad atómica que
/// pertenece a (potencialmente varias) Mónadas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub id: FileId,
pub path: PathBuf,
/// Hash de contenido (blake3) — sólo se computa si el archivo es
/// chico o el usuario lo pidió. `None` por default en Phase 0.
#[serde(default)]
pub content_hash: Option<[u8; 32]>,
/// Tamaño en bytes.
pub size: u64,
/// `mtime` como ms desde UNIX_EPOCH.
pub mtime_ms: u64,
/// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
#[serde(default)]
pub extension: Option<String>,
}
// =====================================================================
// Lens — la "vista" preferida de una Mónada
// =====================================================================
/// Lente de visualización dominante. La UI (nahual) elige cómo renderizar
/// los miembros de una Mónada según este hint.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lens {
/// Grid genérico: thumbnail + nombre + meta.
#[default]
Grid,
/// Editor de código con highlighting (rs, py, ts, ...).
Code,
/// Galería de imágenes (png, jpg, svg, ...).
Gallery,
/// Vista tabular (csv, sqlite, ...).
Database,
/// Texto renderizado (md, rst, txt).
Markdown,
/// Árbol jerárquico (cuando la Mónada es estructural).
Tree,
}
// =====================================================================
// MonadManifest — la Tarjeta de Presentación de la Mónada
// =====================================================================
/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
/// Presentación de Brahman, pero para una agrupación de datos.
///
/// Se serializa a JSON/TOML para persistencia y debugging; es el
/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
/// el disco.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonadManifest {
/// Versión del esquema. Bump = romper compatibilidad de DB.
pub schema_version: u16,
/// Identificador opaco. ULID — orderable por tiempo de creación.
pub id: MonadId,
/// Mónada de la que ésta fue derivada (split, merge), si aplica.
#[serde(default)]
pub lineage: Option<MonadId>,
/// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
pub label: String,
/// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
/// la masa de la Mónada justifica la consulta.
#[serde(default)]
pub summary: String,
/// Centroide vectorial (embedding promedio de los miembros). Vacío
/// en Phase 0 (sin embeddings); se llena cuando entran las
/// pseudo-embeddings o el modelo real.
#[serde(default)]
pub centroid: Vec<f32>,
/// Identificador del modelo que produjo `centroid`. Si está set, los
/// consumidores deben verificar coincidencia antes de comparar vía
/// cosine similarity con embeddings recientes; al cambiar de modelo
/// (mock-pseudo-32d → real-fastembed-384d, etc.) los centroides
/// previos quedan inválidos por dimensión y semántica.
/// `None` = legacy (centroides sin tag, pre-versioning).
#[serde(default)]
pub centroid_model: Option<String>,
/// Identidad estable derivada del origen de los miembros. Para
/// Mónadas creadas por `cluster::by_directory`, es el path
/// canónico del directorio padre. Permite que la hidratación
/// reuse el mismo ULID across re-scans (mismo path_hint = misma
/// identidad, aunque cambien los miembros internamente).
/// `None` para Mónadas creadas por estrategias que no se anclan a
/// un origen físico.
#[serde(default)]
pub path_hint: Option<String>,
/// Tokens dominantes: extensiones, palabras clave, etc.
/// 5-10 elementos típicamente.
#[serde(default)]
pub keywords: Vec<String>,
/// Cantidad de miembros (== `members.len()`). Cacheado para evitar
/// el cost de leer la lista cada vez.
pub cardinality: u32,
/// Métrica de dispersión interna [0.0, 1.0]:
/// - 0.0: todos los miembros son muy similares (Mónada coherente).
/// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
///
/// Calculada como entropía de Shannon normalizada sobre las
/// extensiones de los miembros.
#[serde(default)]
pub entropy: f32,
/// Lente preferido para visualización en la UI.
#[serde(default)]
pub dominant_lens: Lens,
/// Archivos anclados manualmente: NO se mueven en re-clustering
/// automático. El usuario "fija" estos miembros.
#[serde(default)]
pub pins: BTreeSet<FileId>,
/// IDs de archivos miembros (incluye pins).
pub members: BTreeSet<FileId>,
/// Unix ms de creación de la Mónada.
pub created_at_ms: u64,
/// Unix ms de la última actualización (re-cluster, re-name, ...).
pub updated_at_ms: u64,
/// Forward-compat: campos JSON desconocidos preservados.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, serde_json::Value>,
}
// =====================================================================
// Errores y validación
// =====================================================================
#[derive(Debug, Error)]
pub enum MonadError {
#[error("schema mismatch: got {got}, expected {expected}")]
SchemaMismatch { got: u16, expected: u16 },
#[error("label vacío")]
EmptyLabel,
#[error("label demasiado largo: {0} bytes (max 256)")]
LabelTooLong(usize),
#[error("entropía fuera de [0,1]: {0}")]
InvalidEntropy(f32),
#[error("Monad sin miembros y sin pins")]
Empty,
#[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
CardinalityMismatch { declared: u32, actual: u32 },
#[error("JSON inválido: {0}")]
Json(#[from] serde_json::Error),
}
impl MonadManifest {
/// Constructor con defaults razonables. `id` y timestamps se
/// generan; resto vacío.
pub fn new(label: impl Into<String>) -> Self {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
Self {
schema_version: MONAD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
summary: String::new(),
centroid: Vec::new(),
centroid_model: None,
path_hint: None,
keywords: Vec::new(),
cardinality: 0,
entropy: 0.0,
dominant_lens: Lens::default(),
pins: BTreeSet::new(),
members: BTreeSet::new(),
created_at_ms: now_ms,
updated_at_ms: now_ms,
extensions: BTreeMap::new(),
}
}
/// Validación semántica.
pub fn validate(&self) -> Result<(), MonadError> {
if self.schema_version != MONAD_SCHEMA_VERSION {
return Err(MonadError::SchemaMismatch {
got: self.schema_version,
expected: MONAD_SCHEMA_VERSION,
});
}
if self.label.trim().is_empty() {
return Err(MonadError::EmptyLabel);
}
if self.label.len() > 256 {
return Err(MonadError::LabelTooLong(self.label.len()));
}
if !(0.0..=1.0).contains(&self.entropy) {
return Err(MonadError::InvalidEntropy(self.entropy));
}
if self.members.is_empty() && self.pins.is_empty() {
return Err(MonadError::Empty);
}
let actual = self.members.len() as u32;
if self.cardinality != actual {
return Err(MonadError::CardinalityMismatch {
declared: self.cardinality,
actual,
});
}
Ok(())
}
/// Serializa a JSON pretty.
pub fn to_json_pretty(&self) -> Result<String, MonadError> {
Ok(serde_json::to_string_pretty(self)?)
}
/// Deserializa desde JSON y valida.
pub fn from_json(src: &str) -> Result<Self, MonadError> {
let m: Self = serde_json::from_str(src)?;
m.validate()?;
Ok(m)
}
/// Recalcula `cardinality` y `updated_at_ms` desde `members`.
/// Usar tras mutaciones del set de miembros.
pub fn touch(&mut self) {
self.cardinality = self.members.len() as u32;
self.updated_at_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
}
/// Proyecta el `MonadManifest` a la `card_core::Card` que viaja
/// por el protocolo. La Card resultante:
///
/// - hereda `id` y `label` del manifiesto (ULID estable).
/// - `kind = CardKind::Data` (se distingue de un Ente).
/// - `payload = Virtual`, `supervision = Delegate`,
/// `lifecycle = Daemon` — placeholder semántico: la Mónada no se
/// "ejecuta", el daemon dueño la mantiene viva.
/// - `data = Some(DataFacet { ... })` con summary, keywords,
/// centroide, member_count, dispersión y un hint de presentación
/// derivado del `dominant_lens`.
/// - Los miembros completos NO viajan en la Card — se consultan al
/// daemon dueño bajo demanda. Lo que viaja es metadata liviana
/// apta para el wire postcard.
pub fn to_brahman_card(&self) -> card_core::Card {
use card_core::{
Card, CardKind, DataFacet, Lifecycle, Payload, Priority, Supervision,
};
let presentation_hint = match self.dominant_lens {
Lens::Grid => "grid",
Lens::Code => "code",
Lens::Gallery => "gallery",
Lens::Database => "database",
Lens::Markdown => "markdown",
Lens::Tree => "tree",
}
.to_string();
Card {
schema_version: card_core::CARD_SCHEMA_VERSION,
id: self.id,
label: self.label.clone(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Data,
data: Some(DataFacet {
summary: self.summary.clone(),
keywords: self.keywords.clone(),
centroid: self.centroid.clone(),
member_count: self.cardinality,
dispersion: self.entropy,
presentation_hint,
}),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_minimal() {
let mut m = MonadManifest::new("test");
m.members.insert(Ulid::new());
m.touch();
m.validate().expect("debe validar");
}
#[test]
fn empty_label_rejected() {
let mut m = MonadManifest::new("x");
m.label = String::new();
m.members.insert(Ulid::new());
m.touch();
assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
}
#[test]
fn entropy_out_of_range_rejected() {
let mut m = MonadManifest::new("x");
m.members.insert(Ulid::new());
m.entropy = 1.5;
m.touch();
assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
}
#[test]
fn empty_members_rejected() {
let m = MonadManifest::new("x");
assert!(matches!(m.validate(), Err(MonadError::Empty)));
}
#[test]
fn cardinality_mismatch_caught() {
let mut m = MonadManifest::new("x");
m.members.insert(Ulid::new());
// No llamamos touch — cardinality queda en 0 con 1 miembro.
assert!(matches!(
m.validate(),
Err(MonadError::CardinalityMismatch { .. })
));
}
#[test]
fn projects_to_brahman_card() {
let mut m = MonadManifest::new("test-monad");
m.summary = "monad de prueba".into();
m.keywords = vec!["rs".into(), "toml".into()];
m.dominant_lens = Lens::Code;
m.entropy = 0.42;
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.touch();
let bc = m.to_brahman_card();
assert_eq!(bc.id, m.id);
assert_eq!(bc.label, "test-monad");
assert_eq!(bc.kind, card_core::CardKind::Data);
let data = bc.data.expect("data facet presente");
assert_eq!(data.summary, "monad de prueba");
assert_eq!(data.keywords, vec!["rs".to_string(), "toml".to_string()]);
assert_eq!(data.member_count, 3);
assert!((data.dispersion - 0.42).abs() < 1e-6);
assert_eq!(data.presentation_hint, "code");
}
#[test]
fn json_roundtrip() {
let mut m = MonadManifest::new("test-monad");
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.keywords = vec!["rs".into(), "toml".into()];
m.summary = "test summary".into();
m.dominant_lens = Lens::Code;
m.touch();
let s = m.to_json_pretty().unwrap();
let m2 = MonadManifest::from_json(&s).unwrap();
assert_eq!(m2.label, m.label);
assert_eq!(m2.cardinality, 2);
assert_eq!(m2.dominant_lens, Lens::Code);
assert_eq!(m2.keywords, m.keywords);
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Wire types para consultar al daemon `chasqui` por sus Mónadas.
//!
//! El daemon expone un Unix socket (cuyo path se publica en
//! `Card.service_socket` y se descubre vía broker MatchEvent). Cada
//! conexión es single-shot: una request JSON terminada en `\n`,
//! una response JSON terminada en `\n`, cierre.
//!
//! Mismo patrón que `chasqui-nous` (mock/real ↔ chasqui-core), reusado
//! ahora para que la UI (`chasqui-explorer`) descubra y consulte al
//! daemon sin hardcodear sockets ni pasar por brahman-admin.
//!
//! ## Contrato
//!
//! ```text
//! C → S: {"kind":"list_monads"}\n
//! S → C: {"engine":{...},"monads":[...]}\n
//! ```
//!
//! En caso de error:
//!
//! ```text
//! S → C: {"error":"unsupported kind"}\n
//! ```
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ulid::Ulid;
use crate::{FileEntry, FileId, Lens, MonadId, MonadManifest};
// =====================================================================
// Constants compartidos para el broker brahman
// =====================================================================
/// Nombre del flow output del daemon (input del consumer/explorer).
pub const FLOW_MONAD_LIST: &str = "monad-list";
/// Tipo del flow: el wire es JSON, así que el TypeRef es `primitive::json`.
pub const FLOW_TYPE_NAME: &str = "json";
// =====================================================================
// Wire request
// =====================================================================
/// Request al daemon. El wire es JSON line-delimited (un objeto + `\n`
/// por conexión).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum QueryRequest {
/// Lista todas las Mónadas vivas del daemon, junto con metadata
/// del engine. Pensado para que la UI haga snapshot polling.
ListMonads,
/// Resuelve los **archivos miembros** de una Mónada concreta. La vista
/// de `ListMonads` es slim (sin member set) para que el poll sea liviano;
/// cuando la UI despliega una Mónada en el navegador pide sus archivos
/// con esto, bajo demanda. nouser sigue siendo la fuente autoritativa de
/// qué archivos componen la Mónada (no el filesystem por su cuenta).
ResolveMonad {
/// La Mónada cuyos miembros se quieren.
id: MonadId,
},
}
// =====================================================================
// Wire response
// =====================================================================
/// Response a `ListMonads`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMonadsResponse {
/// Datos del engine (la Card que es "dueña" de las Mónadas).
pub engine: EngineInfo,
/// Mónadas vivas en este momento. Vista slim sin centroide ni
/// member set para que el wire sea liviano: una Mónada con 50k
/// archivos no debe transmitir 50k ULIDs cada poll.
pub monads: Vec<MonadView>,
}
/// Identidad del engine (Card kind=Ente que owns las Mónadas).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineInfo {
pub id: Ulid,
pub label: String,
/// Path del directorio que el daemon está observando. `None` si
/// el daemon corre sin watcher.
#[serde(default)]
pub watching: Option<String>,
}
/// Vista slim de una Mónada — los campos que la UI necesita para
/// renderizar una card sin pull del centroide ni del member set.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonadView {
pub id: MonadId,
pub label: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub keywords: Vec<String>,
pub cardinality: u32,
#[serde(default)]
pub entropy: f32,
#[serde(default)]
pub dominant_lens: Lens,
#[serde(default)]
pub path_hint: Option<String>,
#[serde(default)]
pub centroid_model: Option<String>,
}
impl MonadView {
/// Proyecta un MonadManifest completo a su vista slim para wire.
pub fn from_manifest(m: &MonadManifest) -> Self {
Self {
id: m.id,
label: m.label.clone(),
summary: m.summary.clone(),
keywords: m.keywords.clone(),
cardinality: m.cardinality,
entropy: m.entropy,
dominant_lens: m.dominant_lens,
path_hint: m.path_hint.clone(),
centroid_model: m.centroid_model.clone(),
}
}
}
/// Vista slim de un archivo miembro de una Mónada — lo que la UI necesita
/// para pintar una fila en el navegador. Omite el `content_hash` (32 bytes
/// que no se muestran) para que el wire sea liviano.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileView {
pub id: FileId,
/// Ruta del archivo, como string (el wire es JSON; `PathBuf` viajaría
/// igual pero string es explícito y portable).
pub path: String,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub extension: Option<String>,
#[serde(default)]
pub mtime_ms: u64,
}
impl FileView {
/// Proyecta un [`FileEntry`] a su vista slim para wire.
pub fn from_entry(f: &FileEntry) -> Self {
Self {
id: f.id,
path: f.path.display().to_string(),
size: f.size,
extension: f.extension.clone(),
mtime_ms: f.mtime_ms,
}
}
}
/// Response a [`QueryRequest::ResolveMonad`]: los archivos miembros de la
/// Mónada pedida. `members` vacío si la Mónada no existe (o no tiene
/// miembros resolubles) — el daemon no distingue, igual que `resolve_members`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolveMonadResponse {
/// La Mónada consultada (eco del request, para que la UI confirme).
pub monad: MonadId,
/// Sus archivos miembros, en el orden que el daemon los entrega.
pub members: Vec<FileView>,
}
/// Error de protocolo retornado en lugar de la response normal.
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
#[error("chasqui-engine: {error}")]
pub struct ErrorResponse {
pub error: String,
}
// =====================================================================
// Transport
// =====================================================================
pub mod transport {
use std::path::PathBuf;
/// Variable de entorno para sobreescribir la ruta del socket del
/// daemon (útil para tests / multi-daemon).
pub const SOCKET_ENV: &str = "NOUSER_ENGINE_SOCKET";
/// Nombre por defecto del socket.
pub const SOCKET_NAME: &str = "chasqui-engine.sock";
/// Ruta canónica al socket del daemon. Honra `NOUSER_ENGINE_SOCKET`
/// si está set, sino arma sobre `$XDG_RUNTIME_DIR` (con fallback
/// `$TMPDIR`).
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(SOCKET_NAME)
}
}
// =====================================================================
// Cliente blocking — vive con los wire types para que un consumer
// (UI, CLI, otro módulo) pueda hablar con el daemon importando sólo
// `chasqui-card`, sin arrastrar `chasqui-core` (notify/walkdir/sled/blake3).
// =====================================================================
/// Cliente síncrono para el query socket del daemon. Sólo Unix (el
/// resto del ecosistema brahman es Unix-only de facto).
#[cfg(unix)]
pub mod client {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use serde::de::DeserializeOwned;
use super::{ErrorResponse, ListMonadsResponse, MonadId, QueryRequest, ResolveMonadResponse};
#[derive(Debug, thiserror::Error)]
pub enum QueryError {
#[error("conectar a {path}: {source}")]
Connect {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
#[error("serializacion: {0}")]
Serde(#[from] serde_json::Error),
#[error("daemon: {0}")]
Daemon(String),
#[error("response vacía del daemon")]
Empty,
}
/// Envía un `QueryRequest` al daemon en `socket` y deserializa la
/// response al tipo `R`. `timeout` se aplica al read y al write. Si el
/// daemon responde un `ErrorResponse`, se devuelve `QueryError::Daemon`.
fn request<R: DeserializeOwned>(
socket: &Path,
req: &QueryRequest,
timeout: Duration,
) -> Result<R, QueryError> {
let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect {
path: socket.to_path_buf(),
source: e,
})?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let line = serde_json::to_string(req)?;
stream.write_all(line.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
let n = reader.read_line(&mut response)?;
if n == 0 {
return Err(QueryError::Empty);
}
if let Ok(resp) = serde_json::from_str::<R>(response.trim()) {
return Ok(resp);
}
let err: ErrorResponse = serde_json::from_str(response.trim())?;
Err(QueryError::Daemon(err.error))
}
/// Envía `ListMonads` al daemon en `socket` y devuelve la response.
/// `timeout` se aplica tanto al read como al write del stream.
pub fn list_monads(
socket: &Path,
timeout: Duration,
) -> Result<ListMonadsResponse, QueryError> {
request(socket, &QueryRequest::ListMonads, timeout)
}
/// Pide los archivos miembros de la Mónada `id` al daemon en `socket`.
/// Para el nivel de archivos del navegador: la lista slim de `list_monads`
/// no los trae, se resuelven bajo demanda al desplegar una Mónada.
pub fn resolve_monad(
socket: &Path,
id: MonadId,
timeout: Duration,
) -> Result<ResolveMonadResponse, QueryError> {
request(socket, &QueryRequest::ResolveMonad { id }, timeout)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_roundtrips_json_with_tag() {
let req = QueryRequest::ListMonads;
let s = serde_json::to_string(&req).unwrap();
assert_eq!(s, r#"{"kind":"list_monads"}"#);
let back: QueryRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn resolve_monad_request_roundtrips_with_id() {
let id = Ulid::new();
let req = QueryRequest::ResolveMonad { id };
let s = serde_json::to_string(&req).unwrap();
assert!(s.contains(r#""kind":"resolve_monad""#), "{s}");
let back: QueryRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn resolve_monad_response_roundtrip() {
use crate::FileEntry;
use std::path::PathBuf;
let entry = FileEntry {
id: Ulid::new(),
path: PathBuf::from("/proj/src/lib.rs"),
content_hash: None,
size: 1234,
mtime_ms: 42,
extension: Some("rs".into()),
};
let resp = ResolveMonadResponse {
monad: Ulid::new(),
members: vec![FileView::from_entry(&entry)],
};
let s = serde_json::to_string(&resp).unwrap();
let back: ResolveMonadResponse = serde_json::from_str(&s).unwrap();
assert_eq!(back.members.len(), 1);
assert_eq!(back.members[0].path, "/proj/src/lib.rs");
assert_eq!(back.members[0].extension.as_deref(), Some("rs"));
assert_eq!(back.members[0].size, 1234);
}
#[test]
fn response_roundtrip_preserves_view() {
let m = MonadManifest::new("x/src");
let view = MonadView::from_manifest(&m);
let resp = ListMonadsResponse {
engine: EngineInfo {
id: Ulid::new(),
label: "brahman.nouser_engine".into(),
watching: Some("/tmp/x".into()),
},
monads: vec![view.clone()],
};
let s = serde_json::to_string(&resp).unwrap();
let back: ListMonadsResponse = serde_json::from_str(&s).unwrap();
assert_eq!(back.monads.len(), 1);
assert_eq!(back.monads[0].label, view.label);
assert_eq!(back.engine.label, "brahman.nouser_engine");
}
#[test]
fn view_is_slim_no_centroid_no_members() {
// Construimos una Mónada con centroid + members "pesados",
// proyectamos a view, verificamos que esos campos no viajan.
let mut m = MonadManifest::new("test");
m.centroid = vec![0.1; 384]; // peso "real-fastembed"
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.cardinality = 2;
let view = MonadView::from_manifest(&m);
let s = serde_json::to_string(&view).unwrap();
// Chequeo con `:` para distinguir el field "centroid" del
// field "centroid_model" (que sí es metadata liviana y debe ir).
assert!(
!s.contains("\"centroid\":"),
"MonadView no debe serializar el vector centroid: {s}"
);
assert!(
!s.contains("\"members\":"),
"MonadView no debe serializar members: {s}"
);
assert!(s.contains("\"cardinality\":2"), "cardinality sí va: {s}");
}
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "chasqui-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Nouser — explorador de Mónadas: scanner, clustering determinista, DB en memoria."
[dependencies]
chasqui-card = { path = "../chasqui-card" }
chasqui-nous = { path = "../chasqui-nous" }
shuma-discern = { workspace = true }
card-core = { workspace = true }
card-handshake = { path = "../card-handshake" }
card-sidecar = { path = "../card-sidecar" }
blake3 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sled = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
ulid = { workspace = true }
walkdir = "2"
notify = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[[bin]]
name = "chasqui"
path = "src/bin/nouser.rs"
+9
View File
@@ -0,0 +1,9 @@
# chasqui-core
> Tipos compartidos de [chasqui](../README.md): `Topic`, `Message`, `Schema`, `Subscription`.
Núcleo sin transport, sin red. Lo importan tanto el broker como cualquier cliente.
## Deps
- `serde`, `uuid`
+9
View File
@@ -0,0 +1,9 @@
# chasqui-core
> Shared types of [chasqui](../README.md): `Topic`, `Message`, `Schema`, `Subscription`.
Core without transport or network. Imported by both the broker and any client.
## Deps
- `serde`, `uuid`
@@ -0,0 +1,795 @@
//! `chasqui` CLI — explorador de Mónadas.
//!
//! Subcomandos:
//!
//! - `scan <dir>` recorre `dir` y muestra las Mónadas detectadas.
//! - `show <dir> <id?>` scan + detalles de la Mónada con prefijo de ID.
//! - `json <dir>` scan + dump JSON con los manifests.
//!
//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
//! sesión termina y todo se descarta. Phase B agrega persistencia y
//! presencia ante el Init.
use std::path::PathBuf;
use std::process::ExitCode;
use chasqui_core::{
cluster, db, embed,
scanner::{self, ScanConfig},
};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let prog = args.first().cloned().unwrap_or_else(|| "chasqui".into());
let sub = match args.get(1).map(String::as_str) {
Some(s) => s,
None => {
print_usage(&prog);
return ExitCode::from(2);
}
};
let rest = &args[2..];
let result = match sub {
"scan" => cmd_scan(rest),
"show" => cmd_show(rest),
"json" => cmd_json(rest),
"daemon" => cmd_daemon(rest),
"attract" => cmd_attract(rest),
"--help" | "-h" | "help" => {
print_usage(&prog);
return ExitCode::SUCCESS;
}
other => {
eprintln!("chasqui: comando desconocido '{other}'");
print_usage(&prog);
return ExitCode::from(2);
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("chasqui: {e}");
ExitCode::from(1)
}
}
}
fn print_usage(prog: &str) {
eprintln!("uso: {prog} <comando> [args]");
eprintln!();
eprintln!("comandos:");
eprintln!(" scan <dir> recorre un directorio y lista las Mónadas detectadas");
eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
eprintln!(" json <dir> scan + dump JSON de todos los manifests");
eprintln!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
eprintln!();
eprintln!("env:");
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
eprintln!(" NOUSER_DB_PATH si está set, abre sled en esa ruta (persistencia)");
eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)");
}
type Cmd = Result<(), Box<dyn std::error::Error>>;
fn min_files() -> usize {
std::env::var("NOUSER_MIN_FILES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
}
fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = args.first().ok_or("falta argumento <dir>")?;
Ok(PathBuf::from(dir))
}
fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
let files = scanner::scan_directory(dir, &ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory(&files, min_files());
let mut db = open_db()?;
db.ingest_files(files);
db.replace_monads(monads);
Ok((db, n_files))
}
/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
/// si no, store en memoria.
fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
Ok(db::MonadDb::open(&path)?)
} else {
Ok(db::MonadDb::new())
}
}
fn cmd_scan(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let (db, n_files) = run_scan(&dir)?;
println!(
"scan: {} archivos en {}, {} mónadas (min_files={})",
n_files,
dir.display(),
db.monad_count(),
min_files()
);
if db.monad_count() == 0 {
println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
return Ok(());
}
println!();
for m in db.monads() {
let id_short = format!("{}", m.id);
let id_short = &id_short[..8];
println!(
" [{}] {:30} card={} ent={:.2} lens={:?}",
id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
);
if !m.keywords.is_empty() {
println!(" keywords: {}", m.keywords.join(", "));
}
}
Ok(())
}
fn cmd_show(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
let (db, _) = run_scan(&dir)?;
let m = db
.monads()
.find(|m| m.id.to_string().starts_with(prefix))
.ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
println!("Monad {}", m.id);
println!(" label: {}", m.label);
println!(" summary: {}", m.summary);
println!(" cardinality: {}", m.cardinality);
println!(" entropy: {:.4}", m.entropy);
println!(" lens: {:?}", m.dominant_lens);
println!(" keywords: {}", m.keywords.join(", "));
println!(" members ({}):", m.members.len());
for f in db.resolve_members(m.id) {
println!(
" {:>10} bytes {}",
f.size,
f.path.display()
);
}
Ok(())
}
fn cmd_json(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let (db, _) = run_scan(&dir)?;
let manifests: Vec<_> = db.monads().cloned().collect();
println!("{}", serde_json::to_string_pretty(&manifests)?);
Ok(())
}
fn cmd_daemon(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let pool = std::sync::Arc::new(
card_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
);
// 1. Decidir el path del query socket ANTES de armar el engine
// Card (porque viaja como service_socket en la Card).
let query_socket = chasqui_card::query::transport::default_socket_path();
// 2. Engine como Ente. Declara service_socket + flow.output para
// que el broker pueda emitir MatchEvent::Available a consumers
// interesados en `flow.input = monad-list:json`.
let engine_card = build_engine_card(query_socket.clone());
let engine_id = engine_card.id;
let engine_label = engine_card.label.clone();
eprintln!(
"chasqui daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
engine_label,
engine_id,
query_socket.display()
);
pool.spawn(engine_card);
// 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
// publicar lo que ya tenemos ANTES del re-scan. brahman-status
// ve mónadas reales en milisegundos, no en segundos.
let mut db = open_db()?;
let prior_count = db.monad_count();
if prior_count > 0 {
let mut hydrated = 0usize;
let mut skipped_model = 0usize;
for monad in db.monads() {
// Sólo publicamos centroides del modelo actual; los demás
// son data muerta hasta que el re-scan los reemplace.
let valid = monad
.centroid_model
.as_deref()
.map(|id| id == embed::MODEL_ID)
.unwrap_or(false);
if !valid {
skipped_model += 1;
continue;
}
let mut card = monad.to_brahman_card();
card.references.push(card_core::CardReference {
kind: card_core::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.clone(),
});
pool.spawn(card);
hydrated += 1;
}
eprintln!(
"chasqui daemon: hidratadas {} mónadas previas{} en O(1)",
hydrated,
if skipped_model > 0 {
format!(" ({} dropeadas por centroid_model distinto)", skipped_model)
} else {
String::new()
}
);
}
// 3. Re-scan con hidratación: las Mónadas con mismo path_hint
// reusan id, así que NO generamos sesiones duplicadas para los
// mismos directorios — el sidecar previo ya tiene esa identidad.
let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db));
let scanned_count = monads.len();
eprintln!(
"chasqui daemon: re-scan {} archivos en {}{} mónadas",
n_files,
dir.display(),
scanned_count
);
// Publicamos sólo las Mónadas NUEVAS (las que no estaban en la
// hidratación inicial). El criterio: si el id estaba en la DB
// previa, el sidecar de la hidratación ya cubre esa identidad.
let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect();
let mut newly_spawned = 0usize;
for monad in &monads {
if prior_ids.contains(&monad.id) {
continue; // ya publicada en hidratación
}
let mut card = monad.to_brahman_card();
card.references.push(card_core::CardReference {
kind: card_core::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.clone(),
});
pool.spawn(card);
newly_spawned += 1;
}
// Reescribimos la DB con el set actual (idempotente para los
// hidratados; reemplazo para los nuevos).
db.ingest_files(files);
db.replace_monads(monads);
eprintln!(
"chasqui daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
scanned_count, newly_spawned
);
// Engine query socket: bind antes del watcher para que cualquier
// consumer descubierto vía broker pueda consultarnos enseguida.
// Si el bind falla, seguimos sin él — la UI degrada a "no
// alcanzable" pero el daemon sigue procesando cambios.
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
let _query_listener = match chasqui_core::engine_socket::spawn_listener(
chasqui_core::engine_socket::ListenerConfig {
socket_path: query_socket.clone(),
engine_id,
engine_label: engine_label.clone(),
watching: Some(dir.clone()),
},
db_shared.clone(),
) {
Ok(h) => {
eprintln!(
"chasqui daemon: query socket activo en {} (proto: chasqui_card::query)",
query_socket.display()
);
Some(h)
}
Err(e) => {
eprintln!(
"chasqui daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
);
None
}
};
// Watcher: cada cambio en el árbol — coalescido con debounce de
// 150ms — dispara un re-scan + re-cluster del directorio y
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
// gracias al replace en `SidecarPool::spawn`).
let _watcher = match spawn_fs_watcher(
dir.clone(),
db_shared.clone(),
pool.clone(),
engine_id,
engine_label.clone(),
) {
Ok(w) => {
eprintln!(
"chasqui daemon: watcher activo en {} (debounce 150ms, re-publish on) — Ctrl-C para terminar.",
dir.display()
);
Some(w)
}
Err(e) => {
eprintln!(
"chasqui daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
);
None
}
};
std::thread::park();
drop(_watcher);
drop(_query_listener);
let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
drop(pool);
Ok(())
}
/// Ventana de debounce: notify dispara Create+Modify(+) por cada
/// edición; sin coalescer veríamos N reacciones por un solo `:w`.
/// 150ms es generoso para editores típicos (vim/code) y mantiene el
/// feedback "vivo" para el usuario.
const WATCHER_DEBOUNCE_MS: u64 = 150;
/// Watcher de filesystem con debounce + re-publish al broker.
///
/// Pipeline:
///
/// 1. **notify** dispara eventos crudos a un canal interno.
/// 2. **dispatcher**: filtra a Create/Modify/Remove de paths bajo
/// `dir`, descarta el resto, reenvía al canal de debounce.
/// 3. **coordinator**: mantiene un `HashMap<PathBuf, Instant>`.
/// Cada vez que el canal queda en silencio durante
/// `WATCHER_DEBOUNCE_MS`, agrupa los paths cuya última actividad
/// superó la ventana y los procesa en **un solo batch**.
/// 4. **process_change_batch**: re-scan + re-cluster hidratado +
/// diff vs DB + `pool.drop_session` para Mónadas desaparecidas
/// + `pool.spawn` para Mónadas nuevas o con composición distinta.
/// `pool.spawn` reemplaza la sesión previa con el mismo `Card.id`,
/// así que el broker ve el manifest fresco sin sesiones huérfanas.
fn spawn_fs_watcher(
dir: std::path::PathBuf,
db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
pool: std::sync::Arc<card_sidecar::SidecarPool>,
engine_id: card_core::ulid::Ulid,
engine_label: String,
) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
use notify::{Event, EventKind, RecursiveMode, Watcher};
let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = notify_tx.send(res);
})?;
watcher.watch(&dir, RecursiveMode::Recursive)?;
let (path_tx, path_rx) = std::sync::mpsc::channel::<std::path::PathBuf>();
// Dispatcher: notify → filtro → canal de paths.
let dispatch_dir = dir.clone();
std::thread::Builder::new()
.name("chasqui-watcher-dispatch".into())
.spawn(move || {
for res in notify_rx {
let event = match res {
Ok(e) => e,
Err(e) => {
eprintln!("[watcher] error: {e}");
continue;
}
};
// Create/Modify viven; Remove también nos importa
// (puede colapsar Mónadas).
let interesting = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if !interesting {
continue;
}
for path in event.paths {
if !path.starts_with(&dispatch_dir) {
continue;
}
let _ = path_tx.send(path);
}
}
})?;
// Coordinator: debounce + batch dispatch.
let coord_dir = dir;
std::thread::Builder::new()
.name("chasqui-watcher-coord".into())
.spawn(move || {
let debounce = std::time::Duration::from_millis(WATCHER_DEBOUNCE_MS);
let mut pending: std::collections::HashMap<
std::path::PathBuf,
std::time::Instant,
> = std::collections::HashMap::new();
loop {
match path_rx.recv_timeout(debounce) {
Ok(path) => {
pending.insert(path, std::time::Instant::now());
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
let now = std::time::Instant::now();
let due: Vec<std::path::PathBuf> = pending
.iter()
.filter(|(_, t)| now.duration_since(**t) >= debounce)
.map(|(p, _)| p.clone())
.collect();
if due.is_empty() {
continue;
}
for p in &due {
pending.remove(p);
}
process_change_batch(
&due,
&coord_dir,
&db,
&pool,
engine_id,
&engine_label,
);
}
})?;
Ok(watcher)
}
/// Procesa un batch de paths cambiados: re-scanea el árbol, re-clusteriza
/// con hidratación, y propaga el delta de Mónadas al broker.
///
/// El re-scan global es deliberado: el clustering por directorio es global
/// por diseño, así que un cambio en `src/foo.rs` puede mover Mónadas en
/// `src/` sin tocar `tests/`. Coste O(N archivos), aceptable para
/// directorios típicos (<10k archivos). Optimizar a re-cluster parcial
/// cuando duela.
fn process_change_batch(
paths: &[std::path::PathBuf],
dir: &std::path::Path,
db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
pool: &std::sync::Arc<card_sidecar::SidecarPool>,
engine_id: card_core::ulid::Ulid,
engine_label: &str,
) {
eprintln!(
"[watcher] ⚙ batch: {} path(s) coalescidos → re-scan",
paths.len()
);
let files = match scanner::scan_directory(dir, &scanner::ScanConfig::default()) {
Ok(f) => f,
Err(e) => {
eprintln!("[watcher] re-scan falló: {e}");
return;
}
};
let mut db_lock = match db.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("[watcher] mutex envenenado — abortando batch");
return;
}
};
let prior_monads: Vec<chasqui_card::MonadManifest> = db_lock.monads().cloned().collect();
let prior_ref: &db::MonadDb = &db_lock;
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(prior_ref));
let prior_ids: std::collections::BTreeSet<_> =
prior_monads.iter().map(|m| m.id).collect();
let new_ids: std::collections::BTreeSet<_> = monads.iter().map(|m| m.id).collect();
// Mónadas que ya no existen (directorio quedó por debajo de
// min_files o fue removido): cerramos su sesión en el broker.
let mut removed = 0usize;
for id in prior_ids.difference(&new_ids) {
pool.drop_session(*id);
removed += 1;
if let Some(prev) = prior_monads.iter().find(|m| &m.id == id) {
eprintln!(
"[watcher] ✖ {} ({}) desapareció — sesión cerrada",
&id.to_string()[..8],
prev.label
);
}
}
// Mónadas nuevas o cuya composición cambió (members/centroid):
// (re)spawn — el pool reemplaza la sesión previa con el mismo id.
let mut respawned = 0usize;
let mut fresh = 0usize;
for monad in &monads {
let prev = prior_monads.iter().find(|m| m.id == monad.id);
let is_new = prev.is_none();
let changed = match prev {
Some(p) => p.members != monad.members || p.centroid != monad.centroid,
None => true,
};
if !changed {
continue;
}
let mut card = monad.to_brahman_card();
card.references.push(card_core::CardReference {
kind: card_core::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.to_string(),
});
pool.spawn(card);
if is_new {
fresh += 1;
eprintln!(
"[watcher] ✦ {} nace ({} miembros, lens={:?})",
monad.label, monad.cardinality, monad.dominant_lens
);
} else {
respawned += 1;
let prev = prev.unwrap();
let delta_members = monad.members.len() as i64 - prev.members.len() as i64;
eprintln!(
"[watcher] ↻ {} refresh ({} miembros, Δ={:+})",
monad.label, monad.cardinality, delta_members
);
}
}
if removed == 0 && fresh == 0 && respawned == 0 {
eprintln!("[watcher] (sin cambios estructurales tras re-cluster)");
} else {
eprintln!(
"[watcher] ⌃ delta: {} nuevas, {} refrescadas, {} cerradas — {} sesiones vivas",
fresh,
respawned,
removed,
pool.live_sessions()
);
}
db_lock.ingest_files(files);
db_lock.replace_monads(monads);
}
fn cmd_attract(args: &[String]) -> Cmd {
let mut remote = false;
let mut positional: Vec<&String> = Vec::new();
for a in args {
if a == "--remote" {
remote = true;
} else {
positional.push(a);
}
}
let dir = positional
.first()
.map(|s| std::path::PathBuf::from(s.as_str()))
.ok_or("falta argumento <dir>")?;
let file_path = positional.get(1).ok_or("falta argumento <file>")?;
let file_path = std::path::PathBuf::from(file_path.as_str());
if !file_path.exists() {
return Err(format!("archivo no existe: {}", file_path.display()).into());
}
let (db, _) = run_scan(&dir)?;
// Construimos un FileEntry para el archivo objetivo.
let metadata = std::fs::metadata(&file_path)?;
let mtime_ms = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let target = chasqui_card::FileEntry {
id: chasqui_card::FileId::from(chasqui_card::ulid::Ulid::new()),
path: file_path.clone(),
content_hash: None,
size: metadata.len(),
mtime_ms,
extension: file_path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase()),
};
// Embedding del target + identificación del modelo que lo produjo.
// Local: pseudo-32d. Remote: lo que devuelva el provider electo
// (mock=pseudo-32d, real=fastembed-384d).
let (target_vec, target_model, source) = if remote {
let (v, model) = remote_embed(&target)?;
(v, model, "remote")
} else {
(
embed::embed(&target).to_vec(),
embed::MODEL_ID.to_string(),
"local",
)
};
// Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
// 32-d con 384-d daría scores sin sentido (diferente semántica
// y cosine no compara cross-modelo).
let mut ranked: Vec<(&chasqui_card::MonadManifest, f32)> = db
.monads()
.filter(|m| !m.centroid.is_empty())
.filter(|m| match &m.centroid_model {
Some(id) => id == &target_model,
None => true, // legacy sin tag — comparamos best-effort
})
.map(|m| (m, embed::attraction_score(&target_vec, m)))
.collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count();
let skipped = total_monads - ranked.len();
if ranked.is_empty() {
println!("ninguna Mónada con centroide en {}", dir.display());
return Ok(());
}
println!("archivo: {}", file_path.display());
println!("scan dir: {}", dir.display());
println!("embed: {} ({})", source, target_model);
if skipped > 0 {
println!(
"skipped: {} mónada(s) con centroid_model distinto (no comparables)",
skipped
);
}
println!("ranking de atracción (cosine similarity):");
println!();
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
"🧲"
} else if i == 0 {
"·"
} else {
" "
};
let id_short = format!("{}", m.id);
let id_short = &id_short[..8];
println!(
" {} {:.4} [{}] {:30} ({})",
marker, score, id_short, m.label, m.summary
);
}
if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
println!();
println!(
" (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
ranked[0].1,
embed::DEFAULT_ATTRACTION_THRESHOLD
);
}
Ok(())
}
/// Pipeline completo del modo `--remote`:
/// 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (override
/// explícito, atajo para tests).
/// 2. Si no, delega en `card_sidecar::await_provider_blocking` —
/// el sidecar se conecta al broker, registra un consumer Card con
/// `flow.input = embed-result:json`, espera el primer
/// `MatchEvent::Available` y devuelve el socket. Esto activa la
/// lógica de `priority_contexts`: bajo `BRAHMAN_BROKER_CONTEXT=test/prod`,
/// el proveedor electo cambia sin que este código toque nada.
/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
///
/// Devuelve `(embedding, model_id)` — el caller necesita ambos para
/// comparar contra centroides taggeados con su mismo `centroid_model`.
fn remote_embed(
file: &chasqui_card::FileEntry,
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
let sock = std::path::PathBuf::from(explicit);
return embed_via(&sock, file);
}
let consumer = card_sidecar::build_consumer_card(
"chasqui.attract-cli",
chasqui_nous::FLOW_EMBED_RESULT,
chasqui_nous::FLOW_TYPE_NAME,
);
let producer_sock = card_sidecar::await_provider_blocking(
consumer,
std::time::Duration::from_secs(3),
)?;
embed_via(&producer_sock, file)
}
/// RPC blocking contra un socket chasqui-nous concreto. Devuelve
/// `(embedding, model_id)` — el `model_id` viaja en la response y
/// permite al caller saber qué centroides son comparables.
fn embed_via(
sock_path: &std::path::Path,
file: &chasqui_card::FileEntry,
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
if !sock_path.exists() {
return Err(format!("socket no existe: {}", sock_path.display()).into());
}
let mut stream = UnixStream::connect(sock_path)?;
let req = chasqui_nous::EmbedRequest {
kind: chasqui_nous::RequestKind::EmbedFile,
payload: serde_json::to_value(chasqui_nous::EmbedFilePayload {
path: file.path.display().to_string(),
extension: file.extension.clone(),
size: file.size,
mtime_ms: file.mtime_ms,
})?,
};
let line = serde_json::to_string(&req)?;
stream.write_all(line.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response)?;
if response.is_empty() {
return Err("chasqui-nous cerró sin respuesta".into());
}
if let Ok(resp) = serde_json::from_str::<chasqui_nous::EmbedResponse>(&response) {
return Ok((resp.embedding, resp.model));
}
let err: chasqui_nous::ErrorResponse = serde_json::from_str(&response)?;
Err(format!("chasqui-nous: {}", err.error).into())
}
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
///
/// Declara `service_socket` y `flow.output = monad-list:json` para
/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
/// MatchEvent y consultarle por sus Mónadas sin pasar por
/// brahman-admin.
fn build_engine_card(service_socket: std::path::PathBuf) -> card_core::Card {
use card_core::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
use chasqui_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
Card {
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
service_socket: Some(service_socket),
flow: Flows {
input: vec![],
output: vec![Flow {
name: FLOW_MONAD_LIST.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
},
..Card::new("brahman.nouser_engine")
}
}
@@ -0,0 +1,355 @@
//! Clustering determinista (Phase A).
//!
//! Estrategia: agrupar por **directorio padre** + ranking por
//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
//! Esta capa cubre el 90% de los casos prácticos:
//!
//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
//! - Notas en `~/notes/` → Mónada con lente Markdown.
//!
//! Los casos donde esta heurística falla (archivos relacionados pero
//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
//! del clustering por Nous (Phase D).
use std::collections::BTreeMap;
use std::path::PathBuf;
use chasqui_card::{FileEntry, Lens, MonadManifest};
use crate::embed;
/// Mínimo de archivos para que un directorio sea promovido a Mónada.
/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
/// Agrupa archivos en Mónadas por directorio padre.
///
/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
/// directorios con menos de `min_files` no producen Mónada.
pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
by_directory_hydrated(files, min_files, None)
}
/// Variante con hidratación: si `prior` está presente, busca Mónadas
/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa
/// su `id` y `lineage`. Esto preserva identidad across re-scans —
/// fundamental para que el daemon pueda republicar tras hidratar de
/// sled sin generar duplicados en el broker.
pub fn by_directory_hydrated(
files: &[FileEntry],
min_files: usize,
prior: Option<&crate::db::MonadDb>,
) -> Vec<MonadManifest> {
let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
for f in files {
if let Some(parent) = f.path.parent() {
by_parent.entry(parent.to_path_buf()).or_default().push(f);
}
}
let mut out = Vec::new();
for (parent, group) in by_parent {
if group.len() < min_files {
continue;
}
let mut m = build_monad(&parent, &group);
if let Some(db) = prior {
// Reusamos id si encontramos Mónada previa con mismo
// path_hint Y mismo centroid_model. Distintas hipótesis
// de modelo no comparten identidad — son objetos
// semánticos distintos, aunque parecidos.
if let Some(existing) = db.monads().find(|prev| {
prev.path_hint.as_deref() == m.path_hint.as_deref()
&& prev.centroid_model == m.centroid_model
}) {
m.id = existing.id;
m.lineage = existing.lineage;
m.created_at_ms = existing.created_at_ms;
m.touch();
}
}
out.push(m);
}
out
}
fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
let label = label_from_path(parent);
let keywords = top_extensions(group, 5);
let lens = pick_lens(group);
let entropy = shannon_entropy_normalized(group);
let summary = build_summary(parent, group, &keywords);
// Centroide vectorial: promedio de los embeddings de los miembros.
// Esto es lo que permite "atracción" determinista de archivos
// nuevos sin tocar Nous.
let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
let centroid = embed::centroid(&member_vecs);
let mut m = MonadManifest::new(label);
m.summary = summary;
m.keywords = keywords;
m.dominant_lens = lens;
m.entropy = entropy;
m.centroid = centroid;
// Taggeamos el centroide con su modelo. attract verifica esto
// antes de comparar para no mezclar pseudo-32d con real-384d.
m.centroid_model = Some(embed::MODEL_ID.to_string());
// path_hint = identidad estable across re-scans para
// hidratación. Display es lossy con UTF-8 inválido pero los
// paths legítimos se imprimen consistentes.
m.path_hint = Some(parent.display().to_string());
m.members = group.iter().map(|f| f.id).collect();
m.touch();
m
}
/// Construye un label legible tomando los últimos hasta 2 componentes
/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
/// etc. Para directorios shallow (root o un nivel), cae al
/// `file_name()` simple.
fn label_from_path(p: &std::path::Path) -> String {
let normals: Vec<&str> = p
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => s.to_str(),
_ => None,
})
.collect();
if normals.is_empty() {
return "unnamed".to_string();
}
let take = normals.len().min(2);
let start = normals.len() - take;
normals[start..].join("/")
}
fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
let path_str = parent.display();
let n = group.len();
let exts = if keywords.is_empty() {
"(sin extensiones)".to_string()
} else {
keywords.join(", ")
};
format!("{n} archivos en {path_str} (ext: {exts})")
}
/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for f in files {
if let Some(ext) = &f.extension {
*counts.entry(ext.clone()).or_default() += 1;
}
}
let mut sorted: Vec<_> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
sorted.into_iter().take(n).map(|(k, _)| k).collect()
}
/// Elige el lente dominante según la extensión más frecuente, con
/// fallback a `shuma-discern` sobre el head del archivo más
/// representativo cuando la extensión no da hint claro (Lens::Grid).
fn pick_lens(files: &[&FileEntry]) -> Lens {
let dominant = top_extensions(files, 1).into_iter().next();
let by_ext = match dominant.as_deref() {
Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
| "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
Lens::Gallery
}
Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
_ => Lens::Grid,
};
if by_ext != Lens::Grid {
return by_ext;
}
// Fallback: samplear el primer archivo del grupo con shuma-discern.
// Sólo si tiene path real (FileEntry con path absoluto/relativo).
if let Some(first) = files.first() {
if let Some(lens) = discern_lens(&first.path) {
return lens;
}
}
Lens::Grid
}
fn discern_lens(path: &std::path::Path) -> Option<Lens> {
use std::io::Read;
let mut buf = vec![0u8; 4096];
let mut f = std::fs::File::open(path).ok()?;
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
let pipeline = shuma_discern::DiscernPipeline::default_pipeline();
let path_str = path.to_str();
let d = pipeline.discern(
&buf,
&shuma_discern::Hint {
path: path_str,
size_total: None,
},
)?;
match d.lens.as_deref()? {
"code" => Some(Lens::Code),
"gallery" => Some(Lens::Gallery),
"markdown" => Some(Lens::Markdown),
"database" => Some(Lens::Database),
"tree" => Some(Lens::Tree),
_ => None,
}
}
/// Entropía de Shannon normalizada sobre la distribución de extensiones.
/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
/// distributed entre `n` extensiones (máx información).
fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
let total = files.len() as f32;
if total <= 1.0 {
return 0.0;
}
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for f in files {
let ext = f.extension.as_deref().unwrap_or("(none)");
*counts.entry(ext.to_string()).or_default() += 1;
}
let entropy: f32 = counts
.values()
.map(|&c| {
let p = c as f32 / total;
-p * p.log2()
})
.sum();
let max_entropy = (counts.len() as f32).log2();
if max_entropy <= 0.0 {
0.0
} else {
(entropy / max_entropy).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::FileId;
use std::path::PathBuf;
use ulid::Ulid;
fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: PathBuf::from(path),
content_hash: None,
size: 100,
mtime_ms: 0,
extension: ext.map(String::from),
}
}
#[test]
fn groups_by_parent_directory() {
let files = vec![
mkfile("/proj/src/a.rs", Some("rs")),
mkfile("/proj/src/b.rs", Some("rs")),
mkfile("/proj/src/c.rs", Some("rs")),
mkfile("/proj/docs/readme.md", Some("md")),
mkfile("/proj/docs/guide.md", Some("md")),
mkfile("/proj/docs/notes.md", Some("md")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads.len(), 2);
let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
// Phase B: labels usan los últimos 2 componentes del path para
// desambiguar (proj/src vs proj/docs en lugar de src vs docs).
assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
assert!(labels.iter().any(|l| l.as_str() == "proj/docs"));
}
#[test]
fn small_groups_not_promoted() {
let files = vec![
mkfile("/proj/single.txt", Some("txt")),
mkfile("/proj/sub/a.txt", Some("txt")),
mkfile("/proj/sub/b.txt", Some("txt")),
mkfile("/proj/sub/c.txt", Some("txt")),
];
// min=3 → /proj/single solo no se promueve, /proj/sub sí.
let monads = by_directory(&files, 3);
assert_eq!(monads.len(), 1);
assert_eq!(monads[0].label, "proj/sub");
}
#[test]
fn label_from_root_only_one_component() {
// Un solo componente normal en el path → no hay "padre" útil.
let p = std::path::Path::new("/onlyone");
assert_eq!(label_from_path(p), "onlyone");
}
#[test]
fn label_from_deep_path_takes_last_two() {
let p = std::path::Path::new("/a/b/c/d/e");
assert_eq!(label_from_path(p), "d/e");
}
#[test]
fn lens_picked_by_dominant_extension() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.rs", Some("rs")),
mkfile("/x/c.rs", Some("rs")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads[0].dominant_lens, Lens::Code);
let files = vec![
mkfile("/y/1.png", Some("png")),
mkfile("/y/2.png", Some("png")),
mkfile("/y/3.png", Some("png")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads[0].dominant_lens, Lens::Gallery);
}
#[test]
fn entropy_zero_for_homogeneous() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.rs", Some("rs")),
mkfile("/x/c.rs", Some("rs")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads[0].entropy, 0.0);
}
#[test]
fn entropy_high_for_diverse() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.md", Some("md")),
mkfile("/x/c.json", Some("json")),
mkfile("/x/d.png", Some("png")),
];
let monads = by_directory(&files, 3);
// 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
}
#[test]
fn top_extensions_orders_by_freq_then_alpha() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.rs", Some("rs")),
mkfile("/x/c.md", Some("md")),
mkfile("/x/d.py", Some("py")),
];
let refs: Vec<&FileEntry> = files.iter().collect();
let top = top_extensions(&refs, 3);
assert_eq!(top, vec!["rs", "md", "py"]);
}
}
+313
View File
@@ -0,0 +1,313 @@
//! DB de Mónadas y archivos. Backend dual:
//!
//! - **Memoria** (default, cache): `BTreeMap<Id, T>` para reads O(log n).
//! - **Persistencia** (opcional): sled-backed write-through. Si se abre
//! con `MonadDb::open(path)`, cada `insert_*` escribe a sled además
//! de la cache. Reads siempre vienen de la cache.
//!
//! Wire format: JSON via serde_json. Los manifestos son chicos y
//! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre
//! postcard en debuggability.
use std::collections::BTreeMap;
use std::path::Path;
use chasqui_card::{FileEntry, FileId, MonadId, MonadManifest};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MonadDbError {
#[error("sled: {0}")]
Sled(#[from] sled::Error),
#[error("JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("ULID inválido en clave: {0}")]
BadKey(String),
}
const TREE_FILES: &str = "files";
const TREE_MONADS: &str = "monads";
/// Store de Mónadas + archivos. Cache en memoria + persistencia
/// opcional sled.
pub struct MonadDb {
files: BTreeMap<FileId, FileEntry>,
monads: BTreeMap<MonadId, MonadManifest>,
persistence: Option<sled::Db>,
}
impl Default for MonadDb {
fn default() -> Self {
Self::new()
}
}
impl MonadDb {
/// Store en memoria pura (sin persistencia). El estado se pierde al salir.
pub fn new() -> Self {
Self {
files: BTreeMap::new(),
monads: BTreeMap::new(),
persistence: None,
}
}
/// Abre (o crea) un store sled-backed en `path`. Carga el contenido
/// existente a la cache antes de devolver.
pub fn open(path: impl AsRef<Path>) -> Result<Self, MonadDbError> {
let db = sled::open(path)?;
let mut files = BTreeMap::new();
let mut monads = BTreeMap::new();
let files_tree = db.open_tree(TREE_FILES)?;
for kv in files_tree.iter() {
let (k, v) = kv?;
let id = decode_key(&k)?;
let entry: FileEntry = serde_json::from_slice(&v)?;
files.insert(id, entry);
}
let monads_tree = db.open_tree(TREE_MONADS)?;
for kv in monads_tree.iter() {
let (k, v) = kv?;
let id = decode_key(&k)?;
let monad: MonadManifest = serde_json::from_slice(&v)?;
monads.insert(id, monad);
}
Ok(Self {
files,
monads,
persistence: Some(db),
})
}
/// `true` si tiene backend persistente.
pub fn is_persistent(&self) -> bool {
self.persistence.is_some()
}
// ---- Files ----
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
if let Some(db) = &self.persistence {
// Write-through: si falla el persist, lo logeamos pero la
// memoria queda actualizada. Filosofía: cache nunca miente
// sobre el último estado conocido en este proceso.
if let Err(e) = persist_file(db, &file) {
eprintln!("[MonadDb] persist file falló: {e}");
}
}
self.files.insert(file.id, file)
}
pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
for f in files {
self.insert_file(f);
}
}
pub fn file(&self, id: FileId) -> Option<&FileEntry> {
self.files.get(&id)
}
pub fn files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
self.files.values()
}
pub fn file_count(&self) -> usize {
self.files.len()
}
// ---- Monads ----
pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
if let Some(db) = &self.persistence {
if let Err(e) = persist_monad(db, &monad) {
eprintln!("[MonadDb] persist monad falló: {e}");
}
}
self.monads.insert(monad.id, monad)
}
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
// Si hay persistencia, limpiar tree antes de insertar.
if let Some(db) = &self.persistence {
if let Ok(tree) = db.open_tree(TREE_MONADS) {
let _ = tree.clear();
}
}
self.monads.clear();
for m in monads {
self.insert_monad(m);
}
}
pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> {
self.monads.get(&id)
}
pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
self.monads.values()
}
pub fn monad_count(&self) -> usize {
self.monads.len()
}
/// Resuelve los archivos miembros de una Mónada como referencias.
/// Skipea silenciosamente IDs que ya no estén en la tabla `files`.
pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> {
match self.monads.get(&monad_id) {
Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(),
None => Vec::new(),
}
}
}
fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> {
let tree = db.open_tree(TREE_FILES)?;
let key = f.id.to_string();
let val = serde_json::to_vec(f)?;
tree.insert(key.as_bytes(), val)?;
Ok(())
}
fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> {
let tree = db.open_tree(TREE_MONADS)?;
let key = m.id.to_string();
let val = serde_json::to_vec(m)?;
tree.insert(key.as_bytes(), val)?;
Ok(())
}
fn decode_key(k: &[u8]) -> Result<ulid::Ulid, MonadDbError> {
let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?;
ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::Lens;
use ulid::Ulid;
fn mk_file(path: &str) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: std::path::PathBuf::from(path),
content_hash: None,
size: 100,
mtime_ms: 0,
extension: Some("rs".into()),
}
}
#[test]
fn ingest_and_lookup() {
let mut db = MonadDb::new();
let f1 = mk_file("/a/x.rs");
let f2 = mk_file("/a/y.rs");
let id1 = f1.id;
db.ingest_files(vec![f1, f2]);
assert_eq!(db.file_count(), 2);
assert!(db.file(id1).is_some());
assert!(!db.is_persistent());
}
#[test]
fn resolve_members_filters_missing() {
let mut db = MonadDb::new();
let f1 = mk_file("/x/a.rs");
let id1 = f1.id;
db.insert_file(f1);
let mut m = MonadManifest::new("test");
m.members.insert(id1);
m.members.insert(FileId::from(Ulid::new())); // miembro fantasma
m.dominant_lens = Lens::Code;
m.touch();
let mid = m.id;
db.insert_monad(m);
let resolved = db.resolve_members(mid);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].id, id1);
}
#[test]
fn replace_monads_clears_old() {
let mut db = MonadDb::new();
let mut m1 = MonadManifest::new("a");
m1.members.insert(FileId::from(Ulid::new()));
m1.touch();
db.insert_monad(m1);
assert_eq!(db.monad_count(), 1);
let mut m2 = MonadManifest::new("b");
m2.members.insert(FileId::from(Ulid::new()));
m2.touch();
db.replace_monads(vec![m2]);
assert_eq!(db.monad_count(), 1);
assert!(db.monads().next().unwrap().label == "b");
}
#[test]
fn persistence_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let dbpath = tmp.path().join("monads.sled");
// Escribimos algunos datos
{
let mut db = MonadDb::open(&dbpath).expect("open");
assert!(db.is_persistent());
let f = mk_file("/persist/a.rs");
let fid = f.id;
db.insert_file(f);
let mut m = MonadManifest::new("persist-test");
m.members.insert(fid);
m.dominant_lens = Lens::Code;
m.touch();
db.insert_monad(m);
}
// Reabrimos y verificamos que están
let db = MonadDb::open(&dbpath).expect("reopen");
assert_eq!(db.file_count(), 1);
assert_eq!(db.monad_count(), 1);
let m = db.monads().next().unwrap();
assert_eq!(m.label, "persist-test");
assert_eq!(m.cardinality, 1);
}
#[test]
fn replace_monads_purges_persistent_tree() {
let tmp = tempfile::tempdir().unwrap();
let dbpath = tmp.path().join("replace.sled");
{
let mut db = MonadDb::open(&dbpath).unwrap();
let mut m1 = MonadManifest::new("old");
m1.members.insert(FileId::from(Ulid::new()));
m1.touch();
db.insert_monad(m1);
}
// Reabrir, replace, verificar
{
let mut db = MonadDb::open(&dbpath).unwrap();
assert_eq!(db.monad_count(), 1);
let mut m2 = MonadManifest::new("new");
m2.members.insert(FileId::from(Ulid::new()));
m2.touch();
db.replace_monads(vec![m2]);
assert_eq!(db.monad_count(), 1);
}
// Tercera apertura: sólo "new" sobrevive
let db = MonadDb::open(&dbpath).unwrap();
assert_eq!(db.monad_count(), 1);
assert_eq!(db.monads().next().unwrap().label, "new");
}
}
+298
View File
@@ -0,0 +1,298 @@
//! Pseudo-embeddings de archivos: vectores deterministas derivados de
//! metadatos (sin LLM).
//!
//! Implementan el "imán semántico" matemático que el diseño de Kairos
//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
//! cerca (cosine similarity).
//!
//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
//! - Bootstrapping sin Nous corriendo.
//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
//! muy juntos en el espacio vectorial).
//!
//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
//!
//! - dims 0..8: `blake3(extension)` → identidad de tipo
//! - dims 8..16: `blake3(parent_dir)` → identidad de contenedor
//! - dims 16..24: `blake3(file_stem)` → identidad léxica del archivo
//! - dims 24..28: tamaño (log scale + flags binarios)
//! - dims 28..32: mtime (escala día + features cíclicas)
//!
//! ## Propiedades empíricas
//!
//! - Mismo dir + misma ext → similitud > 0.7 (alta cohesión).
//! - Mismo dir + ext distinta → similitud ~ 0.5.
//! - Dirs distintos + misma ext → similitud ~ 0.5.
//! - Sin parecido → similitud < 0.3.
use chasqui_card::{FileEntry, MonadId, MonadManifest};
/// Dimensión del vector embedding.
pub const EMBED_DIM: usize = 32;
/// Identificador del modelo que produce este embedding. Se usa para
/// taggear `MonadManifest.centroid_model`: los consumidores comparan
/// este string contra el suyo antes de hacer cosine similarity.
/// Mezclar centroides de distinto MODEL_ID corrompe scores
/// silenciosamente (dimensiones distintas, semántica distinta).
pub const MODEL_ID: &str = "chasqui-pseudo-32d";
/// Computa el embedding de un archivo. Determinístico: misma input
/// → mismo vector. El vector queda L2-normalizado.
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
let mut v = [0.0f32; EMBED_DIM];
// dims 0..8: extension hash
fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
// dims 8..16: parent dir name hash
let parent = file
.path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("");
fill_from_hash(&mut v[8..16], parent);
// dims 16..24: file stem hash (sin extensión)
let stem = file
.path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("");
fill_from_hash(&mut v[16..24], stem);
// dims 24..28: tamaño (centrado en 0 para que dot products entre
// archivos de tamaño diferente sumen 0 en expectativa).
let log_size = (file.size.max(1) as f32).log10();
v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
v[25] = (log_size.fract() - 0.5) * 2.0;
v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
// dims 28..32: mtime — escala día + cíclicas (centradas).
let day = file.mtime_ms / (86_400 * 1000);
v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
normalize(&mut v);
v
}
/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
/// así dos vectores hash distintos (de strings no relacionados) tendrían
/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
/// que cosine similarity sea una métrica útil de afinidad.
fn fill_from_hash(out: &mut [f32], input: &str) {
let h = blake3::hash(input.as_bytes());
let bytes = h.as_bytes();
for (i, slot) in out.iter_mut().enumerate() {
*slot = (bytes[i] as f32 - 127.5) / 127.5;
}
}
/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
fn normalize(v: &mut [f32]) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
/// no coinciden, devuelve 0.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
/// L2-normalización. El resultado es un vector unidad apto para
/// comparar con miembros nuevos vía cosine similarity.
pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
if vectors.is_empty() {
return Vec::new();
}
let dim = vectors[0].len();
let mut c = vec![0.0f32; dim];
for v in vectors {
if v.len() != dim {
continue;
}
for (i, x) in v.iter().enumerate() {
c[i] += x;
}
}
let n = vectors.len() as f32;
for x in c.iter_mut() {
*x /= n;
}
normalize(&mut c);
c
}
/// Cohesión interna: media de cosine similarity de cada miembro contra
/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
if member_vectors.is_empty() || centroid.is_empty() {
return 0.0;
}
let sum: f32 = member_vectors
.iter()
.map(|v| cosine_similarity(centroid, v))
.sum();
sum / member_vectors.len() as f32
}
/// Score de atracción de un archivo nuevo a una Mónada existente:
/// cosine similarity de su embedding contra el centroide de la Mónada.
/// Mayor score = mayor afinidad.
pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
if monad.centroid.is_empty() {
return 0.0;
}
cosine_similarity(file_vec, &monad.centroid)
}
/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
/// `(MonadId, score)` o `None` si ninguna tiene centroide.
pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
where
I: IntoIterator<Item = &'a MonadManifest>,
{
monads
.into_iter()
.filter(|m| !m.centroid.is_empty())
.map(|m| (m.id, attraction_score(file_vec, m)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
}
/// Umbral por defecto para "se pega": si el score es ≥ esto, el
/// archivo se asigna automáticamente. Ajustable por el caller.
pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::FileId;
use std::path::PathBuf;
use ulid::Ulid;
fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: PathBuf::from(path),
content_hash: None,
size,
mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
extension: ext.map(String::from),
}
}
#[test]
fn embed_is_deterministic() {
let a = mk("/x/foo.rs", Some("rs"), 1024);
let b = mk("/x/foo.rs", Some("rs"), 1024);
let va = embed(&a);
let vb = embed(&b);
// Mismos metadatos → mismo vector (los IDs no entran al embedding).
assert_eq!(va, vb);
}
#[test]
fn embed_is_unit_normalized() {
let f = mk("/x/foo.rs", Some("rs"), 1024);
let v = embed(&f);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
}
#[test]
fn same_dir_same_ext_high_similarity() {
let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
let sim = cosine_similarity(&a, &b);
assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
}
#[test]
fn unrelated_files_low_similarity() {
let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
let sim = cosine_similarity(&a, &b);
assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
}
#[test]
fn centroid_is_unit_and_close_to_members() {
let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
// Norma unitaria.
let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
// Cohesión alta porque los miembros son similares.
let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
assert!(cohesion > 0.9, "cohesion={cohesion}");
}
#[test]
fn attraction_picks_correct_monad() {
// Construimos dos Mónadas: una de Rust, otra de imágenes.
let rust_files = vec![
embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
];
let img_files = vec![
embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
];
let mut rust_monad = MonadManifest::new("rust");
rust_monad.members.insert(FileId::from(Ulid::new()));
rust_monad.touch();
rust_monad.centroid = centroid(&rust_files);
let mut img_monad = MonadManifest::new("photos");
img_monad.members.insert(FileId::from(Ulid::new()));
img_monad.touch();
img_monad.centroid = centroid(&img_files);
// Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
.expect("best match");
assert_eq!(best_id, rust_monad.id);
// Y al revés.
let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
.expect("best match");
assert_eq!(best_id, img_monad.id);
}
#[test]
fn empty_centroid_skipped_in_attraction() {
let mut m = MonadManifest::new("empty");
m.members.insert(FileId::from(Ulid::new()));
m.touch();
// m.centroid queda vacío
let v = embed(&mk("/x/y.rs", Some("rs"), 100));
assert!(best_attraction(&v, [&m].into_iter()).is_none());
}
}
@@ -0,0 +1,318 @@
//! Listener Unix-socket que sirve [`chasqui_card::query::QueryRequest`].
//!
//! El daemon `chasqui` lo monta para que cualquier consumer (UI, CLI,
//! otro módulo) pueda preguntarle por sus Mónadas sin pasar por
//! brahman-admin. El path del socket viaja en el `Card.service_socket`
//! del engine; el broker brahman lo enseña vía MatchEvent::Available
//! cuando un consumer declara `flow.input = monad-list:json`.
//!
//! Wire: line-delimited JSON, single-shot por conexión. Mismo patrón
//! que `chasqui-nous` (mock/real ↔ chasqui-core), reutilizado.
//!
//! Threading: un thread dedicado, blocking I/O. No vale la pena traer
//! tokio acá — la frecuencia esperada es muy baja (UI poll cada 2s)
//! y el handler es trivial (lock db → snapshot → write).
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use chasqui_card::query::{
EngineInfo, ErrorResponse, FileView, ListMonadsResponse, MonadView, QueryRequest,
ResolveMonadResponse,
};
use chasqui_card::MonadId;
use chasqui_card::ulid::Ulid;
use crate::db::MonadDb;
/// Configuración del listener.
pub struct ListenerConfig {
pub socket_path: PathBuf,
pub engine_id: Ulid,
pub engine_label: String,
/// Path del directorio que el daemon está observando, para incluir
/// en `EngineInfo.watching`. `None` si el daemon no observa nada.
pub watching: Option<PathBuf>,
}
/// Bind del socket + spawn de un thread con accept loop. Devuelve el
/// path final (útil para confirmar) y un `JoinHandle` para shutdown
/// explícito (drop = thread sigue, listener queda).
///
/// Si el socket ya existe (sesión anterior crasheada), se intenta
/// removerlo antes del bind. Errores de bind se propagan al caller.
pub fn spawn_listener(
config: ListenerConfig,
db: Arc<Mutex<MonadDb>>,
) -> std::io::Result<std::thread::JoinHandle<()>> {
if config.socket_path.exists() {
let _ = std::fs::remove_file(&config.socket_path);
}
if let Some(parent) = config.socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
let listener = UnixListener::bind(&config.socket_path)?;
let handle = std::thread::Builder::new()
.name("chasqui-engine-listener".into())
.spawn(move || {
for conn in listener.incoming() {
match conn {
Ok(stream) => {
// Handler sincrónico inline. La frecuencia
// esperada (UI poll cada N segundos) no
// amerita spawn-per-connection; si en el
// futuro hay carga, agregar un threadpool.
if let Err(e) = handle_conn(stream, &db, &config) {
eprintln!("[engine-socket] conn falló: {e}");
}
}
Err(e) => {
eprintln!("[engine-socket] accept falló: {e}");
}
}
}
})?;
Ok(handle)
}
fn handle_conn(
mut stream: UnixStream,
db: &Arc<Mutex<MonadDb>>,
config: &ListenerConfig,
) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(());
}
let resp_bytes = match serde_json::from_str::<QueryRequest>(line.trim()) {
Ok(QueryRequest::ListMonads) => match handle_list_monads(db, config) {
Ok(json) => json,
Err(e) => encode_error(format!("list_monads falló: {e}")),
},
Ok(QueryRequest::ResolveMonad { id }) => match handle_resolve_monad(db, id) {
Ok(json) => json,
Err(e) => encode_error(format!("resolve_monad falló: {e}")),
},
Err(e) => encode_error(format!("JSON inválido: {e}")),
};
stream.write_all(resp_bytes.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let _ = stream.shutdown(std::net::Shutdown::Both);
Ok(())
}
fn handle_list_monads(
db: &Arc<Mutex<MonadDb>>,
config: &ListenerConfig,
) -> Result<String, String> {
let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?;
let monads: Vec<MonadView> = db_lock.monads().map(MonadView::from_manifest).collect();
let resp = ListMonadsResponse {
engine: EngineInfo {
id: config.engine_id,
label: config.engine_label.clone(),
watching: config.watching.as_ref().map(|p| p.display().to_string()),
},
monads,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn handle_resolve_monad(db: &Arc<Mutex<MonadDb>>, id: MonadId) -> Result<String, String> {
let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?;
let members: Vec<FileView> = db_lock
.resolve_members(id)
.into_iter()
.map(FileView::from_entry)
.collect();
let resp = ResolveMonadResponse { monad: id, members };
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn encode_error(msg: String) -> String {
let err = ErrorResponse { error: msg };
serde_json::to_string(&err).unwrap_or_else(|_| "{\"error\":\"encode\"}".into())
}
// El cliente blocking vive en `chasqui_card::query::client` — junto a
// los wire types — para que un consumer pueda hablar con el daemon
// importando sólo `chasqui-card`, sin arrastrar el peso de
// `chasqui-core` (scanner / db / sled / notify / walkdir / blake3).
#[cfg(test)]
mod tests {
use super::*;
use crate::db::MonadDb;
use chasqui_card::query::client as query_client;
use chasqui_card::MonadManifest;
use std::time::Duration;
fn fresh_socket_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir();
let unique = format!("{}-{}-{}.sock", name, std::process::id(), Ulid::new());
dir.join(unique)
}
#[test]
fn list_monads_roundtrip_empty() {
let socket = fresh_socket_path("chasqui-engine-test");
let db = Arc::new(Mutex::new(MonadDb::new()));
let engine_id = Ulid::new();
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id,
engine_label: "test-engine".into(),
watching: Some(PathBuf::from("/tmp/x")),
},
db.clone(),
)
.unwrap();
// Pequeña espera para que el bind se asiente (en práctica el
// socket existe inmediatamente tras el bind, pero algunos FS
// necesitan un tick). Si esto resulta flaky, agregar un loop
// de wait_for(socket.exists()).
std::thread::sleep(Duration::from_millis(50));
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
assert_eq!(resp.engine.id, engine_id);
assert_eq!(resp.engine.label, "test-engine");
assert_eq!(resp.engine.watching.as_deref(), Some("/tmp/x"));
assert!(resp.monads.is_empty());
let _ = std::fs::remove_file(&socket);
}
#[test]
fn list_monads_returns_views() {
let socket = fresh_socket_path("chasqui-engine-test-views");
let db = Arc::new(Mutex::new(MonadDb::new()));
let m1 = MonadManifest::new("alpha");
let m2 = MonadManifest::new("beta");
{
let mut g = db.lock().unwrap();
g.replace_monads(vec![m1.clone(), m2.clone()]);
}
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id: Ulid::new(),
engine_label: "test".into(),
watching: None,
},
db.clone(),
)
.unwrap();
std::thread::sleep(Duration::from_millis(50));
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
assert_eq!(resp.monads.len(), 2);
let labels: Vec<_> = resp.monads.iter().map(|m| m.label.as_str()).collect();
assert!(labels.contains(&"alpha"));
assert!(labels.contains(&"beta"));
let _ = std::fs::remove_file(&socket);
}
#[test]
fn resolve_monad_returns_member_files() {
use chasqui_card::FileEntry;
use std::path::PathBuf;
let socket = fresh_socket_path("chasqui-engine-test-resolve");
let db = Arc::new(Mutex::new(MonadDb::new()));
// Dos archivos y una Mónada que los tiene de miembros.
let f1 = FileEntry {
id: Ulid::new(),
path: PathBuf::from("/proj/a.rs"),
content_hash: None,
size: 10,
mtime_ms: 1,
extension: Some("rs".into()),
};
let f2 = FileEntry {
id: Ulid::new(),
path: PathBuf::from("/proj/b.rs"),
content_hash: None,
size: 20,
mtime_ms: 2,
extension: Some("rs".into()),
};
let mut m = MonadManifest::new("proj");
m.members.insert(f1.id);
m.members.insert(f2.id);
m.cardinality = 2;
let monad_id = m.id;
{
let mut g = db.lock().unwrap();
g.ingest_files(vec![f1, f2]);
g.replace_monads(vec![m]);
}
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id: Ulid::new(),
engine_label: "test".into(),
watching: None,
},
db.clone(),
)
.unwrap();
std::thread::sleep(Duration::from_millis(50));
let resp =
query_client::resolve_monad(&socket, monad_id, Duration::from_secs(2)).unwrap();
assert_eq!(resp.monad, monad_id);
assert_eq!(resp.members.len(), 2);
let paths: Vec<_> = resp.members.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"/proj/a.rs"));
assert!(paths.contains(&"/proj/b.rs"));
let _ = std::fs::remove_file(&socket);
}
#[test]
fn invalid_request_returns_error_response() {
let socket = fresh_socket_path("chasqui-engine-test-bad");
let db = Arc::new(Mutex::new(MonadDb::new()));
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id: Ulid::new(),
engine_label: "test".into(),
watching: None,
},
db.clone(),
)
.unwrap();
std::thread::sleep(Duration::from_millis(50));
// Bypass del cliente tipado: mandamos JSON inválido a mano.
use std::io::{BufRead, BufReader, Write};
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(b"not json\n").unwrap();
stream.flush().unwrap();
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response).unwrap();
assert!(
response.contains("\"error\""),
"esperaba ErrorResponse, got: {response}"
);
let _ = std::fs::remove_file(&socket);
}
}
+34
View File
@@ -0,0 +1,34 @@
//! `chasqui-core` — el explorador de Mónadas.
//!
//! Implementa la pipeline determinista descrita en el diseño de Kairos:
//!
//! 1. [`scanner`]: recorre directorios y emite [`FileEntry`] (sin tocar
//! contenido en Phase 0 — sólo metadatos).
//! 2. [`cluster`]: agrupa archivos en [`MonadManifest`] usando
//! heurísticas (parent dir + extensión dominante). 0 LLM.
//! 3. [`db`]: store en memoria con índices files↔monads.
//!
//! Pipeline:
//! ```text
//! scan_directory(path)
//! → Vec<FileEntry>
//! → cluster::by_directory(min_files=N)
//! → Vec<MonadManifest>
//! → MonadDb::ingest(...)
//! ```
//!
//! Lo importante: en este crate no hay IA, no hay embeddings. Es la
//! capa determinista que cubre el 90% de los casos. Los embeddings
//! (`Phase C`) y Nous (`Phase D`) se enchufan después como módulos
//! separados que producen flows brahman.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod cluster;
pub mod db;
pub mod embed;
pub mod engine_socket;
pub mod scanner;
pub use chasqui_card::*;
@@ -0,0 +1,178 @@
//! Recorrido de directorios. Sólo metadatos — no lee contenido.
//!
//! Usa `walkdir` (sequential). Para árboles muy grandes considerar
//! migrar a `jwalk` (paralelo); por ahora la simplicidad gana.
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use chasqui_card::{FileEntry, FileId};
use thiserror::Error;
use ulid::Ulid;
use walkdir::WalkDir;
#[derive(Debug, Error)]
pub enum ScanError {
#[error("ruta no existe: {0}")]
NotFound(PathBuf),
#[error("no se pudo leer: {0}")]
Walk(String),
}
/// Configuración del scan.
#[derive(Debug, Clone)]
pub struct ScanConfig {
/// Profundidad máxima (None = ilimitada).
pub max_depth: Option<usize>,
/// Sigue symlinks (default: false, evita ciclos).
pub follow_links: bool,
/// Ignora archivos ocultos (.dotfiles).
pub skip_hidden: bool,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
max_depth: None,
follow_links: false,
skip_hidden: true,
}
}
}
/// Recorre `root` y devuelve un `FileEntry` por cada archivo regular.
/// Errores de permisos en sub-paths se ignoran silenciosamente.
pub fn scan_directory(root: &Path, config: &ScanConfig) -> Result<Vec<FileEntry>, ScanError> {
if !root.exists() {
return Err(ScanError::NotFound(root.to_path_buf()));
}
let mut walker = WalkDir::new(root).follow_links(config.follow_links);
if let Some(d) = config.max_depth {
walker = walker.max_depth(d);
}
let mut entries = Vec::new();
for entry_result in walker {
let entry = match entry_result {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
if config.skip_hidden && is_hidden(entry.path()) {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let mtime_ms = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let extension = entry
.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
entries.push(FileEntry {
id: FileId::from(Ulid::new()),
path: entry.path().to_path_buf(),
content_hash: None,
size: metadata.len(),
mtime_ms,
extension,
});
}
Ok(entries)
}
/// `true` si alguno de los componentes del path empieza con `.`.
/// Excluye el primer componente (root) para no descartar el directorio raíz
/// si el usuario apuntó a un dotfile-dir explícito.
fn is_hidden(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn scans_basic_tree() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.rs"), "fn main(){}");
write(&root.join("b.rs"), "fn b(){}");
write(&root.join("data/x.json"), "{}");
write(&root.join("data/y.json"), "{}");
let files = scan_directory(root, &ScanConfig::default()).unwrap();
assert_eq!(files.len(), 4);
let exts: std::collections::BTreeSet<_> = files
.iter()
.filter_map(|f| f.extension.clone())
.collect();
assert!(exts.contains("rs"));
assert!(exts.contains("json"));
}
#[test]
fn skips_hidden_by_default() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("visible.txt"), "x");
write(&root.join(".hidden"), "x");
let files = scan_directory(root, &ScanConfig::default()).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path.ends_with("visible.txt"));
}
#[test]
fn missing_root_errors() {
let p = std::path::Path::new("/nonexistent-12345-abc");
assert!(matches!(
scan_directory(p, &ScanConfig::default()),
Err(ScanError::NotFound(_))
));
}
#[test]
fn max_depth_limits() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("top.txt"), "x");
write(&root.join("a/b/deep.txt"), "x");
let cfg = ScanConfig {
max_depth: Some(1),
..Default::default()
};
let files = scan_directory(root, &cfg).unwrap();
// max_depth=1 incluye archivos en root pero no anidados profundos.
let names: Vec<_> = files
.iter()
.filter_map(|f| f.path.file_name().and_then(|s| s.to_str()))
.map(String::from)
.collect();
assert!(names.contains(&"top.txt".to_string()));
assert!(!names.contains(&"deep.txt".to_string()));
}
}
@@ -0,0 +1,28 @@
[package]
name = "chasqui-explorer-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Explorador Llimphi de Mónadas: panel que descubre al daemon chasqui vía broker brahman y consulta sus Mónadas dinámicamente. Reemplazo del `chasqui-explorer` GPUI; el discovery (card-sidecar) y el cliente de query no cambian, sólo el frontend."
[dependencies]
card-core = { workspace = true }
card-sidecar = { path = "../card-sidecar" }
chasqui-card = { path = "../chasqui-card" }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-app-header = { workspace = true }
llimphi-widget-banner = { workspace = true }
llimphi-widget-card = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
app-bus = { workspace = true }
rimay-localize = { workspace = true }
wawa-config = { workspace = true }
[[bin]]
name = "chasqui-explorer-llimphi"
path = "src/main.rs"
@@ -0,0 +1,16 @@
# chasqui-explorer-llimphi
> UI Llimphi: log de mensajes en vivo del broker de [chasqui](../README.md).
Filtros por topic + autor + schema; pause/resume del stream; inspección del mensaje en detalle. Útil para debug de protocolos entre apps.
## Uso
```sh
cargo run --release -p chasqui-explorer-llimphi
```
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
- [`llimphi-ui`](../../llimphi/) + widgets `list`, `text-area`
@@ -0,0 +1,16 @@
# chasqui-explorer-llimphi
> Llimphi UI: live message log of [chasqui](../README.md)'s broker.
Filters by topic + author + schema; pause/resume the stream; inspect message detail. Useful for debugging protocols between apps.
## Usage
```sh
cargo run --release -p chasqui-explorer-llimphi
```
## Deps
- [`chasqui-core`](../chasqui-core/README.md), [`chasqui-nous-real`](../chasqui-nous-real/README.md)
- [`llimphi-ui`](../../llimphi/) + widgets `list`, `text-area`
@@ -0,0 +1,800 @@
//! `chasqui-explorer-llimphi` — panel Llimphi que descubre al daemon
//! `chasqui` vía broker brahman y muestra sus Mónadas en vivo.
//!
//! Diseño: ventana standalone que cada N segundos consulta el query
//! socket del daemon (`chasqui_card::query::client::list_monads`). El
//! path del socket NO está hardcoded — se descubre vía
//! `card_sidecar::await_provider_blocking` para el flow
//! `monad-list:json`. Si el daemon cae, el socket cacheado se invalida
//! y la próxima iteración re-descubre.
//!
//! Sin integración con nahual-shell — es su propio binario para que el
//! ecosistema sea visible incluso sin la shell completa.
//!
//! Uso:
//! ```sh
//! cargo run -p chasqui-explorer-llimphi
//! # con override del init socket (heredado de brahman-handshake):
//! BRAHMAN_INIT_SOCKET=/tmp/init.sock cargo run -p chasqui-explorer-llimphi
//! ```
#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::time::Duration;
use card_sidecar::{await_provider_blocking, build_consumer_card, ConsumerError};
use chasqui_card::query::client as query_client;
use chasqui_card::query::{transport, ListMonadsResponse, FLOW_MONAD_LIST, FLOW_TYPE_NAME};
use chasqui_card::Lens;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style},
Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_motion::{animate, motion, Tween};
use llimphi_widget_app_header::{app_header, AppHeaderPalette};
use llimphi_widget_banner::{banner_view, BannerKind};
use llimphi_widget_card::{card_view, CardOptions, CardPalette};
use llimphi_widget_context_menu::{
context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
};
use llimphi_widget_menubar::{
menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
DEFAULT_HEIGHT as MENU_H,
};
use app_bus::{AppMenu, Menu, MenuItem};
use std::sync::Arc;
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3);
const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
struct Model {
theme: Theme,
socket: Option<PathBuf>,
snapshot: Option<ListMonadsResponse>,
error: Option<String>,
/// Última fuente del socket activo: "discovery"/"broker"/"cache"/
/// "default-path". Sólo informativo en el header.
socket_source: Option<&'static str>,
/// Barra de menú principal: índice del menú raíz abierto (`None`
/// cerrado).
menu_open: Option<usize>,
/// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna).
menu_active: usize,
/// Animación de aparición del dropdown.
menu_anim: Tween<f32>,
/// Mónada seleccionada (índice en `snapshot.monads`). `None` si
/// ninguna. La selección sólo resalta y habilita el menú contextual;
/// el explorer es de sólo lectura.
selected: Option<usize>,
/// Menú contextual sobre una Mónada: `(idx, x, y)` ancla en ventana.
/// `None` cerrado.
context_menu: Option<(usize, f32, f32)>,
}
#[derive(Clone)]
enum Msg {
Tick,
Refresh(TickOutcome),
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Navega la fila activa del dropdown (+1/-1).
MenuNav(i32),
/// Ejecuta el comando de la fila activa (Enter).
MenuActivate,
/// No-op: sólo fuerza re-render durante la animación del dropdown.
MenuTick,
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
/// Cicla el tema claro/oscuro.
CycleTheme,
/// Fuerza re-descubrimiento del socket: invalida el cacheado y
/// dispara un Tick. Mapea "Reconectar" del menú Ver.
Reconnect,
/// Selecciona una Mónada por índice (resalta).
SelectMonad(usize),
/// Right-click en la raíz → abre el menú contextual anclado en
/// `(x, y)` de ventana sobre la Mónada seleccionada. Sin selección
/// es no-op.
ContextMenuOpen(f32, f32),
}
#[derive(Clone)]
enum TickOutcome {
Ok {
socket: PathBuf,
source: &'static str,
snapshot: Box<ListMonadsResponse>,
},
DiscoveryFailed(String),
QueryFailed(String),
}
struct Explorer;
impl App for Explorer {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"Chasqui — Mónadas"
}
fn initial_size() -> (u32, u32) {
(900, 640)
}
fn init(handle: &Handle<Msg>) -> Model {
// Cargar el idioma global del SO al arrancar.
let wawa_cfg = wawa_config::WawaConfig::load();
let _ = rimay_localize::set_locale(&wawa_cfg.lang);
// Primer refresh inmediato + ticks periódicos. El tick dispara
// discovery+query en un thread (vía `Handle::spawn` desde
// update); así el broker bloqueante no congela el UI.
handle.dispatch(Msg::Tick);
handle.spawn_periodic(REFRESH_INTERVAL, || Msg::Tick);
Model {
theme: Theme::dark(),
socket: None,
snapshot: None,
error: None,
socket_source: None,
menu_open: None,
menu_active: usize::MAX,
menu_anim: Tween::idle(1.0),
selected: None,
context_menu: None,
}
}
fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
if event.state != KeyState::Pressed {
return None;
}
// Menú principal abierto: las flechas navegan. ←/→ cambian de menú
// raíz (wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc cierra.
if let Some(mi) = model.menu_open {
let n = app_menu().menus.len().max(1);
return match &event.key {
Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
_ => None,
};
}
None
}
fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
let mut m = model;
match msg {
Msg::Tick => {
let prior_socket = m.socket.clone();
handle.spawn(move || Msg::Refresh(tick(prior_socket)));
}
Msg::Refresh(outcome) => match outcome {
TickOutcome::Ok { socket, source, snapshot } => {
m.socket = Some(socket);
m.socket_source = Some(source);
m.snapshot = Some(*snapshot);
m.error = None;
// Si la selección quedó fuera de rango tras el
// refresh, la descartamos.
let count = m.snapshot.as_ref().map(|s| s.monads.len()).unwrap_or(0);
if m.selected.map(|i| i >= count).unwrap_or(false) {
m.selected = None;
m.context_menu = None;
}
}
TickOutcome::DiscoveryFailed(msg) => {
m.socket = None;
m.socket_source = None;
m.error = Some(msg);
}
TickOutcome::QueryFailed(msg) => {
// Invalida el socket cacheado: la próxima iteración
// re-descubre.
m.socket = None;
m.socket_source = None;
m.error = Some(msg);
}
},
Msg::MenuOpen(which) => {
m.menu_open = which;
// Abrir un menú raíz cierra cualquier contextual.
m.context_menu = None;
m.menu_active = usize::MAX;
if which.is_some() {
m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
animate(handle, motion::FAST, || Msg::MenuTick);
}
}
Msg::MenuNav(dir) => {
if let Some(mi) = m.menu_open {
let menu = app_menu();
m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir);
}
}
Msg::MenuActivate => {
if let Some(mi) = m.menu_open {
let menu = app_menu();
if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) {
m.menu_open = None;
return handle_menu_command(m, &cmd, handle);
}
}
}
Msg::MenuTick => {}
Msg::CloseMenus => {
m.menu_open = None;
m.menu_active = usize::MAX;
m.context_menu = None;
}
Msg::MenuCommand(cmd) => {
m.menu_open = None;
m.menu_active = usize::MAX;
return handle_menu_command(m, &cmd, handle);
}
Msg::CycleTheme => {
m.theme = Theme::next_after(m.theme.name);
}
Msg::Reconnect => {
// Invalida el socket cacheado y re-dispara discovery.
m.socket = None;
m.socket_source = None;
m.error = None;
handle.dispatch(Msg::Tick);
}
Msg::SelectMonad(i) => {
m.selected = Some(i);
m.context_menu = None;
}
Msg::ContextMenuOpen(x, y) => {
// Sólo si hay una Mónada seleccionada válida.
let count = m.snapshot.as_ref().map(|s| s.monads.len()).unwrap_or(0);
if let Some(i) = m.selected.filter(|i| *i < count) {
m.menu_open = None;
m.context_menu = Some((i, x, y));
}
}
}
m
}
fn view(model: &Model) -> View<Msg> {
let theme = &model.theme;
let menu = app_menu();
let menubar = menubar_view(&menubar_spec(&menu, model, theme));
let header_palette = AppHeaderPalette::from_theme(theme);
let card_palette = CardPalette::from_theme(theme);
// Acentos por kind del dominio chasqui: engine cyan, data
// purple. Señales semánticas locales del explorer.
let accent_engine = Color::from_rgba8(0x88, 0xc0, 0xd0, 0xff);
let accent_data = Color::from_rgba8(0xb4, 0x8e, 0xad, 0xff);
let header_text = match (&model.snapshot, &model.socket, model.socket_source) {
(Some(s), Some(sock), Some(src)) => {
let watching = s
.engine
.watching
.as_deref()
.map(|w| {
rimay_localize::t_args(
"chasqui-header-watching",
&[("name", w.into())],
)
})
.unwrap_or_default();
rimay_localize::t_args(
"chasqui-header",
&[
("engine", s.engine.label.as_str().into()),
("count", s.monads.len().to_string().into()),
("socket", sock.display().to_string().into()),
("src", src.into()),
("watching", watching.into()),
],
)
}
_ => rimay_localize::t("chasqui-header-searching"),
};
let header = app_header::<Msg>(header_text, vec![], &header_palette);
let mut body_children: Vec<View<Msg>> = Vec::new();
if let Some(ref e) = model.error {
body_children.push(banner_view::<Msg>(BannerKind::Error, e.clone()));
}
if let Some(snap) = &model.snapshot {
body_children.push(engine_card(snap, accent_engine, theme, &card_palette));
for (i, m) in snap.monads.iter().enumerate() {
let selected = model.selected == Some(i);
let card = monad_card(m, accent_data, theme, &card_palette);
// Click selecciona la Mónada. El menú contextual se abre
// por right-click en la raíz (coords de ventana) sobre la
// selección actual — ver `view()`.
let card = card.on_click(Msg::SelectMonad(i));
let card = if selected {
// Resalte sutil de la card seleccionada.
card.fill(theme.bg_selected)
} else {
card
};
body_children.push(card);
}
}
let body = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(12.0_f32),
bottom: length(16.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(8.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(body_children);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
// Right-click en la raíz (origen 0,0 ⇒ local == ventana) abre el
// menú contextual sobre la Mónada seleccionada.
.on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y)))
.children(vec![menubar, header, body])
}
fn view_overlay(model: &Model) -> Option<View<Msg>> {
// El menú contextual de la Mónada tiene prioridad si está abierto.
if let Some((idx, x, y)) = model.context_menu {
let label = model
.snapshot
.as_ref()
.and_then(|s| s.monads.get(idx))
.map(|m| m.label.clone())
.unwrap_or_else(|| rimay_localize::t("chasqui-explorer-monad-label"));
let viewport = viewport_of(model);
// Acciones reales del explorer: ver/seleccionar y refrescar.
// El explorer es de sólo lectura, no inventamos edición.
let t = rimay_localize::t;
let items = vec![
ContextMenuItem::action(t("chasqui-explorer-ctx-detail")),
ContextMenuItem::action(t("refresh")),
];
let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(move |i: usize| match i {
0 => Msg::SelectMonad(idx),
_ => Msg::Tick,
});
return Some(context_menu_view(ContextMenuSpec {
anchor: (x, y),
viewport,
header: Some(label),
items,
active: usize::MAX,
on_pick,
on_dismiss: Msg::CloseMenus,
palette: ContextMenuPalette::from_theme(&model.theme),
}));
}
// Si no, el dropdown del menú principal.
let menu = app_menu();
menubar_overlay_animated(
&menubar_spec(&menu, model, &model.theme),
model.menu_active,
model.menu_anim.value(),
)
}
}
/// Viewport para clampear overlays: tamaño de ventana del Model si lo
/// llevara; como el explorer no lo trackea, usamos `initial_size()`.
fn viewport_of(_model: &Model) -> (f32, f32) {
let (w, h) = Explorer::initial_size();
(w as f32, h as f32)
}
/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
fn menubar_spec<'a>(
menu: &'a AppMenu,
model: &Model,
theme: &'a Theme,
) -> MenuBarSpec<'a, Msg> {
MenuBarSpec {
menu,
open: model.menu_open,
theme,
viewport: viewport_of(model),
height: MENU_H,
on_open: Arc::new(Msg::MenuOpen),
on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
}
}
/// El menú principal del explorer. Archivo / Ver / Ayuda — sólo comandos
/// que mapean a acciones reales (refrescar, reconectar, tema). Sin
/// "Editar": el explorer no tiene campos de texto editables.
fn app_menu() -> AppMenu {
let t = rimay_localize::t;
// Menú de idioma: autónimos sin traducir (convención del SO). El item
// activo lleva ✔. El comando `lang.<code>` lo resuelve `handle_menu_command`.
let cur = rimay_localize::current_locale();
let lang_item = |label: &str, code: &str| {
let mut it = MenuItem::new(label, format!("lang.{code}"));
if cur == code {
it = it.icon("\u{2714}");
}
it
};
AppMenu::new()
.menu(
Menu::new(t("file"))
.item(MenuItem::new(t("refresh"), "file.refresh").shortcut("Ctrl+R"))
.item(MenuItem::new(t("exit"), "file.quit").shortcut("Ctrl+Q").separated()),
)
.menu(
Menu::new(t("view"))
.item(MenuItem::new(t("reconnect"), "view.reconnect"))
.item(MenuItem::new(t("cycle-theme"), "view.theme").separated()),
)
.menu(Menu::new(t("help")).item(MenuItem::new(t("about"), "help.about")))
.menu(
Menu::new(t("language"))
.item(lang_item("Español", "es-PE"))
.item(lang_item("English", "en-US"))
.item(lang_item("Runasimi", "qu-PE")),
)
}
/// Traduce un command id del menú principal al `Msg`/efecto real.
fn handle_menu_command(model: Model, cmd: &str, handle: &Handle<Msg>) -> Model {
// Cambio de idioma desde el menú "Idioma": aplica el locale en caliente
// y lo persiste en la capa de usuario de wawa-config.
if let Some(code) = cmd.strip_prefix("lang.") {
let _ = rimay_localize::set_locale(code);
let mut cfg = wawa_config::WawaConfig::load();
cfg.lang = code.to_string();
let _ = cfg.save();
return model;
}
match cmd {
"file.refresh" => {
handle.dispatch(Msg::Tick);
model
}
"file.quit" => std::process::exit(0),
"view.reconnect" => {
handle.dispatch(Msg::Reconnect);
model
}
"view.theme" => {
handle.dispatch(Msg::CycleTheme);
model
}
// "help.about" y desconocidos: no-op (sin diálogo todavía).
_ => model,
}
}
fn engine_card(
snap: &ListMonadsResponse,
accent: Color,
theme: &Theme,
palette: &CardPalette,
) -> View<Msg> {
let mut rows: Vec<View<Msg>> = vec![
kind_row("[engine]", &snap.engine.label, accent, theme),
muted_line(
&rimay_localize::t_args(
"chasqui-field-id",
&[("id", snap.engine.id.to_string().into())],
),
theme,
),
];
if let Some(w) = &snap.engine.watching {
rows.push(muted_line(
&rimay_localize::t_args(
"chasqui-field-watching",
&[("name", w.as_str().into())],
),
theme,
));
}
card_view(
rows,
CardOptions {
accent: Some(accent),
..Default::default()
},
palette,
)
}
fn monad_card(
m: &chasqui_card::query::MonadView,
accent: Color,
theme: &Theme,
palette: &CardPalette,
) -> View<Msg> {
let lens = lens_label(m.dominant_lens);
let stats = rimay_localize::t_args(
"chasqui-explorer-monad-stats",
&[
("count", m.cardinality.to_string().into()),
("entropy", format!("{:.2}", m.entropy).into()),
("lens", lens.into()),
],
);
let mut rows: Vec<View<Msg>> = vec![
kind_row_with_stats("[monad]", &m.label, &stats, accent, theme),
muted_line(
&rimay_localize::t_args(
"chasqui-field-id",
&[("id", m.id.to_string().into())],
),
theme,
),
];
if !m.summary.is_empty() {
rows.push(text_line(&m.summary, theme.fg_text, theme));
}
let keywords = m.keywords.join(", ");
if !keywords.is_empty() {
rows.push(muted_line(
&rimay_localize::t_args(
"chasqui-field-keywords",
&[("keywords", keywords.as_str().into())],
),
theme,
));
}
if let Some(p) = m.path_hint.as_deref().filter(|p| !p.is_empty()) {
rows.push(muted_line(
&rimay_localize::t_args("chasqui-field-path", &[("path", p.into())]),
theme,
));
}
if let Some(model_name) = m.centroid_model.as_deref().filter(|s| !s.is_empty()) {
rows.push(muted_line(
&rimay_localize::t_args("chasqui-field-model", &[("name", model_name.into())]),
theme,
));
}
card_view(
rows,
CardOptions {
accent: Some(accent),
..Default::default()
},
palette,
)
}
fn kind_row(tag: &str, label: &str, accent: Color, theme: &Theme) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
View::new(Style {
size: Size {
width: length(72.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(tag.to_string(), 11.0, accent, Alignment::Start),
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(label.to_string(), 15.0, theme.fg_text, Alignment::Start),
])
}
fn kind_row_with_stats(
tag: &str,
label: &str,
stats: &str,
accent: Color,
theme: &Theme,
) -> View<Msg> {
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
align_items: Some(AlignItems::Center),
gap: Size {
width: length(8.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![
View::new(Style {
size: Size {
width: length(72.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(tag.to_string(), 11.0, accent, Alignment::Start),
View::new(Style {
size: Size {
width: Dimension::auto(),
height: length(18.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(label.to_string(), 15.0, theme.fg_text, Alignment::Start),
View::new(Style {
size: Size {
width: Dimension::auto(),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(stats.to_string(), 11.0, theme.fg_muted, Alignment::Start),
])
}
fn muted_line(text: &str, theme: &Theme) -> View<Msg> {
text_line(text, theme.fg_muted, theme)
}
fn text_line(text: &str, color: Color, _theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(16.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), 11.0, color, Alignment::Start)
}
fn tick(prior_socket: Option<PathBuf>) -> TickOutcome {
let (socket, source) = match prior_socket {
Some(p) => (p, "cache"),
None => match resolve_socket() {
Ok(found) => found,
Err(e) => return TickOutcome::DiscoveryFailed(e),
},
};
match query_client::list_monads(&socket, QUERY_TIMEOUT) {
Ok(resp) => TickOutcome::Ok {
socket,
source,
snapshot: Box::new(resp),
},
Err(e) => TickOutcome::QueryFailed(format!(
"query a {}: {e} — re-descubriendo en próxima iteración",
socket.display()
)),
}
}
/// Resuelve el socket del daemon en dos pasos:
/// 1. **Broker**: consumer Card + `await_provider_blocking`. Path
/// "consciente" (ecosistema brahman activo).
/// 2. **Default path**: si el broker no responde, probamos
/// `transport::default_socket_path()` directo. Path "soberano"
/// (daemon corriendo solo, sin init).
fn resolve_socket() -> Result<(PathBuf, &'static str), String> {
match discover_via_broker() {
Ok(p) => Ok((p, "broker")),
Err(broker_err) => {
let fallback = transport::default_socket_path();
if fallback.exists() {
Ok((fallback, "default-path"))
} else {
Err(format!(
"broker: {broker_err}; fallback {} no existe",
fallback.display()
))
}
}
}
}
fn discover_via_broker() -> Result<PathBuf, ConsumerError> {
let card = build_consumer_card("chasqui-explorer-llimphi", FLOW_MONAD_LIST, FLOW_TYPE_NAME);
await_provider_blocking(card, DISCOVERY_TIMEOUT)
}
fn lens_label(l: Lens) -> &'static str {
match l {
Lens::Grid => "grid",
Lens::Code => "code",
Lens::Gallery => "gallery",
Lens::Database => "database",
Lens::Markdown => "markdown",
Lens::Tree => "tree",
}
}
fn main() {
rimay_localize::init();
llimphi_ui::run::<Explorer>();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lens_labels_cover_all_variants() {
// Sanity: cualquier Lens devuelve un string no vacío.
for l in [
Lens::Grid,
Lens::Code,
Lens::Gallery,
Lens::Database,
Lens::Markdown,
Lens::Tree,
] {
assert!(!lens_label(l).is_empty());
}
}
#[test]
fn resolve_socket_fails_with_message_when_nothing_responds() {
// El test depende de que ni init socket ni default path tengan
// un daemon vivo — en CI sin daemon corriendo eso se cumple.
// Si en local hay un nouser vivo este test pasa por la rama Ok,
// sin assert estricto. La condición esencial: nunca panic.
let _ = resolve_socket();
}
}
@@ -0,0 +1,25 @@
[package]
name = "chasqui-nous-mock"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Nouser — Nous mock determinístico: implementa el contrato nouser-nous con pseudo-embeddings de Phase C. Stand-in para tests y para `BRAHMAN_BROKER_CONTEXT=test`."
[dependencies]
card-core = { workspace = true }
card-sidecar = { path = "../card-sidecar" }
chasqui-card = { path = "../chasqui-card" }
chasqui-core = { path = "../chasqui-core" }
chasqui-nous = { path = "../chasqui-nous" }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
ulid = { workspace = true }
[[bin]]
name = "chasqui-nous-mock"
path = "src/main.rs"
@@ -0,0 +1,10 @@
# chasqui-nous-mock
> Transport in-process para tests de [chasqui](../README.md).
Implementa [`Nous`](../chasqui-nous/README.md) usando canales `tokio::sync::mpsc` en memoria. Cero red. Tests deterministas.
## Deps
- [`chasqui-nous`](../chasqui-nous/README.md), [`chasqui-core`](../chasqui-core/README.md)
- `tokio`
@@ -0,0 +1,10 @@
# chasqui-nous-mock
> In-process transport for tests of [chasqui](../README.md).
Implements [`Nous`](../chasqui-nous/README.md) using in-memory `tokio::sync::mpsc` channels. Zero network. Deterministic tests.
## Deps
- [`chasqui-nous`](../chasqui-nous/README.md), [`chasqui-core`](../chasqui-core/README.md)
- `tokio`
@@ -0,0 +1,247 @@
//! `chasqui-nous-mock` — proveedor de embeddings determinista (sin LLM).
//!
//! Implementa el contrato `chasqui-nous` usando los pseudo-embeddings
//! de Phase C (`chasqui_core::embed`). Sirve como:
//!
//! - **Mock para tests**: en `BRAHMAN_BROKER_CONTEXT=test`, el
//! `priority_offset` per-contexto declarado en su Card lo prioriza
//! sobre cualquier proveedor real.
//! - **Bootstrap**: hasta que llegue el LLM real (Phase D futura), el
//! sistema funciona end-to-end con embeddings determinísticos.
//!
//! ## Vida del proceso
//!
//! 1. Sidecarea a brahman-init declarando una Card con flow output
//! `embed-result:json` y flow input `embed-request:json`. Su
//! `priority_contexts.test = { priority_offset: +1 }` lo prioriza
//! cuando el broker corre bajo contexto test.
//! 2. Bind del Unix socket en `$NOUSER_NOUS_SOCKET` (default
//! `$XDG_RUNTIME_DIR/chasqui-nous.sock`).
//! 3. Loop: accept → read line JSON → process → write line JSON → close.
//! 4. Cada request se loggea (info) — útil para verificar que el
//! consumidor está usando este proveedor.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use card_core::{
ulid::Ulid, Card, CardKind, ContextBias, Flow, Flows, Lifecycle, Payload, Priority,
Supervision, TypeRef,
};
use chasqui_card::FileEntry;
use chasqui_core::embed;
use chasqui_nous::{
transport, EmbedFilePayload, EmbedRequest, EmbedResponse, EmbedTextPayload, ErrorResponse,
PingResponse, RequestKind, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tracing::{info, warn};
/// El mock implementa el MISMO algoritmo que `chasqui_core::embed`,
/// así que reportamos el mismo `MODEL_ID` que él. De otro modo el
/// consumer filtraría las Mónadas como "modelo distinto" y los
/// scores quedarían vacíos.
const MODEL_ID: &str = chasqui_core::embed::MODEL_ID;
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
init_tracing();
// 1. Resolver socket del data-plane ANTES de armar la Card, para
// declararlo en `Card.service_socket` y que los consumidores lo
// descubran vía MatchEvent.
let sock_path = transport::provider_socket_path("mock");
if sock_path.exists() {
std::fs::remove_file(&sock_path)?;
}
if let Some(parent) = sock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let listener = UnixListener::bind(&sock_path)?;
info!(socket = %sock_path.display(), "chasqui-nous-mock escuchando");
// 2. Sidecar al brahman-init con la Card que declara el socket.
let card = build_card(sock_path.clone());
info!(label = %card.label, "publicando Card al brahman-init");
card_sidecar::spawn(card);
// 3. Accept loop.
loop {
let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move {
if let Err(e) = handle_conn(stream).await {
warn!(error = %e, "conn falló");
}
});
}
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.with_target(false)
.compact()
.init();
}
/// Card que el mock anuncia al brahman-init. Es kind=Ente (un proceso),
/// con flujos JSON, bias de prioridad para contexto `test`, y el socket
/// data-plane declarado en `service_socket` (consumidores lo reciben
/// directo en el `MatchEvent::Available`).
fn build_card(service_socket: std::path::PathBuf) -> Card {
let mut priority_contexts = BTreeMap::new();
priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: None,
// En contexto test, este mock gana sobre cualquier real-nous.
priority_offset: 1,
},
);
Card {
schema_version: card_core::CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: "chasqui.nous_mock".into(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
service_socket: Some(service_socket),
flow: Flows {
input: vec![Flow {
name: FLOW_EMBED_REQUEST.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
output: vec![Flow {
name: FLOW_EMBED_RESULT.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
},
priority_contexts,
..Default::default()
}
}
/// Procesa una conexión single-shot: lee una línea JSON, despacha,
/// escribe una línea JSON, cierra.
async fn handle_conn(stream: UnixStream) -> std::io::Result<()> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let req: EmbedRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
return write_error(reader.into_inner(), format!("JSON inválido: {e}")).await;
}
};
let started = Instant::now();
let result = match req.kind {
RequestKind::EmbedFile => handle_embed_file(req.payload, started),
RequestKind::EmbedText => handle_embed_text(req.payload, started),
RequestKind::Ping => handle_ping(),
};
let mut stream = reader.into_inner();
match result {
Ok(payload) => {
stream.write_all(payload.as_bytes()).await?;
stream.write_all(b"\n").await?;
}
Err(msg) => {
return write_error(stream, msg).await;
}
}
stream.shutdown().await?;
Ok(())
}
fn handle_embed_file(payload: serde_json::Value, started: Instant) -> Result<String, String> {
let p: EmbedFilePayload =
serde_json::from_value(payload).map_err(|e| format!("payload inválido: {e}"))?;
info!(path = %p.path, "embed_file");
let file = FileEntry {
id: chasqui_card::FileId::from(Ulid::new()),
path: PathBuf::from(p.path),
content_hash: None,
size: p.size,
mtime_ms: p.mtime_ms,
extension: p.extension,
};
let v = embed::embed(&file);
let resp = EmbedResponse {
embedding: v.to_vec(),
model: MODEL_ID.into(),
elapsed_ms: started.elapsed().as_millis() as u64,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn handle_embed_text(payload: serde_json::Value, started: Instant) -> Result<String, String> {
let p: EmbedTextPayload =
serde_json::from_value(payload).map_err(|e| format!("payload inválido: {e}"))?;
info!(text_len = p.text.len(), "embed_text");
// Mock: tratamos el texto como un "stem" sintético y rellenamos el
// resto del vector con ceros. No es semánticamente útil, pero respeta
// la forma para que el cliente no se rompa.
let synthetic = FileEntry {
id: chasqui_card::FileId::from(Ulid::new()),
path: PathBuf::from(format!("synthetic://{}", p.text)),
content_hash: None,
size: p.text.len() as u64,
mtime_ms: now_ms(),
extension: Some("text".into()),
};
let v = embed::embed(&synthetic);
let resp = EmbedResponse {
embedding: v.to_vec(),
model: MODEL_ID.into(),
elapsed_ms: started.elapsed().as_millis() as u64,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn handle_ping() -> Result<String, String> {
let resp = PingResponse {
model: MODEL_ID.into(),
embed_dim: embed::EMBED_DIM as u32,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
async fn write_error(mut stream: UnixStream, msg: String) -> std::io::Result<()> {
warn!(error = %msg, "respuesta de error");
let resp = ErrorResponse { error: msg };
let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
stream.write_all(json.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.shutdown().await?;
Ok(())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
@@ -0,0 +1,40 @@
[package]
name = "chasqui-nous-real"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Nouser — proveedor Nous con LLM real (text-embedding via ONNX). El soporte AI vive detrás del feature `embeddings`; sin él, este crate compila como stub mínimo."
[features]
# Sin features = stub que arranca y rechaza requests. Compila en
# segundos, sin descargar nada.
default = []
# Con feature embeddings: pulls fastembed + ONNX Runtime descargado.
# Modelo default: all-MiniLM-L6-v2 (384-d, ~80MB descargado al primer
# run y cacheado).
embeddings = ["dep:fastembed"]
[dependencies]
card-core = { workspace = true }
card-sidecar = { path = "../card-sidecar" }
arje-cas = { workspace = true }
chasqui-nous = { path = "../chasqui-nous" }
serde_json = { workspace = true }
sled = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
ulid = { workspace = true }
# Opcional: gateado por feature `embeddings`.
fastembed = { version = "4", optional = true }
[dev-dependencies]
tempfile = { workspace = true }
[[bin]]
name = "chasqui-nous-real"
path = "src/main.rs"
@@ -0,0 +1,10 @@
# chasqui-nous-real
> Transport TCP/Unix binario de [chasqui](../README.md).
Implementa [`Nous`](../chasqui-nous/README.md) sobre `tokio` con framing length-prefix + serialización `postcard` (compacta, sin reflexión). Reconnect automático con backoff exponencial.
## Deps
- [`chasqui-nous`](../chasqui-nous/README.md), [`chasqui-core`](../chasqui-core/README.md)
- `tokio`, `postcard`
@@ -0,0 +1,10 @@
# chasqui-nous-real
> Binary TCP/Unix transport of [chasqui](../README.md).
Implements [`Nous`](../chasqui-nous/README.md) over `tokio` with length-prefix framing + `postcard` serialization (compact, reflection-free). Automatic reconnect with exponential backoff.
## Deps
- [`chasqui-nous`](../chasqui-nous/README.md), [`chasqui-core`](../chasqui-core/README.md)
- `tokio`, `postcard`
@@ -0,0 +1,199 @@
//! Cache de embeddings keyed por sha256 del contenido + model_id.
//!
//! Razón de existir: el modelo real (`fastembed-allMiniLML6V2`) es
//! caro (1-50 ms por archivo según tamaño y CPU). Cada vez que el
//! daemon de chasqui re-publica una Mónada o el watcher dispara un
//! re-cluster por cambio de FS, todos los archivos pasan otra vez
//! por embed. Para árboles de 1000 archivos, eso son segundos
//! desperdiciados re-embedidando contenido que no cambió.
//!
//! ## Diseño
//!
//! - **Cache key**: `sha256(bytes que el modelo realmente vio)` +
//! `MODEL_ID` (string). Usar el sha de los bytes-vistos garantiza
//! que la cache no devuelva un embedding de contenido viejo
//! simplemente porque el path no cambió.
//! - **Cache value**: el `Vec<f32>` serializado como bytes
//! little-endian (4 bytes por f32). Compacto, sin overhead de
//! bincode/postcard para datos numéricos puros.
//! - **Backend**: sled, tree único `embed_cache_v1`. Path:
//! `$XDG_CACHE_HOME/brahman/chasqui-nous-real-embed-cache.sled`.
//!
//! ## Versionado
//!
//! El nombre del tree (`embed_cache_v1`) es el "schema version" del
//! format value. Si bumpeamos a (p. ej.) almacenar también el
//! tiempo de cómputo o el ONNX session id, creamos `embed_cache_v2`
//! y el viejo queda como dato muerto que sled puede limpiar.
//!
//! El `MODEL_ID` viaja dentro del key, así que cambiar de modelo
//! invalida implícitamente las entradas viejas (no se accede más
//! a esos keys; sled las mantiene hasta GC manual).
use std::path::PathBuf;
/// Wrapper sobre sled::Db con la API justa que necesita `handle_file`.
#[derive(Clone)]
pub struct EmbedCache {
tree: sled::Tree,
}
impl EmbedCache {
/// Abre (o crea) la cache en su path canónico. El sled::Db queda
/// referenciado por el Tree; mientras `EmbedCache` viva, el DB
/// vive.
pub fn open() -> Result<Self, sled::Error> {
let path = default_path();
if let Some(parent) = path.parent() {
// best-effort: si no podemos crear el dir, sled falla con
// mensaje específico abajo.
let _ = std::fs::create_dir_all(parent);
}
let db = sled::open(&path)?;
let tree = db.open_tree("embed_cache_v1")?;
Ok(Self { tree })
}
/// Variante para tests: cache efímera bajo `dir`.
#[cfg(test)]
pub fn open_at(dir: &std::path::Path) -> Result<Self, sled::Error> {
let db = sled::open(dir)?;
let tree = db.open_tree("embed_cache_v1")?;
Ok(Self { tree })
}
/// Lookup. `None` si miss; `Some(vec)` si hit.
pub fn get(&self, file_sha: &[u8; 32], model_id: &str) -> Option<Vec<f32>> {
let key = build_key(file_sha, model_id);
let bytes = self.tree.get(&key).ok()??;
decode_embedding(&bytes)
}
/// Almacena. Errores se loggean pero no propagan — cache miss es
/// recuperable, no querés tirar el embed válido por fallo de I/O
/// de cache.
pub fn put(&self, file_sha: &[u8; 32], model_id: &str, embedding: &[f32]) {
let key = build_key(file_sha, model_id);
let bytes = encode_embedding(embedding);
if let Err(e) = self.tree.insert(key, bytes) {
tracing::warn!(error = %e, "embed-cache put falló (no-fatal)");
}
}
/// Cantidad actual de entradas (best-effort para logs).
pub fn len(&self) -> usize {
self.tree.len()
}
}
/// Path default. Honra `XDG_CACHE_HOME`, cae a `$HOME/.cache`, y de
/// último recurso a `/tmp` (sin persistencia, pero al menos no
/// crashea en entornos minimalistas como CI sin HOME).
fn default_path() -> PathBuf {
if let Ok(p) = std::env::var("NOUSER_NOUS_REAL_CACHE") {
return PathBuf::from(p);
}
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".cache"))
})
.unwrap_or_else(std::env::temp_dir);
base.join("brahman").join("chasqui-nous-real-embed-cache.sled")
}
fn build_key(file_sha: &[u8; 32], model_id: &str) -> Vec<u8> {
let mut k = Vec::with_capacity(32 + 1 + model_id.len());
k.extend_from_slice(file_sha);
// separator byte para que prefijos de model_id no choquen con
// bytes del sha (improbable pero barato).
k.push(0xff);
k.extend_from_slice(model_id.as_bytes());
k
}
fn encode_embedding(v: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(v.len() * 4);
for f in v {
out.extend_from_slice(&f.to_le_bytes());
}
out
}
fn decode_embedding(bytes: &[u8]) -> Option<Vec<f32>> {
if bytes.len() % 4 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 4);
for chunk in bytes.chunks_exact(4) {
out.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn sha(s: &[u8]) -> [u8; 32] {
arje_cas::sha256_of(s)
}
#[test]
fn roundtrip_returns_same_vector() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"hello world");
let v = vec![0.1f32, -0.5, 1.0, 3.14159];
cache.put(&key, "real-fastembed-allMiniLML6V2-384d", &v);
let got = cache
.get(&key, "real-fastembed-allMiniLML6V2-384d")
.expect("hit esperado");
assert_eq!(got, v);
}
#[test]
fn miss_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"never stored");
assert!(cache.get(&key, "real-fastembed-allMiniLML6V2-384d").is_none());
}
#[test]
fn different_models_do_not_collide() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"same content");
cache.put(&key, "model-a", &[1.0, 2.0]);
cache.put(&key, "model-b", &[7.0, 8.0]);
assert_eq!(cache.get(&key, "model-a").unwrap(), vec![1.0, 2.0]);
assert_eq!(cache.get(&key, "model-b").unwrap(), vec![7.0, 8.0]);
}
#[test]
fn different_content_different_keys() {
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let k1 = sha(b"abc");
let k2 = sha(b"abd");
cache.put(&k1, "m", &[1.0]);
assert!(cache.get(&k2, "m").is_none());
}
#[test]
fn corrupted_value_returns_none() {
// Si sled devuelve bytes con length no múltiplo de 4, decode
// debe fallar limpio (None) en vez de panicar.
let dir = tempfile::tempdir().unwrap();
let cache = EmbedCache::open_at(dir.path()).unwrap();
let key = sha(b"x");
// Insertamos manualmente bytes inválidos.
let raw_key = build_key(&key, "m");
cache.tree.insert(raw_key, &[1u8, 2, 3][..]).unwrap();
assert!(cache.get(&key, "m").is_none());
}
}
@@ -0,0 +1,205 @@
//! Modo embeddings: usa fastembed-rs (ONNX Runtime) para producir
//! vectores reales de text-embedding.
//!
//! Modelo default: `all-MiniLM-L6-v2` (384-d). Se descarga al primer
//! arranque a `~/.cache/fastembed` y queda cacheado.
//!
//! ## Mapeo del contrato
//!
//! - `EmbedText`: pasa el texto al modelo, devuelve el vector 384-d.
//! - `EmbedFile`: lee hasta los primeros 8 KiB del archivo, los
//! interpreta como UTF-8 con replacement-char, y los embeda como
//! texto. Para archivos binarios el resultado no es semánticamente
//! útil — caller decide qué hacer.
//! - `Ping`: devuelve `model_id` y `embed_dim` reales.
use std::fs::File;
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
use chasqui_nous::{
EmbedFilePayload, EmbedRequest, EmbedResponse, EmbedTextPayload, ErrorResponse, PingResponse,
RequestKind,
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tracing::{info, warn};
use crate::cache::EmbedCache;
const MAX_FILE_BYTES: usize = 8192;
/// Backend concreto: posee el modelo cargado.
pub struct Backend {
model: TextEmbedding,
}
impl Backend {
pub fn init() -> Result<Self, String> {
info!("cargando modelo all-MiniLM-L6-v2 (puede descargar ~80MB la primera vez)");
let opts = InitOptions::new(EmbeddingModel::AllMiniLML6V2)
.with_show_download_progress(true);
let model = TextEmbedding::try_new(opts).map_err(|e| format!("fastembed init: {e}"))?;
info!("modelo listo");
Ok(Self { model })
}
fn embed_one(&self, text: &str) -> Result<Vec<f32>, String> {
let out = self
.model
.embed(vec![text], None)
.map_err(|e| format!("embed: {e}"))?;
out.into_iter()
.next()
.ok_or_else(|| "fastembed devolvió 0 vectores".to_string())
}
}
pub async fn handle_conn(
stream: UnixStream,
backend: Arc<Backend>,
cache: Option<EmbedCache>,
) -> std::io::Result<()> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let req: EmbedRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
return write_error(reader.into_inner(), format!("JSON inválido: {e}")).await;
}
};
let started = Instant::now();
let result = match req.kind {
RequestKind::EmbedFile => handle_file(req.payload, &backend, cache.as_ref(), started),
RequestKind::EmbedText => handle_text(req.payload, &backend, started),
RequestKind::Ping => handle_ping(),
};
let mut stream = reader.into_inner();
match result {
Ok(json) => {
stream.write_all(json.as_bytes()).await?;
stream.write_all(b"\n").await?;
}
Err(msg) => return write_error(stream, msg).await,
}
stream.shutdown().await?;
Ok(())
}
fn handle_text(
payload: serde_json::Value,
backend: &Backend,
started: Instant,
) -> Result<String, String> {
let p: EmbedTextPayload =
serde_json::from_value(payload).map_err(|e| format!("payload: {e}"))?;
info!(text_len = p.text.len(), "embed_text");
let v = backend.embed_one(&p.text)?;
let resp = EmbedResponse {
embedding: v,
model: super::model_id().to_string(),
elapsed_ms: started.elapsed().as_millis() as u64,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn handle_file(
payload: serde_json::Value,
backend: &Backend,
cache: Option<&EmbedCache>,
started: Instant,
) -> Result<String, String> {
let p: EmbedFilePayload =
serde_json::from_value(payload).map_err(|e| format!("payload: {e}"))?;
let path = PathBuf::from(&p.path);
let mut file = File::open(&path).map_err(|e| format!("abrir archivo: {e}"))?;
let mut buf = vec![0u8; MAX_FILE_BYTES];
let n = file.read(&mut buf).map_err(|e| format!("leer archivo: {e}"))?;
buf.truncate(n);
let model_id = super::model_id();
// Hash de los bytes que el modelo realmente verá. Si el archivo
// crece pasada la ventana MAX_FILE_BYTES sin modificar la cabeza,
// el hash NO cambia — el embedding cacheado sigue siendo válido
// bajo la semántica del proveedor (el modelo nunca vio los bytes
// adicionales). Si la cabeza cambia, el hash cambia y caemos a
// re-embed naturalmente.
let file_sha = arje_cas::sha256_of(&buf);
if let Some(cache) = cache {
if let Some(cached) = cache.get(&file_sha, model_id) {
info!(
path = %p.path,
sha = %arje_cas::hex(&file_sha),
bytes = n,
"embed_file: cache HIT"
);
let resp = EmbedResponse {
embedding: cached,
model: model_id.to_string(),
elapsed_ms: started.elapsed().as_millis() as u64,
};
return serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"));
}
}
info!(
path = %p.path,
sha = %arje_cas::hex(&file_sha),
bytes = n,
"embed_file: cache MISS — invocando modelo"
);
// Write-through al CAS de arje: hacemos la cabeza del archivo
// direccionable por contenido. No es la fuente de verdad para
// el cache (sled lo es) pero deja un registro consultable por
// herramientas como `ente-cas gc` y permite que otros consumers
// resuelvan los bytes por hash.
if let Err(e) = arje_cas::store(&buf) {
// No-fatal: si CAS no escribe, cacheamos el embedding igual.
warn!(error = %e, "arje_cas::store falló (no-fatal)");
}
let text = String::from_utf8_lossy(&buf).to_string();
let v = backend.embed_one(&text)?;
if let Some(cache) = cache {
cache.put(&file_sha, model_id, &v);
}
let resp = EmbedResponse {
embedding: v,
model: model_id.to_string(),
elapsed_ms: started.elapsed().as_millis() as u64,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn handle_ping() -> Result<String, String> {
let resp = PingResponse {
model: super::model_id().to_string(),
embed_dim: super::embed_dim(),
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
async fn write_error(mut stream: UnixStream, msg: String) -> std::io::Result<()> {
warn!(error = %msg, "respuesta de error");
let resp = ErrorResponse { error: msg };
let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
stream.write_all(json.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.shutdown().await?;
Ok(())
}
@@ -0,0 +1,202 @@
//! `chasqui-nous-real` — proveedor Nous con LLM real (gated por feature).
//!
//! ## Build modes
//!
//! - `cargo build -p chasqui-nous-real`
//! Compila como **stub**: bin que arranca, sidecarea al brahman-init
//! pero rechaza toda request con un error explicando que falta la
//! feature. Útil para que `cargo build --workspace` no requiera ML
//! deps.
//!
//! - `cargo build -p chasqui-nous-real --features embeddings`
//! Compila con `fastembed` + ONNX Runtime descargado por Cargo.
//! Modelo default: `all-MiniLM-L6-v2` (384-d, ~80 MB descargado al
//! primer run y cacheado en `~/.cache/fastembed`).
//!
//! ## Diseño
//!
//! Mismo contrato wire que `chasqui-nous-mock` (`chasqui-nous` crate). La
//! diferencia operativa: real produce 384-d con semantic content
//! (text-embedding del modelo); mock produce 32-d con metadata-hashing.
//! No son intercambiables a media-deployment — los centroides de
//! Mónadas calculadas con uno NO matchean con el otro.
//!
//! La Card declara `priority_contexts.prod = { priority_offset: +1 }`,
//! contrapeso del mock que tiene `+1 en test`. Así el broker brahman
//! elige automáticamente:
//! - `BRAHMAN_BROKER_CONTEXT=test` → mock gana.
//! - `BRAHMAN_BROKER_CONTEXT=prod` → real gana.
//! - sin contexto → empate por label alfabético.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use card_core::{
ulid::Ulid, Card, CardKind, ContextBias, Flow, Flows, Lifecycle, Payload, Priority,
Supervision, TypeRef,
};
use chasqui_nous::{transport, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME};
use tokio::net::UnixListener;
use tracing::info;
#[cfg(feature = "embeddings")]
mod cache;
#[cfg(feature = "embeddings")]
mod embeddings;
#[cfg(not(feature = "embeddings"))]
mod stub;
#[cfg(feature = "embeddings")]
const MODEL_ID: &str = "real-fastembed-allMiniLML6V2-384d";
#[cfg(not(feature = "embeddings"))]
const MODEL_ID: &str = "real-stub-no-feature";
#[cfg(feature = "embeddings")]
const EMBED_DIM: u32 = 384;
#[cfg(not(feature = "embeddings"))]
const EMBED_DIM: u32 = 0;
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
init_tracing();
#[cfg(not(feature = "embeddings"))]
info!(
"chasqui-nous-real corriendo en modo STUB (compilá con \
--features embeddings para activar el modelo)"
);
// 1. Resolver socket del data-plane (default `chasqui-nous-real.sock`,
// distinto del mock para coexistir).
let sock_path = transport::provider_socket_path("real");
if sock_path.exists() {
std::fs::remove_file(&sock_path)?;
}
if let Some(parent) = sock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let listener = UnixListener::bind(&sock_path)?;
info!(socket = %sock_path.display(), "chasqui-nous-real escuchando");
// 2. Sidecar al brahman-init con Card declarando el socket.
let card = build_card(sock_path.clone());
info!(label = %card.label, mode = MODEL_ID, "publicando Card al brahman-init");
card_sidecar::spawn(card);
// 3. Inicializar el modelo (sólo en modo embeddings).
#[cfg(feature = "embeddings")]
let backend = embeddings::Backend::init().map_err(|e| {
std::io::Error::other(format!("init modelo: {e}"))
})?;
#[cfg(feature = "embeddings")]
let backend = std::sync::Arc::new(backend);
// 4. Abrir el cache de embeddings (sled local, sha256-keyed).
// Si falla, seguimos sin cache — degrada a "siempre embed".
#[cfg(feature = "embeddings")]
let embed_cache = match cache::EmbedCache::open() {
Ok(c) => {
info!(entries = c.len(), "embed-cache abierto");
Some(c)
}
Err(e) => {
tracing::warn!(error = %e, "embed-cache no disponible — todas las requests irán al modelo");
None
}
};
// 5. Accept loop.
loop {
let (stream, _addr) = listener.accept().await?;
#[cfg(feature = "embeddings")]
{
let backend = backend.clone();
let cache = embed_cache.clone();
tokio::spawn(async move {
if let Err(e) = embeddings::handle_conn(stream, backend, cache).await {
tracing::warn!(error = %e, "conn falló");
}
});
}
#[cfg(not(feature = "embeddings"))]
{
tokio::spawn(async move {
if let Err(e) = stub::handle_conn(stream).await {
tracing::warn!(error = %e, "conn falló");
}
});
}
}
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.with_target(false)
.compact()
.init();
}
/// Card que real-nous anuncia. Idéntica al mock excepto por:
/// - label distinto (`chasqui.nous_real`) para que coexistan en el broker.
/// - `priority_contexts.prod = +1` (gana en contexto prod).
/// - `service_socket` propio para que clientes lo descubran directo.
fn build_card(service_socket: std::path::PathBuf) -> Card {
let mut priority_contexts = BTreeMap::new();
priority_contexts.insert(
"prod".into(),
ContextBias {
pin_to: None,
priority_offset: 1,
},
);
Card {
schema_version: card_core::CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: "chasqui.nous_real".into(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
service_socket: Some(service_socket),
flow: Flows {
input: vec![Flow {
name: FLOW_EMBED_REQUEST.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
output: vec![Flow {
name: FLOW_EMBED_RESULT.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
},
priority_contexts,
..Default::default()
}
}
// Helpers compartidos. Anotados allow(dead_code) porque en stub mode
// algunos quedan sin uso pero los queremos disponibles consistentemente.
#[allow(dead_code)]
pub(crate) fn model_id() -> &'static str {
MODEL_ID
}
#[allow(dead_code)]
pub(crate) fn embed_dim() -> u32 {
EMBED_DIM
}
@@ -0,0 +1,36 @@
//! Modo stub: arranca el bin pero rechaza las requests con un error
//! que explica que falta la feature `embeddings`.
use chasqui_nous::{EmbedRequest, ErrorResponse};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tracing::warn;
pub async fn handle_conn(stream: UnixStream) -> std::io::Result<()> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
// Parseamos para validar la forma; igual rechazamos.
let _: Result<EmbedRequest, _> = serde_json::from_str(&line);
warn!("rechazando request en modo stub (feature `embeddings` ausente)");
let resp = ErrorResponse {
error: format!(
"chasqui-nous-real compilado sin la feature `embeddings`. \
Rebuild con: cargo build -p chasqui-nous-real --features embeddings"
),
};
let mut stream = reader.into_inner();
let payload = serde_json::to_string(&resp).unwrap_or_else(|_| {
"{\"error\":\"stub mode and serialization failed\"}".to_string()
});
stream.write_all(payload.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.shutdown().await?;
Ok(())
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "chasqui-nous"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Nouser — protocolo Nous: contrato JSON line-delimited entre nouser-core y los proveedores de embeddings (mock o LLM real)."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+10
View File
@@ -0,0 +1,10 @@
# chasqui-nous
> Trait del transport de [chasqui](../README.md).
`Nous = transport`. Define cómo se mandan/reciben mensajes entre cliente y broker. Implementado por [`chasqui-nous-mock`](../chasqui-nous-mock/README.md) (in-process) y [`chasqui-nous-real`](../chasqui-nous-real/README.md) (TCP).
## Deps
- [`chasqui-core`](../chasqui-core/README.md)
- `async-trait`
+10
View File
@@ -0,0 +1,10 @@
# chasqui-nous
> Transport trait of [chasqui](../README.md).
`Nous = transport`. Defines how messages are sent/received between client and broker. Implemented by [`chasqui-nous-mock`](../chasqui-nous-mock/README.md) (in-process) and [`chasqui-nous-real`](../chasqui-nous-real/README.md) (TCP).
## Deps
- [`chasqui-core`](../chasqui-core/README.md)
- `async-trait`
+196
View File
@@ -0,0 +1,196 @@
//! `chasqui-nous` — el contrato del proveedor de embeddings.
//!
//! Define el wire-format compartido entre `chasqui-core` (consumidor) y
//! cualquier implementación de Nous (mock determinista o LLM real). El
//! protocolo es **line-delimited JSON** sobre Unix socket: cada conexión
//! envía una request, recibe una response, y cierra. Single-shot por
//! conexión, igual al admin de brahman.
//!
//! ## Contrato
//!
//! ```text
//! C → S: {"kind":"embed_file","payload":{...}}\n
//! S → C: {"embedding":[...],"model":"mock-pseudo-32d","elapsed_ms":1}\n
//! ```
//!
//! En caso de error:
//!
//! ```text
//! S → C: {"error":"unsupported kind"}\n
//! ```
//!
//! ## Por qué un crate aparte
//!
//! El consumidor (chasqui-core) y el proveedor (chasqui-nous-mock,
//! chasqui-nous-real) deben acordar en types EXACTOS. Tener el contrato
//! en su crate evita que cada lado declare structs paralelos que se
//! desincronizan. Si bumpeás el wire, bumpeás aquí.
//!
//! ## Swap por priority_contexts
//!
//! Cuando existan dos proveedores (mock-nous y real-nous), ambos declaran
//! el mismo `flow.output: { name: "embed-result", type: ... }` y
//! `flow.input: "embed-request"`. El broker brahman los matchea contra
//! los consumidores; el `priority_offset` per-contexto del Card hace que
//! mock-nous gane en `test` y real-nous en `prod`. chasqui-core sólo
//! consume el flow, sin saber cuál implementación corre.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use serde::{Deserialize, Serialize};
use thiserror::Error;
// =====================================================================
// Wire types
// =====================================================================
/// Request al proveedor Nous.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedRequest {
pub kind: RequestKind,
pub payload: serde_json::Value,
}
/// Tipo de request. El payload se interpreta según el kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RequestKind {
/// payload = `EmbedFilePayload` (path + metadata mínima).
EmbedFile,
/// payload = `EmbedTextPayload` (string libre).
EmbedText,
/// payload = `{}`. Devuelve `PingResponse`.
Ping,
}
/// Payload para `EmbedFile`. Es la información mínima que el proveedor
/// necesita para producir un embedding de archivo determinista.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedFilePayload {
pub path: String,
pub extension: Option<String>,
pub size: u64,
/// `mtime` en ms desde UNIX_EPOCH.
pub mtime_ms: u64,
}
/// Payload para `EmbedText`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedTextPayload {
pub text: String,
}
/// Response exitosa con un embedding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedResponse {
/// Vector. Su longitud depende del modelo (mock=32, llama=384, etc.).
pub embedding: Vec<f32>,
/// Identificador del modelo que produjo el embedding (útil para logs
/// y para invalidar caches al cambiar de proveedor).
pub model: String,
/// Tiempo de cómputo en ms (proveedor lo reporta).
pub elapsed_ms: u64,
}
/// Response a Ping. Útil para health-checks y discovery.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingResponse {
pub model: String,
pub embed_dim: u32,
}
/// Error retornado por el proveedor en lugar de una response normal.
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
#[error("nous: {error}")]
pub struct ErrorResponse {
pub error: String,
}
// =====================================================================
// Transport
// =====================================================================
pub mod transport {
use std::path::PathBuf;
/// Variable de entorno para sobreescribir la ruta del socket.
pub const SOCKET_ENV: &str = "NOUSER_NOUS_SOCKET";
/// Nombre genérico del socket cuando hay un solo proveedor.
pub const SOCKET_NAME: &str = "chasqui-nous.sock";
/// Ruta canónica al socket cuando un único proveedor está activo
/// (consumidores que no quieren elegir).
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
runtime_base().join(SOCKET_NAME)
}
/// Ruta default para un proveedor identificado (`"mock"`, `"real"`,
/// etc). Permite que mock y real coexistan sin clash de socket.
/// `NOUSER_NOUS_SOCKET` igual override esta función si está set.
pub fn provider_socket_path(provider: &str) -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
runtime_base().join(format!("chasqui-nous-{}.sock", provider))
}
fn runtime_base() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}
}
// =====================================================================
// Names compartidos para el broker brahman
// =====================================================================
/// Nombre del flow output del proveedor (entrada del consumidor).
pub const FLOW_EMBED_RESULT: &str = "embed-result";
/// Nombre del flow input del proveedor (salida del consumidor).
pub const FLOW_EMBED_REQUEST: &str = "embed-request";
/// Tipo del flow: el wire es JSON serializado, así que el TypeRef
/// declarado en la Card es `primitive::json`.
pub const FLOW_TYPE_NAME: &str = "json";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_roundtrip_json() {
let req = EmbedRequest {
kind: RequestKind::EmbedFile,
payload: serde_json::to_value(EmbedFilePayload {
path: "/x/y.rs".into(),
extension: Some("rs".into()),
size: 1024,
mtime_ms: 1_700_000_000_000,
})
.unwrap(),
};
let s = serde_json::to_string(&req).unwrap();
let parsed: EmbedRequest = serde_json::from_str(&s).unwrap();
assert_eq!(parsed.kind, RequestKind::EmbedFile);
}
#[test]
fn response_roundtrip() {
let resp = EmbedResponse {
embedding: vec![0.1, 0.2, 0.3],
model: "mock-pseudo-32d".into(),
elapsed_ms: 1,
};
let s = serde_json::to_string(&resp).unwrap();
let parsed: EmbedResponse = serde_json::from_str(&s).unwrap();
assert_eq!(parsed.model, "mock-pseudo-32d");
assert_eq!(parsed.embedding.len(), 3);
}
}