From 6c34484fbeb1c1cabe5d1b40bdc1a00f39cdb7d2 Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 20:28:16 +0000 Subject: [PATCH] =?UTF-8?q?Fase=203:=20content=5Fhash=20post-mutaci=C3=B3n?= =?UTF-8?q?=20+=20de-dup=20idempotente=20en=20el=20diario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:"), 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) --- Cargo.lock | 1 + crates/hammer-journal/Cargo.toml | 1 + crates/hammer-journal/src/lib.rs | 137 +++++++++++++++++++++++++++++++ crates/hammerd/src/watcher.rs | 18 +++- docs/10-roadmap.md | 7 +- 5 files changed, 161 insertions(+), 3 deletions(-) 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> { + 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 { + 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> { + 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).