docs(issues): roadmap fase 2 navegador + ports Go + runtime embebido

Anade siete issues que definen el camino para hacer graph_explorer
distribuible como binario Windows autocontenido (sin WSL):

- 0032 — browser_session enrichers via Playwright (login interactivo,
  cookies persistentes, fetch_webpage_browser, web_search_browser).
- 0033 — dispatcher multi-lenguaje (lang: go|python|bash en manifest)
  + runtime Python embebido en <app>/runtime/. 3 fases (A=dispatcher,
  B=runtime, C=UI badges).
- 0033b — vendoring de funciones Python por enricher (_vendored/ +
  .vendor.lock) para que los enrichers no dependan de registry_root
  en runtime.
- 0033c — fn check vendored: drift detection con --fix.
- 0033d — fn index lee python_runtime / python_runtime_deps de app.md.
- 0033e — /compile orquesta freeze + vendor + go builds.
- 0034 — port de los 5 enrichers de sistema a Go. Reusa funciones
  Go del registry directamente (no copias). Tests pytest existentes
  pasan sin cambios.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:10:35 +02:00
parent 5fe856b30e
commit 35ace544d9
7 changed files with 937 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
---
id: 0032
title: Fase 2 — Navegador controlable con sesion persistente (cookies, login, JS)
status: pending
priority: high
created: 2026-05-02
depends_on: [0028, 0029]
---
## Contexto y objetivo
La fase 1 (enrichers `fetch_webpage`, `web_search`, `extract_links`,
`extract_text_entities`, `extract_domain`) cubre el caso "sitio publico,
HTML estatico, sin JS". Limitaciones reales que estamos viendo:
- DDG HTML cambia el markup y hay que reparar el parser. Ademas en
busquedas masivas devuelve captcha tras N requests.
- Sitios SPA (LinkedIn, X, Telegram) no se pueden enriquecer porque
`requests` no ejecuta JS — el HTML viene vacio.
- Para investigaciones serias hacen falta sesiones autenticadas
(LinkedIn, foros, Telegram web). Sin cookies persistentes, cada
enricher empieza de cero y se rompe el flujo.
Fase 2 introduce un **navegador controlable** que comparten todos los
enrichers que necesiten JS, cookies o login.
## Decision de stack
| Opcion | Pros | Contras | Veredicto |
|---|---|---|---|
| Playwright (Python) | Bateria incluida, captura cookies/storage, easy install via pip, multinavegador | 200 MB de Chromium descargado | **Elegida** — mejor DX para enrichers Python |
| pychrome + Chrome instalado | Mas ligero | API basica, reinventar capa de helpers | descartada |
| CDP en Go (issue 0029) | Reusa funciones del registry, binario unico | Mantener wrapper aparte por enricher | aplazado a v3 |
| Selenium | Estandar viejo | Mas lento que Playwright, peor API moderna | descartada |
**Stack final:** Playwright Python en `python/.venv`, persistencia de
estado con `BrowserContext.storage_state` (cookies + localStorage) en
`<app_dir>/browser_profiles/<profile>.json`.
## Arquitectura
### Componente compartido: `browser_session_py_browser`
Funcion del registry nueva en `python/functions/browser/`. NO es un
enricher — es la primitiva que usan los enrichers.
```python
def open_session(profile: str, *, headless: bool = True,
user_agent: str | None = None) -> BrowserSession: ...
```
`BrowserSession` expone:
- `goto(url, wait="load") -> Page`
- `html() -> str`
- `screenshot(path, full_page=True)`
- `cookies() -> list[dict]` / `set_cookies(list)`
- `evaluate(js) -> any`
- `close()` — persiste storage_state al disco antes de cerrar.
El profile vive en `<app_dir>/browser_profiles/<name>.json`. Si no
existe se crea vacio. Echo y los enrichers comparten profile via param
`browser_profile` (default `"default"`).
### Enrichers nuevos
| id | applies_to | reemplaza/complementa |
|---|---|---|
| `fetch_webpage_browser` | Url, Webpage | superset de `fetch_webpage` para JS/auth |
| `web_search_browser` | text, Concept | superset de `web_search` (Google/DDG con JS y sin captcha facil) |
| `fetch_screenshot` | Url, Webpage | nuevo — solo evidencia visual |
| `browser_login` | Account, Credential | nuevo — abre login interactivo, guarda cookies |
| `extract_dom_data` | Webpage | nuevo — extrae datos via selector CSS o XPath (JSON-LD, microdata, etc.) |
Todos comparten params:
```yaml
- { name: browser_profile, type: string, default: "default" }
- { name: headless, type: bool, default: true }
- { name: timeout_s, type: int, default: 30 }
```
### Login interactivo (`browser_login`)
Modo especial: lanza Chromium **no-headless** y deja que el humano
haga el login a mano (CAPTCHA, 2FA, etc.). Cuando el usuario cierra la
ventana, las cookies quedan guardadas en el profile. Los enrichers
posteriores que usen ese profile heredan la sesion.
UX en Echo:
> Usuario: "Echo, prepara una sesion para LinkedIn"
> Echo: ejecuta `browser_login` con `profile=linkedin`, target=linkedin.com.
> Aparece la ventana del browser. Usuario hace login. Cierra ventana.
> Echo confirma: "sesion linkedin guardada, 15 cookies".
### `web_search_browser` con multiples motores
```yaml
params:
- { name: engine, type: string, default: "google" } # google|ddg|bing|brave
- { name: limit, type: int, default: 10 }
- { name: browser_profile, type: string, default: "default" }
```
Con browser real, Google deja de bloquear. Cada engine tiene su
selector CSS para resultados (mantenidos en `engines.yaml` dentro del
enricher). El enricher cae en orden engine → engine si uno falla:
google → ddg → bing.
### Extraccion CSS/XPath (`extract_dom_data`)
Para nodos `Webpage` enriquecidos con un browser, permite definir
selectores en el `Type` del nodo:
```yaml
# en types.yaml de un proyecto
- name: LinkedInProfile
selectors:
full_name: "h1.profile-name"
headline: "div.profile-headline"
company: "[data-section=experience] li:first-child .company"
```
`extract_dom_data` lee los selectores del Type, los aplica al DOM
post-JS y guarda los valores en `metadata`. Esto **es lo que conecta**
el grafo con scrapers tipados sin escribir un enricher por sitio.
## Integracion con Echo
Echo gana 3 tools nuevas en el MCP server (`gx-cli`):
- `browser_login(profile, url)` — pide al usuario hacer login.
- `browser_session_status(profile)` — lista profiles, valid/expired,
cookie count, ultima url visitada.
- `browser_close(profile)` — cierra la sesion y persiste.
System prompt amplia el bloque WORKFLOW:
> Cuando una URL devuelva HTML vacio o redirija a login, propon usar
> `fetch_webpage_browser` con un profile autenticado. Si no existe
> profile, propon `browser_login` antes.
## Plan de implementacion (fases)
1. **0032a**`python/functions/browser/browser_session.py` con
Playwright. Test que abre about:blank, persiste storage_state,
recarga y verifica cookies.
2. **0032b**`fetch_webpage_browser` enricher. Test contra
`httpbin.org/cookies/set` para verificar persistencia.
3. **0032c**`fetch_screenshot` (la mas simple, valida la pipeline
visual end-to-end).
4. **0032d**`web_search_browser` con google + ddg + fallback.
Tests con paginas guardadas en `tests/fixtures/`.
5. **0032e**`browser_login` con UI no-headless. Test manual.
6. **0032f**`extract_dom_data` + extension del schema de Types
con `selectors`. Test con HTML local complejo.
7. **0032g** — Tools MCP en `gx-cli` y prompt update en chat.cpp.
## Riesgos y mitigaciones
| Riesgo | Mitigacion |
|---|---|
| Playwright pesa 200 MB | Lazy install: `pip install playwright && playwright install chromium` solo cuando se ejecuta el primer enricher browser. Documentar en app.md. |
| Profiles con secretos en disco | `<app_dir>/browser_profiles/` en .gitignore. Documentar advertencia en `browser_login`. |
| Sites detectan headless | Default user-agent realista. Bloque opcional `stealth: true` en params (usa playwright-stealth). |
| Concurrencia: 2 jobs leyendo el mismo profile | Lock por profile en sqlite (`browser_locks` tabla en operations.db). Si esta tomado, esperar 30s antes de fallar. |
| Tests con red real | NO hay tests con red real. Fixtures HTML guardados o servidor mock con `pytest-httpserver`. |
## Definicion de hecho
- Echo puede pedir al usuario "abre LinkedIn y haz login" y a partir
de ahi enriquecer perfiles.
- `web_search_browser` engine=google funciona masivamente (50+
busquedas seguidas) sin captcha.
- Un Webpage enriquecido con `extract_dom_data` usando un Type con
`selectors` queda con todos los campos en `metadata`.
- Tests pasan en CI sin red — todos los enrichers browser tienen
tests con fixtures locales.
## Fuera de alcance (v3)
- Reescribir los enrichers a Go con CDP (issue 0029 sigue vivo como
alternativa de bajo nivel si Playwright no escala).
- Captcha solving — manual via `browser_login`, nunca automatico.
- Anti-bot bypass agresivo (residential proxies, fingerprint
randomizacion). Pendiente hasta que se demuestre necesidad real.
@@ -0,0 +1,261 @@
---
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 (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.
+69
View File
@@ -0,0 +1,69 @@
---
id: 0033b
title: Vendoring de funciones Python por enricher
status: pending
priority: high
created: 2026-05-02
depends_on: [0033]
---
## Objetivo
Para que el `.exe` Windows sea portable a cualquier PC, los enrichers
con `lang: python` no pueden importar de `<registry_root>/python/functions/`
en runtime. Cada enricher copia las funciones del registry que
declara en `uses_functions` a `<enricher>/_vendored/` durante el
build, y `run.py` importa de ahi.
## Implementacion
### 1. Script `tools/vendor_enricher_python.sh`
Recibe `<enricher_dir>` y `<registry_root>`. Lee `uses_functions` del
manifest, filtra los IDs `*_py_*`, resuelve `file_path` desde
`registry.db`, copia el `.py` a `_vendored/<domain>/<name>.py` y
crea `__init__.py` por dominio. Genera `.vendor.lock` con
`<id> <sha256> <src_path>` por linea. Idempotente: si el sha256 de
origen coincide con el del lock, no copia.
### 2. Adaptacion de `run.py`
Cada enricher con `lang: python` que use funciones del registry
cambia el import:
```python
# antes
sys.path.insert(0, os.path.join(registry_root, "python", "functions"))
from cybersecurity.cybersecurity import normalize_url
# despues
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_vendored"))
from cybersecurity.normalize_url import normalize_url
```
### 3. .gitignore
Anadir `_vendored/` y `.vendor.lock` al `.gitignore` del repo (o de
cada app). Son artefactos de build, regenerables.
### 4. Hook al build
`/compile` (o el script de freeze del 0033) recorre
`apps/<app>/enrichers/*/manifest.yaml`. Para cada manifest con
`lang: python`, ejecuta `vendor_enricher_python.sh`.
## Tests
- Test unit del script bash: dado un manifest con dos `uses_functions`
Python conocidas, verificar que `_vendored/` queda con la estructura
correcta y `.vendor.lock` lista los hashes.
- Test e2e: `fetch_webpage` (lang: python) ejecuta correctamente con
registry_root vacio en el ctx tras vendoring.
## Definicion de hecho
- `tools/vendor_enricher_python.sh` existe y es idempotente.
- Cada enricher Python del proyecto tiene `_vendored/` poblado y
funciona sin `registry_root` en runtime.
- El binario zippeado a Desktop arranca enrichers Python sin acceso
al fn_registry.
+65
View File
@@ -0,0 +1,65 @@
---
id: 0033c
title: Comando `fn check vendored` — drift detection
status: pending
priority: medium
created: 2026-05-02
depends_on: [0033b]
---
## Objetivo
Subcomando del CLI `fn` para detectar drift entre los `_vendored/`
de los enrichers y la fuente del registry. Permite auditoria en CI y
diagnostico rapido cuando algo deja de funcionar tras editar una
funcion.
## CLI
```bash
fn check vendored # recorre todas las apps con vendoring
fn check vendored apps/graph_explorer # solo esta app
fn check vendored --fix # re-vendoriza si detecta drift
```
Salida esperada:
```
[OK] apps/graph_explorer/enrichers/fetch_webpage 3 funcs, lock OK
[DRIFT] apps/graph_explorer/enrichers/extract_links 1 func cambio
- extract_urls_py_cybersecurity (lock=abc123, current=def456)
[MISS] apps/graph_explorer/enrichers/web_search sin .vendor.lock
```
Exit codes:
- 0: todo OK
- 1: drift detectado
- 2: error operativo (manifest invalido, registry.db inaccesible)
## Implementacion
`cmd/fn/check_vendored.go`:
1. Glob `apps/*/enrichers/*/manifest.yaml` y
`projects/*/apps/*/enrichers/*/manifest.yaml`.
2. Filtrar los con `lang: python` y `uses_functions` no vacio.
3. Por cada uno:
- Si no existe `.vendor.lock` → MISS.
- Por cada linea del lock: leer `file_path` actual de registry.db,
calcular sha256, comparar.
4. Con `--fix`: invocar `vendor_enricher_python.sh` cuando se detecta
drift o miss.
## Tests
- Caso OK: manifest + lock + ficheros consistentes.
- Caso DRIFT: editamos un .py vendorizado, el comando lo detecta.
- Caso MISS: enricher con `lang: python` y `uses_functions` no vacio
pero sin lock.
- Caso `--fix`: tras drift, ejecutar con `--fix` deja todo OK.
## Definicion de hecho
- `fn check vendored` reporta el estado correctamente en los 3
casos.
- `--fix` re-vendoriza y deja exit code 0.
- Tests Go en `cmd/fn/check_vendored_test.go`.
@@ -0,0 +1,63 @@
---
id: 0033d
title: Indexer lee `python_runtime` y `python_runtime_deps` de app.md
status: pending
priority: low
created: 2026-05-02
depends_on: []
blocks: [0033]
---
## Objetivo
Que `fn index` recoja dos campos nuevos del frontmatter `app.md` y
los almacene en `apps` (registry.db). Permite consultas
"que apps necesitan runtime Python" y enable hooks de build
condicionales en `/compile`.
## Cambios
### Schema (`registry/migrations` o `registry/schema.go`)
```sql
ALTER TABLE apps ADD COLUMN python_runtime INTEGER NOT NULL DEFAULT 0;
ALTER TABLE apps ADD COLUMN python_runtime_deps TEXT NOT NULL DEFAULT '[]';
```
### Parser de `app.md`
Extender el parser de frontmatter de apps en `registry/parser.go`
para leer:
```yaml
python_runtime: true # bool, default false
python_runtime_deps: # array, default []
- requests
- certifi
```
### Frontmatter del template
Anadir los campos al template `docs/templates/app.md`:
```yaml
python_runtime: false
python_runtime_deps: []
```
Comentar que solo aplica a apps que ejecutan enrichers Python.
## Tests
- Parser test: `app.md` con los dos campos → struct correcto.
- Indexer test: tras `fn index`, query `SELECT python_runtime,
python_runtime_deps FROM apps WHERE id='graph_explorer'` devuelve
los valores esperados.
## Definicion de hecho
- `fn index` no rompe en apps sin los campos (default 0/`[]`).
- `graph_explorer/app.md` actualizado con `python_runtime: true` y
deps reales (`requests` minimo).
- Query `SELECT id FROM apps WHERE python_runtime = 1` lista las
apps que necesitan runtime embebido.
@@ -0,0 +1,61 @@
---
id: 0033e
title: Skill `/compile` orquesta freeze + vendor + go builds
status: pending
priority: medium
created: 2026-05-02
depends_on: [0033, 0033b, 0033d]
---
## Objetivo
Tras compilar el `.exe` Windows de un app, el skill `/compile` debe
preparar todo lo necesario para que el zip distribuible sea
self-contained.
## Pasos del skill (post-build)
```
1. Compilar .exe Windows (ya lo hace).
2. Si app.python_runtime == 1:
a. tools/freeze_python_runtime.sh <app_dir> windows
b. para cada enricher con lang: python:
tools/vendor_enricher_python.sh <enricher_dir> $(pwd)
3. Para cada enricher con lang: go:
bash <enricher_dir>/build.sh (cross-compile linux+windows)
4. Copiar arbol final a /mnt/c/Users/lucas/Desktop/apps/<app>/
incluyendo: <app>.exe, runtime/, enrichers/ con run.exe + _vendored/.
5. Verificar layout: smoke check que <app>.exe + runtime/python/python.exe
+ enrichers/*/run.exe existen.
```
## Cambios
Editar el skill `~/.claude/plugins/<plugin>/compile/skill.md` (o
donde viva el `/compile`) para anadir los pasos 2-5. Si el skill ya
copia archivos, extender la lista; si no, anadir el bloque entero.
## Test manual
Tras un `/compile` limpio:
```bash
ls /mnt/c/Users/lucas/Desktop/apps/graph_explorer/
# Esperado:
# graph_explorer.exe
# runtime/python/python.exe
# enrichers/extract_domain/run.exe (Go)
# enrichers/web_search/run.py + _vendored/ (Python custom, ej.)
# ...
```
Y desde Windows (no WSL): doble click en `graph_explorer.exe`,
arrancar Echo, ejecutar `web_search` sobre un nodo text. Debe
funcionar sin que WSL este corriendo.
## Definicion de hecho
- `/compile` (o equivalente) ejecuta los 5 pasos sin intervencion
manual.
- Un PC Windows limpio (sin WSL) corre la app desde el zip
copiado a Desktop.
+236
View File
@@ -0,0 +1,236 @@
---
id: 0034
title: Port de los 5 enrichers de sistema a Go
status: pending
priority: medium
created: 2026-05-02
depends_on: [0033]
---
## Contexto
Tras 0033 el dispatcher acepta `lang: go` y los binarios se
distribuyen junto al `.exe` Windows. Los 5 enrichers de sistema
(`extract_domain`, `fetch_webpage`, `extract_links`,
`extract_text_entities`, `web_search`) son hoy Python — funcionan,
estan testeados y son el flujo OSINT canonico de la app. Portarlos
a Go nos da:
- Cero dependencia de Python para el flujo base. El runtime
embebido pasa a ser **opcional** (solo apps con enrichers
custom Python lo necesitan).
- ~10x menos arranque por job (Python cold ~120 ms, Go ~10 ms).
- Reuso directo de funciones Go testeadas del registry (extract_iocs,
extract_urls, normalize_url, html_to_markdown). Esto cierra el
bucle del registry: lo que se testea en Go se ejecuta en Go.
- Distribucion: el `.exe` + binarios `enrichers/<id>/run.exe` son
~25 MB totales; sin runtime Python si no hace falta.
Los tests pytest existentes **no cambian** — testean el wire
protocol (stdin JSON / stdout JSON / exit code), no la
implementacion. Cada port intercambia el binario pero deja el
contrato intacto.
## Funciones del registry necesarias
Los 5 enrichers comparten una decena de funciones del dominio
`cybersecurity` y `core`. Verificar que existen en Go con `fn search`
antes de portar; si falta alguna, anadirla al registry primero.
| Funcion Python actual | Equivalente Go que hace falta | Estado |
|---|---|---|
| `cybersecurity.normalize_url` | `normalize_url_go_cybersecurity` | revisar |
| `core.html_to_markdown` | `html_to_markdown_go_core` | crear si no esta |
| `cybersecurity.extract_urls` | `extract_urls_go_cybersecurity` | revisar |
| `cybersecurity.extract_iocs` | `extract_iocs_go_cybersecurity` | crear si no esta |
`fn check params` + `fn list -d cybersecurity --lang go` muestra que
hay y que falta. Las que falten se crean primero (issue separada
por funcion si la complejidad lo justifica, ej. `html_to_markdown`
necesita un parser HTML — `golang.org/x/net/html` resuelve).
## Layout por enricher
```
enrichers/<id>/
manifest.yaml # actualizado: lang: go
main.go # entry point con main()
go.mod # dependencias propias
run # binario Linux (gitignored, generado)
run.exe # binario Windows (gitignored, generado)
build.sh # cross-compile recipe
run.py # ELIMINADO tras port + tests verde
```
Ejemplo de `build.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o run .
GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o run.exe .
```
Hook al build de la app: `cpp/CMakeLists.txt` (o `compile.sh`) corre
`build.sh` de cada enricher Go antes de copiar la app a Desktop. Idem
en linux build.
## Estructura del `main.go` (template)
```go
package main
import (
"encoding/json"
"fmt"
"io"
"os"
// Funciones del registry — paths relativos al repo root.
"fn_registry/cybersecurity/extractiocs"
"fn_registry/cybersecurity/extracturls"
)
type ctxIn struct {
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
NodeType string `json:"node_type"`
Metadata map[string]any `json:"metadata"`
OpsDBPath string `json:"ops_db_path"`
AppDir string `json:"app_dir"`
CacheDir string `json:"cache_dir"`
RegistryRoot string `json:"registry_root"`
Params map[string]any `json:"params"`
}
type ctxOut struct {
EntitiesAdded int `json:"entities_added"`
RelationsAdded int `json:"relations_added"`
Error string `json:"error,omitempty"`
// ... campos especificos del enricher
}
func progress(p float64, stage string) {
fmt.Fprintf(os.Stderr, "PROGRESS:%.2f %s\n", p, stage)
}
func main() {
raw, _ := io.ReadAll(os.Stdin)
var in ctxIn
if err := json.Unmarshal(raw, &in); err != nil {
fmt.Fprintln(os.Stderr, "stdin not valid JSON:", err)
os.Exit(2)
}
out, code := run(in)
enc, _ := json.Marshal(out)
fmt.Println(string(enc))
os.Exit(code)
}
func run(in ctxIn) (ctxOut, int) {
// Logica especifica del enricher, devolviendo (resumen, exit code).
}
```
## Plan de port (orden y por que)
### Fase 1 — `extract_domain` (1 sesion)
El mas simple: regex puro, sin red, sin parser HTML. Sirve de
**referencia canonica** para los demas. Confirma toda la cadena de
build + dispatcher + tests pytest sin tocar dependencias externas.
### Fase 2 — `web_search` (1-2 sesiones)
Recien escrito en Python, todavia caliente. Portar ahora minimiza el
trabajo de mantenerlo en dos sitios. Dependencias: `net/http` (stdlib),
parser HTML con `golang.org/x/net/html`.
### Fase 3 — `extract_links` (1 sesion)
Lee markdown del cache, extract_urls. Si `extract_urls_go_cybersecurity`
existe, port directo; si no, escribir la funcion antes (regex de URLs).
### Fase 4 — `extract_text_entities` (1-2 sesiones)
Si `extract_iocs_go_cybersecurity` no existe, crear primero como
funcion del registry — es el plato fuerte del port.
### Fase 5 — `fetch_webpage` (2 sesiones)
El mas complejo: HTTP con redirects, decode de encoding, parse HTML
a markdown, escritura de cache. Lo dejamos para el final cuando ya
tenemos las primitivas Go probadas.
## Tests
**Cero cambios necesarios** en los tests pytest si el dispatcher
detecta correctamente el binario Go. El stub `tests/_stubs/requests.py`
deja de aplicar para los enrichers Go — en su lugar se inyecta un
servidor HTTP local con `httptest.Server` desde un test Go nativo
**adicional** (no sustituto):
```go
// enrichers/web_search/main_test.go
func TestWebSearchParsesDDGFixture(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w, r) {
body, _ := os.ReadFile("../../tests/fixtures/ddg_results.html")
w.Write(body)
}))
defer srv.Close()
// ... ctx con DDGEndpoint sobreescrito a srv.URL ...
}
```
Los tests pytest se mantienen como **integration tests del wire
protocol** y los tests Go cubren la logica unitaria. Total post-port:
- 16 tests pytest (sin cambios) — wire protocol.
- ~20 tests Go nuevos — logica de cada enricher.
## Limpieza tras port
Por enricher, una vez los 16+N tests estan verde:
1. Borrar `run.py`.
2. Actualizar `manifest.yaml`: `lang: go`.
3. Actualizar `app.md` `python_runtime_deps` quitando lo que ya no se
use (si extract_text_entities ya no necesita `requests`, fuera).
Cuando los 5 esten portados, decidir:
- Si `python_runtime: true` sigue siendo util → mantener (custom
enrichers, prototipado).
- Si nadie escribe Python custom → marcar `python_runtime: false` y
el `.exe` deja de embeber Python por completo. Re-habilitable con
un solo flag.
## Definicion de hecho
- 5 enrichers con `lang: go` y binarios precompilados para
Linux + Windows en el build pipeline.
- 16 tests pytest pasan contra los binarios Go (mismo wire
protocol).
- Tests Go nativos cubren parsing/regex/IO de cada enricher.
- `graph_explorer.exe` distribuido a Desktop sin runtime Python
ejecuta el flujo OSINT completo (search → fetch → extract).
- `python_runtime: true` queda como flag opcional, no obligatorio.
## Riesgos y mitigaciones
| Riesgo | Mitigacion |
|---|---|
| Falta `extract_iocs` o `html_to_markdown` en Go | Issue dependiente que las crea primero. Marcar este 0034 como bloqueado hasta que existan. |
| Diferencia de comportamiento Python vs Go (regex, normalizacion HTML) | Tests pytest comparten fixtures de input — si Go produce salida distinta a Python, falla. Iteramos hasta paridad. |
| Cross-compile de Go con cgo | Los enrichers no necesitan cgo. Build estatico simple `CGO_ENABLED=0`. |
| Mantener dos implementaciones durante el port | NO se mantienen dos. Cada enricher se porta en una rama corta, tests verde, merge, eliminacion del `run.py`. Nada de toggles. |
| Echo escribiendo enrichers nuevos durante el port | Echo escribe Python custom — eso vive feliz junto a los Go portados (gracias al dispatcher de 0033). Sin conflicto. |
## Fuera de alcance
- Port de enrichers de fase 2 (browser session, screenshot, login).
Esos viven en `lang: python` con Playwright porque la libreria Go
equivalente (`chromedp`) duplica esfuerzo sin ganancia clara.
Justamente el caso de uso ideal del runtime embebido.
- Reescribir el sistema de jobs en Go (issue futura si el panel C++
se queda corto).
- Ports en otros lenguajes (Rust, Zig) — no aporta ahora.