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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user