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>
This commit is contained in:
Sergio
2026-06-10 20:28:16 +00:00
co-authored by Claude Opus 4.8
parent 7a6bfb4c4d
commit 6c34484fbe
5 changed files with 161 additions and 3 deletions
Generated
+1
View File
@@ -471,6 +471,7 @@ name = "hammer-journal"
version = "0.0.1"
dependencies = [
"anyhow",
"blake3",
"hammer-core",
"serde",
"serde_json",
+1
View File
@@ -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
+137
View File
@@ -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");
+16 -2
View File
@@ -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 {
+6 -1
View File
@@ -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).