From 5d5e4a3ae4e92f264765a11cfc6a5c9079b1eb5a Mon Sep 17 00:00:00 2001 From: Sergio Date: Thu, 4 Jun 2026 12:17:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20chasqui=20standalone=20=E2=80=94=20desc?= =?UTF-8?q?ubrimiento=20+=20transporte=20P2P=20soberano=20(DHT,=20relay,?= =?UTF-8?q?=20NAT=20traversal)=20(front-door,=20git-dep=20al=20monorepo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 3 + 02_ruway/chasqui/ARQUITECTURA.md | 202 + 02_ruway/chasqui/LEEME.md | 41 + 02_ruway/chasqui/README.md | 38 + 02_ruway/chasqui/README.qu.md | 40 + 02_ruway/chasqui/card-admin/Cargo.toml | 27 + .../card-admin/examples/brahman-status.rs | 98 + 02_ruway/chasqui/card-admin/src/client.rs | 48 + 02_ruway/chasqui/card-admin/src/lib.rs | 23 + 02_ruway/chasqui/card-admin/src/server.rs | 110 + 02_ruway/chasqui/card-admin/src/snapshot.rs | 24 + 02_ruway/chasqui/card-admin/src/transport.rs | 20 + 02_ruway/chasqui/card-handshake/Cargo.toml | 37 + .../chasqui/card-handshake/examples/probe.rs | 51 + .../card-handshake/examples/subscriber.rs | 83 + 02_ruway/chasqui/card-handshake/src/client.rs | 312 + 02_ruway/chasqui/card-handshake/src/codec.rs | 72 + .../chasqui/card-handshake/src/identity.rs | 358 + 02_ruway/chasqui/card-handshake/src/lib.rs | 33 + .../chasqui/card-handshake/src/messages.rs | 234 + .../chasqui/card-handshake/src/network.rs | 289 + .../chasqui/card-handshake/src/peer_policy.rs | 582 ++ 02_ruway/chasqui/card-handshake/src/server.rs | 855 ++ .../chasqui/card-handshake/src/signature.rs | 155 + .../chasqui/card-handshake/src/transport.rs | 52 + .../chasqui/card-handshake/tests/handshake.rs | 480 + .../card-handshake/tests/network_discovery.rs | 340 + .../card-handshake/tests/network_libp2p.rs | 525 ++ 02_ruway/chasqui/card-sidecar/Cargo.toml | 33 + .../examples/presence-conscious.rs | 99 + .../chasqui/card-sidecar/examples/presence.rs | 70 + .../chasqui/card-sidecar/src/discovery.rs | 453 + 02_ruway/chasqui/card-sidecar/src/lib.rs | 255 + .../card-sidecar/tests/remote_discovery.rs | 256 + .../Cargo.toml | 28 + .../chasqui-broker-explorer-llimphi/LEEME.md | 16 + .../chasqui-broker-explorer-llimphi/README.md | 16 + .../src/main.rs | 868 ++ 02_ruway/chasqui/chasqui-broker/Cargo.toml | 15 + 02_ruway/chasqui/chasqui-broker/LEEME.md | 16 + 02_ruway/chasqui/chasqui-broker/README.md | 16 + 02_ruway/chasqui/chasqui-broker/src/lib.rs | 995 ++ 02_ruway/chasqui/chasqui-card/Cargo.toml | 16 + 02_ruway/chasqui/chasqui-card/LEEME.md | 10 + 02_ruway/chasqui/chasqui-card/README.md | 10 + 02_ruway/chasqui/chasqui-card/src/lib.rs | 431 + 02_ruway/chasqui/chasqui-card/src/query.rs | 385 + 02_ruway/chasqui/chasqui-core/Cargo.toml | 33 + 02_ruway/chasqui/chasqui-core/LEEME.md | 9 + 02_ruway/chasqui/chasqui-core/README.md | 9 + .../chasqui/chasqui-core/src/bin/nouser.rs | 795 ++ 02_ruway/chasqui/chasqui-core/src/cluster.rs | 355 + 02_ruway/chasqui/chasqui-core/src/db.rs | 313 + 02_ruway/chasqui/chasqui-core/src/embed.rs | 298 + .../chasqui/chasqui-core/src/engine_socket.rs | 318 + 02_ruway/chasqui/chasqui-core/src/lib.rs | 34 + 02_ruway/chasqui/chasqui-core/src/scanner.rs | 178 + .../chasqui-explorer-llimphi/Cargo.toml | 28 + .../chasqui/chasqui-explorer-llimphi/LEEME.md | 16 + .../chasqui-explorer-llimphi/README.md | 16 + .../chasqui-explorer-llimphi/src/main.rs | 800 ++ 02_ruway/chasqui/chasqui-nous-mock/Cargo.toml | 25 + 02_ruway/chasqui/chasqui-nous-mock/LEEME.md | 10 + 02_ruway/chasqui/chasqui-nous-mock/README.md | 10 + .../chasqui/chasqui-nous-mock/src/main.rs | 247 + 02_ruway/chasqui/chasqui-nous-real/Cargo.toml | 40 + 02_ruway/chasqui/chasqui-nous-real/LEEME.md | 10 + 02_ruway/chasqui/chasqui-nous-real/README.md | 10 + .../chasqui/chasqui-nous-real/src/cache.rs | 199 + .../chasqui-nous-real/src/embeddings.rs | 205 + .../chasqui/chasqui-nous-real/src/main.rs | 202 + .../chasqui/chasqui-nous-real/src/stub.rs | 36 + 02_ruway/chasqui/chasqui-nous/Cargo.toml | 14 + 02_ruway/chasqui/chasqui-nous/LEEME.md | 10 + 02_ruway/chasqui/chasqui-nous/README.md | 10 + 02_ruway/chasqui/chasqui-nous/src/lib.rs | 196 + Cargo.lock | 8292 +++++++++++++++++ Cargo.toml | 448 + LICENSE | 21 + README.md | 11 + 80 files changed, 22318 insertions(+) create mode 100644 .gitignore create mode 100644 02_ruway/chasqui/ARQUITECTURA.md create mode 100644 02_ruway/chasqui/LEEME.md create mode 100644 02_ruway/chasqui/README.md create mode 100644 02_ruway/chasqui/README.qu.md create mode 100644 02_ruway/chasqui/card-admin/Cargo.toml create mode 100644 02_ruway/chasqui/card-admin/examples/brahman-status.rs create mode 100644 02_ruway/chasqui/card-admin/src/client.rs create mode 100644 02_ruway/chasqui/card-admin/src/lib.rs create mode 100644 02_ruway/chasqui/card-admin/src/server.rs create mode 100644 02_ruway/chasqui/card-admin/src/snapshot.rs create mode 100644 02_ruway/chasqui/card-admin/src/transport.rs create mode 100644 02_ruway/chasqui/card-handshake/Cargo.toml create mode 100644 02_ruway/chasqui/card-handshake/examples/probe.rs create mode 100644 02_ruway/chasqui/card-handshake/examples/subscriber.rs create mode 100644 02_ruway/chasqui/card-handshake/src/client.rs create mode 100644 02_ruway/chasqui/card-handshake/src/codec.rs create mode 100644 02_ruway/chasqui/card-handshake/src/identity.rs create mode 100644 02_ruway/chasqui/card-handshake/src/lib.rs create mode 100644 02_ruway/chasqui/card-handshake/src/messages.rs create mode 100644 02_ruway/chasqui/card-handshake/src/network.rs create mode 100644 02_ruway/chasqui/card-handshake/src/peer_policy.rs create mode 100644 02_ruway/chasqui/card-handshake/src/server.rs create mode 100644 02_ruway/chasqui/card-handshake/src/signature.rs create mode 100644 02_ruway/chasqui/card-handshake/src/transport.rs create mode 100644 02_ruway/chasqui/card-handshake/tests/handshake.rs create mode 100644 02_ruway/chasqui/card-handshake/tests/network_discovery.rs create mode 100644 02_ruway/chasqui/card-handshake/tests/network_libp2p.rs create mode 100644 02_ruway/chasqui/card-sidecar/Cargo.toml create mode 100644 02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs create mode 100644 02_ruway/chasqui/card-sidecar/examples/presence.rs create mode 100644 02_ruway/chasqui/card-sidecar/src/discovery.rs create mode 100644 02_ruway/chasqui/card-sidecar/src/lib.rs create mode 100644 02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs create mode 100644 02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md create mode 100644 02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs create mode 100644 02_ruway/chasqui/chasqui-broker/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-broker/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-broker/README.md create mode 100644 02_ruway/chasqui/chasqui-broker/src/lib.rs create mode 100644 02_ruway/chasqui/chasqui-card/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-card/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-card/README.md create mode 100644 02_ruway/chasqui/chasqui-card/src/lib.rs create mode 100644 02_ruway/chasqui/chasqui-card/src/query.rs create mode 100644 02_ruway/chasqui/chasqui-core/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-core/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-core/README.md create mode 100644 02_ruway/chasqui/chasqui-core/src/bin/nouser.rs create mode 100644 02_ruway/chasqui/chasqui-core/src/cluster.rs create mode 100644 02_ruway/chasqui/chasqui-core/src/db.rs create mode 100644 02_ruway/chasqui/chasqui-core/src/embed.rs create mode 100644 02_ruway/chasqui/chasqui-core/src/engine_socket.rs create mode 100644 02_ruway/chasqui/chasqui-core/src/lib.rs create mode 100644 02_ruway/chasqui/chasqui-core/src/scanner.rs create mode 100644 02_ruway/chasqui/chasqui-explorer-llimphi/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-explorer-llimphi/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-explorer-llimphi/README.md create mode 100644 02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs create mode 100644 02_ruway/chasqui/chasqui-nous-mock/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-nous-mock/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-nous-mock/README.md create mode 100644 02_ruway/chasqui/chasqui-nous-mock/src/main.rs create mode 100644 02_ruway/chasqui/chasqui-nous-real/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-nous-real/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-nous-real/README.md create mode 100644 02_ruway/chasqui/chasqui-nous-real/src/cache.rs create mode 100644 02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs create mode 100644 02_ruway/chasqui/chasqui-nous-real/src/main.rs create mode 100644 02_ruway/chasqui/chasqui-nous-real/src/stub.rs create mode 100644 02_ruway/chasqui/chasqui-nous/Cargo.toml create mode 100644 02_ruway/chasqui/chasqui-nous/LEEME.md create mode 100644 02_ruway/chasqui/chasqui-nous/README.md create mode 100644 02_ruway/chasqui/chasqui-nous/src/lib.rs create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/02_ruway/chasqui/ARQUITECTURA.md b/02_ruway/chasqui/ARQUITECTURA.md new file mode 100644 index 0000000..bddc6a5 --- /dev/null +++ b/02_ruway/chasqui/ARQUITECTURA.md @@ -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, outputs: Vec, + wit: Option, // matching fino si el módulo es "consciente" + priority_contexts: BTreeMap, // sesgos por contexto ("test"/"prod") + kind: CardKind /*Ente|Data*/, data: Option, service_socket: Option, +} +struct Match { consumer: Endpoint, producer: Endpoint, ty: TypeRef, via: MatchStrategy, pinned: bool } +struct ContextBias { pin_to: Option, priority_offset: i16 } +struct Broker { cards: BTreeMap, 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, + label, summary, centroid: Vec, centroid_model: Option/*"chasqui-pseudo-32d"|"real-fastembed-384d"*/, + path_hint: Option/*dir padre canónico = identidad estable*/, keywords: Vec, + cardinality: u32, entropy: f32/*[0,1] cohesión de extensiones*/, dominant_lens: Lens, + pins: BTreeSet/*anclados, no migran*/, members: BTreeSet, + created_at_ms, updated_at_ms, extensions: BTreeMap/*forward-compat*/, +} +``` + +### Pipeline determinista (`chasqui-core`, 2215 LOC) + +``` +scanner.rs recorre directorios → Vec +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, 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. diff --git a/02_ruway/chasqui/LEEME.md b/02_ruway/chasqui/LEEME.md new file mode 100644 index 0000000..e86434b --- /dev/null +++ b/02_ruway/chasqui/LEEME.md @@ -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. diff --git a/02_ruway/chasqui/README.md b/02_ruway/chasqui/README.md new file mode 100644 index 0000000..2366596 --- /dev/null +++ b/02_ruway/chasqui/README.md @@ -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. diff --git a/02_ruway/chasqui/README.qu.md b/02_ruway/chasqui/README.qu.md new file mode 100644 index 0000000..c06942d --- /dev/null +++ b/02_ruway/chasqui/README.qu.md @@ -0,0 +1,40 @@ + + +# 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. diff --git a/02_ruway/chasqui/card-admin/Cargo.toml b/02_ruway/chasqui/card-admin/Cargo.toml new file mode 100644 index 0000000..ffe3483 --- /dev/null +++ b/02_ruway/chasqui/card-admin/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/card-admin/examples/brahman-status.rs b/02_ruway/chasqui/card-admin/examples/brahman-status.rs new file mode 100644 index 0000000..d6d58f2 --- /dev/null +++ b/02_ruway/chasqui/card-admin/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(()) +} diff --git a/02_ruway/chasqui/card-admin/src/client.rs b/02_ruway/chasqui/card-admin/src/client.rs new file mode 100644 index 0000000..40cba19 --- /dev/null +++ b/02_ruway/chasqui/card-admin/src/client.rs @@ -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) -> Result { + 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) -> Result { + 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) +} diff --git a/02_ruway/chasqui/card-admin/src/lib.rs b/02_ruway/chasqui/card-admin/src/lib.rs new file mode 100644 index 0000000..1cb1d5b --- /dev/null +++ b/02_ruway/chasqui/card-admin/src/lib.rs @@ -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"); diff --git a/02_ruway/chasqui/card-admin/src/server.rs b/02_ruway/chasqui/card-admin/src/server.rs new file mode 100644 index 0000000..ebfddc5 --- /dev/null +++ b/02_ruway/chasqui/card-admin/src/server.rs @@ -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, +} + +/// Servidor admin escuchando en un Unix socket. +pub struct AdminServer { + listener: UnixListener, + socket_path: PathBuf, + broker: Arc>, + config: AdminConfig, +} + +impl AdminServer { + /// Crea el listener. Si `path` existe, lo elimina (asume socket stale). + pub fn bind( + path: impl Into, + broker: Arc>, + config: AdminConfig, + ) -> std::io::Result { + 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>, + 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>, 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, + } +} diff --git a/02_ruway/chasqui/card-admin/src/snapshot.rs b/02_ruway/chasqui/card-admin/src/snapshot.rs new file mode 100644 index 0000000..5503635 --- /dev/null +++ b/02_ruway/chasqui/card-admin/src/snapshot.rs @@ -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, + /// Cards actualmente registradas (sesiones vivas). + pub sessions: Vec, + /// Matches consumer↔producer derivados del set actual. + pub matches: Vec, +} diff --git a/02_ruway/chasqui/card-admin/src/transport.rs b/02_ruway/chasqui/card-admin/src/transport.rs new file mode 100644 index 0000000..d2afed6 --- /dev/null +++ b/02_ruway/chasqui/card-admin/src/transport.rs @@ -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) +} diff --git a/02_ruway/chasqui/card-handshake/Cargo.toml b/02_ruway/chasqui/card-handshake/Cargo.toml new file mode 100644 index 0000000..dbc93cc --- /dev/null +++ b/02_ruway/chasqui/card-handshake/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/card-handshake/examples/probe.rs b/02_ruway/chasqui/card-handshake/examples/probe.rs new file mode 100644 index 0000000..96f6904 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/examples/probe.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(()) +} diff --git a/02_ruway/chasqui/card-handshake/examples/subscriber.rs b/02_ruway/chasqui/card-handshake/examples/subscriber.rs new file mode 100644 index 0000000..b858170 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/examples/subscriber.rs @@ -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() { + "" + } else { + &ev.producer_label + }, + ev.producer_flow, + ev.via, + if ev.pinned { " 📌" } else { "" } + ); + } + None => { + let _ts = client.ping().await?; + } + } + } +} diff --git a/02_ruway/chasqui/card-handshake/src/client.rs b/02_ruway/chasqui/card-handshake/src/client.rs new file mode 100644 index 0000000..d93dd33 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/client.rs @@ -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 { + stream: S, + session: SessionId, + server_info: HelloAck, + pending_events: VecDeque, +} + +impl Client { + /// 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, card: Card) -> Result { + 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, + card: Card, + wit: Option, + ) -> Result { + let stream = UnixStream::connect(path).await?; + Self::connect_with_stream(stream, card, wit).await + } +} + +impl Client +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, + ) -> Result { + 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, + keypair: &Keypair, + ) -> Result { + 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, + session_keypair: &Keypair, + identity_cert: SessionCert, + ) -> Result { + Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await + } + + async fn connect_inner( + mut stream: S, + card: Card, + wit: Option, + keypair: Option<&Keypair>, + identity_cert: Option, + ) -> Result { + 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 { + 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 { + 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, 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 { + 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 { + 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(()) + } +} diff --git a/02_ruway/chasqui/card-handshake/src/codec.rs b/02_ruway/chasqui/card-handshake/src/codec.rs new file mode 100644 index 0000000..adfb038 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/codec.rs @@ -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: &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: &mut R) -> Result { + 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"), + } + } +} diff --git a/02_ruway/chasqui/card-handshake/src/identity.rs b/02_ruway/chasqui/card-handshake/src/identity.rs new file mode 100644 index 0000000..ac2a46c --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/identity.rs @@ -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`. 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, +} + +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) -> 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 { + 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, + /// Public key de la master identity, en format libp2p protobuf. + /// El verificador deriva el `master_peer_id` desde acá. + pub master_pubkey: Vec, + /// 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, +} + +#[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 { + 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 { + 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" + ); + } +} diff --git a/02_ruway/chasqui/card-handshake/src/lib.rs b/02_ruway/chasqui/card-handshake/src/lib.rs new file mode 100644 index 0000000..c744925 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/lib.rs @@ -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"); diff --git a/02_ruway/chasqui/card-handshake/src/messages.rs b/02_ruway/chasqui/card-handshake/src/messages.rs new file mode 100644 index 0000000..d1f47e7 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/messages.rs @@ -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)` — 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, + /// 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, + /// 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, +} + +/// 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, + /// Bytes de la firma Ed25519 sobre el payload canonical. + pub signature: Vec, +} + +/// 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, +} + +#[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, + /// Nombres de los `flow.input` que la Card declara consumir. + pub inputs: Vec, + /// `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, +} + +/// 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, +} + +/// 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), +} diff --git a/02_ruway/chasqui/card-handshake/src/network.rs b/02_ruway/chasqui/card-handshake/src/network.rs new file mode 100644 index 0000000..66ab228 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/network.rs @@ -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; + +/// 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, + net: Arc, +) -> 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` 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, + 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, + keypair: &Keypair, +) -> Result, 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, + session_keypair: &Keypair, + cert: SessionCert, +) -> Result, 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 { + let key = flow_dht_key(flow_name, type_ref); + net.find_providers(&key).await +} diff --git a/02_ruway/chasqui/card-handshake/src/peer_policy.rs b/02_ruway/chasqui/card-handshake/src/peer_policy.rs new file mode 100644 index 0000000..54a81a9 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/peer_policy.rs @@ -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>, + paths: Arc, + /// `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>` + /// 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>>>, +} + +#[derive(Default)] +struct PolicyInner { + /// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto. + allow: Option>, + /// Peers baneados. Vacío = sin denylist. + deny: BTreeSet, +} + +#[derive(Default)] +struct PolicyPaths { + allow_path: Option, + deny_path: Option, +} + +/// 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>, deny: BTreeSet) -> 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 { + 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) { + 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) { + // 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, new: &BTreeSet) { + 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> { + 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, 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::() + .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, + deny_path: Option, +) { + use notify::{RecursiveMode, Watcher}; + + let (tx, rx) = std::sync::mpsc::channel::>(); + 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 = 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" + ); + } +} diff --git a/02_ruway/chasqui/card-handshake/src/server.rs b/02_ruway/chasqui/card-handshake/src/server.rs new file mode 100644 index 0000000..b39d813 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/server.rs @@ -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>>; + +/// Broker compartido (opcional) que el servidor mantiene en sincronía con +/// el ciclo de vida de las sesiones. +pub type SharedBroker = Arc>; + +/// 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>>>; + +/// 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>>>; + +/// 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, + /// 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>, + /// 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, +} + +// 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(|_| "")) + .field("net", &self.net.as_ref().map(|_| "")) + .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, config: ServerConfig) -> std::io::Result { + 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> { + 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(&self, stream: S) -> Session + 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( + &self, + stream: S, + peer: PeerId, + ) -> Session + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + self.session_from_stream_inner(stream, Some(peer)) + } + + fn session_from_stream_inner( + &self, + stream: S, + expected_peer: Option, + ) -> Session + 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 { + 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, +} + +impl Session +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` y por eso vive afuera. +// ============================================================================ + +async fn run_post_handshake( + 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::(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( + session_id: SessionId, + frame: Frame, + writer: &Arc>>, + sessions: &SessionRegistry, + broker_for_match: Option<&SharedBroker>, +) -> std::io::Result +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( + stream: &mut S, + config: &ServerConfig, + sessions: &SessionRegistry, + expected_peer: Option, +) -> std::io::Result> +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, + 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 { + 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) +} diff --git a/02_ruway/chasqui/card-handshake/src/signature.rs b/02_ruway/chasqui/card-handshake/src/signature.rs new file mode 100644 index 0000000..c9b2fc5 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/signature.rs @@ -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)`. 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)` 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)`. +fn payload_bytes(card: &WireCard, wit: &Option) -> Result, 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, +) -> Result { + 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, + 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:?}"); + } +} diff --git a/02_ruway/chasqui/card-handshake/src/transport.rs b/02_ruway/chasqui/card-handshake/src/transport.rs new file mode 100644 index 0000000..03e7b51 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/src/transport.rs @@ -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); + } +} diff --git a/02_ruway/chasqui/card-handshake/tests/handshake.rs b/02_ruway/chasqui/card-handshake/tests/handshake.rs new file mode 100644 index 0000000..3c716b6 --- /dev/null +++ b/02_ruway/chasqui/card-handshake/tests/handshake.rs @@ -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, output: Vec) -> 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>, 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(); +} diff --git a/02_ruway/chasqui/card-handshake/tests/network_discovery.rs b/02_ruway/chasqui/card-handshake/tests/network_discovery.rs new file mode 100644 index 0000000..4ddde1e --- /dev/null +++ b/02_ruway/chasqui/card-handshake/tests/network_discovery.rs @@ -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 + ); +} diff --git a/02_ruway/chasqui/card-handshake/tests/network_libp2p.rs b/02_ruway/chasqui/card-handshake/tests/network_libp2p.rs new file mode 100644 index 0000000..c7e28ec --- /dev/null +++ b/02_ruway/chasqui/card-handshake/tests/network_libp2p.rs @@ -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" + ); +} diff --git a/02_ruway/chasqui/card-sidecar/Cargo.toml b/02_ruway/chasqui/card-sidecar/Cargo.toml new file mode 100644 index 0000000..a6be679 --- /dev/null +++ b/02_ruway/chasqui/card-sidecar/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs b/02_ruway/chasqui/card-sidecar/examples/presence-conscious.rs new file mode 100644 index 0000000..c2ecf69 --- /dev/null +++ b/02_ruway/chasqui/card-sidecar/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(); +} diff --git a/02_ruway/chasqui/card-sidecar/examples/presence.rs b/02_ruway/chasqui/card-sidecar/examples/presence.rs new file mode 100644 index 0000000..837f3c6 --- /dev/null +++ b/02_ruway/chasqui/card-sidecar/examples/presence.rs @@ -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(); +} diff --git a/02_ruway/chasqui/card-sidecar/src/discovery.rs b/02_ruway/chasqui/card-sidecar/src/discovery.rs new file mode 100644 index 0000000..6f9fb1b --- /dev/null +++ b/02_ruway/chasqui/card-sidecar/src/discovery.rs @@ -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, + #[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, + flow_name: impl Into, + type_name: impl Into, +) -> 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 { + 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 { + 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), +} + +/// 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 { + // 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, + peers: Vec, +) -> Result, 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 = 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, +) -> Result { + 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, +) -> Result { + 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, +) -> Result { + 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, +) -> Result { + 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"); + } +} diff --git a/02_ruway/chasqui/card-sidecar/src/lib.rs b/02_ruway/chasqui/card-sidecar/src/lib.rs new file mode 100644 index 0000000..1c86df8 --- /dev/null +++ b/02_ruway/chasqui/card-sidecar/src/lib.rs @@ -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, + /// 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> { + 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>>, + _thread: JoinHandle<()>, +} + +impl SidecarPool { + /// Crea un pool nuevo. Bloquea hasta que el runtime esté listo. + pub fn new() -> std::io::Result { + let (handle_tx, handle_rx) = mpsc::sync_channel::(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; + } + } +} diff --git a/02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs b/02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs new file mode 100644 index 0000000..0d39488 --- /dev/null +++ b/02_ruway/chasqui/card-sidecar/tests/remote_discovery.rs @@ -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:?}"), + } +} diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml new file mode 100644 index 0000000..a49c7d5 --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md new file mode 100644 index 0000000..c47770e --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md new file mode 100644 index 0000000..dd0a19b --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs new file mode 100644 index 0000000..0229101 --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker-explorer-llimphi/src/main.rs @@ -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, + last_match_keys: HashSet, + timeline: VecDeque, + /// Barra de menú principal: índice del menú raíz abierto (`None` + /// cerrado). + menu_open: Option, + /// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna). + menu_active: usize, + /// Animación de aparición del dropdown. + menu_anim: Tween, + /// 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, + /// 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, + matches: Option, + elapsed_ms: u64, + }, + /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar). + MenuOpen(Option), + /// 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) -> 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 { + 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) -> 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 { + 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::(header_text, vec![], &header_palette); + + let mut body_children: Vec> = Vec::new(); + + // Banner permanente con el estado actual. + match &model.state { + ProbeState::Pending => {} + ProbeState::Down { reason } => body_children.push(banner_view::( + BannerKind::Error, + format!("Broker DOWN — {reason}"), + )), + ProbeState::UpNoProvider { .. } => body_children.push(banner_view::( + BannerKind::Warning, + "Broker UP, sin provider para el flow".to_string(), + )), + ProbeState::UpWithProvider { .. } => body_children.push(banner_view::( + 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::( + "Estado", + state_value, + &state_descr, + state_accent, + &[], + &stat_palette, + )); + + // Sessions card. + let sessions_items: Vec = 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::( + "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::( + "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> { + // 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 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) -> 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, + list: &card_handshake::messages::MatchList, +) -> (Vec, HashSet) { + use card_handshake::messages::MatchEventKind; + let now = std::time::SystemTime::now(); + let current_keys: HashSet = 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::(); +} + +#[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)); + } +} diff --git a/02_ruway/chasqui/chasqui-broker/Cargo.toml b/02_ruway/chasqui/chasqui-broker/Cargo.toml new file mode 100644 index 0000000..05eac74 --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker/Cargo.toml @@ -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 } diff --git a/02_ruway/chasqui/chasqui-broker/LEEME.md b/02_ruway/chasqui/chasqui-broker/LEEME.md new file mode 100644 index 0000000..1d77c74 --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-broker/README.md b/02_ruway/chasqui/chasqui-broker/README.md new file mode 100644 index 0000000..adf8219 --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-broker/src/lib.rs b/02_ruway/chasqui/chasqui-broker/src/lib.rs new file mode 100644 index 0000000..b096637 --- /dev/null +++ b/02_ruway/chasqui/chasqui-broker/src/lib.rs @@ -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.`, ese bias se aplica durante el match. + /// `None` = sin biases per-contexto, sólo se usa lo estático. + pub current_context: Option, +} + +/// 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, + pub outputs: Vec, + /// Interfaz WIT extraída si el módulo es "consciente"; `None` si agnóstico. + pub wit: Option, + /// Biases per-contexto, propagados desde `Card.priority_contexts`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub priority_contexts: BTreeMap, + /// 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, + /// Socket de servicio (data plane) si lo declara la Card. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_socket: Option, + /// Referencias a otras Cards (relaciones declaradas por esta Card). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub references: Vec, +} + +impl BrokeredCard { + fn from_card(session: SessionId, card: &Card, wit: Option) -> 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, + 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, + ) -> Option { + self.cards + .insert(session, BrokeredCard::from_card(session, card, wit)) + } + + /// Quita una Card por sesión. + pub fn unregister(&mut self, session: SessionId) -> Option { + 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 + '_ { + self.cards.keys().copied() + } + + /// Iterador sobre las Cards registradas (vista compartida). + pub fn cards(&self) -> impl Iterator + '_ { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/02_ruway/chasqui/chasqui-card/Cargo.toml b/02_ruway/chasqui/chasqui-card/Cargo.toml new file mode 100644 index 0000000..a1de45c --- /dev/null +++ b/02_ruway/chasqui/chasqui-card/Cargo.toml @@ -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 } diff --git a/02_ruway/chasqui/chasqui-card/LEEME.md b/02_ruway/chasqui/chasqui-card/LEEME.md new file mode 100644 index 0000000..3ab1ed3 --- /dev/null +++ b/02_ruway/chasqui/chasqui-card/LEEME.md @@ -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) diff --git a/02_ruway/chasqui/chasqui-card/README.md b/02_ruway/chasqui/chasqui-card/README.md new file mode 100644 index 0000000..6d8bf7a --- /dev/null +++ b/02_ruway/chasqui/chasqui-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) diff --git a/02_ruway/chasqui/chasqui-card/src/lib.rs b/02_ruway/chasqui/chasqui-card/src/lib.rs new file mode 100644 index 0000000..3712b80 --- /dev/null +++ b/02_ruway/chasqui/chasqui-card/src/lib.rs @@ -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, +} + +// ===================================================================== +// 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, + + /// 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, + + /// 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, + + /// 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, + + /// Tokens dominantes: extensiones, palabras clave, etc. + /// 5-10 elementos típicamente. + #[serde(default)] + pub keywords: Vec, + + /// 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, + + /// IDs de archivos miembros (incluye pins). + pub members: BTreeSet, + + /// 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, +} + +// ===================================================================== +// 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) -> 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 { + Ok(serde_json::to_string_pretty(self)?) + } + + /// Deserializa desde JSON y valida. + pub fn from_json(src: &str) -> Result { + 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); + } +} diff --git a/02_ruway/chasqui/chasqui-card/src/query.rs b/02_ruway/chasqui/chasqui-card/src/query.rs new file mode 100644 index 0000000..23c15d6 --- /dev/null +++ b/02_ruway/chasqui/chasqui-card/src/query.rs @@ -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, +} + +/// 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, +} + +/// 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, + pub cardinality: u32, + #[serde(default)] + pub entropy: f32, + #[serde(default)] + pub dominant_lens: Lens, + #[serde(default)] + pub path_hint: Option, + #[serde(default)] + pub centroid_model: Option, +} + +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, + #[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, +} + +/// 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( + socket: &Path, + req: &QueryRequest, + timeout: Duration, + ) -> Result { + 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::(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 { + 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 { + 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}"); + } +} diff --git a/02_ruway/chasqui/chasqui-core/Cargo.toml b/02_ruway/chasqui/chasqui-core/Cargo.toml new file mode 100644 index 0000000..1827a00 --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/chasqui-core/LEEME.md b/02_ruway/chasqui/chasqui-core/LEEME.md new file mode 100644 index 0000000..3b61846 --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-core/README.md b/02_ruway/chasqui/chasqui-core/README.md new file mode 100644 index 0000000..00a7768 --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-core/src/bin/nouser.rs b/02_ruway/chasqui/chasqui-core/src/bin/nouser.rs new file mode 100644 index 0000000..f47087f --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/bin/nouser.rs @@ -0,0 +1,795 @@ +//! `chasqui` CLI — explorador de Mónadas. +//! +//! Subcomandos: +//! +//! - `scan ` recorre `dir` y muestra las Mónadas detectadas. +//! - `show ` scan + detalles de la Mónada con prefijo de ID. +//! - `json ` 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 = 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} [args]"); + eprintln!(); + eprintln!("comandos:"); + eprintln!(" scan recorre un directorio y lista las Mónadas detectadas"); + eprintln!(" show scan + detalle de la Mónada cuyo ID empieza con "); + eprintln!(" json scan + dump JSON de todos los manifests"); + eprintln!(" daemon scan + sidecarea cada Mónada al Init brahman"); + eprintln!(" attract 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>; + +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> { + let dir = args.first().ok_or("falta argumento ")?; + Ok(PathBuf::from(dir)) +} + +fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box> { + 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> { + 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 ")?; + 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`. +/// 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>, + pool: std::sync::Arc, + engine_id: card_core::ulid::Ulid, + engine_label: String, +) -> Result> { + use notify::{Event, EventKind, RecursiveMode, Watcher}; + + let (notify_tx, notify_rx) = std::sync::mpsc::channel::>(); + 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::(); + + // 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 = 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>, + pool: &std::sync::Arc, + 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 = 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 ")?; + let file_path = positional.get(1).ok_or("falta argumento ")?; + 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, String), Box> { + 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, String), Box> { + 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::(&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") + } +} diff --git a/02_ruway/chasqui/chasqui-core/src/cluster.rs b/02_ruway/chasqui/chasqui-core/src/cluster.rs new file mode 100644 index 0000000..85af5b5 --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/cluster.rs @@ -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` 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 { + 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 { + let mut by_parent: BTreeMap> = 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> = 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 { + let mut counts: BTreeMap = 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 { + 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 = 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"]); + } +} diff --git a/02_ruway/chasqui/chasqui-core/src/db.rs b/02_ruway/chasqui/chasqui-core/src/db.rs new file mode 100644 index 0000000..6674015 --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/db.rs @@ -0,0 +1,313 @@ +//! DB de Mónadas y archivos. Backend dual: +//! +//! - **Memoria** (default, cache): `BTreeMap` 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, + monads: BTreeMap, + persistence: Option, +} + +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) -> Result { + 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 { + 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) { + 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 + '_ { + self.files.values() + } + + pub fn file_count(&self) -> usize { + self.files.len() + } + + // ---- Monads ---- + + pub fn insert_monad(&mut self, monad: MonadManifest) -> Option { + 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) { + // 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 + '_ { + 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 { + 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"); + } +} diff --git a/02_ruway/chasqui/chasqui-core/src/embed.rs b/02_ruway/chasqui/chasqui-core/src/embed.rs new file mode 100644 index 0000000..4113d5e --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/embed.rs @@ -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::().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]) -> Vec { + 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 { + 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, +{ + 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::().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::().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()); + } +} diff --git a/02_ruway/chasqui/chasqui-core/src/engine_socket.rs b/02_ruway/chasqui/chasqui-core/src/engine_socket.rs new file mode 100644 index 0000000..5aa38fb --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/engine_socket.rs @@ -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, +} + +/// 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>, +) -> std::io::Result> { + 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>, + 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::(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>, + config: &ListenerConfig, +) -> Result { + let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?; + let monads: Vec = 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>, id: MonadId) -> Result { + let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?; + let members: Vec = 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); + } +} diff --git a/02_ruway/chasqui/chasqui-core/src/lib.rs b/02_ruway/chasqui/chasqui-core/src/lib.rs new file mode 100644 index 0000000..e0ce4f1 --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/lib.rs @@ -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 +//! → cluster::by_directory(min_files=N) +//! → Vec +//! → 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::*; diff --git a/02_ruway/chasqui/chasqui-core/src/scanner.rs b/02_ruway/chasqui/chasqui-core/src/scanner.rs new file mode 100644 index 0000000..520263c --- /dev/null +++ b/02_ruway/chasqui/chasqui-core/src/scanner.rs @@ -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, + /// 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, 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())); + } +} diff --git a/02_ruway/chasqui/chasqui-explorer-llimphi/Cargo.toml b/02_ruway/chasqui/chasqui-explorer-llimphi/Cargo.toml new file mode 100644 index 0000000..2f0ecd1 --- /dev/null +++ b/02_ruway/chasqui/chasqui-explorer-llimphi/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/chasqui-explorer-llimphi/LEEME.md b/02_ruway/chasqui/chasqui-explorer-llimphi/LEEME.md new file mode 100644 index 0000000..85a7249 --- /dev/null +++ b/02_ruway/chasqui/chasqui-explorer-llimphi/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-explorer-llimphi/README.md b/02_ruway/chasqui/chasqui-explorer-llimphi/README.md new file mode 100644 index 0000000..9d2e007 --- /dev/null +++ b/02_ruway/chasqui/chasqui-explorer-llimphi/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs b/02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs new file mode 100644 index 0000000..155a4e8 --- /dev/null +++ b/02_ruway/chasqui/chasqui-explorer-llimphi/src/main.rs @@ -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, + snapshot: Option, + error: Option, + /// Ú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, + /// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna). + menu_active: usize, + /// Animación de aparición del dropdown. + menu_anim: Tween, + /// 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, + /// 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), + /// 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, + }, + 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) -> 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 { + 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) -> 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 { + 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::(header_text, vec![], &header_palette); + + let mut body_children: Vec> = Vec::new(); + + if let Some(ref e) = model.error { + body_children.push(banner_view::(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> { + // 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 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.` 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) -> 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 { + let mut rows: Vec> = 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 { + 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> = 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 { + 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 { + 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 { + text_line(text, theme.fg_muted, theme) +} + +fn text_line(text: &str, color: Color, _theme: &Theme) -> View { + 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) -> 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 { + 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::(); +} + +#[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(); + } +} diff --git a/02_ruway/chasqui/chasqui-nous-mock/Cargo.toml b/02_ruway/chasqui/chasqui-nous-mock/Cargo.toml new file mode 100644 index 0000000..1e9e1f2 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-mock/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/chasqui-nous-mock/LEEME.md b/02_ruway/chasqui/chasqui-nous-mock/LEEME.md new file mode 100644 index 0000000..881ccfc --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-mock/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-nous-mock/README.md b/02_ruway/chasqui/chasqui-nous-mock/README.md new file mode 100644 index 0000000..286d439 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-mock/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-nous-mock/src/main.rs b/02_ruway/chasqui/chasqui-nous-mock/src/main.rs new file mode 100644 index 0000000..6350b45 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-mock/src/main.rs @@ -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 { + 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 { + 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 { + 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) +} diff --git a/02_ruway/chasqui/chasqui-nous-real/Cargo.toml b/02_ruway/chasqui/chasqui-nous-real/Cargo.toml new file mode 100644 index 0000000..d4b80fb --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/Cargo.toml @@ -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" diff --git a/02_ruway/chasqui/chasqui-nous-real/LEEME.md b/02_ruway/chasqui/chasqui-nous-real/LEEME.md new file mode 100644 index 0000000..ed7254a --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-nous-real/README.md b/02_ruway/chasqui/chasqui-nous-real/README.md new file mode 100644 index 0000000..72ef42b --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-nous-real/src/cache.rs b/02_ruway/chasqui/chasqui-nous-real/src/cache.rs new file mode 100644 index 0000000..cd1cde6 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/src/cache.rs @@ -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` 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 { + 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 { + 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> { + 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 { + 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 { + 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> { + 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()); + } +} diff --git a/02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs b/02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs new file mode 100644 index 0000000..28b0078 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/src/embeddings.rs @@ -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 { + 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, 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, + cache: Option, +) -> 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 { + 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 { + 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 { + 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(()) +} diff --git a/02_ruway/chasqui/chasqui-nous-real/src/main.rs b/02_ruway/chasqui/chasqui-nous-real/src/main.rs new file mode 100644 index 0000000..5ba8570 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/src/main.rs @@ -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 +} diff --git a/02_ruway/chasqui/chasqui-nous-real/src/stub.rs b/02_ruway/chasqui/chasqui-nous-real/src/stub.rs new file mode 100644 index 0000000..78004aa --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous-real/src/stub.rs @@ -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 = 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(()) +} diff --git a/02_ruway/chasqui/chasqui-nous/Cargo.toml b/02_ruway/chasqui/chasqui-nous/Cargo.toml new file mode 100644 index 0000000..db8df3f --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous/Cargo.toml @@ -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 } diff --git a/02_ruway/chasqui/chasqui-nous/LEEME.md b/02_ruway/chasqui/chasqui-nous/LEEME.md new file mode 100644 index 0000000..5bbf6a0 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous/LEEME.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-nous/README.md b/02_ruway/chasqui/chasqui-nous/README.md new file mode 100644 index 0000000..fa5a0f5 --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous/README.md @@ -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` diff --git a/02_ruway/chasqui/chasqui-nous/src/lib.rs b/02_ruway/chasqui/chasqui-nous/src/lib.rs new file mode 100644 index 0000000..d85e37d --- /dev/null +++ b/02_ruway/chasqui/chasqui-nous/src/lib.rs @@ -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, + 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, + /// 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); + } +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..36dd606 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8292 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.12.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "app-bus" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "directories", + "serde", + "toml", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arje-cas" +version = "0.0.1" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "anyhow", + "sha2", + "tracing", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.12.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "card-admin" +version = "0.1.0" +dependencies = [ + "anyhow", + "card-core", + "chasqui-broker", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "ulid", +] + +[[package]] +name = "card-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "ulid", +] + +[[package]] +name = "card-handshake" +version = "0.1.0" +dependencies = [ + "anyhow", + "blake3", + "card-core", + "card-net", + "chasqui-broker", + "futures", + "notify", + "postcard", + "serde", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "ulid", +] + +[[package]] +name = "card-net" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "blake3", + "futures", + "libp2p", + "libp2p-allow-block-list", + "libp2p-stream", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "card-sidecar" +version = "0.1.0" +dependencies = [ + "card-core", + "card-handshake", + "card-net", + "card-wit", + "chasqui-broker", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "card-wit" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "card-core", + "thiserror 2.0.18", + "wit-parser 0.230.0", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chasqui-broker" +version = "0.1.0" +dependencies = [ + "card-core", + "serde", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "chasqui-broker-explorer-llimphi" +version = "0.1.0" +dependencies = [ + "app-bus", + "card-core", + "card-handshake", + "card-sidecar", + "chasqui-broker", + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-app-header", + "llimphi-widget-banner", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "llimphi-widget-stat-card", + "ulid", +] + +[[package]] +name = "chasqui-card" +version = "0.1.0" +dependencies = [ + "card-core", + "serde", + "serde_json", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "chasqui-core" +version = "0.1.0" +dependencies = [ + "blake3", + "card-core", + "card-handshake", + "card-sidecar", + "chasqui-card", + "chasqui-nous", + "notify", + "serde", + "serde_json", + "shuma-discern", + "sled", + "tempfile", + "thiserror 2.0.18", + "tokio", + "ulid", + "walkdir", +] + +[[package]] +name = "chasqui-explorer-llimphi" +version = "0.1.0" +dependencies = [ + "app-bus", + "card-core", + "card-sidecar", + "chasqui-card", + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-app-header", + "llimphi-widget-banner", + "llimphi-widget-card", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "rimay-localize", + "wawa-config", +] + +[[package]] +name = "chasqui-nous" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "chasqui-nous-mock" +version = "0.1.0" +dependencies = [ + "card-core", + "card-sidecar", + "chasqui-card", + "chasqui-core", + "chasqui-nous", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[package]] +name = "chasqui-nous-real" +version = "0.1.0" +dependencies = [ + "arje-cas", + "card-core", + "card-sidecar", + "chasqui-nous", + "fastembed", + "serde_json", + "sled", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastembed" +version = "4.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04c269a76bfc6cea69553b7d040acb16c793119cebd97c756d21e08d0f075ff8" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "ort-sys", + "rayon", + "serde_json", + "tokenizers", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + +[[package]] +name = "fontique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.5", + "icu_locid", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "peniko", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-bounded" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +dependencies = [ + "futures-timer", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.12.5", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.12.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.12.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.4", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.60.2", +] + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "socket2 0.5.10", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot 0.12.5", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.4", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap 0.8.2", + "tinystr 0.8.3", + "writeable 0.6.3", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap 0.7.5", + "tinystr 0.7.6", + "writeable 0.5.5", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable 0.6.3", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation 0.9.4", + "fnv", + "futures", + "if-addrs", + "ipnet", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration", + "tokio", + "windows 0.62.2", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.4", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.2", + "web-time", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.4", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.12.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libp2p" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a" +dependencies = [ + "bytes", + "either", + "futures", + "futures-timer", + "getrandom 0.2.17", + "libp2p-allow-block-list", + "libp2p-autonat", + "libp2p-connection-limits", + "libp2p-core", + "libp2p-dcutr", + "libp2p-dns", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-mdns", + "libp2p-metrics", + "libp2p-noise", + "libp2p-quic", + "libp2p-relay", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-upnp", + "libp2p-yamux", + "multiaddr", + "pin-project", + "rw-stream-sink", + "thiserror 2.0.18", +] + +[[package]] +name = "libp2p-allow-block-list" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-autonat" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab5e25c49a7d48dac83d95d8f3bac0a290d8a5df717012f6e34ce9886396c0b" +dependencies = [ + "async-trait", + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-request-response", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "rand_core 0.6.4", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-connection-limits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-identity", + "multiaddr", + "multihash", + "multistream-select", + "parking_lot 0.12.5", + "pin-project", + "quick-protobuf", + "rand 0.8.6", + "rw-stream-sink", + "thiserror 2.0.18", + "tracing", + "unsigned-varint 0.8.0", + "web-time", +] + +[[package]] +name = "libp2p-dcutr" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4107305e12158af3e66960b6181789c547394c9c9a8696f721521602bfc73a" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "hashlink", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-dns" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot 0.12.5", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-identify" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab792a8b68fdef443a62155b01970c81c3aadab5e659621b063ef252a8e65e8" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "libp2p-identity" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" +dependencies = [ + "bs58", + "ed25519-dalek", + "hkdf", + "multihash", + "prost", + "rand 0.8.6", + "sha2", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "libp2p-kad" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "fnv", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tracing", + "uint", + "web-time", +] + +[[package]] +name = "libp2p-mdns" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443" +dependencies = [ + "futures", + "hickory-proto", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-metrics" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-dcutr", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-relay", + "libp2p-swarm", + "pin-project", + "prometheus-client", + "web-time", +] + +[[package]] +name = "libp2p-noise" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc73eacbe6462a0eb92a6527cac6e63f02026e5407f8831bde8293f19217bfbf" +dependencies = [ + "asynchronous-codec", + "bytes", + "futures", + "libp2p-core", + "libp2p-identity", + "multiaddr", + "multihash", + "quick-protobuf", + "rand 0.8.6", + "snow", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "libp2p-quic" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-tls", + "quinn", + "rand 0.8.6", + "ring", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-relay" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9b0392ed623243ad298326b9f806d51191829ac7585cc825c54c6c67b04d9" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-request-response" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f1cca83488b90102abac7b67d5c36fc65bc02ed47620228af7ed002e6a1478" +dependencies = [ + "async-trait", + "futures", + "futures-bounded", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-stream" +version = "0.4.0-alpha" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "tracing", +] + +[[package]] +name = "libp2p-swarm" +version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce88c6c4bf746c8482480345ea3edfd08301f49e026889d1cbccfa1808a9ed9e" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "hashlink", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm-derive", + "multistream-select", + "rand 0.8.6", + "smallvec", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-swarm-derive" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" +dependencies = [ + "heck", + "quote", + "syn", +] + +[[package]] +name = "libp2p-tcp" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6585b9309699f58704ec9ab0bb102eca7a3777170fa91a8678d73ca9cafa93" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libc", + "libp2p-core", + "socket2 0.6.4", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-tls" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +dependencies = [ + "futures", + "futures-rustls", + "libp2p-core", + "libp2p-identity", + "rcgen", + "ring", + "rustls", + "rustls-webpki", + "thiserror 2.0.18", + "x509-parser", + "yasna", +] + +[[package]] +name = "libp2p-upnp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3" +dependencies = [ + "futures", + "futures-timer", + "igd-next", + "libp2p-core", + "libp2p-swarm", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-yamux" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f15df094914eb4af272acf9adaa9e287baa269943f32ea348ba29cfb9bfc60d8" +dependencies = [ + "either", + "futures", + "libp2p-core", + "thiserror 2.0.18", + "tracing", + "yamux 0.12.1", + "yamux 0.13.10", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.12.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "taffy", +] + +[[package]] +name = "llimphi-motion" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "parley", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", +] + +[[package]] +name = "llimphi-widget-app-header" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-banner" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-menubar" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-context-menu", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-stat-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-card", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.12.1", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiaddr" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961" +dependencies = [ + "arrayref", + "byteorder", + "data-encoding", + "libp2p-identity", + "multibase", + "multihash", + "percent-encoding", + "serde", + "static_assertions", + "unsigned-varint 0.8.0", + "url", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint 0.8.0", +] + +[[package]] +name = "multistream-select" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" +dependencies = [ + "bytes", + "futures", + "log", + "pin-project", + "smallvec", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.12.1", + "cfg_aliases", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.12.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.12.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.12.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.12.1", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ort" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52afb44b6b0cffa9bf45e4d37e5a4935b0334a51570658e279e9e3e6cf324aa5" +dependencies = [ + "ndarray", + "ort-sys", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" +dependencies = [ + "flate2", + "pkg-config", + "sha2", + "tar", + "ureq", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parley" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +dependencies = [ + "fontique", + "hashbrown 0.15.5", + "peniko", + "skrifa 0.31.3", + "swash", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.12.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c" +dependencies = [ + "dtoa", + "itoa", + "parking_lot 0.12.5", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + +[[package]] +name = "quick-protobuf-codec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" +dependencies = [ + "asynchronous-codec", + "bytes", + "quick-protobuf", + "thiserror 1.0.69", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rimay-localize" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "fluent-bundle", + "once_cell", + "parking_lot 0.12.5", + "sys-locale", + "thiserror 2.0.18", + "tracing", + "unic-langid", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rw-stream-sink" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +dependencies = [ + "futures", + "pin-project", + "static_assertions", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.12.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "shuma-discern" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "card-core", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +dependencies = [ + "bytemuck", + "read-fonts 0.33.1", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.12.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core 0.6.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.12.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokenizers" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.1", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.12.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "serde", + "web-time", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr 0.8.3", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr 0.8.3", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa 0.35.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.35.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.230.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808198a69b5a0535583370a51d459baa14261dfab04800c4864ee9e1a14346ed" +dependencies = [ + "bitflags 2.12.1", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.12.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wawa-config" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "directories", + "notify", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.12.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.12.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.12.1", + "cfg_aliases", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.12.1", + "block", + "bytemuck", + "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "ordered-float", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.12.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.12.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.12.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.230.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "679fde5556495f98079a8e6b9ef8c887f731addaffa3d48194075c1dd5cd611b" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.230.0", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.12.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yamux" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed0164ae619f2dc144909a9f082187ebb5893693d8c0196e8085283ccd4b776" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "pin-project", + "rand 0.8.6", + "static_assertions", +] + +[[package]] +name = "yamux" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "pin-project", + "rand 0.9.4", + "static_assertions", + "web-time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..260df3d --- /dev/null +++ b/Cargo.toml @@ -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 "] +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" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede9631 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b7c154 --- /dev/null +++ b/README.md @@ -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.