feat(brahman-handshake): ListMatches endpoint + timeline en broker-explorer

Iter 21. Cierra el loop iniciado en iter 20: ahora se ven sesiones
+ matches actuales + cómo cambian a través del tiempo.

brahman-handshake/messages:
- Frame::ListMatches → Frame::MatchList(Vec<brahman_broker::Match>).

brahman-handshake/server:
- run_post_handshake pasa Option<&SharedBroker> a handle_inbound_frame.
- Sin broker configurado → MatchList vacía (no error).

brahman-handshake/client + brahman-sidecar:
- Client::list_matches() análogo a list_sessions, drena MatchEvents.
- list_matches / list_matches_blocking, mismo patrón.

brahman-broker-explorer:
- Poll-tick agrega list_matches_blocking además de list_sessions.
- last_match_keys: HashSet<MatchKey> para diff entre ticks.
- timeline: VecDeque<TimelineEntry> cap 50.
- diff_matches (free fn): Available para keys nuevas, Lost para
  desaparecidas. Primer tick marca todo Available (boot UX).
- Render: stat_card "Timeline" con HH:MM:SS {+/-} formato compacto.

5 tests broker-explorer (3 nuevos del diff). Stack verde.

Decisión: timeline polled cada POLL_INTERVAL=5s, no push. MatchEvents
del broker son consumer-céntricos (cada session ve sólo SUS matches);
"system-wide timeline" requeriría broker subscribe-all (mucho scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-10 15:53:38 +00:00
parent 99cd685dc1
commit a97f6b98f3
9 changed files with 499 additions and 16 deletions
@@ -172,6 +172,16 @@ where
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,
@@ -263,6 +273,31 @@ where
}
}
/// Pide al servidor el listado de matches actuales del broker
/// (consumer↔producer pares con tipo y estrategia). Mismo patrón
/// de drenado de `MatchEvent`s intermedios.
pub async fn list_matches(&mut self) -> Result<crate::messages::MatchList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListMatches(crate::messages::ListMatches {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::MatchList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-match-list",
});
}
}
}
}
/// Cierre cooperativo. Consume el cliente.
pub async fn farewell(mut self) -> Result<(), ClientError> {
write_frame(
+21 -2
View File
@@ -194,13 +194,30 @@ pub struct SessionList {
pub entries: Vec<SessionEntry>,
}
/// Pedido del listado de matches actuales del broker. La `session`
/// se valida igual que `ListSessions`. Si el server no tiene broker
/// configurado, devuelve la lista vacía (no es un error — refleja
/// que no hay matching activo).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMatches {
pub session: SessionId,
}
/// Respuesta a `ListMatches` con el snapshot de matches consumidor↔productor
/// actualmente computados por el broker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchList {
pub matches: Vec<brahman_broker::Match>,
}
/// Frame único de wire — discriminada por variante. Cada conexión es un
/// stream de frames.
///
/// Direcciones:
/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`.
/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`,
/// `ListMatches`.
/// - Server → Cliente: `HelloAck`, `Pong`, `Error`, `MatchEvent`,
/// `SessionList`.
/// `SessionList`, `MatchList`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Frame {
Hello(Hello),
@@ -212,4 +229,6 @@ pub enum Frame {
MatchEvent(MatchEvent),
ListSessions(ListSessions),
SessionList(SessionList),
ListMatches(ListMatches),
MatchList(MatchList),
}
+37 -1
View File
@@ -317,10 +317,19 @@ where
});
// 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).await {
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),
@@ -350,6 +359,7 @@ async fn handle_inbound_frame<S>(
frame: Frame,
writer: &Arc<Mutex<WriteHalf<S>>>,
sessions: &SessionRegistry,
broker_for_match: Option<&SharedBroker>,
) -> std::io::Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
@@ -405,6 +415,32 @@ where
.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(