feat(brahman-handshake): ListSessions endpoint + cliente + UI broker-explorer

Iter 20. Nuevo flujo end-to-end para observabilidad: cualquier módulo
conectado puede pedir al broker la lista de sesiones activas y mostrar
labels + flows in/out por cada una.

brahman-handshake/messages:
- Frame::ListSessions(ListSessions{session}) → Frame::SessionList(SessionList{entries}).
- SessionEntry: session, label, schema_version, outputs, inputs, conscious.

brahman-handshake/server:
- run_post_handshake pasa SessionRegistry a handle_inbound_frame.
- build_session_list helper proyecta el snapshot bajo lock.
- Validación session_id mismatched → Unauthorized.

brahman-handshake/client:
- Client::list_sessions() async, drena MatchEvents intermedios al
  pending_events buffer, mismo patrón que ping().

brahman-sidecar/discovery:
- list_sessions / list_sessions_blocking arman Card observer mínima,
  piden, Farewell.

brahman-broker-explorer:
- Poll-tick agrega list_sessions_blocking cuando broker está UP*.
- stat_card "Sesiones activas" con count + items ordenados por Ulid:
  label · in:[flows] out:[flows]  (wit)?.

Test list_sessions_returns_currently_registered: 3 clientes
conectados, observer pide list, verifica labels + schema_version
+ conscious=false. 24 handshake tests + sidecar + broker-explorer
verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-10 15:40:33 +00:00
parent 37e40073ef
commit 99cd685dc1
8 changed files with 378 additions and 10 deletions
@@ -89,6 +89,71 @@ async fn full_handshake_roundtrip() {
.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, brahman_card::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");