gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 188Rust 55Shell 30Markdown 281211 Definiciones

arranque-grafo: current/default siempre presentes — interop con el lector de mirada

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 <noreply@anthropic.com>

ef86e4f986f9dd0b03eeebe417ae993a841b1cc0 · por sergio · (hace 3 meses) · padre 3c343bee9 · ver el árbol en este commit

Delta semántico · 5

crates/hammer-cli/src/main.rs

  • cambió por dentro fn main

crates/hammer-upgrade/src/boot_graph.rs

crates/hammer-upgrade/src/lib.rs

3 archivos · +28 −10

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<String>,
-    /// `id` a arrancar si el usuario no elige.
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub default: Option<String>,
+    /// `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<BootNode>,
 }
 
@@ -179,6 +181,8 @@ pub fn build(state_root: &Path) -> Result<BootGraph> {
         });
     }
 
+    // `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}");