gioser

sergio/takana

> 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>

6c34484fbeb1c1cabe5d1b40bdc1a00f39cdb7d2 · por Sergio · (hace 4 meses) · padre 7a6bfb4c4 · ver el árbol en este commit

Delta semántico · 13

crates/hammerd/src/watcher.rs

5 archivos · +161 −3

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).