- proto: mover hammerd::proto a hammer-core::proto para que hammerd y hammer-agent
compartan los tipos del bus sin duplicar.
- hammer-agent (crate nuevo):
* client: AgentClient síncrono. Handshake hello/welcome; compile/inject/query/init
bloqueantes con timeout; reader thread interno demultiplexa async events (Modified/
Crashed) en una cola que el caller drena vía drain_async/next_async.
* translator: trait IntentTranslator + MockTranslator (HashMap<intent, Swm>) +
IntentCatalog YAML (swm_inline o swm_path). El traductor LLM real se enchufa
detrás del mismo trait sin cambios al orquestador.
* orchestrator: Orchestrator::run(intent) -> Proposal con plan -> schema -> base ->
try (overlay|prefix) -> apply (config_edit/file_drop con hammer-core::apply,
source_patch via bus opcional) -> verify (spot-checks) -> propose. Devuelve
overlay_id (para `hammer commit`) o prefix usado.
- hammer-cli: subcomando `hammer ai <intent> --catalog F [--prefix DIR --base-ref F
--bus SOCK --state-root DIR]`. Imprime el Proposal y el siguiente paso humano.
- Tests:
* 10 unit (translator + catalog + orchestrator).
* 3 e2e del bucle agéntico (intent -> archivos esperados bajo un prefix tmp).
* 1 e2e del cliente contra un stub bus (handshake + Compile -> BuildReady +
Modified asíncrono), sin depender de hammerd ni del lab.
- Docs: SDD 08 actualizado con el API del crate; roadmap marca lo cerrado y lo
pendiente (LLM real, lenguaje de consulta, bucle de auto-reparación con Crashed).
276 lines
8.6 KiB
Rust
276 lines
8.6 KiB
Rust
//! E2E del bus de agente (Fase 5):
|
|
//!
|
|
//! Arrancamos `serve_agent_bus` en un thread con un socket bajo tempdir, conectamos como
|
|
//! cliente, completamos el handshake y ejercitamos los caminos no-pesados (`query`, errores).
|
|
//!
|
|
//! `compile` real no se ejercita aquí — depende del lab + red, ya cubierto por el e2e de
|
|
//! grep gated en `HAMMER_NETWORK_TESTS`. Lo que sí verificamos es que un `compile` SIN la
|
|
//! capacidad devuelve `Error{code:"no_cap"}`, lo que prueba el dispatcher + el gate.
|
|
|
|
// El binario no expone una API pública; importamos sus módulos privados desde
|
|
// `path = ...`. `proto` se movió a hammer-core en Fase 6.
|
|
#[path = "../src/events.rs"]
|
|
mod events;
|
|
#[path = "../src/control.rs"]
|
|
mod control;
|
|
#[path = "../src/bus.rs"]
|
|
mod bus;
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline};
|
|
|
|
/// Helper: lanza un bus en background y devuelve la ruta del socket. Limpia al final del
|
|
/// test gracias al `tempfile::TempDir` que se le pasa al caller.
|
|
fn start_bus(
|
|
sock: &std::path::Path,
|
|
policy: bus::CapsPolicy,
|
|
store_root: PathBuf,
|
|
init_control: PathBuf,
|
|
) -> events::EventBus {
|
|
let events = events::EventBus::new();
|
|
let ctx = bus::BusContext {
|
|
store_root,
|
|
init_control,
|
|
events: events.clone(),
|
|
};
|
|
let sock = sock.to_path_buf();
|
|
thread::Builder::new()
|
|
.name("bus-test".into())
|
|
.spawn(move || {
|
|
let _ = bus::serve_agent_bus(&sock, policy, ctx);
|
|
})
|
|
.unwrap();
|
|
// El caller usa `wait_for_sock` para esperar a que `bind` exista en disco.
|
|
events
|
|
}
|
|
|
|
fn wait_for_sock(sock: &std::path::Path) -> UnixStream {
|
|
for _ in 0..200 {
|
|
if let Ok(s) = UnixStream::connect(sock) {
|
|
return s;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
panic!("no pude conectar al bus en {}", sock.display());
|
|
}
|
|
|
|
fn send(stream: &mut UnixStream, cmd: &Command) {
|
|
let line = serde_json::to_string(cmd).unwrap();
|
|
stream.write_all(line.as_bytes()).unwrap();
|
|
stream.write_all(b"\n").unwrap();
|
|
stream.flush().unwrap();
|
|
}
|
|
|
|
fn recv_event(reader: &mut BufReader<UnixStream>) -> Event {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).expect("read event");
|
|
serde_json::from_str(line.trim()).expect("event JSON válido")
|
|
}
|
|
|
|
fn permissive_policy() -> bus::CapsPolicy {
|
|
Arc::new(|_peer: &Peer| {
|
|
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init]
|
|
})
|
|
}
|
|
|
|
fn read_only_policy() -> bus::CapsPolicy {
|
|
Arc::new(|_peer: &Peer| vec![Cap::Query])
|
|
}
|
|
|
|
#[test]
|
|
fn handshake_returns_welcome_with_peer_creds() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("agent.sock");
|
|
start_bus(
|
|
&sock,
|
|
permissive_policy(),
|
|
d.path().join("store"),
|
|
d.path().join("init.ctl"),
|
|
);
|
|
|
|
let stream = wait_for_sock(&sock);
|
|
let mut writer = stream.try_clone().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
send(
|
|
&mut writer,
|
|
&Command::Hello { ver: 1, client: "test".into() },
|
|
);
|
|
let ev = recv_event(&mut reader);
|
|
match ev {
|
|
Event::Welcome { ver, caps, peer } => {
|
|
assert_eq!(ver, 1);
|
|
assert!(caps.contains(&Cap::Query));
|
|
assert_eq!(peer.uid, unsafe { libc::getuid() });
|
|
assert_eq!(peer.pid, std::process::id() as i32);
|
|
}
|
|
other => panic!("esperaba Welcome, llegó {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_cap_returns_error_no_cap() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("agent.sock");
|
|
start_bus(
|
|
&sock,
|
|
read_only_policy(),
|
|
d.path().join("store"),
|
|
d.path().join("init.ctl"),
|
|
);
|
|
|
|
let stream = wait_for_sock(&sock);
|
|
let mut writer = stream.try_clone().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
send(
|
|
&mut writer,
|
|
&Command::Hello { ver: 1, client: "test".into() },
|
|
);
|
|
let _ = recv_event(&mut reader); // welcome
|
|
|
|
send(
|
|
&mut writer,
|
|
&Command::Compile {
|
|
recipe: RecipeInline {
|
|
name: "x".into(),
|
|
repo: "git://x".into(),
|
|
commit: "abc".into(),
|
|
patch: None,
|
|
compiler: "zig-cc".into(),
|
|
target: "x86_64-linux-musl".into(),
|
|
link: "static".into(),
|
|
flags: vec![],
|
|
},
|
|
},
|
|
);
|
|
match recv_event(&mut reader) {
|
|
Event::Error { code, msg } => {
|
|
assert_eq!(code, "no_cap");
|
|
assert!(msg.contains("Compile"), "{msg}");
|
|
}
|
|
other => panic!("esperaba Error{{no_cap}}, llegó {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn query_file_returns_value_for_existing_path() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("agent.sock");
|
|
start_bus(
|
|
&sock,
|
|
permissive_policy(),
|
|
d.path().join("store"),
|
|
d.path().join("init.ctl"),
|
|
);
|
|
|
|
let target = d.path().join("data.txt");
|
|
std::fs::write(&target, b"contenido").unwrap();
|
|
|
|
let stream = wait_for_sock(&sock);
|
|
let mut writer = stream.try_clone().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
send(&mut writer, &Command::Hello { ver: 1, client: "t".into() });
|
|
let _ = recv_event(&mut reader); // welcome
|
|
|
|
send(
|
|
&mut writer,
|
|
&Command::Query {
|
|
what: "file".into(),
|
|
path: Some(target.display().to_string()),
|
|
name: None,
|
|
},
|
|
);
|
|
match recv_event(&mut reader) {
|
|
Event::QueryResult { what, value } => {
|
|
assert_eq!(what, "file");
|
|
assert_eq!(value["exists"], serde_json::json!(true));
|
|
assert_eq!(value["size"], serde_json::json!(9));
|
|
}
|
|
other => panic!("esperaba QueryResult, llegó {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn modified_events_fan_out_to_subscribers() {
|
|
// Validamos sólo el camino del fan-out a través del bus, sin watcher real:
|
|
// disparamos `publish(Modified)` directamente y vemos que llega por el socket.
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("agent.sock");
|
|
let bus_handle = start_bus(
|
|
&sock,
|
|
permissive_policy(),
|
|
d.path().join("store"),
|
|
d.path().join("init.ctl"),
|
|
);
|
|
|
|
let stream = wait_for_sock(&sock);
|
|
let mut writer = stream.try_clone().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
send(&mut writer, &Command::Hello { ver: 1, client: "t".into() });
|
|
let _ = recv_event(&mut reader); // welcome
|
|
|
|
// El forwarder de eventos arranca dentro de handle_connection; damos un margen para
|
|
// que la suscripción esté registrada antes de publicar.
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
bus_handle.publish(&Event::Modified {
|
|
path: "/bin/grep".into(),
|
|
op: "replace".into(),
|
|
ts: "2026-06-09T00:00:00Z".into(),
|
|
});
|
|
match recv_event(&mut reader) {
|
|
Event::Modified { path, op, ts } => {
|
|
assert_eq!(path, "/bin/grep");
|
|
assert_eq!(op, "replace");
|
|
assert_eq!(ts, "2026-06-09T00:00:00Z");
|
|
}
|
|
other => panic!("esperaba Modified, llegó {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn init_command_writes_to_fifo() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("agent.sock");
|
|
let fifo = d.path().join("init.ctl");
|
|
control::ensure_fifo(&fifo).unwrap();
|
|
|
|
// Reader del FIFO en background: lee una línea y termina.
|
|
let fifo_for_reader = fifo.clone();
|
|
let reader_thread = std::thread::spawn(move || {
|
|
let f = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.open(&fifo_for_reader)
|
|
.unwrap();
|
|
let mut r = std::io::BufReader::new(f);
|
|
let mut s = String::new();
|
|
std::io::BufRead::read_line(&mut r, &mut s).unwrap();
|
|
s
|
|
});
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
|
|
start_bus(
|
|
&sock,
|
|
permissive_policy(),
|
|
d.path().join("store"),
|
|
fifo.clone(),
|
|
);
|
|
let stream = wait_for_sock(&sock);
|
|
let mut writer = stream.try_clone().unwrap();
|
|
let mut reader = BufReader::new(stream);
|
|
send(&mut writer, &Command::Hello { ver: 1, client: "t".into() });
|
|
let _ = recv_event(&mut reader);
|
|
|
|
send(&mut writer, &Command::Init { cmd: "start web".into() });
|
|
match recv_event(&mut reader) {
|
|
Event::InitAck { cmd } => assert_eq!(cmd, "start web"),
|
|
other => panic!("esperaba InitAck, llegó {other:?}"),
|
|
}
|
|
let from_fifo = reader_thread.join().unwrap();
|
|
assert_eq!(from_fifo, "start web\n");
|
|
}
|