La evidencia corre del lado del BUILD (hammer-agent no depende de hammer-build:
separación PROPONE/CONSTRUYE). Piezas:
- proto: RecipeInline lleva `evidence`; Event::BuildReady lleva
`verdict: Option<EvidenceVerdict>` (+ EvidenceCheckVerdict). Ambos con
skip_serializing_if ⇒ wire compatible con clientes pre-H1c.
- hammerd/bus: run_compile ejecuta la evidencia declarada tras sellar el
artefacto (run_evidence vía swm_bridge::recipe_from_source_patch) y adjunta
el veredicto en BuildReady. Si ni se pudo ejecutar ⇒ veredicto fallido
sintético (no pasa en silencio). El lab reporta; el gate vive en el agente.
- client: compile() devuelve CompileOutcome { artifact, verdict }.
- orchestrator: VERIFY lee el veredicto; si all_passed=false empuja
VerifyCheck::fail y ABORTA antes de hidratar (artefacto sellado, no toca el
sistema). Nuevo constructor VerifyCheck::fail.
Tests: roundtrip del veredicto en proto; stub-bus refleja evidencia→verdict;
nueva prueba de integración orchestrator_evidence_gate (veredicto fallido ⇒
run() aborta con "no se propone" y no hidrata). Suite completa verde (33 suites).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
7.5 KiB
Rust
204 lines
7.5 KiB
Rust
//! E2E del cliente del bus (Fase 6) contra un servidor stub.
|
|
//!
|
|
//! No depende de `hammerd` ni del lab: un mini-servidor JSON-líneas que conoce el
|
|
//! handshake y responde a `Compile` con `BuildReady` sintético. Demuestra que el cliente
|
|
//! síncrono multiplexa correctamente respuestas (BuildReady) y eventos asíncronos (Modified).
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::os::unix::net::{UnixListener, UnixStream};
|
|
use std::path::PathBuf;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
|
|
|
|
fn start_stub_bus(sock: PathBuf) -> std::sync::mpsc::Sender<Event> {
|
|
let (tx_async, rx_async) = std::sync::mpsc::channel::<Event>();
|
|
let _ = std::fs::remove_file(&sock);
|
|
let listener = UnixListener::bind(&sock).expect("bind stub");
|
|
thread::Builder::new()
|
|
.name("stub-bus".into())
|
|
.spawn(move || {
|
|
// Sólo aceptamos una conexión: el Receiver no es Clone, se mueve al handler.
|
|
if let Some(Ok(stream)) = listener.incoming().next() {
|
|
thread::spawn(move || stub_serve(stream, rx_async));
|
|
}
|
|
})
|
|
.unwrap();
|
|
tx_async
|
|
}
|
|
|
|
fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver<Event>) {
|
|
let reader_stream = stream.try_clone().unwrap();
|
|
let mut writer = stream;
|
|
let writer_clone = writer.try_clone().unwrap();
|
|
|
|
// Hilo que reenvía los eventos asíncronos al cliente. Mover el writer impide que
|
|
// se mezclen los bytes con las respuestas (cada Sender los serializa atómicamente).
|
|
let async_writer = std::sync::Arc::new(std::sync::Mutex::new(writer_clone));
|
|
let aw_for_async = async_writer.clone();
|
|
thread::spawn(move || {
|
|
for ev in rx_async {
|
|
let line = serde_json::to_string(&ev).unwrap();
|
|
let mut g = aw_for_async.lock().unwrap();
|
|
let _ = g.write_all(line.as_bytes());
|
|
let _ = g.write_all(b"\n");
|
|
let _ = g.flush();
|
|
}
|
|
});
|
|
|
|
let mut reader = BufReader::new(reader_stream);
|
|
let mut first = String::new();
|
|
reader.read_line(&mut first).unwrap();
|
|
let cmd: Command = serde_json::from_str(first.trim()).unwrap();
|
|
let Command::Hello { ver: _, client: _ } = cmd else {
|
|
return;
|
|
};
|
|
// welcome
|
|
let w = Event::Welcome {
|
|
ver: PROTOCOL_VERSION,
|
|
caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init],
|
|
peer: Peer { uid: 1000, gid: 1000, pid: 1 },
|
|
};
|
|
let mut g = async_writer.lock().unwrap();
|
|
g.write_all(serde_json::to_string(&w).unwrap().as_bytes()).unwrap();
|
|
g.write_all(b"\n").unwrap();
|
|
g.flush().unwrap();
|
|
drop(g);
|
|
|
|
// Loop de comandos.
|
|
loop {
|
|
let mut line = String::new();
|
|
match reader.read_line(&mut line) {
|
|
Ok(0) => break,
|
|
Ok(_) => {}
|
|
Err(_) => break,
|
|
}
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let cmd: Command = match serde_json::from_str(line.trim()) {
|
|
Ok(c) => c,
|
|
Err(_) => continue,
|
|
};
|
|
let response = match cmd {
|
|
Command::Compile { recipe } => {
|
|
// El stub refleja la evidencia declarada como un veredicto "todo pasa" para
|
|
// ejercitar el camino H1c del cliente (sin lab real que la ejecute).
|
|
let verdict = (!recipe.evidence.is_empty()).then(|| {
|
|
hammer_core::proto::EvidenceVerdict {
|
|
all_passed: true,
|
|
max_level: recipe.evidence.checks.iter().map(|c| c.kind).max(),
|
|
checks: recipe
|
|
.evidence
|
|
.checks
|
|
.iter()
|
|
.map(|c| hammer_core::proto::EvidenceCheckVerdict {
|
|
kind: c.kind,
|
|
cmd: c.cmd.clone(),
|
|
passed: true,
|
|
detail: "stub ok".into(),
|
|
})
|
|
.collect(),
|
|
}
|
|
});
|
|
Event::BuildReady {
|
|
recipe: recipe.name.clone(),
|
|
artifact: format!(
|
|
"b3:stub-{}",
|
|
recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc")
|
|
),
|
|
verdict,
|
|
}
|
|
}
|
|
Command::Query { what, .. } => Event::QueryResult {
|
|
what,
|
|
value: serde_json::json!({"stub": true}),
|
|
},
|
|
Command::Init { cmd } => Event::InitAck { cmd },
|
|
Command::Inject { artifact, target, .. } => Event::Injected {
|
|
artifact,
|
|
target,
|
|
files: 1,
|
|
},
|
|
Command::Hello { .. } => Event::Error {
|
|
code: "duplicate_hello".into(),
|
|
msg: "ya hicimos hello".into(),
|
|
},
|
|
};
|
|
let mut g = async_writer.lock().unwrap();
|
|
g.write_all(serde_json::to_string(&response).unwrap().as_bytes())
|
|
.unwrap();
|
|
g.write_all(b"\n").unwrap();
|
|
g.flush().unwrap();
|
|
drop(g);
|
|
|
|
// Cierra solo si el cliente cierra; aquí seguimos.
|
|
if !writer.flush().is_ok() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn client_handshake_compile_and_async_modified() {
|
|
let d = tempfile::tempdir().unwrap();
|
|
let sock = d.path().join("stub.sock");
|
|
let async_tx = start_stub_bus(sock.clone());
|
|
|
|
// Pequeño wait para que el listener bindee.
|
|
for _ in 0..50 {
|
|
if sock.exists() { break }
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
|
|
let mut client = hammer_agent::AgentClient::connect(&sock).expect("connect");
|
|
assert_eq!(client.welcome.ver, PROTOCOL_VERSION);
|
|
assert!(client.welcome.caps.contains(&Cap::Compile));
|
|
|
|
let recipe = RecipeInline {
|
|
name: "grep".into(),
|
|
repo: Some("git://example/grep.git".into()),
|
|
commit: Some("abc123".into()),
|
|
tarball: None,
|
|
sha256: None,
|
|
patch: None,
|
|
compiler: "zig-cc".into(),
|
|
target: "x86_64-linux-musl".into(),
|
|
link: "static".into(),
|
|
flags: vec![],
|
|
evidence: hammer_core::Evidence {
|
|
checks: vec![hammer_core::EvidenceCheck {
|
|
kind: hammer_core::EvidenceKind::CmdExit,
|
|
cmd: "grep --version".into(),
|
|
expected_exit: 0,
|
|
expected_output: None,
|
|
}],
|
|
},
|
|
};
|
|
let outcome = client
|
|
.compile(recipe, Duration::from_secs(2))
|
|
.expect("BuildReady");
|
|
assert_eq!(outcome.artifact, "b3:stub-abc123");
|
|
// [H1c] el veredicto de la evidencia llega por BuildReady y el cliente lo expone.
|
|
let verdict = outcome.verdict.expect("verdict presente (declaramos evidencia)");
|
|
assert!(verdict.all_passed);
|
|
assert_eq!(verdict.checks.len(), 1);
|
|
assert_eq!(verdict.max_level, Some(hammer_core::EvidenceKind::CmdExit));
|
|
|
|
// El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos
|
|
// que el cliente lo entrega por drain_async()/next_async().
|
|
async_tx
|
|
.send(Event::Modified {
|
|
path: "/bin/grep".into(),
|
|
op: "replace".into(),
|
|
ts: "2026-06-09T00:00:00Z".into(),
|
|
})
|
|
.unwrap();
|
|
let ev = client.next_async(Duration::from_secs(2)).expect("async");
|
|
match ev {
|
|
Event::Modified { path, .. } => assert_eq!(path, "/bin/grep"),
|
|
other => panic!("esperaba Modified, llegó {other:?}"),
|
|
}
|
|
}
|