Files
graph_explorer/issues/0033-multilang-dispatcher-embedded-python.md
egutierrez 30f6f3758f feat(jobs): runtime Python embebido + cadena de fallback (issue 0033 fase B)
Permite distribuir graph_explorer.exe Windows sin dependencia de WSL
ni del .venv del registry. Tambien funciona en Linux como bundle
autocontenido portable.

Cambios:

1. tools/freeze_python_runtime.sh
   - Linux: copia python-build-standalone (uv) ~87 MB,
     elimina marker EXTERNALLY-MANAGED, instala wheels.
   - Windows: descarga python-3.12.7-embed-amd64.zip oficial
     (~12 MB), habilita site-packages, instala wheels via
     pip install --target --platform win_amd64.
   - Idempotente via runtime/.lock con SHA256 del estado.
   - Lee python_runtime_deps del frontmatter de app.md.

2. jobs.cpp::cached_python_runtime() — resolver con cadena:
     1. <exe_dir>/runtime/python/{python.exe|bin/python3}  (embedded)
     2. $FN_PYTHON                                         (env)
     3. <registry_root>/python/.venv/bin/python3           (registry_venv)
     4. python3 del PATH                                   (system)
   Loggea procedencia al iniciar jobs_init.

3. POSIX run_subprocess: usa el runtime resuelto en lugar del
   path hardcodeado.

4. Windows run_subprocess: ramifica por needs_wsl. Si embedded
   o env, lanza Python Windows nativo via CreateProcessW
   directamente (run_path tambien Windows nativo). Solo el
   legacy registry_venv sigue por wsl.exe.

5. app.md: nuevos campos python_runtime: true y
   python_runtime_deps: [requests, certifi, urllib3].

6. .gitignore extendido con runtime/, projects/, _vendored/,
   .vendor.lock, binarios Go de enrichers.

Tests: 26/26 verde — 16 originales + 6 dispatcher fase A + 4
nuevos del resolver fase B (con/sin embed, FN_PYTHON, idempotencia
del freeze script).

Smoke E2E manual: runtime/python/bin/python3 ejecuta web_search
con cwd /tmp y registry_root pasado en ctx, sin tocar el .venv del
registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:51:02 +02:00

10 KiB

id, title, status, priority, created, depends_on, blocks
id title status priority created depends_on blocks
0033 Dispatcher multi-lenguaje + runtime Python embebido pending high 2026-05-02
0026
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

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<dir>/<exec>.py (default run.py).
  • lang: go<dir>/<exec> (Linux) o <dir>/<exec>.exe (Windows).
  • lang: bash<dir>/<exec>.sh.
  • Default si falta lang: python (retrocompat con los 5 enrichers existentes).

2. EnricherSpec (C++) extendido

// enrichers.h
struct EnricherSpec {
    std::string id;
    std::string name;
    std::string description;
    std::vector<std::string> 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 <dir>/<exec_basename>.py.
  • go: busca primero <dir>/<exec_basename>.exe en Windows o <dir>/<exec_basename> en Linux. Si no existe, log de warning y el enricher queda deshabilitado (no aparece en el menu).
  • bash: <dir>/<exec_basename>.sh.

3. Dispatcher en jobs.cpp

run_subprocess() elige el argv segun spec.lang:

std::vector<std::string> 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. <exe_dir>/runtime/python/python (Linux) o python.exe (Windows)
  2. $FN_PYTHON env var
  3. <registry_root>/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

<app_dir>/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:

#!/usr/bin/env bash
# Genera runtime/ embebido para una app.
#
# Uso: freeze_python_runtime.sh <app_dir> <platform>
#   <platform> = linux | windows
#
# Lee deps de <app_dir>/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:

---
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: <exe_dir>/runtime/python/{python.exe|bin/python3}$FN_PYTHON<registry_root>/python/.venv/bin/python3python3 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 <hash-fase-B>. 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.