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
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+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);
}
}
Generated
+8292
View File
File diff suppressed because it is too large Load Diff
+448
View File
@@ -0,0 +1,448 @@
# Cargo.toml raíz STANDALONE de chasqui — front-door sobre Llimphi.
# Solo el código de chasqui; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
[workspace]
resolver = "2"
members = [
"02_ruway/chasqui/card-admin",
"02_ruway/chasqui/card-handshake",
"02_ruway/chasqui/card-sidecar",
"02_ruway/chasqui/chasqui-broker",
"02_ruway/chasqui/chasqui-broker-explorer-llimphi",
"02_ruway/chasqui/chasqui-card",
"02_ruway/chasqui/chasqui-core",
"02_ruway/chasqui/chasqui-explorer-llimphi",
"02_ruway/chasqui/chasqui-nous",
"02_ruway/chasqui/chasqui-nous-mock",
"02_ruway/chasqui/chasqui-nous-real",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/chasqui"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
lsp-types = "0.97"
serde-big-array = "0.5"
postcard = { version = "1", features = ["use-std"] }
toml = "0.8"
ron = "0.8"
bincode = "1"
base64 = "0.22"
# === Errores ===
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
anyhow = "1"
# === Async ===
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
async-trait = "0.1"
futures = "0.3"
# === Observabilidad ===
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
# === Linux primitives (arje) ===
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
libc = "0.2"
# === IDs / Hash / Crypto ===
ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
sha2 = "0.10"
blake3 = "1.5"
ed25519-dalek = "2"
aes-gcm = "0.10"
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
# === WASM (arje) ===
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
wasmi = "1.0"
wat = "1"
# === Storage / DB ===
sled = "0.34"
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
pdf-extract = "0.7"
epub = "2.1"
# === Bulk import Wikipedia (iniy-wiki dump) ===
bzip2 = "0.4"
# === Compresión (minga multi-bundle) ===
zstd = "0.13"
# === HTTP server (iniy-server) ===
axum = "0.7"
tower = "0.5"
# === ANN sobre embeddings (iniy nli --ann) ===
instant-distance = "0.6"
# === P2P (minga) ===
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
libp2p-stream = "=0.4.0-alpha"
libp2p-allow-block-list = "0.6"
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
russh = "0.54"
# === Math determinista cross-platform (dominium) ===
libm = "0.2"
# === SMF (takiy-midi) ===
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
midly = "0.5"
# === Code parsing (minga) ===
arboard = "3"
ropey = "1.6"
tree-sitter = "0.24"
tree-sitter-rust = "0.23"
tree-sitter-python = "0.23"
tree-sitter-typescript = "0.23"
tree-sitter-javascript = "0.23"
tree-sitter-go = "0.23"
# === FS notify ===
notify = "6.1"
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
petgraph = "0.6"
# === Image decoding (nahual-image-viewer-llimphi) ===
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
# los pide específicamente.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# === FUSE (minga-vfs) ===
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
fuser = { version = "0.15", default-features = false }
# === CLI / auth (minga) ===
clap = { version = "4", features = ["derive"] }
rpassword = "7"
# === PAM (auth-core) ===
pam = "0.8"
# === D-Bus (arje compat) ===
zbus = { version = "4", default-features = false, features = ["tokio"] }
# === Tests ===
tempfile = "3"
# === Llimphi (motor gráfico soberano) ===
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" }
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === Filesystem helpers ===
directories = "5"
# === Diff line-based (llimphi-module-diff-viewer) ===
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
# zero deps fuera de std. La 2.x es estable hace años.
similar = "2"
# === Fuzzy matching (shuma-history) ===
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
# que necesitamos (Matcher + Pattern + score).
nucleo-matcher = "0.3"
# === Transporte autenticado (shuma-link) ===
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
# conoce la pubkey del servidor, server descubre la del cliente y la
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
snow = "0.9"
hex = "0.4"
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
# portable-pty aloja un PTY cross-platform; lo usamos para los
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
# movement + erase + screen state) y mantiene un buffer de pantalla
# renderizable como grid.
portable-pty = "0.9"
vt100 = "0.16"
# === WASM web (gioser) ===
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = "0.3"
glam = "0.30"
# === Markdown (pluma) ===
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
# === Archivos comprimidos (nahual archive viewer) ===
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
# los datos de cada entrada — sólo leemos headers.
zip = { version = "2.4", default-features = false }
tar = { version = "0.4", default-features = false }
# === Fuentes (nahual font viewer) ===
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" }
pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" }
cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
approx = "0.5"
byteorder = "1.5"
cc = "1.0"
chrono = "0.4"
crc32fast = "1.4"
criterion = "0.5"
csv = "1.4"
flate2 = "1.0"
glob = "0.3"
indicatif = "0.18"
lz4_flex = "0.11"
memmap2 = "0.9"
mockito = "1.0"
ndarray = "0.15"
num-traits = "0.2"
once_cell = "1.19"
parking_lot = "0.12"
png = "0.18"
proptest = "1.4"
quick-xml = "0.31"
rayon = "1.8"
regex = "1.11"
reqwest = "0.12"
tiff = "0.11"
wide = "0.7"
wiremock = "0.6"
# === i18n (rimay-localize) ===
fluent-bundle = "0.15"
unic-langid = { version = "0.9", features = ["macros"] }
sys-locale = "0.3"
# === Servo (puriy-engine) ===
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
# evita pull de tokio en el engine.
html5ever = "0.39"
markup5ever = "0.39"
markup5ever_rcdom = "0.39"
cssparser = "0.35"
url = "2"
ureq = { version = "2", default-features = false, features = ["tls"] }
# === takiy-synth (SoundFont MIDI) ===
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
rustysynth = "1.3"
# === takiy-playback (audio device output) ===
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
# abrir el device default y empujar muestras f32 — nada de mezclado
# ni efectos en el callback.
cpal = "0.15"
# === media-source-wav (decoder PCM en disco) ===
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
# stems de prueba sin meter ffmpeg/symphonia.
hound = "3.5"
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
# ese tier patentado entra por shared/foreign-av.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
ogg = "0.9"
opus-wave = "3"
# === media-source-webm (demux nativo Matroska/WebM) ===
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
matroska-demuxer = "0.7"
# === git-deps al monorepo (agregados por la extracción) ===
arje-cas = { git = "https://gitea.gioser.net/sergio/gioser.git" }
card-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
card-net = { git = "https://gitea.gioser.net/sergio/gioser.git" }
card-wit = { git = "https://gitea.gioser.net/sergio/gioser.git" }
rimay-localize = { git = "https://gitea.gioser.net/sergio/gioser.git" }
shuma-discern = { git = "https://gitea.gioser.net/sergio/gioser.git" }
wawa-config = { git = "https://gitea.gioser.net/sergio/gioser.git" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sergio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# chasqui
> Sovereign peer discovery + transport — DHT, relay, NAT traversal — in Rust.
`chasqui` (Quechua: *Inca relay messenger*) is the networking layer: peer discovery (UDP + Kademlia DHT), relay/dcutr/autonat for NAT traversal, and authenticated transport over the `card` identity. Channels and capabilities are expressed as typed cards; no TCP/IP assumptions leak into the apps above it.
## How dependencies work
Front-door repo: only `chasqui-*` crates here. `card` (identity), `arje` primitives, shared leaves are git-dependencies of the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo.
## License
MIT. Part of the [gioser](https://gitea.gioser.net/sergio/gioser) suite.