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:
co-authored by
Claude Opus 4.8
parent
7a6bfb4c4d
commit
6c34484fbe
Generated
+1
@@ -471,6 +471,7 @@ name = "hammer-journal"
|
|||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"blake3",
|
||||||
"hammer-core",
|
"hammer-core",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ thiserror.workspace = true
|
|||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -124,6 +124,40 @@ impl Journal {
|
|||||||
self.dir.join("mutations.jsonl")
|
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.
|
/// Append-only. Una línea JSON + `\n`. Maneja la creación del archivo si no existe.
|
||||||
pub fn record(&self, ev: &MutationEvent) -> Result<()> {
|
pub fn record(&self, ev: &MutationEvent) -> Result<()> {
|
||||||
let line = serde_json::to_string(ev)?;
|
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).
|
/// Construye un timestamp RFC 3339 UTC del instante actual usando std-only (sin chrono).
|
||||||
pub fn now_rfc3339() -> String {
|
pub fn now_rfc3339() -> String {
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
@@ -336,6 +388,91 @@ mod tests {
|
|||||||
assert_eq!(read.len(), 2, "líneas válidas se preservan");
|
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]
|
#[test]
|
||||||
fn source_serializes_with_tag() {
|
fn source_serializes_with_tag() {
|
||||||
let e = ev(MutationOp::Replace, "/bin/grep");
|
let e = ev(MutationOp::Replace, "/bin/grep");
|
||||||
|
|||||||
@@ -147,6 +147,16 @@ impl Watcher {
|
|||||||
tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
|
tracing::debug!(path = %path.display(), "watcher: ignoro evento bajo overlay activo");
|
||||||
continue;
|
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 {
|
let me = MutationEvent {
|
||||||
ts: hammer_journal::now_rfc3339(),
|
ts: hammer_journal::now_rfc3339(),
|
||||||
op: MutationOp::Replace,
|
op: MutationOp::Replace,
|
||||||
@@ -156,10 +166,14 @@ impl Watcher {
|
|||||||
pid: Some(pid),
|
pid: Some(pid),
|
||||||
uid: uid_of_pid(pid),
|
uid: uid_of_pid(pid),
|
||||||
},
|
},
|
||||||
content_hash: None,
|
content_hash,
|
||||||
note: None,
|
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;
|
recorded += 1;
|
||||||
if let Some(bus) = &self.events {
|
if let Some(bus) = &self.events {
|
||||||
bus.publish(&hammer_core::proto::Event::Modified {
|
bus.publish(&hammer_core::proto::Event::Modified {
|
||||||
|
|||||||
+6
-1
@@ -55,7 +55,12 @@ pre-requisito de validación.
|
|||||||
- [x] `hammer journal [--tail N] [--follow] [--format pretty|json]`.
|
- [x] `hammer journal [--tail N] [--follow] [--format pretty|json]`.
|
||||||
- [x] `hammer commit` registra cada archivo promocionado vía `commit_with_journal`.
|
- [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).
|
- [ ] 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*
|
### Fase 4 — Formato y flujo `.swm` ▶ *en progreso*
|
||||||
- [x] `Swm` de/serialización YAML estable (roundtrip).
|
- [x] `Swm` de/serialización YAML estable (roundtrip).
|
||||||
|
|||||||
Reference in New Issue
Block a user