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>
670 lines
26 KiB
Rust
670 lines
26 KiB
Rust
//! 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, command } => {
|
|
let rules_dir =
|
|
rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix.as_deref());
|
|
let p = hammer_core::apply::apply_init_rule(
|
|
&rules_dir, action, service, command,
|
|
)
|
|
.map_err(|e| Error::Orchestrate(format!("init_rule '{action} {service}': {e}")))?;
|
|
applied.init_rule += 1;
|
|
checks.push(VerifyCheck::pass(format!(
|
|
"init_rule '{action} {service}' → {}",
|
|
p.display()
|
|
)));
|
|
}
|
|
Mutation::SourcePatch {
|
|
repo,
|
|
commit,
|
|
tarball,
|
|
sha256,
|
|
strip_components: _,
|
|
patch,
|
|
patch_url: _,
|
|
build,
|
|
target_bin,
|
|
expected_hash,
|
|
deps: _,
|
|
evidence, // [H1c] viaja al lab; el veredicto vuelve en BuildReady
|
|
} => {
|
|
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(),
|
|
tarball: tarball.clone(),
|
|
sha256: sha256.clone(),
|
|
patch: patch.clone(),
|
|
compiler: build.compiler.clone(),
|
|
target: build.target.clone(),
|
|
link: build.link.clone(),
|
|
flags: build.flags.clone(),
|
|
evidence: evidence.clone(),
|
|
};
|
|
let client = maybe_client.as_mut().expect("client conectado arriba");
|
|
let outcome = client
|
|
.compile(recipe, *timeout)
|
|
.map_err(Error::Client)?;
|
|
let artifact = outcome.artifact;
|
|
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})"
|
|
)));
|
|
}
|
|
}
|
|
// [H1c] VERIFY con evidencia: el lab ya ejecutó la evidencia declarada tras
|
|
// sellar el artefacto y nos devolvió el veredicto. Es el gate "no proponer":
|
|
// si algún check falló, NO hidratamos (el artefacto queda sellado en el store,
|
|
// pero no toca el sistema) y abortamos con el detalle.
|
|
if let Some(verdict) = &outcome.verdict {
|
|
for c in &verdict.checks {
|
|
if !c.passed {
|
|
checks.push(VerifyCheck::fail(format!(
|
|
"evidencia [{}] {}: {}",
|
|
c.kind.as_str(),
|
|
c.cmd,
|
|
c.detail
|
|
)));
|
|
}
|
|
}
|
|
if !verdict.all_passed {
|
|
let n_fail = verdict.checks.iter().filter(|c| !c.passed).count();
|
|
return Err(Error::Orchestrate(format!(
|
|
"source_patch {target_bin}: {n_fail}/{} check(s) de evidencia \
|
|
fallaron ⇒ no se propone (artefacto {artifact} sellado pero \
|
|
NO hidratado)",
|
|
verdict.checks.len()
|
|
)));
|
|
}
|
|
let lvl = verdict.max_level.map(|k| k.as_str()).unwrap_or("—");
|
|
checks.push(VerifyCheck::pass(format!(
|
|
"evidencia: {} check(s) pasan (verificado hasta el estrato '{lvl}')",
|
|
verdict.checks.len()
|
|
)));
|
|
}
|
|
|
|
// 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,
|
|
repair_chain: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Variante con auto-reparación. Tras cada aplicación, si la política trae un
|
|
/// `event_sock`, abre un cliente del bus y espera `crash_window` para ver si
|
|
/// llega un `Crashed`. Si llega, traduce `(service, code)` a una nueva intención
|
|
/// con `policy.format_intent` y vuelve a entrar al bucle, hasta `max_attempts`.
|
|
///
|
|
/// El `Proposal` devuelto siempre lleva la cadena completa de intentos en
|
|
/// `repair_chain`. Si el último intento se estabilizó (sin crash dentro de la
|
|
/// ventana), su `triggered_crash` es `None`. Si se agotaron los intentos con un
|
|
/// crash todavía pendiente, la última entrada lo refleja — el humano lee la cadena
|
|
/// y decide si commit, discard o seguir manualmente.
|
|
pub fn run_with_repair(&self, intent: &str, policy: &RepairPolicy) -> Result<Proposal> {
|
|
let max = policy.max_attempts.max(1);
|
|
let mut chain: Vec<RepairAttempt> = Vec::new();
|
|
let mut current = intent.to_string();
|
|
let mut last: Option<Proposal> = None;
|
|
|
|
for attempt in 0..max {
|
|
let p = self.run(¤t)?;
|
|
let overlay_id = p.overlay_id.clone();
|
|
|
|
let crash = wait_for_crash(policy);
|
|
chain.push(RepairAttempt {
|
|
attempt,
|
|
intent: current.clone(),
|
|
overlay_id,
|
|
triggered_crash: crash.clone(),
|
|
});
|
|
last = Some(p);
|
|
|
|
match crash {
|
|
None => break, // estabilizado
|
|
Some(c) => {
|
|
current = (policy.format_intent)(&c.service, c.code);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut p = last.expect("max_attempts >= 1, así que hubo al menos un run");
|
|
p.repair_chain = chain;
|
|
Ok(p)
|
|
}
|
|
}
|
|
|
|
/// Conecta al bus y drena eventos hasta encontrar un `Crashed` dentro de `crash_window`.
|
|
/// Devuelve `None` si:
|
|
/// - la política no tiene socket;
|
|
/// - la conexión falla (caso "no hay bus") — el bucle decide entonces "estabilizado";
|
|
/// - expira la ventana sin recibir Crashed.
|
|
fn wait_for_crash(policy: &RepairPolicy) -> Option<CrashInfo> {
|
|
let Some(sock) = &policy.event_sock else { return None };
|
|
let client = match AgentClient::connect(sock) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "auto-repair: no pude conectar al bus, ignoro");
|
|
return None;
|
|
}
|
|
};
|
|
let deadline = std::time::Instant::now() + policy.crash_window;
|
|
// Drenamos primero la cola asíncrona ya acumulada (eventos llegados durante el `run`).
|
|
for ev in client.drain_async() {
|
|
if let hammer_core::proto::Event::Crashed { service, code } = ev {
|
|
return Some(CrashInfo { service, code });
|
|
}
|
|
}
|
|
while let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) {
|
|
match client.next_async(remaining) {
|
|
Ok(hammer_core::proto::Event::Crashed { service, code }) => {
|
|
return Some(CrashInfo { service, code });
|
|
}
|
|
Ok(_other) => continue,
|
|
Err(_) => break, // timeout o cierre
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// 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() }
|
|
}
|
|
/// [H1c] Un check que FALLÓ. Hoy lo produce el gate de evidencia: la mutación no se propone.
|
|
pub fn fail(msg: impl Into<String>) -> Self {
|
|
Self { level: CheckLevel::Fail, 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>,
|
|
/// Si la propuesta provino del bucle de auto-reparación, la cadena completa de
|
|
/// intentos. La última entrada corresponde a este `Proposal`. Para corridas sin
|
|
/// auto-reparación queda vacío.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub repair_chain: Vec<RepairAttempt>,
|
|
}
|
|
|
|
/// Un intento dentro del bucle de auto-reparación. Sirve para que el humano vea
|
|
/// cómo evolucionó la intención: original → reparación A → reparación B …
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct RepairAttempt {
|
|
pub attempt: u32,
|
|
pub intent: String,
|
|
/// `Some` cuando el ciclo abrió un overlay; preservado para que el humano pueda hacer
|
|
/// `hammer discard <id>` sobre intentos intermedios si lo desea.
|
|
pub overlay_id: Option<String>,
|
|
/// Si tras este intento llegó un `Crashed`, el servicio y código. `None` ⇒ estabilizado.
|
|
pub triggered_crash: Option<CrashInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct CrashInfo {
|
|
pub service: String,
|
|
pub code: i32,
|
|
}
|
|
|
|
/// Política del bucle de auto-reparación. La IA NUNCA decide por sí sola seguir
|
|
/// reintentando: la política la fija el llamador (CLI o cliente) y acota el blast-radius.
|
|
#[derive(Clone)]
|
|
pub struct RepairPolicy {
|
|
/// Máximo de intentos en total (incluyendo el primero). Mínimo efectivo: 1.
|
|
pub max_attempts: u32,
|
|
/// Ventana de espera tras aplicar para detectar un `Crashed` por el bus.
|
|
pub crash_window: Duration,
|
|
/// Socket del bus de eventos. Si `None`, el orquestador no escucha y devuelve
|
|
/// el `Proposal` tras la primera aplicación (equivalente a `run`).
|
|
pub event_sock: Option<PathBuf>,
|
|
/// Convierte un crash `(service, code)` en la próxima intención. Por defecto:
|
|
/// `"repair service <s> crashed code <c>"`.
|
|
pub format_intent: fn(&str, i32) -> String,
|
|
}
|
|
|
|
impl std::fmt::Debug for RepairPolicy {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("RepairPolicy")
|
|
.field("max_attempts", &self.max_attempts)
|
|
.field("crash_window", &self.crash_window)
|
|
.field("event_sock", &self.event_sock)
|
|
.field("format_intent", &"fn(&str, i32) -> String")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
pub fn default_crash_intent(service: &str, code: i32) -> String {
|
|
format!("repair service {service} crashed code {code}")
|
|
}
|
|
|
|
impl Default for RepairPolicy {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_attempts: 3,
|
|
crash_window: Duration::from_secs(5),
|
|
event_sock: None,
|
|
format_intent: default_crash_intent,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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: Some("git://x".into()),
|
|
commit: Some("abc".into()),
|
|
tarball: None,
|
|
sha256: None,
|
|
strip_components: None,
|
|
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![],
|
|
phases: Default::default(),
|
|
zig_version: None,
|
|
},
|
|
target_bin: "/bin/x".into(),
|
|
expected_hash: None,
|
|
deps: Default::default(),
|
|
evidence: Default::default(),
|
|
}],
|
|
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}");
|
|
}
|
|
}
|