tawasuyu

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 1574Shell 204Rust 111Markdown 993763 Definiciones

build: desacoplar el lab (.dev-fs) del padre del store

Atar el lab al PADRE DEL STORE era una suposición oculta y cara. Al anclar el store de un worker a
un volumen persistente, el lab pasó a buscarse en /mnt/cosecha/.dev-fs —que no existe— y toda
receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje que apunta al
lugar equivocado: el zig estaba, y estaba bien, en /opt/hammer/.dev-fs/tools/. Dos campañas
leyeron eso como deuda de la cascada GUI. El commit anterior lo tapó con un bind-mount, que respeta
la suposición en vez de eliminarla; esto la elimina.

Son dos decisiones independientes: dónde se GUARDA lo sellado (almacenamiento) y dónde vive el
toolchain de desarrollo (entorno).

`work_root` NO se desacopla, y es deliberado: el seal final es un rename, que sólo es atómico
dentro del mismo filesystem, así que work DEBE seguir al store. Ése es un acoplamiento real, no una
suposición — el test lo fija para que nadie lo "arregle" de más.

Resolución del lab, de más explícito a más adivinado: `HAMMER_LAB` → hermano del store si existe
(preserva EXACTAMENTE el comportamiento histórico en la topología normal) → hacia arriba desde el
CWD, como git con .git (ésta es la que desacopla) → None, que cae al default de siempre para que
los errores sigan apuntando a un lugar previsible.

La parte que huele el entorno (env + CWD) queda sólo en `from_env_or_defaults`;
`defaults_for_store` se mantiene PURA para que los tests sigan siendo herméticos.

Los hashes NO se mueven: `artifact_hash` no recibe BuildConfig y `hash_inputs` sólo mezcla
contenido de la receta (source id, compiler, target, link, el string zig_version, patches, flags,
phases, hashes de deps) — ninguna ruta del lab. Verificado empíricamente además de por lectura:
`hammer hash --check` sobre el corpus da 754 selladas / 14 sin sellar de 768, calcado al grafo de
estado (12 deuda + 2 nunca). Si algún hash se hubiera movido, una sellada diría NO-SELLADO.

Verificado también end-to-end: con un store cuyo padre no tiene .dev-fs, hammer sube desde el CWD,
encuentra el lab y construye (antes moría en el acto); y la topología normal sigue dando cache-hit
instantáneo con el mismo hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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

Delta semántico · 12

1 archivos · +128 −6

diff --git a/crates/hammer-build/src/config.rs b/crates/hammer-build/src/config.rs
index 6cda2eb6..918c8466 100644
--- a/crates/hammer-build/src/config.rs
+++ b/crates/hammer-build/src/config.rs
@@ -21,27 +21,88 @@ pub struct BuildConfig {
     pub cache_root: Option<PathBuf>,
 }
 
+/// Nombre del directorio del lab, buscado hacia arriba como hace git con `.git`.
+const LAB_DIR: &str = ".dev-fs";
+
+/// Busca el lab (`.dev-fs`) desde `start` hacia arriba. Pura y testeable: no mira env ni CWD.
+fn find_lab_upward(start: &Path) -> Option<PathBuf> {
+    start.ancestors().map(|d| d.join(LAB_DIR)).find(|c| c.is_dir())
+}
+
+/// Resuelve dónde vive el lab SIN atarlo a dónde vive el store.
+///
+/// Orden, de más explícito a más adivinado:
+///   1. `HAMMER_LAB` — el operador manda.
+///   2. Hermano del store (`<store>/../.dev-fs`) **si existe**. Preserva exactamente el
+///      comportamiento histórico en la topología normal (lab y store en la raíz del proyecto).
+///   3. Hacia arriba desde el CWD. Ésta es la que desacopla: cubre el caso en que el store fue
+///      movido a otro filesystem (volumen persistente) y el lab se quedó en el proyecto.
+///   4. `None` ⇒ el llamador cae al default histórico, para que los mensajes de error sigan
+///      apuntando a un lugar previsible en vez de a la nada.
+fn resolve_lab_dir(store_root: &Path) -> Option<PathBuf> {
+    if let Some(v) = std::env::var_os("HAMMER_LAB") {
+        return Some(PathBuf::from(v));
+    }
+    if let Some(parent) = store_root.parent() {
+        let sibling = parent.join(LAB_DIR);
+        if sibling.is_dir() {
+            return Some(sibling);
+        }
+    }
+    find_lab_upward(&std::env::current_dir().ok()?)
+}
+
 impl BuildConfig {
     /// Defaults relativos al store: `.dev-fs` y `work/` como hermanos del store.
     /// `store_root = /…/store` → rootfs = `/…/.dev-fs/alpine`, zig = `/…/.dev-fs/tools/zig`,
     /// work = `/…/work`, cache = `/…/.dev-fs/cache`.
+    ///
+    /// PURA a propósito (no mira env ni CWD): los tests montan configs herméticas con esto. Quien
+    /// quiera la resolución real del entorno usa [`Self::from_env_or_defaults`].
     pub fn defaults_for_store(store_root: &Path) -> Self {
         let project_root = store_root
             .parent()
             .unwrap_or_else(|| Path::new("."))
             .to_path_buf();
+        Self::defaults_for_store_with_lab(store_root, &project_root.join(LAB_DIR))
+    }
+
+    /// Igual que [`Self::defaults_for_store`] pero con el lab (`.dev-fs`) puesto a mano.
+    ///
+    /// POR QUÉ EXISTE (2026-07-22): atar el lab al PADRE DEL STORE resultó ser una suposición
+    /// oculta y cara. Al anclar el store de un worker a un volumen persistente (symlink a
+    /// `/mnt/cosecha/store`), el lab pasó a buscarse en `/mnt/cosecha/.dev-fs` — que no existe — y
+    /// TODA receta con `zig_version` murió con "no encuentro el ejecutable zig en …", un mensaje
+    /// que apunta al lugar equivocado (el zig estaba, y estaba bien). Dos campañas leyeron eso como
+    /// deuda de la cascada GUI. Son dos decisiones independientes: dónde se GUARDA lo sellado
+    /// (almacenamiento) y dónde vive el toolchain de desarrollo (entorno).
+    ///
+    /// `work_root` SÍ sigue al store, y es deliberado: el `seal` final es un rename, que sólo es
+    /// atómico dentro del mismo filesystem. Ése es un acoplamiento real, no una suposición.
+    pub fn defaults_for_store_with_lab(store_root: &Path, lab: &Path) -> Self {
+        let store_parent = store_root
+            .parent()
+            .unwrap_or_else(|| Path::new("."))
+            .to_path_buf();
         Self {
-            rootfs: project_root.join(".dev-fs/alpine"),
-            zig_dir: project_root.join(".dev-fs/tools/zig"),
-            work_root: project_root.join("work"),
-            cache_root: Some(project_root.join(".dev-fs/cache")),
+            rootfs: lab.join("alpine"),
+            zig_dir: lab.join("tools/zig"),
+            work_root: store_parent.join("work"),
+            cache_root: Some(lab.join("cache")),
         }
     }
 
-    /// Lee overrides desde entorno (`HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`,
+    /// Lee overrides desde entorno (`HAMMER_LAB`, `HAMMER_ROOTFS`, `HAMMER_ZIG`, `HAMMER_WORK`,
     /// `HAMMER_CACHE`). `HAMMER_CACHE=""` (vacío) desactiva la caché.
+    ///
+    /// Es el constructor que HUELE EL ENTORNO (env + CWD); `defaults_for_store` se mantiene pura.
+    /// `HAMMER_LAB` mueve el lab entero de una; los `HAMMER_ROOTFS`/`HAMMER_ZIG`/`HAMMER_CACHE`
+    /// siguen pisando pieza por pieza y ganan sobre él.
     pub fn from_env_or_defaults(store_root: &Path) -> Self {
-        let mut cfg = Self::defaults_for_store(store_root);
+        let mut cfg = match resolve_lab_dir(store_root) {
+            Some(lab) => Self::defaults_for_store_with_lab(store_root, &lab),
+            None => Self::defaults_for_store(store_root),
+        };
         if let Ok(v) = std::env::var("HAMMER_ROOTFS") {
             cfg.rootfs = PathBuf::from(v);
         }
@@ -57,3 +118,64 @@ impl BuildConfig {
         cfg
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// La topología normal (lab y store hermanos en la raíz) no se mueve.
+    #[test]
+    fn lab_hermano_del_store_sigue_igual() {
+        let cfg = BuildConfig::defaults_for_store(Path::new("/proj/store"));
+        assert_eq!(cfg.rootfs, Path::new("/proj/.dev-fs/alpine"));
+        assert_eq!(cfg.zig_dir, Path::new("/proj/.dev-fs/tools/zig"));
+        assert_eq!(cfg.cache_root.unwrap(), Path::new("/proj/.dev-fs/cache"));
+        assert_eq!(cfg.work_root, Path::new("/proj/work"));
+    }
+
+    /// LA REGRESIÓN: store mudado a un volumen, lab quieto en el proyecto. El lab debe seguir al
+    /// proyecto y NO al store; `work` debe seguir al STORE (mismo filesystem ⇒ seal atómico).
+    #[test]
+    fn lab_y_store_pueden_vivir_en_filesystems_distintos() {
+        let cfg = BuildConfig::defaults_for_store_with_lab(
+            Path::new("/mnt/cosecha/store"),
+            Path::new("/opt/hammer/.dev-fs"),
+        );
+        assert_eq!(cfg.zig_dir, Path::new("/opt/hammer/.dev-fs/tools/zig"));
+        assert_eq!(cfg.rootfs, Path::new("/opt/hammer/.dev-fs/alpine"));
+        assert_eq!(cfg.cache_root.unwrap(), Path::new("/opt/hammer/.dev-fs/cache"));
+        assert_eq!(
+            cfg.work_root,
+            Path::new("/mnt/cosecha/work"),
+            "work DEBE seguir al store: el seal es un rename y sólo es atómico en el mismo FS"
+        );
+    }
+
+    #[test]
+    fn find_lab_upward_encuentra_desde_un_subdirectorio() {
+        let d = tempfile::tempdir().unwrap();
+        let raiz = d.path().join("proj");
+        std::fs::create_dir_all(raiz.join(LAB_DIR)).unwrap();
+        let hondo = raiz.join("a/b/c");
+        std::fs::create_dir_all(&hondo).unwrap();
+        assert_eq!(find_lab_upward(&hondo), Some(raiz.join(LAB_DIR)));
+    }
+
+    #[test]
+    fn find_lab_upward_no_inventa_lo_que_no_existe() {
+        let d = tempfile::tempdir().unwrap();
+        let hondo = d.path().join("sin/lab/alguno");
+        std::fs::create_dir_all(&hondo).unwrap();
+        assert_eq!(find_lab_upward(&hondo), None);
+    }
+
+    /// Un fichero llamado `.dev-fs` no es un lab: la búsqueda exige directorio.
+    #[test]
+    fn find_lab_upward_ignora_un_fichero_homonimo() {
+        let d = tempfile::tempdir().unwrap();
+        let raiz = d.path().join("proj");
+        std::fs::create_dir_all(&raiz).unwrap();
+        std::fs::write(raiz.join(LAB_DIR), b"no soy un lab").unwrap();
+        assert_eq!(find_lab_upward(&raiz), None);
+    }
+}