//! `AgentClient`: cliente síncrono del bus de agente. Ver `docs/07-agent-bus.md` §3. //! //! Modelo: una conexión = un thread del que llama. El reader thread interno demultiplexa //! eventos por tipo: respuestas correlativas a un comando se devuelven al caller via //! `recv_*_event_blocking`; eventos asíncronos (`Modified`, `Crashed`) se acumulan en una //! cola que el caller drena con `drain_async()`. //! //! No hay correlation-id en el protocolo todavía (el SDD 07 no lo exige), así que la //! correlación se hace por **tipo**: tras un `Compile` esperamos `BuildReady|BuildFailed`, //! cualquier otra cosa que no sea `Modified`/`Crashed` se reporta como protocol error. use std::collections::VecDeque; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::Path; use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline}; #[derive(Debug, thiserror::Error)] pub enum ClientError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("json: {0}")] Json(#[from] serde_json::Error), #[error("protocolo: {0}")] Protocol(String), #[error("timeout esperando {0}")] Timeout(&'static str), #[error("el bus devolvió error code={code} msg={msg}")] Bus { code: String, msg: String }, #[error("la conexión se cerró")] Closed, } pub type Result = std::result::Result; #[derive(Debug, Clone)] pub struct Welcome { pub ver: u32, pub caps: Vec, pub peer: Peer, } /// Estado compartido entre el reader thread y el caller. struct Inbox { /// Eventos asíncronos (Modified/Crashed) en orden de llegada. async_events: VecDeque, /// Próximo evento "respuesta" al último comando. Sólo cabe uno a la vez en este /// cliente síncrono (el caller envía + espera antes del siguiente). pending: Option, closed: bool, } #[derive(Clone)] struct Shared { inbox: Arc>, cv: Arc, } pub struct AgentClient { writer: UnixStream, shared: Shared, pub welcome: Welcome, } impl AgentClient { /// Conecta al socket, hace handshake `Hello` y arranca el reader thread. Devuelve un /// cliente listo para enviar comandos. pub fn connect(sock_path: &Path) -> Result { Self::connect_with_client_name(sock_path, "hammer-agent") } pub fn connect_with_client_name(sock_path: &Path, client: &str) -> Result { let stream = UnixStream::connect(sock_path)?; let reader_stream = stream.try_clone()?; let writer = stream; let shared = Shared { inbox: Arc::new(Mutex::new(Inbox { async_events: VecDeque::new(), pending: None, closed: false, })), cv: Arc::new(Condvar::new()), }; // Reader thread: drena el socket línea a línea hasta EOF. let s_for_reader = shared.clone(); thread::Builder::new() .name("agent-client-reader".into()) .spawn(move || reader_loop(reader_stream, s_for_reader)) .ok(); let mut me = Self { writer, shared, welcome: Welcome { ver: 0, caps: vec![], peer: Peer { uid: 0, gid: 0, pid: 0 }, }, }; me.send(&Command::Hello { ver: hammer_core::proto::PROTOCOL_VERSION, client: client.to_string(), })?; let ev = me.recv_pending(Duration::from_secs(5))?; match ev { Event::Welcome { ver, caps, peer } => { me.welcome = Welcome { ver, caps, peer }; } Event::Error { code, msg } => return Err(ClientError::Bus { code, msg }), other => { return Err(ClientError::Protocol(format!( "esperaba Welcome, llegó {other:?}" ))); } } Ok(me) } /// Bloquea hasta `BuildReady` o `BuildFailed`. `timeout` es el wall-clock máximo — /// el lab puede tardar varios minutos, así que el default debería ser generoso. pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result { self.send(&Command::Compile { recipe })?; match self.recv_pending(timeout)? { Event::BuildReady { artifact, .. } => Ok(artifact), Event::BuildFailed { reason, .. } => Err(ClientError::Bus { code: "build_failed".into(), msg: reason, }), Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), other => Err(ClientError::Protocol(format!( "esperaba BuildReady|BuildFailed, llegó {other:?}" ))), } } pub fn inject( &mut self, artifact: &str, target: &str, overlay: Option<&str>, timeout: Duration, ) -> Result { self.send(&Command::Inject { artifact: artifact.to_string(), target: target.to_string(), overlay: overlay.map(String::from), })?; match self.recv_pending(timeout)? { Event::Injected { files, .. } => Ok(files), Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), other => Err(ClientError::Protocol(format!("esperaba Injected, llegó {other:?}"))), } } pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result { self.send(&Command::Query { what: "file".into(), path: Some(path.to_string()), name: None, expr: None, })?; match self.recv_pending(timeout)? { Event::QueryResult { value, .. } => Ok(value), Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), other => Err(ClientError::Protocol(format!( "esperaba QueryResult, llegó {other:?}" ))), } } /// Evalúa una expresión del mini-lenguaje (`bin:grep`, `pin:musl`, …) en el daemon. /// El JSON devuelto sigue el schema de `hammer_core::query::eval`. pub fn query_expr(&mut self, expr: &str, timeout: Duration) -> Result { self.send(&Command::Query { what: "expr".into(), path: None, name: None, expr: Some(expr.to_string()), })?; match self.recv_pending(timeout)? { Event::QueryResult { value, .. } => Ok(value), Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), other => Err(ClientError::Protocol(format!( "esperaba QueryResult, llegó {other:?}" ))), } } pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()> { self.send(&Command::Init { cmd: cmd.to_string() })?; match self.recv_pending(timeout)? { Event::InitAck { .. } => Ok(()), Event::Error { code, msg } => Err(ClientError::Bus { code, msg }), other => Err(ClientError::Protocol(format!("esperaba InitAck, llegó {other:?}"))), } } /// Vacía la cola de eventos asíncronos. No bloquea. pub fn drain_async(&self) -> Vec { let Ok(mut g) = self.shared.inbox.lock() else { return vec![] }; std::mem::take(&mut g.async_events).into_iter().collect() } /// Espera el próximo evento asíncrono (típicamente para verificar que un `Modified` /// específico haya llegado tras una operación). pub fn next_async(&self, timeout: Duration) -> Result { let g = self.shared.inbox.lock().unwrap(); let (mut g, _) = self .shared .cv .wait_timeout_while(g, timeout, |i| { !i.closed && i.async_events.is_empty() }) .unwrap(); if let Some(ev) = g.async_events.pop_front() { Ok(ev) } else if g.closed { Err(ClientError::Closed) } else { Err(ClientError::Timeout("async event")) } } fn send(&mut self, cmd: &Command) -> Result<()> { let line = serde_json::to_string(cmd)?; self.writer.write_all(line.as_bytes())?; self.writer.write_all(b"\n")?; self.writer.flush()?; Ok(()) } fn recv_pending(&self, timeout: Duration) -> Result { let deadline = Instant::now() + timeout; let g = self.shared.inbox.lock().unwrap(); let (mut g, _) = self .shared .cv .wait_timeout_while(g, timeout, |i| !i.closed && i.pending.is_none()) .unwrap(); if let Some(ev) = g.pending.take() { return Ok(ev); } if g.closed { return Err(ClientError::Closed); } // Si el wait expiró exactamente en el momento de un evento, vuelvo a comprobar. if Instant::now() >= deadline { return Err(ClientError::Timeout("response")); } Err(ClientError::Timeout("response")) } } fn reader_loop(stream: UnixStream, shared: Shared) { let reader = BufReader::new(stream); for line in reader.lines() { let line = match line { Ok(l) => l, Err(e) => { tracing::warn!(error = %e, "agent-client: read error, cerrando"); break; } }; if line.trim().is_empty() { continue; } let ev: Event = match serde_json::from_str(&line) { Ok(e) => e, Err(e) => { tracing::warn!(error = %e, line = %line, "agent-client: línea no parseable"); continue; } }; let is_async = matches!( &ev, Event::Modified { .. } | Event::Crashed { .. } ); let mut g = match shared.inbox.lock() { Ok(g) => g, Err(_) => break, }; if is_async { g.async_events.push_back(ev); } else { // Si ya hay un pending sin recoger, eso es un bug del caller (envió dos // comandos antes de leer la primera respuesta). Lo logueamos y reemplazamos. if g.pending.is_some() { tracing::warn!( "agent-client: pending ya tenía respuesta; el caller no la recogió" ); } g.pending = Some(ev); } shared.cv.notify_all(); } if let Ok(mut g) = shared.inbox.lock() { g.closed = true; shared.cv.notify_all(); } }