--- id: 0026 title: Sistema de jobs — enrichers asincronos en background status: completed priority: high created: 2026-05-01 blocks: [0027, 0028, 0029, 0030] supersedes: [0001, 0002, 0003] --- ## Objetivo Convertir el menu "Run enricher" (hoy placeholder en main.cpp:485) en un sistema real de jobs asincronos: el usuario lanza un enricher sobre un nodo, vuelve a la app y sigue trabajando mientras el enricher procesa en background. Al terminar, el grafo se recarga automaticamente con las entidades/relaciones nuevas. Este issue solo cubre la **infra**. Los enrichers concretos se escriben en 0028, 0029, 0030. ## Decisiones tomadas 1. **Workers concurrentes**: 2 por defecto, configurable via Settings. 2. **Cache de documentos**: `/cache//.{html,md,png}`. Carpeta gitignored en el sub-repo. 3. **Webpage vs Url**: tipos separados (issue 0027). Url = link suelto, Webpage = documento descargado con cuerpo. 4. **Subprocess Python por job** (no daemon residente): cold start ~200 ms aceptable. Si molesta, issue futura. 5. **Estado en `graph_explorer.db`** (NO en `operations.db`): jobs son especificos de la app, no del grafo. ## Tabla `jobs` (graph_explorer.db) ```sql CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, -- ULID enricher_id TEXT NOT NULL, -- ej: "fetch_webpage" node_id TEXT, -- nodo objetivo (NULL si batch) node_name TEXT DEFAULT '', -- snapshot para mostrar en UI params_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL, -- queued|running|done|error|cancelled progress REAL NOT NULL DEFAULT 0, -- 0..1 stage TEXT NOT NULL DEFAULT '', -- mensaje corto: "fetching", "extracting" result_json TEXT, -- {entities_added: N, relations_added: M, ...} error TEXT, pid INTEGER, -- subprocess pid para cancelar created_at INTEGER NOT NULL, started_at INTEGER, finished_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, created_at); ``` ## Runtime C++ ### `jobs.{h,cpp}` (nuevo) - `JobRunner`: pool de N std::thread workers (default 2). - Cola en memoria `std::queue` + persistencia en BD. - API publico: - `bool jobs_init(const char* db_path, int n_workers);` - `bool jobs_submit(const char* enricher_id, const char* node_id, const char* params_json, char* out_id);` - `bool jobs_cancel(const char* job_id);` - `void jobs_shutdown();` - `int jobs_dirty_counter();` // incrementa al completar un job; render lo lee - `bool jobs_list(std::vector* out);` - Worker hace: 1. Pop de cola → mark running, started_at = now, pid = subprocess pid. 2. Spawn `python/.venv/bin/python3 enrichers//run.py` con stdin = JSON. 3. Lee stderr line-by-line buscando `PROGRESS: ` para actualizar fila. 4. Lee stdout completo al cerrar — JSON final con entities/relations/node_updates. 5. Aplica al `operations.db` desde el worker (entity_insert/relation_insert/entity_update). 6. Marca done o error con result_json/error, finished_at, increment dirty_counter. - Al arrancar: marca jobs `running` huerfanos como `error: "process died"`. ### `enrichers.{h,cpp}` (nuevo) - Escanea `enrichers/*/manifest.yaml` al arrancar. - Estructura `EnricherSpec`: ``` std::string id, name, description; std::vector applies_to; // tipos validos std::vector params; std::string run_path; // enrichers//run.py absoluto ``` - API: - `void enrichers_load(const char* enrichers_dir);` - `std::vector enrichers_for_type(const char* type_ref);` ## Contrato enricher (wire protocol) Cada enricher vive en `apps/graph_explorer/enrichers//`: ``` enrichers/ fetch_webpage/ manifest.yaml run.py ``` ### `manifest.yaml` ```yaml id: fetch_webpage name: "Fetch web page" description: "Descarga HTML, extrae markdown y guarda en cache." applies_to: [Webpage, Url] params: - { name: timeout_s, type: int, default: 15 } - { name: use_browser, type: bool, default: false } ``` ### `run.py` — stdin/stdout/stderr **stdin** (una linea JSON): ```json {"node_id":"webpage_123","node_type":"Webpage","node_name":"...", "metadata":{"url":"https://..."},"params":{"timeout_s":15}, "db_path":"/path/operations.db","cache_dir":"/path/cache", "registry_root":"/home/lucas/fn_registry"} ``` **stderr** (lineas de progreso, opcional): ``` PROGRESS:0.10 connecting PROGRESS:0.50 parsing PROGRESS:0.90 writing ``` **stdout** (una linea JSON al final): ```json {"node_updates":[ {"id":"webpage_123","metadata_patch":{"title":"...","status_code":200}} ], "entities":[ {"type":"Domain","name":"example.com","metadata":{}} ], "relations":[ {"from_id":"webpage_123","to_name":"example.com","to_type":"Domain","kind":"BELONGS_TO"} ], "notes":""} ``` Resolucion de relaciones: - Si `to_id` esta presente, se usa directamente. - Si no, se busca por `(to_name, to_type)` en operations.db; si no existe, se crea primero. ## UI ### Panel "Jobs" - Nuevo panel dockeable (entry en `g_panels[]`). - Tabla con columnas: enricher, target node (clicable → centra viewport), status (badge), progress bar, stage, duracion, error tooltip. - Botones inline por fila: cancelar (running), reintentar (error/cancelled), borrar (terminal). - Filtro: all | active | done | error. ### Toolbar - Badge en la toolbar superior con contador de `running + queued`. Click abre el panel Jobs. ### Context menu (main.cpp:485) Reemplazar el `TextDisabled("coming soon")` por: ```cpp auto specs = ge::enrichers_for_type(node->type_ref); if (specs.empty()) { ImGui::TextDisabled("(no hay enrichers para este tipo)"); } else { for (const auto& s : specs) { if (ImGui::MenuItem(s.name.c_str())) { char job_id[64]; ge::jobs_submit(s.id.c_str(), node->id.c_str(), "{}", job_id); } } } ``` ## Fases del bucle reactivo en BD - **CONSTRUIR**: enricher escrito en `enrichers//`. - **EJECUTAR**: subprocess corre, escribe progress en `jobs`. - **RECOPILAR**: stdout JSON parseado, entities/relations aplicadas a operations.db. - **ANALIZAR**: jobs.result_json contiene metricas (entities_added, duration_ms). - **MEJORAR**: si un enricher falla repetidamente, futura issue de health checks. ## Cancelacion - Boton "Cancel" en panel Jobs: - Lee `pid` de la fila. - `kill(pid, SIGTERM)` (Linux/WSL) o `TerminateProcess` (Windows). - El worker captura el exit code y marca `cancelled`. - Si el job aun no salio de la cola (status = queued), el worker simplemente no lo coge — al pop chequea status y skipea cancelled. ## Definicion de hecho - Tabla `jobs` se crea al arrancar la app. - `JobRunner` con 2 workers acepta `jobs_submit` y procesa. - Panel Jobs muestra estado en tiempo real (progress bar avanza visiblemente). - Cancelar mata subprocess y marca `cancelled`. - Al completar, el grafo se recarga (dirty_counter detectado en render). - Subir el contador de jobs en la toolbar. - Test manual: enricher dummy `noop` (incluido en este issue) que duerme 3 s emitiendo PROGRESS y termina sin entidades. Lanzarlo y comprobar UI. ## Trabajo posterior - 0027: tipo Webpage + cache. - 0028: primer enricher real (`fetch_webpage`) end-to-end. - 0028b: enrichers extract_domain, extract_links, extract_text_entities. - 0029: enrichers via CDP (browser headless). - 0030: macro "Deep enrich" + expand_domain.