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
+60 -5
View File
@@ -254,6 +254,7 @@ where
let result = run_post_handshake(
stream,
session_id,
sessions.clone(),
push_table.clone(),
last_matches.clone(),
config.clone(),
@@ -282,6 +283,7 @@ where
async fn run_post_handshake<S>(
stream: S,
session_id: SessionId,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
@@ -317,11 +319,13 @@ where
// Reader loop principal.
let result: std::io::Result<()> = loop {
match read_frame(&mut reader).await {
Ok(frame) => match handle_inbound_frame(session_id, frame, &writer).await {
Ok(true) => continue,
Ok(false) => break Ok(()),
Err(e) => break Err(e),
},
Ok(frame) => {
match handle_inbound_frame(session_id, frame, &writer, &sessions).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(());
@@ -345,6 +349,7 @@ async fn handle_inbound_frame<S>(
session_id: SessionId,
frame: Frame,
writer: &Arc<Mutex<WriteHalf<S>>>,
sessions: &SessionRegistry,
) -> std::io::Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
@@ -381,6 +386,25 @@ where
.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)
}
_ => {
let mut w = writer.lock().await;
write_frame(
@@ -395,6 +419,37 @@ where
}
}
/// 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