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 1572Shell 203Rust 109Markdown 993728 Definiciones

waypipe: el escritorio en una ventana — ciclo de segundos, y el hueco de la libc queda medido

Dos piezas para mirar la distro sin QEMU, que es lo que hacía falta para cazar el puntero
invisible: ese defecto no lo ve ninguna métrica de clausura Y TAMPOCO una captura headless — el
cursor no está en el framebuffer que devuelve screencopy. Hay que mirar la ventana.

`hydrate-profile.py <perfil>`: proyecta el cierre ENTERO de un perfil de `targets.toml`. Los
`hydrate-*.sh` por escritorio traen la lista de raíces escrita a mano dentro del script, o sea DOS
fuentes de verdad: hoy mismo se añadió `adwaita-cursors` a cosmic y sway y esos scripts —que no la
conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva.
Acá el cierre sale de `yupana.membresia()`, la misma función que usan build-state y vigia-imagen.

Y trae la sonda que costó dos intentos: `hydrate` ENLAZA, y `linkat()` no cruza un punto de
MONTAJE aunque sea el mismo disco. El store y `work/out` son dos binds del mismo /dev/sdb ⇒
`st_dev` COINCIDE y el hardlink falla igual. La primera versión comparaba `st_dev` y no disparó:
salieron 174 «artefactos que no proyectaron» y un rootfs de 272 ficheros con pinta de problema de
recetas. Ahora la sonda es FUNCIONAL —se intenta un enlace de verdad— y el error nombra el montaje
del store.

`sway-nested.sh`: arranca `escritorio-sway` como ventana del compositor que ya tenés delante.
Evidencia de esta corrida, con el rootfs hidratado del perfil:

    [wlr] [xcursor] Loaded cursor theme 'default' at size 24 (62 available cursors)

o sea el tema llamado literalmente `default` —el alias que fabrica `adwaita-cursors`— resolviendo
a los 62 cursores de Adwaita. Antes de hoy esa línea no existía.

Tres cosas que sólo salen corriéndolo:
· **Ningún perfil incluye `musl`.** Ni `base`. El cierre de escritorio-sway no trae UN `ld-musl` y
  sway es `link=dynamic` con intérprete `/lib/ld-musl-x86_64.so.1`: la libc sale del bootstrap, no
  de una receta del perfil. Por eso el arnés monta DOS overlays. No es un bug, pero no estaba
  escrito en ningún lado y un rootfs de perfil solo no arranca.
· **`yambar` no va en `bar { status_command … }`**: es una barra completa de layer-shell, no un
  productor de estado para swaybar. Puesta ahí, sway rechaza la config ENTERA y arranca pelado —
  se lee como «el escritorio está roto» cuando lo único mal es una línea.
· **`grim` hay que apuntarlo al socket de NUESTRO sway.** Adentro `WAYLAND_DISPLAY` es el del HOST
  (es lo que sway necesita para nestear), así que un `grim` a secas devuelve una captura perfecta…
  del escritorio de al lado. La primera captura fue exactamente eso.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGyBj4QyeCZiwLMkP9GJp6

b08212a7f173ac4f3387a7d7bdecf1d1ba6c8b08 · por Sergio · (hace 1 mes) · padre 8c4e1a6b2 · ver el árbol en este commit

Delta semántico · 2

scripts/hydrate-profile.py

2 archivos · +289 −0

diff --git a/scripts/hydrate-profile.py b/scripts/hydrate-profile.py
new file mode 100755
index 00000000..b74514b7
--- /dev/null
+++ b/scripts/hydrate-profile.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+# hydrate-profile.py — proyecta al FHS el cierre ENTERO de un perfil de `targets.toml`.
+#
+# ══ POR QUÉ EXISTE, SI YA HAY UN hydrate POR ESCRITORIO ════════════════════════════════════════
+# `scripts/{kde,gnome,cosmic}/hydrate-*.sh` traen la lista de raíces ESCRITA A MANO dentro del
+# propio script. Desde que `docs/state/targets.toml` es la declaración de qué lleva cada imagen,
+# eso son DOS fuentes de verdad para lo mismo, y divergen sin que nada lo diga: el 2026-09-04 se
+# añadió `adwaita-cursors` a las raíces de cosmic y sway, y los scripts de hidratación —que no la
+# conocen— habrían seguido armando un rootfs sin cursores mientras el perfil decía que los lleva.
+# La métrica y la imagen dirían cosas distintas, que es exactamente el modo de fallo que
+# `vigia-imagen.py` existe para cazar.
+#
+# Acá el cierre sale de donde tiene que salir: `yupana.membresia()` sobre `targets.toml`, la misma
+# función que usan `build-state.py` y `vigia-imagen.py`. Si el perfil cambia, esto cambia solo.
+#
+# ══ EL ARTEFACTO SE ELIGE POR HASH, NO POR FECHA ═══════════════════════════════════════════════
+# `hammer hash <receta>` da el ArtifactHash de la receta de HOY sin construir. Con dos artefactos
+# del mismo paquete conviviendo en el store —lo normal tras cualquier re-hash— el más reciente
+# puede ser el viejo. La fecha miente; el hash no.
+#
+# ══ UN NODO SIN ARTEFACTO NO SE SALTEA EN SILENCIO ═════════════════════════════════════════════
+# Se listan todos y se sale con código ≠ 0 salvo `--allow-missing`. Un rootfs al que le faltan
+# tres paquetes arranca y falla tres capas más abajo (`CLAUDE.md` regla 3, y la cicatriz de sway
+# llegando a 121/121 sin emulador de terminal).
+#
+#   uso:  scripts/hydrate-profile.py <perfil> [--into DIR] [--store DIR]
+#                                    [--link static|dynamic] [--keep] [--allow-missing]
+#
+#   La superficie va en INGLÉS (`CLAUDE.md` regla 4); los mensajes, en castellano.
+import os, sys, glob, shutil, subprocess
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import yupana
+import targets as targets_mod
+
+ROOT = yupana.ROOT
+HAMMER = str(ROOT / "target/release/hammer")
+
+
+def opcion(nombre, defecto=None):
+    """`--flag valor`. Devuelve `defecto` si no está."""
+    if nombre in sys.argv:
+        i = sys.argv.index(nombre)
+        if i + 1 < len(sys.argv):
+            return sys.argv[i + 1]
+        sys.exit("falta el valor de %s" % nombre)
+    return defecto
+
+
+def main():
+    libres = [a for i, a in enumerate(sys.argv[1:], 1)
+              if not a.startswith("--") and not (sys.argv[i - 1].startswith("--")
+                                                 and sys.argv[i - 1] in FLAGS_CON_VALOR)]
+    if not libres:
+        sys.exit(__doc__ or "uso: scripts/hydrate-profile.py <perfil> [--into DIR]")
+    perfil = libres[0]
+    store = opcion("--store", os.environ.get("HAMMER_STORE", str(ROOT / "store")))
+    # Dónde cae el rootfs por defecto. Se puede fijar por entorno porque el sitio CORRECTO depende
+    # de cómo esté montado el store en cada máquina, no del perfil (ver la sonda EXDEV de abajo).
+    base_into = os.environ.get("HAMMER_ROOTFS_DIR", str(ROOT / "work/out"))
+    into = opcion("--into", os.path.join(base_into, perfil.replace("escritorio-", "") + "-rootfs"))
+    link = opcion("--link", "static")
+    keep = "--keep" in sys.argv
+    permitir_faltantes = "--allow-missing" in sys.argv
+
+    perfiles = targets_mod.load()
+    if perfil not in perfiles:
+        sys.exit("perfil desconocido: %s (hay: %s)" % (perfil, ", ".join(sorted(perfiles))))
+    colas = yupana.cargar_colas()
+    memb = yupana.membresia(colas, perfiles)
+    nodos = sorted(n for n, ps in memb.items() if perfil in ps and n[0] != "wanted")
+    if not nodos:
+        sys.exit("el cierre de %s salió vacío — ¿el perfil declara raíces?" % perfil)
+
+    print("== %s: %d nodos en el cierre → %s" % (perfil, len(nodos), into))
+
+    # ── resolver ANTES de tocar el disco: si falta algo, que se sepa antes de borrar nada ──────
+    artefactos, faltan = [], []
+    for cola, nombre in nodos:
+        receta = ROOT / ("recipes/%s.toml" % nombre if cola == "corpus"
+                         else "recipes/%s/%s.toml" % (cola, nombre))
+        if not receta.exists():
+            faltan.append((nombre, "sin receta"))
+            continue
+        r = subprocess.run([HAMMER, "--store", store, "hash", str(receta)],
+                           capture_output=True, text=True)
+        h = r.stdout.strip().replace("b3:", "")
+        if len(h) != 64:
+            faltan.append((nombre, "hash no computable"))
+            continue
+        cand = glob.glob(os.path.join(store, h + "-*"))
+        if not cand:
+            faltan.append((nombre, "no sellado (%s)" % h[:12]))
+            continue
+        artefactos.append((nombre, h, cand[0]))
+
+    if faltan:
+        print("\n   ⚠ %d nodo(s) SIN artefacto en el store:" % len(faltan))
+        for nombre, motivo in faltan:
+            print("     %-28s %s" % (nombre, motivo))
+        if not permitir_faltantes:
+            sys.exit("\nabortado sin tocar %s — con `--allow-missing` se hidrata lo que hay" % into)
+        print("   → se continúa por `--allow-missing`: el rootfs sale INCOMPLETO\n")
+
+    if os.path.isdir(into) and not keep:
+        shutil.rmtree(into)
+    os.makedirs(into, exist_ok=True)
+
+    # ── EXDEV: `hydrate` proyecta por HARDLINK, y `linkat()` NO cruza un punto de MONTAJE ───────
+    # aunque los dos lados sean el mismo filesystem y el mismo disco. Acá el store y `work/out` son
+    # dos bind mounts del MISMO /dev/sdb: `st_dev` coincide y el hardlink falla igual — por eso la
+    # sonda es FUNCIONAL (se intenta un enlace de verdad) y no una comparación de `st_dev`, que fue
+    # el primer intento y no disparó. Sin esto salen 174 «artefactos que no proyectaron» y un rootfs
+    # de 272 ficheros que parece un problema de recetas.
+    sonda_src = os.path.join(store, ".hydrate-profile-probe")
+    sonda_dst = os.path.join(into, ".hydrate-profile-probe")
+    try:
+        with open(sonda_src, "w"):
+            pass
+        os.link(sonda_src, sonda_dst)
+        os.unlink(sonda_dst)
+    except OSError as e:
+        mp = subprocess.run(["findmnt", "-n", "-T", store, "-o", "TARGET"],
+                            capture_output=True, text=True).stdout.strip()
+        sys.exit("EXDEV: no se puede enlazar del store a `%s` (%s).\n"
+                 "       `hydrate` ENLAZA, no copia, y un hardlink no cruza un montaje.\n"
+                 "       el store está montado en: %s\n"
+                 "       elegí un `--into` bajo ESE montaje (o fijá HAMMER_ROOTFS_DIR)."
+                 % (into, e.strerror, mp or "?"))
+    finally:
+        if os.path.exists(sonda_src):
+            os.unlink(sonda_src)
+
+    fallidos = []
+    for i, (nombre, h, _d) in enumerate(artefactos, 1):
+        r = subprocess.run([HAMMER, "--store", store, "hydrate", h,
+                            "--into", into, "--link", link],
+                           capture_output=True, text=True)
+        if r.returncode != 0:
+            fallidos.append((nombre, (r.stderr or r.stdout).strip().splitlines()[-1:] or [""]))
+        if i % 25 == 0 or i == len(artefactos):
+            print("   %d/%d" % (i, len(artefactos)))
+
+    if fallidos:
+        print("\n   ✗ %d artefacto(s) no proyectaron:" % len(fallidos))
+        for nombre, msg in fallidos:
+            print("     %-28s %s" % (nombre, msg[0] if msg else ""))
+
+    n_fich = sum(len(f) for _, _, f in os.walk(into))
+    du = subprocess.run(["du", "-sh", into], capture_output=True, text=True).stdout.split()
+    print("\n== %s: %d ficheros, %s" % (into, n_fich, du[0] if du else "?"))
+    sys.exit(1 if (fallidos or (faltan and not permitir_faltantes)) else 0)
+
+
+FLAGS_CON_VALOR = {"--into", "--store", "--link"}
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/wlr/sway-nested.sh b/scripts/wlr/sway-nested.sh
new file mode 100755
index 00000000..69d3eb2f
--- /dev/null
+++ b/scripts/wlr/sway-nested.sh
@@ -0,0 +1,131 @@
+#!/bin/sh
+# sway-nested.sh — arranca el escritorio `escritorio-sway` como una VENTANA del compositor que ya
+# tenés delante, sin QEMU, sin kernel y sin imagen de disco. Hermano de `sway-headless.sh` (que
+# dibuja contra un backend headless y captura con grim) y de `sway-start.sh` (que arranca dentro de
+# la VM).
+#
+# ══ PARA QUÉ, SI YA ESTÁ EL DE QEMU ════════════════════════════════════════════════════════════
+# Porque el ciclo de QEMU es de decenas de minutos —armar rootfs, imagen EFI, arrancar— y el de acá
+# es de segundos, así que «¿esto se ve bien?» deja de costar una tanda. Y porque hay un tipo de
+# defecto que SÓLO se ve mirando: el puntero invisible por falta de tema de cursor (arreglado el
+# 2026-09-04 con `adwaita-cursors`) no lo caza ninguna métrica de clausura, y en una captura
+# headless tampoco aparece — el cursor no está en el framebuffer que devuelve `grim`.
+#
+# NO sustituye al arranque en QEMU: acá no hay kernel nuestro, ni initramfs, ni DRM, ni PID 1.
+# Prueba el compositor y sus clientes, que es justo lo que la VM tapa detrás de una pantalla negra.
+#
+# ══ LAS DOS CAPAS, Y POR QUÉ SON DOS ═══════════════════════════════════════════════════════════
+# `targets.toml` describe lo que la IMAGEN añade, no un sistema entero: ningún perfil —ni `base`—
+# incluye `musl`, porque la libc, busybox y PID 1 salen del bootstrap (`work/metal-rootfs`), no de
+# una receta del perfil. Medido: el cierre de `escritorio-sway` no trae UN solo `ld-musl`, y sway
+# es `link = dynamic` con intérprete `/lib/ld-musl-x86_64.so.1`. Por eso acá van dos overlays: la
+# capa base (por defecto el dev-fs de Alpine, que es lo que ya usan los `nested-*` de KDE) y encima
+# el rootfs del perfil. Si algún día el perfil declarara su propia base, esto se queda en uno.
+#
+# ══ QUÉ MIRAR ═════════════════════════════════════════════════════════════════════════════════
+# Se abre una ventana con el fondo azul de la config, la barra de `yambar` y una terminal `foot`.
+# Lo que hay que comprobar A OJO y ninguna captura headless muestra: **que el puntero se vea** al
+# entrar el mouse en la ventana.
+#
+#   uso:   scripts/wlr/sway-nested.sh
+#   env:   RFS   (def /mnt/cosecha/escritorios/sway-rootfs)  rootfs del perfil, ya hidratado con
+#                                                            scripts/hydrate-profile.py
+#          BASE  (def .dev-fs/alpine)                        capa con la libc
+#          W/H   (def 1280x800)                              tamaño de la ventana
+#          APP   (def foot)                                  cliente que se lanza dentro
+#          OUT   (def /mnt/cosecha/escritorios)               dónde deja `sway-nested.png`
+#          SECS  (def 0 = quedarse abierto)                   segundos antes de cerrar solo
+set -u
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"; cd "$ROOT"
+RFS="${RFS:-/mnt/cosecha/escritorios/sway-rootfs}"
+BASE="${BASE:-$ROOT/.dev-fs/alpine}"
+W="${W:-1280}"; H="${H:-800}"; APP="${APP:-foot}"
+OUT="${OUT:-/mnt/cosecha/escritorios}"; SECS="${SECS:-0}"
+mkdir -p "$OUT"
+
+[ -d "$RFS" ]  || { echo "falta el rootfs del perfil: $RFS" >&2
+                    echo "   scripts/hydrate-profile.py escritorio-sway --store /mnt/cosecha/store --into $RFS" >&2; exit 1; }
+[ -d "$BASE" ] || { echo "falta la capa base con la libc: $BASE" >&2; exit 1; }
+
+# El socket del compositor del HOST. Es lo único del host que cruza: un socket, no una librería.
+HOST_XDG="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
+HOST_WL="${WAYLAND_DISPLAY:-wayland-0}"
+case "$HOST_WL" in /*) SOCK="$HOST_WL" ;; *) SOCK="$HOST_XDG/$HOST_WL" ;; esac
+[ -S "$SOCK" ] || { echo "no hay socket Wayland del host en $SOCK" >&2; exit 1; }
+echo "== host: $SOCK   rootfs: $RFS"
+
+exec bwrap \
+  --overlay-src "$BASE" --overlay-src "$RFS" --tmp-overlay / \
+  --dev /dev --proc /proc --tmpfs /tmp --tmpfs /run \
+  --dev-bind-try /dev/dri /dev/dri \
+  --ro-bind "$SOCK" /run/wl-host \
+  --bind "$OUT" /salida \
+  --unshare-pid --unshare-uts --hostname sway-nested \
+  --uid 0 --gid 0 --die-with-parent \
+  --setenv PATH /usr/bin:/bin:/usr/sbin:/sbin \
+  --setenv HOME /root --setenv SHELL /bin/sh --setenv TERM xterm-256color \
+  --setenv XDG_RUNTIME_DIR /run/user/0 \
+  --setenv WAYLAND_DISPLAY /run/wl-host \
+  --setenv WLR_BACKENDS wayland \
+  --setenv WLR_RENDERER pixman \
+  --setenv WLR_WL_OUTPUTS 1 \
+  --setenv XDG_DATA_DIRS /usr/share \
+  --setenv FONTCONFIG_PATH /etc/fonts \
+  /bin/sh -c '
+    set -u
+    mkdir -p /run/user/0 /root/.config/sway /etc; chmod 700 /run/user/0
+    # /etc/passwd: sin él, cualquier getpwuid() falla y yambar no arranca (cicatriz de headless).
+    [ -s /etc/passwd ] || echo "root:x:0:0:root:/root:/bin/sh" > /etc/passwd
+    cat > /root/.config/sway/config <<EOF
+output * bg #1a4b8c solid_color
+default_border pixel 2
+EOF
+    echo "-- sway (backend wayland, '"$W"'x'"$H"')…"
+    # `-d`: la línea que dice QUÉ tema de cursor cargó wlroots es de nivel DEBUG. Sin `-d` el log
+    # queda mudo justo sobre lo que este arnés existe para comprobar.
+    WLR_WL_OUTPUT_WIDTH='"$W"' WLR_WL_OUTPUT_HEIGHT='"$H"' \
+      sway -d -c /root/.config/sway/config >/tmp/sway.log 2>&1 &
+    SWAY=$!
+    i=0
+    while [ $i -lt 60 ]; do
+      S=$(ls /run/user/0/sway-ipc.* 2>/dev/null | head -1)
+      [ -n "${S:-}" ] && break
+      kill -0 $SWAY 2>/dev/null || { echo "   !! sway murió:"; tail -20 /tmp/sway.log; exit 1; }
+      i=$((i+1)); sleep 0.5
+    done
+    [ -n "${S:-}" ] || { echo "   !! sway no abrió su IPC en 30 s:"; tail -20 /tmp/sway.log; exit 1; }
+    echo "   sway OK — ventana en tu pantalla"
+    export SWAYSOCK="$S"
+    # El cliente se lanza DESPUÉS del socket: los `exec` de la config disparan antes de que exista
+    # el workspace y mueren con «Failed to create launch context».
+    #
+    # ⚠ `yambar` NO va en un bloque `bar { status_command … }`: es una barra COMPLETA de layer-shell,
+    # no un productor de estado para swaybar. Puesta ahí, sway rechaza la config ENTERA («Error(s)
+    # loading config!») y arranca sin fondo ni bordes — un fallo que se lee como «el escritorio está
+    # roto» cuando lo único mal es una línea. Se lanza como cliente, igual que foot.
+    swaymsg exec '"$APP"' >/dev/null 2>&1 || '"$APP"' &
+    sleep 4
+    echo "== clientes =="; swaymsg -t get_tree 2>/dev/null | tr "," "\n" | grep "\"app_id\"" | grep -v null | sort -u
+    # La línea que PRUEBA el tema de cursor: sin `adwaita-cursors` wlroots no encuentra el tema
+    # `default` y el puntero no se dibuja. Se reintenta porque el tema se carga cuando el puntero
+    # entra por primera vez, no al arrancar — mirarlo una sola vez daba un ⚠ falso.
+    echo "== cursores =="
+    j=0; while [ $j -lt 10 ]; do
+      grep -i "cursor theme" /tmp/sway.log && break
+      j=$((j+1)); sleep 1
+      [ $j -eq 10 ] && echo "   ⚠ wlroots NO cargó ningún tema de cursor"
+    done
+    # La captura. ⚠ `grim` hay que apuntarlo al socket de NUESTRO sway: adentro `WAYLAND_DISPLAY`
+    # es el del HOST (es lo que sway necesita para nestear), así que un `grim` a secas captura la
+    # pantalla del host entera — sale una foto perfecta… del escritorio de al lado. Medido.
+    # El cursor NO sale en la captura (no está en el framebuffer de screencopy): para verlo hay que
+    # mirar la ventana. Esto prueba lo otro — que los clientes dibujaron.
+    WLSWAY=$(basename "$(ls /run/user/0/wayland-* 2>/dev/null | grep -v lock | head -1)")
+    if command -v grim >/dev/null 2>&1 && [ -n "${WLSWAY:-}" ]; then
+      WAYLAND_DISPLAY="$WLSWAY" grim /salida/sway-nested.png 2>/tmp/grim.log \
+        && echo "== captura (por $WLSWAY): '"$OUT"'/sway-nested.png" \
+        || { echo "== captura FALLÓ:"; tail -3 /tmp/grim.log; }
+    fi
+    echo "== sway.log (cola) =="; grep -vi "Basic output test" /tmp/sway.log | tail -4
+    if [ '"$SECS"' -gt 0 ]; then sleep '"$SECS"'; kill $SWAY 2>/dev/null; else wait $SWAY; fi
+  '