---
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 (1-2 sesiones)
1. Extender `EnricherSpec` con `lang`, `exec_basename`.
2. `parse_manifest` lee `lang` (default `"python"`) y `exec`
(default `"run"`).
3. `enrichers_load` resuelve `run_path` segun lang + plataforma. Si
un enricher `lang: go` no tiene su binario compilado, queda
deshabilitado con warning.
4. `run_subprocess` ramifica el `argv` por lang.
5. Tests pytest del dispatcher: un enricher dummy en bash y otro
en python con manifests distintos, verificar que ambos lanzan.
6. **No cambiamos los 5 enrichers existentes.** Siguen `lang: python`
y siguen funcionando. Solo el dispatcher se vuelve poliglota.
### Fase B — runtime Python embebido (1-2 sesiones)
1. Escribir `tools/freeze_python_runtime.sh` (Linux + Windows).
2. Anadir hook al `/compile` skill: tras compilar el `.exe`, ejecutar
el freeze si `python_runtime: true`.
3. `python_runtime_path()` en `jobs.cpp`: busca `runtime/python/`
junto al exe; fallback a env var → registry venv → PATH.
4. Test: arranque limpio de `graph_explorer.exe` con runtime/ junto al
exe — invocar enricher Python sin WSL ni venv del registry.
5. Documentar en `app.md` y en `cpp_apps.md` la nueva regla:
apps con enrichers Python necesitan `python_runtime: true`.
### 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.