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:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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`
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+448
@@ -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" }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user