> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
Fase 3: content_hash post-mutación + de-dup idempotente en el diario
El campo content_hash (ya existía en MutationEvent) ahora se puebla y se
usa para no ensuciar el diario con reescrituras sin cambios.
hammer-journal:
- content_hash_of(bytes) / hash_file(path): blake3 plano (estilo b3sum,
"b3:<hex>"), distinto del of_inputs de artefactos — aquí la pregunta es
"¿cambió el archivo?".
- last_for_path(path): último evento de un path.
- record_dedup(ev): omite el evento si deja el archivo idéntico al último
estado de ese path (content_hash igual) o si es un Delete sobre algo ya
borrado. Devuelve si escribió o no. +7 tests.
hammerd watcher: hashea cada CLOSE_WRITE y usa record_dedup; una
reescritura idéntica ni registra ni emite Modified al bus.
22 binarios de test verdes, sin warnings nuevos.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock
index b0681267..e853c86c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -471,6 +471,7 @@ name = "hammer-journal"
version = "0.0.1"
dependencies = [
"anyhow",
+ "blake3",
"hammer-core",
"serde",
"serde_json",
diff --git a/crates/hammer-journal/Cargo.toml b/crates/hammer-journal/Cargo.toml
index c3e36cb6..bf564133 100644
--- a/crates/hammer-journal/Cargo.toml
+++ b/crates/hammer-journal/Cargo.toml
@@ -14,6 +14,7 @@ thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
+blake3.workspace = true
[dev-dependencies]
tempfile.workspace = true
diff --git a/crates/hammer-journal/src/lib.rs b/crates/hammer-journal/src/lib.rs
index 6b38b659..199d37a3 100644
--- a/crates/hammer-journal/src/lib.rs
+++ b/crates/hammer-journal/src/lib.rs
@@ -124,6 +124,40 @@ impl Journal {
self.dir.join("mutations.jsonl")
}
+ /// Último evento registrado para `path` (o `None` si nunca apareció). Naïve: relee el
+ /// diario; suficiente para la cadencia humana de Fase 3 (cuando crezca, un índice por
+ /// path lo acelera).
+ pub fn last_for_path(&self, path: &std::path::Path) -> Result<Option<MutationEvent>> {
+ let all = self.read_all()?;
+ Ok(all.into_iter().rev().find(|e| e.path == path))
+ }
+
+ /// Como [`record`], pero **de-dup idempotente**: si la mutación deja el archivo en el
+ /// mismo estado que el último evento del diario para ese path, no la registra. Devuelve
+ /// `true` si escribió un evento, `false` si lo consideró redundante.
+ ///
+ /// Dos casos de redundancia (ver SDD 05 — el diario es información, no ruido):
+ /// 1. **Contenido idéntico:** el `content_hash` nuevo coincide con el último de ese path
+ /// (p. ej. un editor que reescribe el archivo sin cambiarlo, o un replay del mismo
+ /// artefacto).
+ /// 2. **Borrado repetido:** un `Delete` sobre un path cuyo último evento ya era `Delete`.
+ pub fn record_dedup(&self, ev: &MutationEvent) -> Result<bool> {
+ if let Some(last) = self.last_for_path(&ev.path)? {
+ let idempotent_content = matches!(
+ (&ev.content_hash, &last.content_hash),
+ (Some(a), Some(b)) if a == b
+ );
+ let repeated_delete =
+ ev.op == MutationOp::Delete && last.op == MutationOp::Delete;
+ if idempotent_content || repeated_delete {
+ tracing::debug!(path = %ev.path.display(), "journal: evento idempotente, omitido");
+ return Ok(false);
+ }
+ }
+ self.record(ev)?;
+ Ok(true)
+ }
+
/// Append-only. Una línea JSON + `\n`. Maneja la creación del archivo si no existe.
pub fn record(&self, ev: &MutationEvent) -> Result<()> {
let line = serde_json::to_string(ev)?;
@@ -211,6 +245,24 @@ impl Journal {
}
}
+/// Hash BLAKE3 del contenido de un archivo, con prefijo `b3:`. Es un blake3 **plano** del
+/// contenido (lo que daría `b3sum`), distinto del `ArtifactHash::of_inputs` de hammer-core
+/// (que prefija longitudes para identidad de artefactos). Aquí queremos "¿el archivo cambió?".
+pub fn content_hash_of(bytes: &[u8]) -> String {
+ format!("b3:{}", blake3::hash(bytes).to_hex())
+}
+
+/// Hashea el contenido de `path` tras una mutación. Devuelve `None` si el archivo ya no existe
+/// (p. ej. fue borrado, o es lo que el watcher ve como `... (deleted)`); propaga otros errores
+/// de IO.
+pub fn hash_file(path: &std::path::Path) -> Result<Option<String>> {
+ match std::fs::read(path) {
+ Ok(bytes) => Ok(Some(content_hash_of(&bytes))),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
+ Err(e) => Err(e.into()),
+ }
+}
+
/// Construye un timestamp RFC 3339 UTC del instante actual usando std-only (sin chrono).
pub fn now_rfc3339() -> String {
use std::time::SystemTime;
@@ -336,6 +388,91 @@ mod tests {
assert_eq!(read.len(), 2, "líneas válidas se preservan");
}
+ fn ev_hash(op: MutationOp, path: &str, hash: Option<&str>) -> MutationEvent {
+ let mut e = ev(op, path);
+ e.content_hash = hash.map(|s| s.to_string());
+ e
+ }
+
+ #[test]
+ fn content_hash_is_plain_blake3_with_prefix() {
+ let h = content_hash_of(b"hola hammer\n");
+ assert!(h.starts_with("b3:"));
+ // Determinista y sensible al contenido.
+ assert_eq!(h, content_hash_of(b"hola hammer\n"));
+ assert_ne!(h, content_hash_of(b"otra cosa"));
+ }
+
+ #[test]
+ fn hash_file_some_and_none() {
+ let d = tempfile::tempdir().unwrap();
+ let f = d.path().join("x");
+ std::fs::write(&f, b"abc").unwrap();
+ assert_eq!(hash_file(&f).unwrap(), Some(content_hash_of(b"abc")));
+ assert_eq!(hash_file(&d.path().join("noexiste")).unwrap(), None);
+ }
+
+ #[test]
+ fn dedup_skips_identical_content() {
+ let d = tempfile::tempdir().unwrap();
+ let j = Journal::open(d.path()).unwrap();
+ let h = content_hash_of(b"v1");
+ // Primer evento: se registra.
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap());
+ // Mismo contenido otra vez: idempotente, se omite.
+ assert!(!j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap());
+ // Contenido distinto: se registra.
+ let h2 = content_hash_of(b"v2");
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h2))).unwrap());
+ // Vuelta a v1 (no es el ÚLTIMO estado): se registra (no colapsamos histórico).
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap());
+
+ let all = j.read_all().unwrap();
+ assert_eq!(all.len(), 3, "v1, v2, v1 — sólo se omitió el v1→v1 consecutivo");
+ }
+
+ #[test]
+ fn dedup_is_per_path() {
+ let d = tempfile::tempdir().unwrap();
+ let j = Journal::open(d.path()).unwrap();
+ let h = content_hash_of(b"same");
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/a", Some(&h))).unwrap());
+ // Mismo hash pero OTRO path: no es redundante.
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/b", Some(&h))).unwrap());
+ assert_eq!(j.read_all().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn dedup_collapses_repeated_delete() {
+ let d = tempfile::tempdir().unwrap();
+ let j = Journal::open(d.path()).unwrap();
+ assert!(j.record_dedup(&ev_hash(MutationOp::Delete, "/etc/x", None)).unwrap());
+ assert!(!j.record_dedup(&ev_hash(MutationOp::Delete, "/etc/x", None)).unwrap());
+ assert_eq!(j.read_all().unwrap().len(), 1);
+ }
+
+ #[test]
+ fn dedup_does_not_collapse_hashless_replaces() {
+ // Dos Replace sin content_hash NO se consideran idempotentes (no sabemos el contenido).
+ let d = tempfile::tempdir().unwrap();
+ let j = Journal::open(d.path()).unwrap();
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/etc/x", None)).unwrap());
+ assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/etc/x", None)).unwrap());
+ assert_eq!(j.read_all().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn last_for_path_finds_latest() {
+ let d = tempfile::tempdir().unwrap();
+ let j = Journal::open(d.path()).unwrap();
+ j.record(&ev(MutationOp::Create, "/etc/a")).unwrap();
+ j.record(&ev(MutationOp::Replace, "/etc/a")).unwrap();
+ j.record(&ev(MutationOp::Edit, "/etc/b")).unwrap();
+ assert_eq!(j.last_for_path(&PathBuf::from("/etc/a")).unwrap().unwrap().op, MutationOp::Replace);
+ assert_eq!(j.last_for_path(&PathBuf::from("/etc/b")).unwrap().unwrap().op, MutationOp::Edit);
+ assert!(j.last_for_path(&PathBuf::from("/etc/z")).unwrap().is_none());
+ }
+
#[test]
fn source_serializes_with_tag() {
let e = ev(MutationOp::Replace, "/bin/grep");
diff --git a/crates/hammerd/src/watcher.rs b/crates/hammerd/src/watcher.rs
index 1e100726..d1ae1361 100644
--- a/crates/hammerd/src/watcher.rs
+++ b/crates/hammerd/src/watcher.rs
@@ -147,6 +147,16 @@ impl Watcher {
tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
continue;
}
+ // Hash del contenido tras la mutación: alimenta el de-dup idempotente y, más
+ // tarde, la correlación en `export`. Si no podemos leer el archivo (borrado,
+ // permiso), seguimos con `None` — el evento sigue siendo información válida.
+ let content_hash = match hammer_journal::hash_file(&path) {
+ Ok(h) => h,
+ Err(e) => {
+ tracing::warn!(path = %path.display(), error = %e, "watcher: no pude hashear contenido");
+ None
+ }
+ };
let me = MutationEvent {
ts: hammer_journal::now_rfc3339(),
op: MutationOp::Replace,
@@ -156,10 +166,14 @@ impl Watcher {
pid: Some(pid),
uid: uid_of_pid(pid),
},
- content_hash: None,
+ content_hash,
note: None,
};
- self.journal.record(&me)?;
+ // De-dup: una reescritura que deja el archivo idéntico no ensucia el diario ni
+ // despierta al bus. Sólo emitimos `Modified` cuando algo cambió de verdad.
+ if !self.journal.record_dedup(&me)? {
+ continue;
+ }
recorded += 1;
if let Some(bus) = &self.events {
bus.publish(&hammer_core::proto::Event::Modified {
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index 3876f008..76ea8932 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -55,7 +55,12 @@ pre-requisito de validación.
- [x] `hammer journal [--tail N] [--follow] [--format pretty|json]`.
- [x] `hammer commit` registra cada archivo promocionado vía `commit_with_journal`.
- [ ] Refinar op del watcher (Create vs Replace vs Edit con FAN_REPORT_DFID_NAME).
-- [ ] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
+- [x] Hash del contenido tras la mutación (`content_hash`) y de-dup idempotente.
+ `hammer_journal::content_hash_of`/`hash_file` (blake3 plano, estilo `b3sum`, distinto del
+ `of_inputs` de artefactos). `Journal::record_dedup` omite el evento si deja el archivo
+ idéntico al último de ese path (o un `Delete` sobre algo ya borrado); `last_for_path` lo
+ resuelve. El watcher hashea cada `CLOSE_WRITE` y usa `record_dedup`: una reescritura sin
+ cambios ni ensucia el diario ni despierta el bus.
### Fase 4 — Formato y flujo `.swm` ▶ *en progreso*
- [x] `Swm` de/serialización YAML estable (roundtrip).