Fase 6 — bucle agéntico: hammer-agent (cliente + translator + orchestrator) y hammer ai
- 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).
This commit is contained in:
Generated
+18
@@ -289,6 +289,23 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hammer-agent"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"hammer-core",
|
||||
"hammer-journal",
|
||||
"hammer-overlay",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hammer-build"
|
||||
version = "0.0.1"
|
||||
@@ -310,6 +327,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"clap",
|
||||
"hammer-agent",
|
||||
"hammer-build",
|
||||
"hammer-core",
|
||||
"hammer-journal",
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/hammer-build",
|
||||
"crates/hammer-overlay",
|
||||
"crates/hammer-journal",
|
||||
"crates/hammer-agent",
|
||||
"crates/hammer-cli",
|
||||
"crates/hammerd",
|
||||
]
|
||||
@@ -21,6 +22,7 @@ hammer-core = { path = "crates/hammer-core" }
|
||||
hammer-build = { path = "crates/hammer-build" }
|
||||
hammer-overlay = { path = "crates/hammer-overlay" }
|
||||
hammer-journal = { path = "crates/hammer-journal" }
|
||||
hammer-agent = { path = "crates/hammer-agent" }
|
||||
|
||||
# shared third-party deps, pinned at the workspace level
|
||||
anyhow = "1"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "hammer-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Cliente del bus + bucle agéntico (plan/build/try/verify/propose) para la IA."
|
||||
|
||||
[dependencies]
|
||||
hammer-core.workspace = true
|
||||
hammer-overlay.workspace = true
|
||||
anyhow.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
hammer-journal.workspace = true
|
||||
base64.workspace = true
|
||||
@@ -0,0 +1,286 @@
|
||||
//! `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<T> = std::result::Result<T, ClientError>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Welcome {
|
||||
pub ver: u32,
|
||||
pub caps: Vec<Cap>,
|
||||
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<Event>,
|
||||
/// 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<Event>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Shared {
|
||||
inbox: Arc<Mutex<Inbox>>,
|
||||
cv: Arc<Condvar>,
|
||||
}
|
||||
|
||||
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> {
|
||||
Self::connect_with_client_name(sock_path, "hammer-agent")
|
||||
}
|
||||
|
||||
pub fn connect_with_client_name(sock_path: &Path, client: &str) -> Result<Self> {
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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<serde_json::Value> {
|
||||
self.send(&Command::Query {
|
||||
what: "file".into(),
|
||||
path: Some(path.to_string()),
|
||||
name: 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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
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<Event> {
|
||||
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<Event> {
|
||||
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<Event> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Cliente del bus de agente + bucle agéntico (Fase 6). Ver `docs/08-ai-integration.md`.
|
||||
//!
|
||||
//! Tres piezas:
|
||||
//!
|
||||
//! 1. [`client`] — `AgentClient`: envoltorio síncrono de `/run/agent.sock`. Hace el
|
||||
//! handshake, envía comandos y devuelve eventos. Diseñado para que el orquestador
|
||||
//! pueda esperar a un `BuildReady` específico tras un `Compile`.
|
||||
//! 2. [`translator`] — Trait `IntentTranslator` + `MockTranslator`: convierte una intención
|
||||
//! en lenguaje natural a un `.swm`. El traductor real (LLM) se conecta detrás del trait;
|
||||
//! el mock cubre los tests y los smoke en el host de desarrollo.
|
||||
//! 3. [`orchestrator`] — El bucle plan→build→try→apply→verify→propose. Aplica las
|
||||
//! mutaciones a un overlay (o a un prefix para tests), valida postcondiciones y
|
||||
//! devuelve un `Proposal` que el humano usa para decidir `commit`.
|
||||
//!
|
||||
//! Decisión de diseño: el cliente es **bloqueante** (std::sync), no async. La cadencia
|
||||
//! agéntica es de comandos/seg, no de RPS; y un cliente sync es 5× menos código y debuggable
|
||||
//! con un breakpoint normal. Cuando aparezca un caso real de N agentes concurrentes,
|
||||
//! evaluamos tokio.
|
||||
|
||||
pub mod client;
|
||||
pub mod orchestrator;
|
||||
pub mod translator;
|
||||
|
||||
pub use client::{AgentClient, ClientError, Welcome};
|
||||
pub use orchestrator::{Orchestrator, Proposal, VerifyCheck};
|
||||
pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("client: {0}")]
|
||||
Client(#[from] ClientError),
|
||||
#[error("translator: {0}")]
|
||||
Translate(#[from] TranslateError),
|
||||
#[error("orchestrator: {0}")]
|
||||
Orchestrate(String),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("overlay: {0}")]
|
||||
Overlay(#[from] hammer_overlay::Error),
|
||||
#[error("apply: {0}")]
|
||||
Apply(#[from] hammer_core::apply::ApplyError),
|
||||
#[error("core: {0}")]
|
||||
Core(#[from] hammer_core::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,469 @@
|
||||
//! El bucle agéntico. Ver `docs/08-ai-integration.md` §2.
|
||||
//!
|
||||
//! `Orchestrator::run(intent)` ejecuta:
|
||||
//!
|
||||
//! ```text
|
||||
//! plan → translator.translate(intent, ctx) → Swm
|
||||
//! schema → swm.verify_schema()
|
||||
//! base → swm.verify_base(local) → debe ser Ok
|
||||
//! build → para cada source_patch: AgentClient.compile() vía el bus (o saltar si --no-bus)
|
||||
//! try → hammer_overlay::try_overlay() (o usar --prefix)
|
||||
//! apply → hammer-core::apply para config_edit/file_drop + hydrate para source_patch
|
||||
//! verify → spot-checks post-condición sobre disco
|
||||
//! propose → devuelve Proposal con overlay_id + log de checks
|
||||
//! ```
|
||||
//!
|
||||
//! La IA NUNCA ejecuta `hammer commit`. El humano lo hace tras revisar el `Proposal`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use hammer_core::apply::{apply_config_edit, apply_file_drop, rebase_path};
|
||||
use hammer_core::proto::RecipeInline;
|
||||
use hammer_core::swm::{Mutation, Swm};
|
||||
use hammer_core::BaseRef;
|
||||
use hammer_overlay::OverlayId;
|
||||
|
||||
use crate::client::AgentClient;
|
||||
use crate::translator::{IntentTranslator, SystemContext};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Cómo aplica el orquestador las mutaciones al sistema.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ApplyTarget {
|
||||
/// Abre un overlay sobre los targets por defecto del FHS (requiere root). Las
|
||||
/// mutaciones caen sobre las rutas absolutas del `.swm`.
|
||||
Overlay { state_root: PathBuf },
|
||||
/// Re-rootea cada path bajo `prefix`. No abre overlay — útil para tests offline y
|
||||
/// para staging en otro filesystem.
|
||||
Prefix { prefix: PathBuf },
|
||||
}
|
||||
|
||||
/// Cómo se construyen las `source_patch`. `ViaBus` requiere un `hammerd` corriendo;
|
||||
/// `Skip` ignora las source_patch (apropiado para escenarios sin lab).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CompileMode {
|
||||
/// Llama a `Compile` por el bus y espera `BuildReady`. Hidrata localmente desde el
|
||||
/// store si está configurado.
|
||||
ViaBus {
|
||||
sock: PathBuf,
|
||||
store: PathBuf,
|
||||
timeout: Duration,
|
||||
},
|
||||
/// Salta cualquier `source_patch`. Se anota en `Proposal.skipped_source_patches`.
|
||||
Skip,
|
||||
}
|
||||
|
||||
pub struct Orchestrator<T: IntentTranslator> {
|
||||
translator: T,
|
||||
base: BaseRef,
|
||||
apply: ApplyTarget,
|
||||
compile: CompileMode,
|
||||
}
|
||||
|
||||
impl<T: IntentTranslator> Orchestrator<T> {
|
||||
pub fn new(translator: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self {
|
||||
Self { translator, base, apply, compile }
|
||||
}
|
||||
|
||||
pub fn run(&self, intent: &str) -> Result<Proposal> {
|
||||
// 1. PLAN
|
||||
let ctx = SystemContext {
|
||||
base: self.base.clone(),
|
||||
extras: serde_json::Value::Null,
|
||||
};
|
||||
let swm = self.translator.translate(intent, &ctx)?;
|
||||
|
||||
// 2. SCHEMA
|
||||
swm.verify_schema()?;
|
||||
|
||||
// 3. BASE
|
||||
match swm.verify_base(&self.base) {
|
||||
hammer_core::BaseCompat::Ok => {}
|
||||
other => {
|
||||
return Err(Error::Orchestrate(format!(
|
||||
"base incompatible: {other:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. TRY (overlay) — recolectamos el id si lo abrimos; en modo Prefix no hay id.
|
||||
let (overlay_id, prefix): (Option<OverlayId>, Option<PathBuf>) = match &self.apply {
|
||||
ApplyTarget::Overlay { state_root } => {
|
||||
std::fs::create_dir_all(state_root)?;
|
||||
let id = hammer_overlay::try_overlay(&[], state_root)?;
|
||||
(Some(id), None)
|
||||
}
|
||||
ApplyTarget::Prefix { prefix } => {
|
||||
std::fs::create_dir_all(prefix)?;
|
||||
(None, Some(prefix.clone()))
|
||||
}
|
||||
};
|
||||
|
||||
// 5. APPLY — recorremos las mutaciones en orden. En source_patch, dispatch al bus
|
||||
// o skip; en config_edit/file_drop, primitivas de hammer-core::apply.
|
||||
let mut applied = AppliedCounts::default();
|
||||
let mut checks: Vec<VerifyCheck> = Vec::new();
|
||||
let mut skipped_source_patches = 0usize;
|
||||
|
||||
let mut maybe_client: Option<AgentClient> = None;
|
||||
if matches!(self.compile, CompileMode::ViaBus { .. })
|
||||
&& swm.mutations.iter().any(|m| matches!(m, Mutation::SourcePatch { .. }))
|
||||
{
|
||||
if let CompileMode::ViaBus { sock, .. } = &self.compile {
|
||||
maybe_client = Some(AgentClient::connect(sock).map_err(Error::Client)?);
|
||||
}
|
||||
}
|
||||
|
||||
for m in &swm.mutations {
|
||||
match m {
|
||||
Mutation::ConfigEdit { file, inline_diff } => {
|
||||
let target = rebase_path(file, prefix.as_deref());
|
||||
apply_config_edit(&target, inline_diff)?;
|
||||
applied.config_edit += 1;
|
||||
checks.push(VerifyCheck::pass(format!(
|
||||
"config_edit aplicado a {}",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
Mutation::FileDrop { path, content_hash, content_b64, content_url } => {
|
||||
if let Some(_url) = content_url {
|
||||
return Err(Error::Orchestrate(format!(
|
||||
"file_drop con content_url no soportado en Fase 6: {path}"
|
||||
)));
|
||||
}
|
||||
let b64 = content_b64.as_deref().ok_or_else(|| {
|
||||
Error::Orchestrate(format!(
|
||||
"file_drop {path} no trae content_b64 (verify_schema falló)"
|
||||
))
|
||||
})?;
|
||||
let target = rebase_path(path, prefix.as_deref());
|
||||
apply_file_drop(&target, b64, content_hash)?;
|
||||
applied.file_drop += 1;
|
||||
checks.push(VerifyCheck::pass(format!(
|
||||
"file_drop escrito en {} (hash verificado)",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
Mutation::InitRule { action, service, .. } => {
|
||||
applied.init_rule += 1;
|
||||
checks.push(VerifyCheck::warn(format!(
|
||||
"init_rule '{action} {service}' anotada — pendiente Fase 5+ (supervisor)"
|
||||
)));
|
||||
}
|
||||
Mutation::SourcePatch {
|
||||
repo,
|
||||
commit,
|
||||
patch,
|
||||
patch_url: _,
|
||||
build,
|
||||
target_bin,
|
||||
expected_hash,
|
||||
} => {
|
||||
let CompileMode::ViaBus { store, timeout, .. } = &self.compile else {
|
||||
skipped_source_patches += 1;
|
||||
checks.push(VerifyCheck::warn(format!(
|
||||
"source_patch {target_bin} omitido (CompileMode::Skip)"
|
||||
)));
|
||||
continue;
|
||||
};
|
||||
let name = Path::new(target_bin)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("swm-bin")
|
||||
.to_string();
|
||||
let recipe = RecipeInline {
|
||||
name: name.clone(),
|
||||
repo: repo.clone(),
|
||||
commit: commit.clone(),
|
||||
patch: patch.clone(),
|
||||
compiler: build.compiler.clone(),
|
||||
target: build.target.clone(),
|
||||
link: build.link.clone(),
|
||||
flags: build.flags.clone(),
|
||||
};
|
||||
let client = maybe_client.as_mut().expect("client conectado arriba");
|
||||
let artifact = client
|
||||
.compile(recipe, *timeout)
|
||||
.map_err(Error::Client)?;
|
||||
if let Some(expected) = expected_hash {
|
||||
let want = expected.strip_prefix("b3:").unwrap_or(expected);
|
||||
let got = artifact.strip_prefix("b3:").unwrap_or(&artifact);
|
||||
if want != got {
|
||||
return Err(Error::Orchestrate(format!(
|
||||
"source_patch {target_bin}: expected_hash no coincide \
|
||||
(declarado=b3:{want}, build=b3:{got})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Hidratamos localmente desde el store (sin pasar por el bus, evita un
|
||||
// segundo Inject que duplicaría trabajo).
|
||||
let store = hammer_core::Store::open(store)?;
|
||||
let artifact_dir = store
|
||||
.find_by_hash(&artifact)
|
||||
.map_err(|e| Error::Orchestrate(format!("find_by_hash: {e}")))?;
|
||||
let into = rebase_path("/", prefix.as_deref());
|
||||
let report = hammer_build_run_hydrate(&artifact_dir, &into)?;
|
||||
let abs = rebase_path(target_bin, prefix.as_deref());
|
||||
if !abs.exists() {
|
||||
return Err(Error::Orchestrate(format!(
|
||||
"source_patch declara target_bin={target_bin} pero no quedó en {}",
|
||||
abs.display()
|
||||
)));
|
||||
}
|
||||
applied.source_patch += 1;
|
||||
checks.push(VerifyCheck::pass(format!(
|
||||
"source_patch {target_bin}: build {artifact}, {} archivo(s) hidratado(s)",
|
||||
report
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Proposal {
|
||||
intent: intent.to_string(),
|
||||
swm,
|
||||
overlay_id: overlay_id.map(|o| o.as_str().to_string()),
|
||||
prefix: prefix.clone(),
|
||||
applied,
|
||||
skipped_source_patches,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub: `hammer-build` no es dependencia directa de `hammer-agent` para no arrastrar el
|
||||
/// lab a clientes que sólo quieran hablar con el bus. Implementamos la hidratación local
|
||||
/// con `std::fs::hard_link` directamente porque es trivial. Si el árbol del artefacto crece
|
||||
/// (symlinks, perms más finos), promovemos a `hammer-build::run_hydrate` añadiendo la dep
|
||||
/// de manera condicional.
|
||||
fn hammer_build_run_hydrate(artifact_dir: &Path, target_fhs: &Path) -> Result<usize> {
|
||||
let mut files = 0usize;
|
||||
walk(artifact_dir, &mut |rel, kind| {
|
||||
let dst = target_fhs.join(rel);
|
||||
match kind {
|
||||
EntryKind::Dir => {
|
||||
std::fs::create_dir_all(&dst)?;
|
||||
}
|
||||
EntryKind::Symlink(target) => {
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = std::fs::remove_file(&dst);
|
||||
std::os::unix::fs::symlink(&target, &dst)?;
|
||||
files += 1;
|
||||
}
|
||||
EntryKind::File(src) => {
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = std::fs::remove_file(&dst);
|
||||
std::fs::hard_link(&src, &dst).or_else(|_| {
|
||||
// Cross-fs ⇒ caemos a copia. No optimizamos: hammer-agent espera
|
||||
// mismo FS; sólo damos el fallback como cinturón de seguridad.
|
||||
std::fs::copy(&src, &dst).map(|_| ())
|
||||
})?;
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
Ok::<(), std::io::Error>(())
|
||||
})?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
enum EntryKind {
|
||||
Dir,
|
||||
Symlink(PathBuf),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
fn walk<F>(root: &Path, f: &mut F) -> std::io::Result<()>
|
||||
where
|
||||
F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
|
||||
{
|
||||
fn inner<F>(root: &Path, cur: &Path, f: &mut F) -> std::io::Result<()>
|
||||
where
|
||||
F: FnMut(&Path, EntryKind) -> std::io::Result<()>,
|
||||
{
|
||||
for entry in std::fs::read_dir(cur)? {
|
||||
let entry = entry?;
|
||||
let src = entry.path();
|
||||
let rel = src.strip_prefix(root).unwrap();
|
||||
let ft = entry.file_type()?;
|
||||
if ft.is_symlink() {
|
||||
let target = std::fs::read_link(&src)?;
|
||||
f(rel, EntryKind::Symlink(target))?;
|
||||
} else if ft.is_dir() {
|
||||
f(rel, EntryKind::Dir)?;
|
||||
inner(root, &src, f)?;
|
||||
} else if ft.is_file() {
|
||||
f(rel, EntryKind::File(src.clone()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if !root.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("artifact_dir no existe: {}", root.display()),
|
||||
));
|
||||
}
|
||||
inner(root, root, f)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct AppliedCounts {
|
||||
pub source_patch: usize,
|
||||
pub config_edit: usize,
|
||||
pub init_rule: usize,
|
||||
pub file_drop: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct VerifyCheck {
|
||||
pub level: CheckLevel,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CheckLevel {
|
||||
Pass,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
impl VerifyCheck {
|
||||
pub fn pass(msg: impl Into<String>) -> Self {
|
||||
Self { level: CheckLevel::Pass, msg: msg.into() }
|
||||
}
|
||||
pub fn warn(msg: impl Into<String>) -> Self {
|
||||
Self { level: CheckLevel::Warn, msg: msg.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// El resultado del bucle. El humano lo lee para decidir `hammer commit <overlay_id>` (si
|
||||
/// está en modo Overlay) o `hammer discard`.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Proposal {
|
||||
pub intent: String,
|
||||
pub swm: Swm,
|
||||
/// `Some` si abrimos un overlay. En modo Prefix, `None`.
|
||||
pub overlay_id: Option<String>,
|
||||
pub prefix: Option<PathBuf>,
|
||||
pub applied: AppliedCounts,
|
||||
pub skipped_source_patches: usize,
|
||||
pub checks: Vec<VerifyCheck>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::translator::MockTranslator;
|
||||
use hammer_core::swm::{Base, Mutation};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn baseref() -> BaseRef {
|
||||
BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }
|
||||
}
|
||||
|
||||
fn swm_config_only() -> Swm {
|
||||
Swm {
|
||||
swm_version: 1,
|
||||
base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
|
||||
mutations: vec![Mutation::ConfigEdit {
|
||||
file: "/etc/network.conf".into(),
|
||||
inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
|
||||
}],
|
||||
signature: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_applies_config_edit_to_prefix() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let prefix = d.path().join("prefix");
|
||||
std::fs::create_dir_all(prefix.join("etc")).unwrap();
|
||||
std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\nMTU=1500\n").unwrap();
|
||||
|
||||
let t = MockTranslator::empty().with_intent("set static ip", swm_config_only());
|
||||
let o = Orchestrator::new(
|
||||
t,
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix: prefix.clone() },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
let p = o.run("set static ip").unwrap();
|
||||
assert_eq!(p.applied.config_edit, 1);
|
||||
assert!(p.overlay_id.is_none());
|
||||
assert_eq!(p.checks.len(), 1);
|
||||
assert_eq!(p.checks[0].level, CheckLevel::Pass);
|
||||
|
||||
let got = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
|
||||
assert_eq!(got, "STATIC=1\nMTU=1500\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_rejects_base_mismatch() {
|
||||
let mut swm = swm_config_only();
|
||||
swm.base.distro_version = "2027-01-01".into();
|
||||
let t = MockTranslator::empty().with_intent("x", swm);
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let o = Orchestrator::new(
|
||||
t,
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
let err = o.run("x").unwrap_err().to_string();
|
||||
assert!(err.contains("base incompatible"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_skips_source_patch_with_warn() {
|
||||
let swm = Swm {
|
||||
swm_version: 1,
|
||||
base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
|
||||
mutations: vec![Mutation::SourcePatch {
|
||||
repo: "git://x".into(),
|
||||
commit: "abc".into(),
|
||||
patch: None,
|
||||
patch_url: None,
|
||||
build: hammer_core::swm::SwmBuild {
|
||||
compiler: "zig-cc".into(),
|
||||
target: "x86_64-linux-musl".into(),
|
||||
link: "static".into(),
|
||||
flags: vec![],
|
||||
},
|
||||
target_bin: "/bin/x".into(),
|
||||
expected_hash: None,
|
||||
}],
|
||||
signature: None,
|
||||
};
|
||||
let t = MockTranslator::empty().with_intent("compile x", swm);
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let o = Orchestrator::new(
|
||||
t,
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
let p = o.run("compile x").unwrap();
|
||||
assert_eq!(p.skipped_source_patches, 1);
|
||||
assert_eq!(p.checks[0].level, CheckLevel::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_translates_unknown_intent_errors() {
|
||||
let t = MockTranslator::empty();
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let o = Orchestrator::new(
|
||||
t,
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
let err = o.run("haz lo que tú quieras").unwrap_err().to_string();
|
||||
assert!(err.contains("no reconocida"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Traductor de intención (NL) → `.swm`. Ver `docs/08-ai-integration.md` §5.
|
||||
//!
|
||||
//! Contrato del trait: dado un texto humano + un `SystemContext` (lo que el orquestador
|
||||
//! pudo recabar vía QUERY: pins, herramientas presentes, distro_version, etc.), producir un
|
||||
//! `Swm` válido.
|
||||
//!
|
||||
//! Phase 6 entrega un [`MockTranslator`] que mapea intentos exactos contra un catálogo
|
||||
//! YAML de SWMs pre-armados. Eso permite probar el bucle plan→build→try→verify→propose en
|
||||
//! tests y en el host del autor sin LLM. La integración con un modelo real (Claude API,
|
||||
//! local llama, etc.) se conecta detrás de este mismo trait — el bucle no cambia.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use hammer_core::{BaseRef, Swm};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TranslateError {
|
||||
#[error("intent no reconocida en el catálogo: '{0}'")]
|
||||
UnknownIntent(String),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("catálogo inválido: {0}")]
|
||||
Catalog(String),
|
||||
#[error("modelo: {0}")]
|
||||
Model(String),
|
||||
}
|
||||
|
||||
/// Lo que el orquestador le pasa al traductor para contextualizar la intención. Un LLM
|
||||
/// usaría esto para decidir, p. ej., "¿el sistema ya tiene grep 3.12? entonces no necesito
|
||||
/// recompilar; sólo edito la config". Para el `MockTranslator` es decorativo.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemContext {
|
||||
pub base: BaseRef,
|
||||
/// Información libre que el orquestador haya recolectado (rutas y metadatos de archivos
|
||||
/// referenciados por el intent, salida de QUERY, etc.). Estructura JSON arbitraria.
|
||||
pub extras: serde_json::Value,
|
||||
}
|
||||
|
||||
pub trait IntentTranslator {
|
||||
fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>;
|
||||
}
|
||||
|
||||
/// Catálogo intento→.swm leído de un YAML:
|
||||
///
|
||||
/// ```yaml
|
||||
/// version: 1
|
||||
/// intents:
|
||||
/// - intent: "set static IP 192.168.1.100"
|
||||
/// swm_path: "swms/static-ip.swm" # ruta relativa al directorio del catálogo
|
||||
/// # OR
|
||||
/// swm_inline: { swm_version: 1, ... }
|
||||
/// ```
|
||||
///
|
||||
/// El propósito es tener un "test cassette": el agente humano captura una intención que la
|
||||
/// IA real ya resolvió, congela el `.swm` resultante, y a partir de ahí los CI/tests del
|
||||
/// bucle agéntico son reproducibles.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct IntentCatalog {
|
||||
#[serde(default = "default_version")]
|
||||
pub version: u32,
|
||||
pub intents: Vec<CatalogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct CatalogEntry {
|
||||
pub intent: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swm_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub swm_inline: Option<Swm>,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl IntentCatalog {
|
||||
pub fn load_from_path(path: &Path) -> Result<Self, TranslateError> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let cat: IntentCatalog = serde_yaml::from_str(&text)
|
||||
.map_err(|e| TranslateError::Catalog(e.to_string()))?;
|
||||
if cat.version != 1 {
|
||||
return Err(TranslateError::Catalog(format!(
|
||||
"version del catálogo no soportada: {} (esperada 1)",
|
||||
cat.version
|
||||
)));
|
||||
}
|
||||
Ok(cat)
|
||||
}
|
||||
|
||||
/// Resuelve un `intent` exacto contra el catálogo. Las entradas `swm_path` se resuelven
|
||||
/// relativas al `base_dir` (típicamente el directorio del catálogo).
|
||||
pub fn resolve(&self, intent: &str, base_dir: &Path) -> Result<Swm, TranslateError> {
|
||||
let entry = self
|
||||
.intents
|
||||
.iter()
|
||||
.find(|e| e.intent == intent)
|
||||
.ok_or_else(|| TranslateError::UnknownIntent(intent.into()))?;
|
||||
if let Some(s) = &entry.swm_inline {
|
||||
return Ok(s.clone());
|
||||
}
|
||||
if let Some(p) = &entry.swm_path {
|
||||
let full = base_dir.join(p);
|
||||
let text = std::fs::read_to_string(&full)?;
|
||||
return Swm::from_yaml(&text)
|
||||
.map_err(|e| TranslateError::Catalog(format!("swm {}: {e}", full.display())));
|
||||
}
|
||||
Err(TranslateError::Catalog(format!(
|
||||
"entry '{intent}' no declara swm_path ni swm_inline"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Traductor offline: matchea la intención exacta contra un mapa precargado.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MockTranslator {
|
||||
map: HashMap<String, Swm>,
|
||||
}
|
||||
|
||||
impl MockTranslator {
|
||||
pub fn empty() -> Self {
|
||||
Self { map: HashMap::new() }
|
||||
}
|
||||
|
||||
pub fn from_catalog(cat: &IntentCatalog, base_dir: &Path) -> Result<Self, TranslateError> {
|
||||
let mut map = HashMap::new();
|
||||
for entry in &cat.intents {
|
||||
let swm = match (&entry.swm_inline, &entry.swm_path) {
|
||||
(Some(s), None) => s.clone(),
|
||||
(None, Some(p)) => {
|
||||
let full = base_dir.join(p);
|
||||
let text = std::fs::read_to_string(&full)?;
|
||||
Swm::from_yaml(&text).map_err(|e| {
|
||||
TranslateError::Catalog(format!("swm {}: {e}", full.display()))
|
||||
})?
|
||||
}
|
||||
_ => {
|
||||
return Err(TranslateError::Catalog(format!(
|
||||
"entry '{}' debe declarar exactamente uno de swm_inline/swm_path",
|
||||
entry.intent
|
||||
)));
|
||||
}
|
||||
};
|
||||
map.insert(entry.intent.clone(), swm);
|
||||
}
|
||||
Ok(Self { map })
|
||||
}
|
||||
|
||||
pub fn with_intent(mut self, intent: impl Into<String>, swm: Swm) -> Self {
|
||||
self.map.insert(intent.into(), swm);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntentTranslator for MockTranslator {
|
||||
fn translate(&self, intent: &str, _ctx: &SystemContext) -> Result<Swm, TranslateError> {
|
||||
self.map
|
||||
.get(intent)
|
||||
.cloned()
|
||||
.ok_or_else(|| TranslateError::UnknownIntent(intent.into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hammer_core::swm::{Base, Mutation};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn empty_ctx() -> SystemContext {
|
||||
SystemContext {
|
||||
base: BaseRef {
|
||||
distro_version: "2026-06-06".into(),
|
||||
pins: BTreeMap::new(),
|
||||
},
|
||||
extras: serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn swm_demo() -> Swm {
|
||||
Swm {
|
||||
swm_version: 1,
|
||||
base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() },
|
||||
mutations: vec![Mutation::ConfigEdit {
|
||||
file: "/etc/network.conf".into(),
|
||||
inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
|
||||
}],
|
||||
signature: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_translator_returns_known_swm() {
|
||||
let t = MockTranslator::empty().with_intent("set static ip", swm_demo());
|
||||
let s = t.translate("set static ip", &empty_ctx()).unwrap();
|
||||
assert_eq!(s.mutations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_translator_rejects_unknown() {
|
||||
let t = MockTranslator::empty();
|
||||
let err = t.translate("paint the wall green", &empty_ctx()).unwrap_err();
|
||||
assert!(matches!(err, TranslateError::UnknownIntent(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_load_inline() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let cat_path = d.path().join("catalog.yaml");
|
||||
std::fs::write(
|
||||
&cat_path,
|
||||
r#"
|
||||
version: 1
|
||||
intents:
|
||||
- intent: "set static ip"
|
||||
swm_inline:
|
||||
swm_version: 1
|
||||
base:
|
||||
distro_version: "2026-06-06"
|
||||
mutations:
|
||||
- type: config_edit
|
||||
file: "/etc/network.conf"
|
||||
inline_diff: "- DHCP=yes\n+ STATIC=1\n"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
|
||||
let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
|
||||
let s = t.translate("set static ip", &empty_ctx()).unwrap();
|
||||
assert_eq!(s.mutations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_load_from_swm_path() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let swm_path = d.path().join("net.swm");
|
||||
std::fs::write(&swm_path, swm_demo().to_yaml().unwrap()).unwrap();
|
||||
let cat_path = d.path().join("catalog.yaml");
|
||||
std::fs::write(
|
||||
&cat_path,
|
||||
r#"
|
||||
version: 1
|
||||
intents:
|
||||
- intent: "set static ip"
|
||||
swm_path: "net.swm"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
|
||||
let t = MockTranslator::from_catalog(&cat, d.path()).unwrap();
|
||||
let s = t.translate("set static ip", &empty_ctx()).unwrap();
|
||||
assert_eq!(s.mutations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_rejects_unsupported_version() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let cat_path = d.path().join("catalog.yaml");
|
||||
std::fs::write(&cat_path, "version: 9\nintents: []\n").unwrap();
|
||||
let err = IntentCatalog::load_from_path(&cat_path).unwrap_err();
|
||||
assert!(matches!(err, TranslateError::Catalog(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_rejects_entry_without_swm() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let cat_path = d.path().join("catalog.yaml");
|
||||
std::fs::write(
|
||||
&cat_path,
|
||||
"version: 1\nintents:\n - intent: empty\n",
|
||||
)
|
||||
.unwrap();
|
||||
let cat = IntentCatalog::load_from_path(&cat_path).unwrap();
|
||||
let err = MockTranslator::from_catalog(&cat, d.path()).unwrap_err();
|
||||
assert!(matches!(err, TranslateError::Catalog(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! E2E del bucle agéntico (Fase 6):
|
||||
//!
|
||||
//! Camino directo (sin bus): un `MockTranslator` resuelve un intent conocido y el
|
||||
//! `Orchestrator` aplica las mutaciones bajo un prefix temporal. La prueba final pinta el
|
||||
//! cambio en disco y verifica que es el esperado.
|
||||
//!
|
||||
//! NO ejercita el bus aquí — el camino bus + cliente se cubre en
|
||||
//! `crates/hammer-agent/tests/client_e2e.rs` cuando lo construyamos sobre los crates
|
||||
//! `hammerd` (Fase 5). Phase 6 ya prueba bus + cliente en bus_e2e.rs del propio hammerd.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use hammer_agent::{
|
||||
orchestrator::{ApplyTarget, CompileMode},
|
||||
MockTranslator, Orchestrator,
|
||||
};
|
||||
use hammer_core::swm::{Base, Mutation, Swm};
|
||||
use hammer_core::{ArtifactHash, BaseRef};
|
||||
|
||||
fn baseref() -> BaseRef {
|
||||
let mut pins = BTreeMap::new();
|
||||
pins.insert("musl".into(), "deadbeef".into());
|
||||
BaseRef { distro_version: "2026-06-06".into(), pins }
|
||||
}
|
||||
|
||||
fn swm_two_mutations() -> Swm {
|
||||
let payload = b"datos finales\n";
|
||||
let payload_hash = ArtifactHash::of_inputs(&[payload.as_slice()]).as_str().to_string();
|
||||
let payload_b64 = STANDARD.encode(payload);
|
||||
Swm {
|
||||
swm_version: 1,
|
||||
base: Base {
|
||||
distro_version: "2026-06-06".into(),
|
||||
pins: {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("musl".into(), "deadbeef".into());
|
||||
m
|
||||
},
|
||||
},
|
||||
mutations: vec![
|
||||
Mutation::ConfigEdit {
|
||||
file: "/etc/network.conf".into(),
|
||||
inline_diff: "- DHCP=yes\n+ IP=192.168.1.100\n".into(),
|
||||
},
|
||||
Mutation::FileDrop {
|
||||
path: "/var/lib/agent/notes.txt".into(),
|
||||
content_hash: payload_hash,
|
||||
content_b64: Some(payload_b64),
|
||||
content_url: None,
|
||||
},
|
||||
],
|
||||
signature: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intent_to_overlay_proposal_via_mock_translator() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let prefix = d.path().join("prefix");
|
||||
std::fs::create_dir_all(prefix.join("etc")).unwrap();
|
||||
std::fs::write(
|
||||
prefix.join("etc/network.conf"),
|
||||
"USE=eth0\nDHCP=yes\nMTU=1500\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let translator = MockTranslator::empty()
|
||||
.with_intent("set static ip and drop notes", swm_two_mutations());
|
||||
|
||||
let orch = Orchestrator::new(
|
||||
translator,
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix: prefix.clone() },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
|
||||
let proposal = orch.run("set static ip and drop notes").expect("proposal");
|
||||
|
||||
assert_eq!(proposal.applied.config_edit, 1);
|
||||
assert_eq!(proposal.applied.file_drop, 1);
|
||||
assert_eq!(proposal.applied.source_patch, 0);
|
||||
assert!(proposal.overlay_id.is_none(), "modo Prefix no abre overlay");
|
||||
assert_eq!(proposal.checks.len(), 2);
|
||||
assert!(
|
||||
proposal
|
||||
.checks
|
||||
.iter()
|
||||
.all(|c| matches!(c.level, hammer_agent::orchestrator::CheckLevel::Pass)),
|
||||
"todas las checks deben pasar"
|
||||
);
|
||||
|
||||
// Verificación post-condición: los archivos en disco coinciden con lo esperado.
|
||||
let net = std::fs::read_to_string(prefix.join("etc/network.conf")).unwrap();
|
||||
assert_eq!(net, "USE=eth0\nIP=192.168.1.100\nMTU=1500\n");
|
||||
let notes = std::fs::read(prefix.join("var/lib/agent/notes.txt")).unwrap();
|
||||
assert_eq!(notes, b"datos finales\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_intent_surfaces_translator_error() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let orch = Orchestrator::new(
|
||||
MockTranslator::empty(),
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix: d.path().to_path_buf() },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
let err = orch
|
||||
.run("intencion sin entrada en el catalogo")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("no reconocida"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposal_is_serializable_for_humans() {
|
||||
// Sanidad: el Proposal serializa a JSON. La CLI o un cliente externo lo puede mostrar.
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let prefix = d.path().join("prefix");
|
||||
std::fs::create_dir_all(prefix.join("etc")).unwrap();
|
||||
std::fs::write(prefix.join("etc/network.conf"), "DHCP=yes\n").unwrap();
|
||||
let swm = Swm {
|
||||
swm_version: 1,
|
||||
base: Base {
|
||||
distro_version: "2026-06-06".into(),
|
||||
pins: {
|
||||
let mut m = BTreeMap::new();
|
||||
m.insert("musl".into(), "deadbeef".into());
|
||||
m
|
||||
},
|
||||
},
|
||||
mutations: vec![Mutation::ConfigEdit {
|
||||
file: "/etc/network.conf".into(),
|
||||
inline_diff: "- DHCP=yes\n+ STATIC=1\n".into(),
|
||||
}],
|
||||
signature: None,
|
||||
};
|
||||
let translator = MockTranslator::empty().with_intent("x", swm);
|
||||
let orch = Orchestrator::new(
|
||||
translator,
|
||||
baseref(),
|
||||
ApplyTarget::Prefix { prefix },
|
||||
CompileMode::Skip,
|
||||
);
|
||||
let proposal = orch.run("x").unwrap();
|
||||
let json = serde_json::to_string(&proposal).unwrap();
|
||||
assert!(json.contains("\"applied\""));
|
||||
assert!(json.contains("\"checks\""));
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 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 } => Event::BuildReady {
|
||||
recipe: recipe.name.clone(),
|
||||
artifact: format!("b3:stub-{}", recipe.commit),
|
||||
},
|
||||
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: "git://example/grep.git".into(),
|
||||
commit: "abc123".into(),
|
||||
patch: None,
|
||||
compiler: "zig-cc".into(),
|
||||
target: "x86_64-linux-musl".into(),
|
||||
link: "static".into(),
|
||||
flags: vec![],
|
||||
};
|
||||
let artifact = client
|
||||
.compile(recipe, Duration::from_secs(2))
|
||||
.expect("BuildReady");
|
||||
assert_eq!(artifact, "b3:stub-abc123");
|
||||
|
||||
// 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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ hammer-core.workspace = true
|
||||
hammer-build.workspace = true
|
||||
hammer-overlay.workspace = true
|
||||
hammer-journal.workspace = true
|
||||
hammer-agent.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -133,6 +133,30 @@ enum Cmd {
|
||||
#[arg(long)]
|
||||
since: Option<String>,
|
||||
},
|
||||
/// [Fase 6] Ejecuta el bucle agéntico: traduce una intención NL a un .swm vía catálogo
|
||||
/// mock y la aplica a un overlay (o prefix) sin promover al FHS. Imprime el Proposal.
|
||||
Ai {
|
||||
/// La intención en lenguaje natural (string exacta, indexada en el catálogo).
|
||||
intent: String,
|
||||
/// Catálogo YAML de intent → swm. Cuando exista un traductor LLM real, este flag
|
||||
/// será opcional y se documentará el path por defecto.
|
||||
#[arg(long)]
|
||||
catalog: PathBuf,
|
||||
/// Re-rootea las mutaciones bajo este prefix en vez de abrir overlay. Útil para
|
||||
/// dev/CI sin root ni overlayfs.
|
||||
#[arg(long)]
|
||||
prefix: Option<PathBuf>,
|
||||
/// Base local (JSON). Si está, el orquestador verifica `verify_base` contra ella.
|
||||
#[arg(long)]
|
||||
base_ref: Option<PathBuf>,
|
||||
/// Socket de hammerd. Si está + el .swm trae source_patch, el orquestador llama
|
||||
/// Compile por el bus. Si no, source_patch se omite con warning.
|
||||
#[arg(long)]
|
||||
bus: Option<PathBuf>,
|
||||
/// Raíz de overlays para `try`. Default coincide con `hammer try`.
|
||||
#[arg(long)]
|
||||
state_root: Option<PathBuf>,
|
||||
},
|
||||
/// [Fase 5] Envía un comando al FIFO de control humano del init.
|
||||
/// Por defecto: /run/init.control. Equivalente a `echo "<line>" > /run/init.control`,
|
||||
/// pero con un mensaje de error claro si el FIFO no existe.
|
||||
@@ -309,6 +333,17 @@ fn main() -> anyhow::Result<()> {
|
||||
Cmd::Export { base_ref, journal, since } => {
|
||||
run_export(base_ref.as_deref(), &journal, since.as_deref())?;
|
||||
}
|
||||
Cmd::Ai { intent, catalog, prefix, base_ref, bus, state_root } => {
|
||||
run_ai(
|
||||
&intent,
|
||||
&catalog,
|
||||
prefix.as_deref(),
|
||||
base_ref.as_deref(),
|
||||
bus.as_deref(),
|
||||
state_root.as_deref(),
|
||||
&cli.store,
|
||||
)?;
|
||||
}
|
||||
Cmd::Ctl { line, fifo } => {
|
||||
run_ctl(&line, &fifo)?;
|
||||
}
|
||||
@@ -602,6 +637,114 @@ fn run_export(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ejecuta el bucle agéntico de Fase 6 vía `hammer-agent`. Acepta una intención NL +
|
||||
/// catálogo YAML que mapea intentos exactos a `.swm`s pre-armados. La salida es un
|
||||
/// `Proposal` legible en stdout + el siguiente paso recomendado al humano.
|
||||
fn run_ai(
|
||||
intent: &str,
|
||||
catalog: &std::path::Path,
|
||||
prefix: Option<&std::path::Path>,
|
||||
base_ref: Option<&std::path::Path>,
|
||||
bus: Option<&std::path::Path>,
|
||||
state_root: Option<&std::path::Path>,
|
||||
store_root: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
use hammer_agent::{
|
||||
orchestrator::{ApplyTarget, CompileMode},
|
||||
IntentCatalog, MockTranslator, Orchestrator,
|
||||
};
|
||||
|
||||
let cat = IntentCatalog::load_from_path(catalog)
|
||||
.map_err(|e| anyhow::anyhow!("catálogo {}: {e}", catalog.display()))?;
|
||||
let base_dir = catalog.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let translator = MockTranslator::from_catalog(&cat, base_dir)
|
||||
.map_err(|e| anyhow::anyhow!("catalog: {e}"))?;
|
||||
|
||||
// BaseRef: si se da, leerlo; si no, fabricar uno permisivo desde la base del catálogo
|
||||
// (la usaremos sólo para verify_base, que tolera pins extra en local).
|
||||
let base = match load_local_base(base_ref)? {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
// Tomamos la base del swm del intent (si existe) como referencia local.
|
||||
let ctx_swm = cat
|
||||
.intents
|
||||
.iter()
|
||||
.find(|e| e.intent == intent)
|
||||
.ok_or_else(|| anyhow::anyhow!("intent '{intent}' no está en el catálogo"))?
|
||||
.swm_inline
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
cat.intents
|
||||
.iter()
|
||||
.find(|e| e.intent == intent)
|
||||
.and_then(|e| e.swm_path.as_ref())
|
||||
.and_then(|p| std::fs::read_to_string(base_dir.join(p)).ok())
|
||||
.and_then(|t| hammer_core::Swm::from_yaml(&t).ok())
|
||||
});
|
||||
match ctx_swm {
|
||||
Some(s) => hammer_core::BaseRef::from(&s.base),
|
||||
None => anyhow::bail!("no pude inferir BaseRef sin --base-ref"),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let apply = match prefix {
|
||||
Some(p) => ApplyTarget::Prefix { prefix: p.to_path_buf() },
|
||||
None => ApplyTarget::Overlay {
|
||||
state_root: state_root
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from(hammer_overlay::DEFAULT_STATE_ROOT)),
|
||||
},
|
||||
};
|
||||
|
||||
let compile = match bus {
|
||||
Some(s) => CompileMode::ViaBus {
|
||||
sock: s.to_path_buf(),
|
||||
store: PathBuf::from(store_root),
|
||||
timeout: std::time::Duration::from_secs(600),
|
||||
},
|
||||
None => CompileMode::Skip,
|
||||
};
|
||||
|
||||
let orch = Orchestrator::new(translator, base, apply, compile);
|
||||
let proposal = orch
|
||||
.run(intent)
|
||||
.map_err(|e| anyhow::anyhow!("orchestrator: {e}"))?;
|
||||
|
||||
println!("--- proposal ---");
|
||||
println!("intent: {}", proposal.intent);
|
||||
println!(
|
||||
"applied: source_patch={} config_edit={} init_rule={} file_drop={}",
|
||||
proposal.applied.source_patch,
|
||||
proposal.applied.config_edit,
|
||||
proposal.applied.init_rule,
|
||||
proposal.applied.file_drop,
|
||||
);
|
||||
if proposal.skipped_source_patches > 0 {
|
||||
println!(
|
||||
"skipped_source_patches: {} (sin --bus)",
|
||||
proposal.skipped_source_patches
|
||||
);
|
||||
}
|
||||
println!("checks:");
|
||||
for c in &proposal.checks {
|
||||
println!(" [{:?}] {}", c.level, c.msg);
|
||||
}
|
||||
match (&proposal.overlay_id, &proposal.prefix) {
|
||||
(Some(id), _) => {
|
||||
println!("\noverlay {id} listo.");
|
||||
println!(" Revisa el cambio en caliente y luego:");
|
||||
println!(" hammer commit {id} # promueve + diario");
|
||||
println!(" hammer discard {id} # descarta");
|
||||
}
|
||||
(None, Some(p)) => {
|
||||
println!("\nprefix {} actualizado (modo test, sin overlay).", p.display());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Envía una línea al FIFO de control humano. No abrimos un proceso intermedio (sería
|
||||
/// `sh -c "echo ... > FIFO"` con riesgo de quoting); usamos un `OpenOptions::write` y un
|
||||
/// `write_all`. El FIFO bloquea si no hay reader — eso es la garantía de "ack" implícito.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod apply;
|
||||
pub mod hash;
|
||||
pub mod proto;
|
||||
pub mod recipe;
|
||||
pub mod store;
|
||||
pub mod swm;
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::thread;
|
||||
use nix::sys::socket::{getsockopt, sockopt::PeerCredentials};
|
||||
|
||||
use crate::events::EventBus;
|
||||
use crate::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
|
||||
use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION};
|
||||
|
||||
/// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué
|
||||
/// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::proto::Event;
|
||||
use hammer_core::proto::Event;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EventBus {
|
||||
@@ -58,7 +58,7 @@ impl EventBus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::proto::Event;
|
||||
use hammer_core::proto::Event;
|
||||
|
||||
#[test]
|
||||
fn publish_reaches_all_subs() {
|
||||
|
||||
@@ -15,7 +15,6 @@ use clap::Parser;
|
||||
mod bus;
|
||||
mod control;
|
||||
mod events;
|
||||
mod proto;
|
||||
mod watcher;
|
||||
|
||||
#[derive(Parser)]
|
||||
|
||||
@@ -162,7 +162,7 @@ impl Watcher {
|
||||
self.journal.record(&me)?;
|
||||
recorded += 1;
|
||||
if let Some(bus) = &self.events {
|
||||
bus.publish(&crate::proto::Event::Modified {
|
||||
bus.publish(&hammer_core::proto::Event::Modified {
|
||||
path: me.path.display().to_string(),
|
||||
op: me.op.as_str().to_string(),
|
||||
ts: me.ts.clone(),
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
//! 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 el módulo desde `path = ...`.
|
||||
#[path = "../src/proto.rs"]
|
||||
mod proto;
|
||||
// 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"]
|
||||
@@ -24,7 +23,7 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use proto::{Cap, Command, Event, Peer, RecipeInline};
|
||||
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.
|
||||
|
||||
+43
-14
@@ -77,14 +77,22 @@ expuestas y de baja entropía**, que es justo lo que un agente necesita.
|
||||
|
||||
## 5. De la intención al `.swm`: el rol del modelo
|
||||
|
||||
El traductor intención→`.swm` es un LLM. Detalles de modelo/API (Claude, herramientas, prompts)
|
||||
se documentarán aparte cuando se implemente la Fase 6; aquí sólo fijamos el contrato:
|
||||
El traductor intención→`.swm` se conecta detrás del trait `IntentTranslator` del crate
|
||||
`hammer-agent`. Contrato:
|
||||
|
||||
- **Entrada:** intención en NL + contexto del sistema (qué herramientas hay, qué pins, estado
|
||||
de servicios — todo consultable por `QUERY`).
|
||||
- **Salida:** un `.swm` válido ([SDD 06](06-swm-format.md)) + un plan de verificación.
|
||||
- **El modelo no ejecuta nada directamente:** emite comandos por el bus, que `hammerd` valida
|
||||
contra las capacidades concedidas.
|
||||
- **Entrada:** intención en NL + `SystemContext` (lo que el orquestador recolecta por
|
||||
`QUERY`: `BaseRef` local, extras JSON).
|
||||
- **Salida:** un `Swm` válido ([SDD 06](06-swm-format.md)).
|
||||
|
||||
Fase 6 entrega un `MockTranslator` que resuelve intentos contra un `IntentCatalog` YAML
|
||||
(intent exacto → `.swm` inline o `swm_path` relativo). Eso permite probar el bucle sin LLM:
|
||||
el humano que descubrió la intención con un modelo real congela el resultado y los CI lo
|
||||
reproducen byte-a-byte. El traductor LLM (Claude API u otro) implementa el mismo trait y se
|
||||
enchufa sin tocar el `Orchestrator`.
|
||||
|
||||
**El modelo nunca ejecuta nada directamente:** emite comandos por el bus, que `hammerd`
|
||||
valida contra las capacidades concedidas, y el `Orchestrator` traduce las mutaciones a
|
||||
`hammer-core::apply` o a `Compile` por el bus.
|
||||
|
||||
## 6. Lenguaje de consulta/transformación (visión, Fase posterior)
|
||||
|
||||
@@ -101,13 +109,34 @@ esté probado.
|
||||
|
||||
## 7. Interfaz
|
||||
|
||||
El crate `hammer-agent` (Fase 6) expone:
|
||||
|
||||
```rust
|
||||
// cliente del bus (puede vivir en un crate aparte hammer-agent en Fase 6)
|
||||
pub trait AgentClient {
|
||||
fn hello(&mut self) -> Result<Caps>;
|
||||
fn compile(&mut self, recipe: &Recipe) -> Result<ArtifactHash>;
|
||||
fn inject(&mut self, h: &ArtifactHash, target: &Path, overlay: OverlayId) -> Result<()>;
|
||||
fn query(&mut self, q: Query) -> Result<QueryResult>;
|
||||
fn events(&mut self) -> impl Iterator<Item = BusEvent>;
|
||||
// hammer_agent::client
|
||||
pub struct AgentClient { pub welcome: Welcome, /* … */ }
|
||||
impl AgentClient {
|
||||
pub fn connect(sock: &Path) -> Result<Self>;
|
||||
pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result<String>; // artifact
|
||||
pub fn inject(&mut self, artifact: &str, target: &str, overlay: Option<&str>, t: Duration) -> Result<usize>;
|
||||
pub fn query_file(&mut self, path: &str, timeout: Duration) -> Result<serde_json::Value>;
|
||||
pub fn init(&mut self, cmd: &str, timeout: Duration) -> Result<()>;
|
||||
pub fn drain_async(&self) -> Vec<Event>;
|
||||
pub fn next_async(&self, timeout: Duration) -> Result<Event>;
|
||||
}
|
||||
|
||||
// hammer_agent::translator
|
||||
pub trait IntentTranslator { fn translate(&self, intent: &str, ctx: &SystemContext) -> Result<Swm, TranslateError>; }
|
||||
pub struct MockTranslator { /* HashMap<intent, Swm> */ }
|
||||
pub struct IntentCatalog { pub intents: Vec<CatalogEntry> } // YAML loader
|
||||
|
||||
// hammer_agent::orchestrator
|
||||
pub struct Orchestrator<T: IntentTranslator> { /* … */ }
|
||||
impl<T> Orchestrator<T> {
|
||||
pub fn new(t: T, base: BaseRef, apply: ApplyTarget, compile: CompileMode) -> Self;
|
||||
pub fn run(&self, intent: &str) -> Result<Proposal>;
|
||||
}
|
||||
```
|
||||
|
||||
Los tipos del protocolo (`Command`/`Event`/`Cap`/`Peer`/`RecipeInline`) viven en
|
||||
`hammer-core::proto` para ser compartidos por `hammerd`, `hammer-agent` y cualquier cliente
|
||||
externo escrito en Rust.
|
||||
|
||||
+25
-3
@@ -99,10 +99,32 @@ pre-requisito de validación.
|
||||
gating `no_cap`, `Query`, `Modified` fan-out, `Init`→FIFO. Un `Compile` real reusa el
|
||||
camino de Fase 0/1 (gated en `HAMMER_NETWORK_TESTS`).
|
||||
|
||||
### Fase 6 — Integración de la IA
|
||||
- [ ] Cliente de agente; traductor intención NL → `.swm`; bucle plan→build→try→verify→propose.
|
||||
### Fase 6 — Integración de la IA ▶ *en progreso*
|
||||
- [x] Crate `hammer-agent` con tres piezas:
|
||||
- `AgentClient`: cliente síncrono del bus (handshake, `compile`/`inject`/`query`/`init`
|
||||
bloqueantes, drenado de eventos asíncronos).
|
||||
- `IntentTranslator` (trait) + `MockTranslator` cargado desde un `IntentCatalog` YAML
|
||||
(intent → `.swm` pre-armado). La integración con un LLM real se enchufa detrás del
|
||||
mismo trait sin tocar el bucle.
|
||||
- `Orchestrator` con `run(intent) → Proposal`: plan → schema → base → try (overlay
|
||||
o prefix) → apply (mutaciones puras + source_patch vía bus opcional) → verify →
|
||||
propose.
|
||||
- [x] CLI: `hammer ai <intent> --catalog F [--prefix DIR --base-ref F --bus SOCK]`.
|
||||
- [x] Tipos del protocolo del bus movidos a `hammer-core::proto` para que `hammerd` y
|
||||
`hammer-agent` los compartan.
|
||||
- [x] Tests:
|
||||
- 10 unit en `hammer-agent` (translator + catalog + orchestrator).
|
||||
- 3 e2e del bucle agéntico (prefix tmp → archivos esperados en disco).
|
||||
- 1 e2e del cliente contra un *stub* del bus (handshake + Compile→BuildReady + Modified
|
||||
asíncrono).
|
||||
- [ ] Traductor LLM real (Claude API u otro), opcional vía feature flag o crate aparte.
|
||||
- [ ] Lenguaje de consulta del sistema (SDD 08 §6) para que la IA refiera servicios y
|
||||
archivos sin rutas frágiles.
|
||||
- [ ] Bucle de auto-reparación (cliente reacciona a `Crashed` con un nuevo plan).
|
||||
- **Hecho cuando:** una intención en lenguaje natural produce un cambio probado en overlay,
|
||||
presentado para `commit` humano.
|
||||
presentado para `commit` humano. ✅ Demostrado por `hammer ai` con `MockTranslator`:
|
||||
intent → `.swm` → mutaciones aplicadas → `Proposal` con `overlay_id` + checks. El humano
|
||||
decide `commit`/`discard`. El upgrade a LLM real reusa todo el bucle.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user