--- id: 0033 title: Dispatcher multi-lenguaje + runtime Python embebido status: pending priority: high created: 2026-05-02 depends_on: [0026] blocks: [0034] --- ## Contexto `graph_explorer` hoy depende de WSL: el `.exe` Windows invoca `python/.venv/bin/python3` que vive dentro de WSL. Consecuencias: - Cada job paga arranque de WSL (~100-300 ms warm, varios segundos cold) + cruce de paths Linux/Windows. - Bugs recurrentes de path mixto (`projects\default\operations.db` → `/home/.../projects\default\...`). - La app no es portable: requiere WSL configurado, distro presente y el `.venv` del registry montado. - El test del path absoluto (issue 0029-fix de hoy) es un parche; el problema de fondo es el cruce de OS. Objetivo: que `graph_explorer.exe` se distribuya **autocontenido** y soporte enrichers escritos en cualquiera de los lenguajes del registry, sin pedirle nada al sistema huésped. ## Diseño ### 1. Manifest extendido ```yaml id: fetch_webpage name: "Fetch web page" applies_to: [Url, Webpage] emits: [Domain] relations: [BELONGS_TO] lang: python # NUEVO — go|python|bash (default: python para retrocompat) exec: run # NUEVO — basename del binario/script (sin extension) params: - { name: timeout_s, type: int, default: 15 } ``` Reglas: - `lang: python` → `/.py` (default `run.py`). - `lang: go` → `/` (Linux) o `/.exe` (Windows). - `lang: bash` → `/.sh`. - Default si falta `lang`: `python` (retrocompat con los 5 enrichers existentes). ### 2. EnricherSpec (C++) extendido ```cpp // enrichers.h struct EnricherSpec { std::string id; std::string name; std::string description; std::vector applies_to; std::string run_path; // path absoluto al ejecutable/script std::string lang; // "python", "go", "bash" (NUEVO) std::string exec_basename; // "run" por defecto (NUEVO) }; ``` `enrichers_load()` resuelve `run_path` segun `lang`: - `python`: busca `/.py`. - `go`: busca primero `/.exe` en Windows o `/` en Linux. Si no existe, log de warning y el enricher queda **deshabilitado** (no aparece en el menu). - `bash`: `/.sh`. ### 3. Dispatcher en `jobs.cpp` `run_subprocess()` elige el `argv` segun `spec.lang`: ```cpp std::vector argv; if (spec.lang == "go") { argv = { spec.run_path }; // ejecutable directo } else if (spec.lang == "bash") { argv = { "/bin/bash", spec.run_path }; } else { // python (default) argv = { python_runtime_path(), spec.run_path }; } ``` `python_runtime_path()` busca en este orden: 1. `/runtime/python/python` (Linux) o `python.exe` (Windows) 2. `$FN_PYTHON` env var 3. `/python/.venv/bin/python3` (legacy) 4. `python3` del PATH (fallback) Retorna `""` si no encuentra ninguno; los enrichers Python quedan deshabilitados con un warning visible en el panel. ### 4. Runtime Python embebido #### 4.1 Layout ``` /runtime/ # ignorado por git, generado por freeze python/ python.exe # Windows python3 # Linux pythonXY.zip # stdlib comprimida DLLs/ Lib/ ... site-packages/ requests/ certifi/ charset_normalizer/ ... ``` En Windows usamos el **embedded zip** oficial de python.org (`python-3.12.X-embed-amd64.zip`, ~12 MB). En Linux empaquetamos `python3` del sistema build con un venv limpio (`python -m venv`) copiado a `runtime/python/`. #### 4.2 Script de freeze `tools/freeze_python_runtime.sh`: ```bash #!/usr/bin/env bash # Genera runtime/ embebido para una app. # # Uso: freeze_python_runtime.sh # = linux | windows # # Lee deps de /app.md frontmatter: # python_runtime_deps: [requests, beautifulsoup4, ...] set -euo pipefail APP_DIR="${1:?app_dir requerido}" PLATFORM="${2:?platform requerido (linux|windows)}" PY_VERSION="3.12.7" RUNTIME_DIR="$APP_DIR/runtime/python" deps=$(yq '.python_runtime_deps[]' "$APP_DIR/app.md" 2>/dev/null || echo "") mkdir -p "$RUNTIME_DIR" if [[ "$PLATFORM" == "windows" ]]; then zip="python-${PY_VERSION}-embed-amd64.zip" [[ -f "/tmp/$zip" ]] || curl -sSL -o "/tmp/$zip" \ "https://www.python.org/ftp/python/$PY_VERSION/$zip" unzip -oq "/tmp/$zip" -d "$RUNTIME_DIR" # Habilitar site-packages en el embedded (viene deshabilitado). pth="$RUNTIME_DIR/python$(echo $PY_VERSION | tr -d . | cut -c1-3)._pth" sed -i 's|^#import site|import site|' "$pth" # Instalar deps con pip de un Python "normal". [[ -n "$deps" ]] && python -m pip install --target "$RUNTIME_DIR/Lib/site-packages" \ --platform win_amd64 --only-binary=:all: --python-version "$PY_VERSION" $deps else python3 -m venv "$RUNTIME_DIR" --copies --without-pip "$RUNTIME_DIR/bin/python3" -m ensurepip --upgrade [[ -n "$deps" ]] && "$RUNTIME_DIR/bin/python3" -m pip install $deps fi echo "Frozen runtime: $RUNTIME_DIR ($PLATFORM)" ``` #### 4.3 Hook en `app.md` Frontmatter de la app declara las dependencias: ```yaml --- name: graph_explorer ... python_runtime: true python_runtime_deps: - requests - certifi - urllib3 --- ``` `fn index` recoge estos campos en `apps.python_runtime` y `apps.python_runtime_deps` (JSON). El build de Windows (`compile.sh`/`/compile`) llama al freeze script si `python_runtime: true`. ### 5. UI: badge de lenguaje en el panel En el menu contextual de enrichers y en el panel Jobs, mostrar al lado del nombre un badge `[Go]` / `[Py]` / `[Sh]` para que el usuario sepa cual va a ejecutar. Coste: 3-4 lineas en `views.cpp` y `jobs.cpp`. ## Plan de implementacion ### Fase A — dispatcher multi-lang sin runtime embebido (COMPLETADA) 1. ✅ Extender `EnricherSpec` con `lang`, `exec_basename`, `disabled`, `disabled_reason`. 2. ✅ `parse_manifest` lee `lang` (default `"python"`) y `exec` (default `"run"`). 3. ✅ `enrichers_load` resuelve `run_path` segun lang + plataforma via `resolve_run_path`. Enrichers Go sin binario quedan `disabled` con razon visible y se ocultan del menu. 4. ✅ `run_subprocess` (POSIX y Windows) ramifica `argv` por lang. 5. ✅ Tests pytest del dispatcher: 6 tests nuevos en `tests/test_dispatcher_lang.py` (default lang, bash wire protocol, regresion de enricher real, etc.). 22/22 verde incluyendo los 16 originales. 6. ✅ Los 5 enrichers existentes funcionan sin cambios — heredan `lang: python` por default. Implementado en commit `fce3f97` (rama `issue/0033a-multilang-dispatcher`). ### Fase B — runtime Python embebido (COMPLETADA) 1. ✅ `tools/freeze_python_runtime.sh` con dos backends: - Linux: copia `python-build-standalone` (uv) ~87 MB, autocontenido. - Windows: descarga `python-3.X.Y-embed-amd64.zip` oficial, habilita site-packages, instala wheels via `pip install --target` cross-platform. Idempotente via `runtime/.lock` con SHA256 de (PY_VERSION, deps, platform). Lee `python_runtime_deps` del frontmatter de `app.md` (override: env `PY_DEPS=...`). 2. ⏳ Hook al `/compile` skill — pendiente, parte del issue 0033e. 3. ✅ `cached_python_runtime()` en `jobs.cpp` con cadena de fallback: `/runtime/python/{python.exe|bin/python3}` → `$FN_PYTHON` → `/python/.venv/bin/python3` → `python3` del PATH. Loggea procedencia (`kind=embedded|env| registry_venv|system`) al iniciar jobs_init. 4. ✅ Tests: - `tests/test_python_runtime_resolver.py` — 4 tests verifican fallback con/sin embed, override via FN_PYTHON e idempotencia del freeze script. - Smoke E2E manual: `runtime/python/bin/python3` ejecuta `web_search` en cwd arbitrario, sin pasar por el venv del registry. 5. ✅ `app.md` actualizado con `python_runtime: true` y `python_runtime_deps: [requests, certifi, urllib3]`. 6. ✅ `.gitignore` creado: ignora `runtime/`, `_vendored/`, `.vendor.lock`, binarios Go de enrichers. Implementado en commits ``. Para Windows nativo, el spawn ahora bifurca: si el resolver es `embedded`/`env`/`system`, lanza Python Windows nativo via CreateProcessW; si es `registry_venv` (legacy), sigue el camino `wsl.exe` previo. ### Fase C — UI hints (0.5 sesion) 1. Badge `[Go]/[Py]/[Sh]` en el menu de enrichers. 2. Panel Jobs muestra `lang` en la columna detalles. 3. Si el dispatcher detecta runtime Python ausente y hay enrichers Python registrados, banner persistente en el panel Echo: "runtime Python no encontrado — los enrichers Py estan deshabilitados". ## Tests - `tests/test_dispatcher.py` — manifest dummy de cada lang lanzando un script trivial que devuelve `{"ok": 1}`. Verifica wire protocol. - `tests/test_python_runtime_path.py` — fakea estructuras de carpeta y verifica que la cadena de fallback resuelve en el orden correcto. - Los 16 tests existentes deben seguir pasando (regresion zero). ## Definicion de hecho - `graph_explorer.exe` lanza enrichers Python sin WSL, usando `runtime/python/python.exe` empaquetado junto al exe. - Los manifests aceptan `lang: go` y un binario en el dir del enricher se ejecuta directamente. - `freeze_python_runtime.sh` es idempotente y se llama desde `/compile`. - 0 fallos por path mixto Linux/Windows en jobs (test que arranca el exe desde un cwd arbitrario en cada SO). ## Riesgos y mitigaciones | Riesgo | Mitigacion | |---|---| | Runtime Python suma 50-100 MB | Lazy/condicional via `python_runtime: true`. Apps 100% Go no pagan. | | Embedded Python en Windows no soporta C extensions complejas | Limitar deps a wheels puras o con binarios precompilados (requests, urllib3, beautifulsoup4 OK; lxml/numpy quedan fuera del embedded — usarlas requiere venv full). Documentar lista soportada. | | Mantener freeze script roto | Test en CI que reconstruye el runtime y lanza un enricher dummy. | | Versionar runtime entre PCs | `runtime/` en .gitignore; cada PC lo regenera con `freeze_python_runtime.sh`. Hash SHA256 del zip embed se cachea en `runtime/.lock`. | | Devs sin paciencia para esperar el freeze | Cachear `/tmp/python-embed-*.zip`. Build incremental: skip freeze si `runtime/.lock` matches deps de `app.md`. | ## Fuera de alcance - Ports de los 5 enrichers a Go (eso es el issue 0034). - Runtime Node.js / Bun para enrichers TypeScript (futuro, baja prioridad). - Runtime Go para enrichers JIT (no aplica, Go se compila AOT). - Distribucion del `.exe` por instalador (msi/setup) — fuera. La app se sigue distribuyendo como carpeta zippeada al Desktop.