---
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` → `<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

```cpp
// 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`:

```cpp
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`:

```bash
#!/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:

```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: `<exe_dir>/runtime/python/{python.exe|bin/python3}` →
   `$FN_PYTHON` → `<registry_root>/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 `<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.
