hammer-upgrade: journal de intención + recover idempotente (E4 endurecimiento #4a)

El apply escribe el plan completo a pending.json ANTES de proyectar y lo limpia
al commitear; un corte a media proyección deja pending.json + FHS a medias. La
proyección se hizo re-entrante (project_plan, backups idempotentes via
backup_existing_once que nunca pisa el original) ⇒ recover COMPLETA (roll-forward,
re-verifica of_tree) o DESHACE (roll-back: restaura backups, borra la gen a
medias, current->padre). apply se niega con PendingExists si hay intento; status
lo avisa. CLI: hammer upgrade recover [--rollback]. 5 tests (corte a media
proyeccion -> ambos modos) + ejercicio en upgrade-e2e-test.sh. 14 tests verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 04:45:27 -04:00
co-authored by Claude Opus 4.8
parent fd09e842b4
commit 01b3a225b2
4 changed files with 400 additions and 61 deletions
+20
View File
@@ -275,6 +275,12 @@ enum UpgradeCmd {
},
/// Revierte la generación viva al árbol anterior (o al estado pre-upgrades).
Rollback,
/// Completa (o deshace con --rollback) un apply interrumpido por un corte/reinicio (intento pendiente).
Recover {
/// Deshace el apply a medias (vuelve al estado pre-apply) en vez de completarlo.
#[arg(long)]
rollback: bool,
},
/// Muestra la generación viva y el historial.
Status,
/// Borra generaciones que el rollback ya no alcanza (huérfanas tras un rollback). Recupera espacio.
@@ -865,7 +871,21 @@ fn main() -> anyhow::Result<()> {
}
println!(" {} restaurados, {} borrados", r.restored.len(), r.deleted.len());
}
UpgradeCmd::Recover { rollback } => {
let j = hammer_journal::Journal::open(&journal)?;
let r = hammer_upgrade::recover(&store, &root, &state_root, Some(&j), rollback)?;
if !r.recovered {
println!("no hay apply interrumpido — nada que recuperar");
} else if r.rolled_forward {
println!("✓ apply interrumpido COMPLETADO (generación {})", r.generation);
} else {
println!("✓ apply interrumpido DESHECHO (generación {} descartada)", r.generation);
}
}
UpgradeCmd::Status => {
if let Some(p) = hammer_upgrade::pending(&state_root)? {
println!("⚠ apply interrumpido pendiente (generación {}) — corré `hammer upgrade recover [--rollback]`", p.id);
}
let cur = hammer_upgrade::current(&state_root)?;
match cur {
Some(id) => println!("generación viva: {id}"),