4ef6a5f7db
Status sincronizado con master: - 0001 chat con Claude -> shipped como panel Echo - 0003 enricher web -> shipped (0028 + 0028b) - 0026 sistema de jobs -> shipped - 0027 tipo Webpage -> shipped - 0028 fetch_webpage -> shipped - 0028b extract trio -> shipped - 0031 layout estable -> shipped Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.4 KiB
7.4 KiB
id, title, status, priority, created, blocks, supersedes
| id | title | status | priority | created | blocks | supersedes | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0026 | Sistema de jobs — enrichers asincronos en background | in_progress | high | 2026-05-01 |
|
|
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
- Workers concurrentes: 2 por defecto, configurable via Settings.
- Cache de documentos:
<app_dir>/cache/<sha256[0:2]>/<sha256>.{html,md,png}. Carpeta gitignored en el sub-repo. - Webpage vs Url: tipos separados (issue 0027). Url = link suelto, Webpage = documento descargado con cuerpo.
- Subprocess Python por job (no daemon residente): cold start ~200 ms aceptable. Si molesta, issue futura.
- Estado en
graph_explorer.db(NO enoperations.db): jobs son especificos de la app, no del grafo.
Tabla jobs (graph_explorer.db)
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<JobId>+ 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 leebool jobs_list(std::vector<JobRow>* out);
- Worker hace:
- Pop de cola → mark running, started_at = now, pid = subprocess pid.
- Spawn
python/.venv/bin/python3 enrichers/<id>/run.pycon stdin = JSON. - Lee stderr line-by-line buscando
PROGRESS:<float> <stage>para actualizar fila. - Lee stdout completo al cerrar — JSON final con entities/relations/node_updates.
- Aplica al
operations.dbdesde el worker (entity_insert/relation_insert/entity_update). - Marca done o error con result_json/error, finished_at, increment dirty_counter.
- Al arrancar: marca jobs
runninghuerfanos comoerror: "process died".
enrichers.{h,cpp} (nuevo)
- Escanea
enrichers/*/manifest.yamlal arrancar. - Estructura
EnricherSpec:std::string id, name, description; std::vector<std::string> applies_to; // tipos validos std::vector<EnricherParam> params; std::string run_path; // enrichers/<id>/run.py absoluto - API:
void enrichers_load(const char* enrichers_dir);std::vector<EnricherSpec> enrichers_for_type(const char* type_ref);
Contrato enricher (wire protocol)
Cada enricher vive en apps/graph_explorer/enrichers/<id>/:
enrichers/
fetch_webpage/
manifest.yaml
run.py
manifest.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):
{"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):
{"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_idesta 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:
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/<id>/. - 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
pidde la fila. kill(pid, SIGTERM)(Linux/WSL) oTerminateProcess(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.
- Lee
Definicion de hecho
- Tabla
jobsse crea al arrancar la app. JobRunnercon 2 workers aceptajobs_submity 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.