feat(jobs): sistema de jobs asincronos + panel UI (issue 0026)

Infra para correr enrichers en background mientras la app sigue interactiva.

C++:
- jobs.{h,cpp}: tabla jobs en graph_explorer.db, JobRunner con N=2 std::thread
  workers, fork+exec POSIX con pipes, parser de PROGRESS:<float> <stage> en
  stderr, captura de stdout JSON, persistencia + dirty_counter.
- enrichers.{h,cpp}: scanner de enrichers/<id>/manifest.yaml, parser YAML
  minimo (id/name/description/applies_to), filtro por tipo de nodo.
- views_jobs.cpp: panel "Jobs" dockeable con tabla (status/enricher/target/
  progress/time), filtro all/active/done/errors, cancelar/borrar inline.

Wiring:
- main.cpp: resolve_registry_root() (FN_REGISTRY_ROOT env o subir desde cwd
  buscando registry.db), jobs_init/enrichers_load antes de fn::run_app,
  jobs_shutdown al cerrar, dirty_counter -> want_reload, jobs_set_ops_db al
  cambiar de proyecto.
- main.cpp:render_context_menu: menu "Run enricher" sustituye placeholder
  con submenu filtrado por type_ref via enrichers_for_type. Submit abre
  panel Jobs auto.
- views.h: AppState::panel_jobs flag + decl views_jobs().
- CMakeLists.txt: anade jobs.cpp + enrichers.cpp + views_jobs.cpp y enlaza
  Threads::Threads.

Wire protocol enricher (subprocess Python):
- stdin:  JSON con node_id, metadata, ops_db_path, app_dir, cache_dir,
          registry_root, params.
- stderr: PROGRESS:<float> <stage> + LOG lineas libres.
- stdout: JSON resumen al final.
- exit 0 = ok, !=0 = error con stderr capturado en panel Jobs.

El run.py escribe directamente al operations.db (sqlite3 stdlib) — C++ solo
orquesta, no parsea entities/relations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 18:24:37 +02:00
parent 020f5dabbe
commit 6df04652d8
8 changed files with 1491 additions and 1 deletions
+105 -1
View File
@@ -25,6 +25,8 @@
#include "layout_store.h"
#include "entity_ops.h"
#include "project_manager.h"
#include "jobs.h"
#include "enrichers.h"
#include "../../../../cpp/vendor/sqlite3/sqlite3.h"
@@ -39,6 +41,13 @@
#include <string>
#include <vector>
#ifndef _WIN32
#include <unistd.h>
#else
#include <direct.h>
#define getcwd _getcwd
#endif
// ----------------------------------------------------------------------------
// Estado global de la app
// ----------------------------------------------------------------------------
@@ -116,6 +125,32 @@ static void apply_static_layout(int mode) {
// Forward decl — definido mas abajo, lo necesita switch_to_project.
static bool load_input();
// ----------------------------------------------------------------------------
// Registry path resolution (issue 0026)
// ----------------------------------------------------------------------------
// Devuelve el path absoluto al root de fn_registry. Estrategia:
// 1) FN_REGISTRY_ROOT env var
// 2) Sube desde getcwd() buscando un dir con `registry.db`
// 3) "" si no se encuentra
static std::string resolve_registry_root() {
if (const char* env = std::getenv("FN_REGISTRY_ROOT")) {
if (env && *env) return env;
}
char cwd[4096];
if (getcwd(cwd, sizeof(cwd)) == nullptr) return "";
std::string p = cwd;
for (int i = 0; i < 8; ++i) {
std::string probe = p + "/registry.db";
FILE* f = std::fopen(probe.c_str(), "rb");
if (f) { std::fclose(f); return p; }
size_t s = p.find_last_of('/');
if (s == std::string::npos || s == 0) break;
p = p.substr(0, s);
}
return "";
}
// ----------------------------------------------------------------------------
// Project lifecycle
// ----------------------------------------------------------------------------
@@ -240,6 +275,9 @@ static bool load_input() {
ge::entity_index_build(g_input.uri, &g_idx);
g_app.input_db_path = g_input.uri ? g_input.uri : "";
// issue 0026 — apunta el JobRunner a la nueva operations.db.
if (g_input.uri) ge::jobs_set_ops_db(g_input.uri);
// Cargar posiciones guardadas para este graph_hash
g_graph_hash = ge::compute_graph_hash(g_input.uri);
int restored = ge::layout_store_load(g_graph_hash, g_graph);
@@ -483,7 +521,27 @@ static void render_context_menu() {
ImGui::Separator();
if (ImGui::BeginMenu("Run enricher")) {
ImGui::TextDisabled("(coming soon — issues 0001/0002/0003)");
// issue 0026 — listamos enrichers cuyo applies_to incluye este type.
const char* type_name = (n.type_id < (uint16_t)g_graph.type_count)
? g_graph.types[n.type_id].name : "";
auto specs = ge::enrichers_for_type(type_name);
if (!sql_id) {
ImGui::TextDisabled("(node has no entity id)");
} else if (specs.empty()) {
ImGui::TextDisabled("(no enrichers para tipo '%s')", type_name);
} else {
for (const auto& s : specs) {
if (ImGui::MenuItem(s.name.c_str())) {
char job_id[64];
bool ok = ge::jobs_submit(s.id.c_str(), sql_id, lbl,
"{}", job_id, sizeof(job_id));
if (ok) g_app.panel_jobs = true; // abrir panel auto
}
if (!s.description.empty() && ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", s.description.c_str());
}
}
}
ImGui::EndMenu();
}
@@ -512,6 +570,7 @@ static fn_ui::PanelToggle g_panels[] = {
{"Note", nullptr, &g_app.panel_note},
{"Types", nullptr, &g_app.panel_type_editor},
{"Table", nullptr, &g_app.panel_table},
{"Jobs", nullptr, &g_app.panel_jobs},
};
static void render() {
@@ -596,6 +655,16 @@ static void render() {
g_app.apply_layout_tick = 0;
}
// issue 0026 — si un job termino con cambios, dispara reload del grafo.
{
static int s_last_dirty = 0;
int d = ge::jobs_dirty_counter();
if (d != s_last_dirty) {
s_last_dirty = d;
g_app.want_reload = true;
}
}
// Triggers desde la toolbar
if (g_app.want_fit) {
graph_viewport_fit(g_graph, g_viewport);
@@ -1194,6 +1263,12 @@ static void render() {
ge::views_table_window(g_app);
ge::views_import_dataset_modal(g_app);
// Jobs panel (issue 0026) — flotante, dockeable.
ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W * 0.20f, top + 40.0f),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(900.0f, 360.0f), ImGuiCond_FirstUseEver);
ge::views_jobs(g_app);
g_first_render = false;
}
@@ -1418,6 +1493,34 @@ int main(int argc, char** argv) {
"cualquier app del registry y permite explorar entidades/relaciones con "
"shapes/iconos/layouts/filtros.");
// issue 0026 — sistema de jobs + enrichers.
{
std::string registry_root = resolve_registry_root();
std::string app_dir = registry_root.empty()
? "."
: registry_root + "/projects/osint_graph/apps/graph_explorer";
std::string enrichers_dir = app_dir + "/enrichers";
// graph_explorer.db es el mismo SQLite usado por layout_store.
const char* app_db = g_layout_db_path.empty()
? "graph_explorer.db" : g_layout_db_path.c_str();
ge::enrichers_load(enrichers_dir.c_str());
if (!ge::jobs_init(app_db,
g_input.uri ? g_input.uri : "",
enrichers_dir.c_str(),
app_dir.c_str(),
registry_root.c_str(),
/*n_workers=*/2)) {
std::fprintf(stderr, "[graph_explorer] jobs_init failed (panel disabled)\n");
} else {
std::fprintf(stdout,
"[graph_explorer] jobs_init OK — enrichers_dir=%s, registry_root=%s, %d enrichers\n",
enrichers_dir.c_str(), registry_root.c_str(),
(int)ge::enrichers_all().size());
}
}
int rc = fn::run_app(
{.title = "graph_explorer",
.width = 1600,
@@ -1429,6 +1532,7 @@ int main(int argc, char** argv) {
render);
// Cleanup
ge::jobs_shutdown();
if (g_gpu_ctx) graph_force_layout_gpu_destroy(g_gpu_ctx);
if (g_atlas) graph_icons_destroy(g_atlas);
graph_viewport_destroy(g_viewport);