Files
graph_explorer/issues/0033-multilang-dispatcher-embedded-python.md
T
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

288 lines
10 KiB
Markdown

---
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.