From ef86e4f986f9dd0b03eeebe417ae993a841b1cc0 Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 6 Jul 2026 22:34:49 -0400 Subject: [PATCH] =?UTF-8?q?arranque-grafo:=20current/default=20siempre=20p?= =?UTF-8?q?resentes=20=E2=80=94=20interop=20con=20el=20lector=20de=20mirad?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verificado contra el parser REAL del otro agente (mirada-boot-core::BootGraph, recién commiteado en tawasuyu): su struct declara `current: String` y `default: String` OBLIGATORIOS (sin Option, sin default). Mi emisor los omitía (`skip_serializing_if`) cuando no hay generación viva ⇒ en un sistema recién instalado (cero upgrades) el JSON era `{"version":1,"nodes":[]}` y mirada fallaba al parsear con "missing field `current`". Fix del lado productor (adaptar la salida a la forma publicada del contrato): BootGraph.current/default pasan a String, presentes siempre, cadena vacía = "sin generación viva" (degrada limpio: default_index() de mirada cae al primer bootable). Probado e2e pasando el JSON real de `hammer boot graph` (vacío + poblado) por mirada-boot-core::BootGraph::from_path → ambos OK. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-cli/src/main.rs | 4 ++-- crates/hammer-upgrade/src/boot_graph.rs | 30 ++++++++++++++++++++----- crates/hammer-upgrade/src/lib.rs | 4 ++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 4a8eaef0..5695032c 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -1221,9 +1221,9 @@ fn main() -> anyhow::Result<()> { } else { boot_graph::emit(&state_root, &out)?; let g = boot_graph::build(&state_root)?; + let vivo = if g.current.is_empty() { "(ninguno)" } else { &g.current }; println!("✓ grafo de arranque escrito en {}", out.display()); - println!(" {} nodo(s); vivo: {}", g.nodes.len(), - g.current.as_deref().unwrap_or("(ninguno)")); + println!(" {} nodo(s); vivo: {vivo}", g.nodes.len()); } } BootCmd::Activate { id, from_select } => { diff --git a/crates/hammer-upgrade/src/boot_graph.rs b/crates/hammer-upgrade/src/boot_graph.rs index 01b47e5f..558a1c3c 100644 --- a/crates/hammer-upgrade/src/boot_graph.rs +++ b/crates/hammer-upgrade/src/boot_graph.rs @@ -74,12 +74,14 @@ pub struct BootNode { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BootGraph { pub version: u32, - /// `id` del nodo vivo (la generación actual), o `None` si el sistema nunca aplicó un upgrade. - #[serde(skip_serializing_if = "Option::is_none")] - pub current: Option, - /// `id` a arrancar si el usuario no elige. - #[serde(skip_serializing_if = "Option::is_none")] - pub default: Option, + /// `id` del nodo vivo (la generación actual). Cadena **vacía** si el sistema nunca aplicó un + /// upgrade (no hay generación registrada) — el lector de mirada exige el campo presente, así que + /// se emite siempre y degrada a "sin preselección" ante `""`. + #[serde(default)] + pub current: String, + /// `id` a arrancar si el usuario no elige. Vacío si no hay generación viva (ver [`current`]). + #[serde(default)] + pub default: String, pub nodes: Vec, } @@ -179,6 +181,8 @@ pub fn build(state_root: &Path) -> Result { }); } + // `current`/`default` van siempre presentes (String); vacío = "sin generación viva". + let current_id = current_id.unwrap_or_default(); Ok(BootGraph { version: 1, current: current_id.clone(), default: current_id, nodes }) } @@ -305,6 +309,20 @@ mod tests { assert!(a.chars().all(|c| c.is_ascii_hexdigit())); } + #[test] + fn empty_state_emits_present_string_fields() { + // Sistema sin upgrades: el grafo debe traer `current`/`default` PRESENTES (cadena vacía), + // no omitidos — el lector de mirada los exige como String obligatorio. + let tmp = tempfile::tempdir().unwrap(); + let g = build(tmp.path()).unwrap(); + assert!(g.nodes.is_empty()); + assert_eq!(g.current, ""); + assert_eq!(g.default, ""); + let json = serde_json::to_string(&g).unwrap(); + assert!(json.contains("\"current\""), "current debe emitirse aun vacío: {json}"); + assert!(json.contains("\"default\""), "default debe emitirse aun vacío: {json}"); + } + #[test] fn tree_name_strips_hash_prefix() { assert_eq!(tree_name("abc123-product-rootfs"), "product-rootfs"); diff --git a/crates/hammer-upgrade/src/lib.rs b/crates/hammer-upgrade/src/lib.rs index e2eee522..2367ba55 100644 --- a/crates/hammer-upgrade/src/lib.rs +++ b/crates/hammer-upgrade/src/lib.rs @@ -1118,7 +1118,7 @@ mod tests { assert_eq!(gen2.parents, vec![gen1.id.clone()], "el DAG cuelga del of_tree del padre"); // current/default = la generación viva (gen 3). let gen3 = &g.nodes[2]; - assert_eq!(g.current.as_deref(), Some(gen3.id.as_str())); + assert_eq!(g.current, gen3.id); assert_eq!(g.default, g.current); // el nodo de recuperación cuelga de la viva. let rec = g.nodes.iter().find(|n| n.kind == NodeKind::Recovery).unwrap(); @@ -1139,7 +1139,7 @@ mod tests { // ahora las gens 2 y 3 son huérfanas (redo no soportado) ⇒ error honesto. let g2 = build(&state).unwrap(); - assert_eq!(g2.current.as_deref(), Some(gen1.id.as_str())); + assert_eq!(g2.current, gen1.id); let err = activate(&root, &state, None, &gen3.id).unwrap_err(); assert!(err.to_string().contains("redo"), "activar hacia adelante debe fallar claro: {err}");