diff --git a/entity_ops.cpp b/entity_ops.cpp index 138171f..29c1711 100644 --- a/entity_ops.cpp +++ b/entity_ops.cpp @@ -199,6 +199,66 @@ bool entity_update_type(const char* db_path, const char* id, const char* new_typ return ok; } +bool entity_clear_group_id(const char* db_path, const char* entity_id) { + if (!db_path || !entity_id || !*entity_id) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + + // Detectar si la columna `group_id` existe en `entities`. En BDs viejas + // (pre-issue 0035a) la columna no esta — el clear es no-op coherente: + // la entidad no puede pertenecer a un grupo si la columna no existe. + bool has_group_id = false; + { + sqlite3_stmt* pst = nullptr; + if (sqlite3_prepare_v2(db, "PRAGMA table_info(entities)", -1, &pst, nullptr) == SQLITE_OK) { + while (sqlite3_step(pst) == SQLITE_ROW) { + const unsigned char* col = sqlite3_column_text(pst, 1); + if (col && std::strcmp((const char*)col, "group_id") == 0) { + has_group_id = true; + break; + } + } + sqlite3_finalize(pst); + } + } + + // Verificar que la entidad existe (independiente del path con/sin + // columna). Si no existe, devolvemos false (mismo contrato que + // entity_update_type). + { + sqlite3_stmt* est = nullptr; + if (sqlite3_prepare_v2(db, "SELECT 1 FROM entities WHERE id = ?", -1, + &est, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(est, 1, entity_id, -1, SQLITE_TRANSIENT); + bool exists = (sqlite3_step(est) == SQLITE_ROW); + sqlite3_finalize(est); + if (!exists) { + sqlite3_close(db); + return false; + } + } + + if (!has_group_id) { + // No-op valido: la entidad existe pero no puede tener group_id. + sqlite3_close(db); + return true; + } + + std::string ts = now_iso(); + const char* p[2] = { ts.c_str(), entity_id }; + bool ok = exec_one(db, + "UPDATE entities SET group_id = NULL, updated_at = ? WHERE id = ?", + p, 2); + sqlite3_close(db); + return ok; +} + bool entity_duplicate(const char* db_path, const char* id, char* out_id, size_t out_id_n) { diff --git a/entity_ops.h b/entity_ops.h index 9e25b0a..a8f694a 100644 --- a/entity_ops.h +++ b/entity_ops.h @@ -43,6 +43,18 @@ bool entity_delete(const char* db_path, const char* id); bool entity_update_type(const char* db_path, const char* id, const char* new_type); +// Saca la entidad de su grupo: UPDATE entities SET group_id = NULL. +// Idempotente — si la entidad ya tenia group_id NULL, no falla. Devuelve +// true si la entidad existe (independientemente de si tenia o no grupo). +// Devuelve false si la entidad no existe o SQLite falla. En BDs antiguas +// sin la columna `group_id` retorna true como no-op (la entidad no puede +// pertenecer a un grupo si la columna no existe). +// +// Issue 0036d: pareja del flujo Promote en NodeGroups window cuando +// kind=Group — saca el nodo del grupo para que aparezca suelto en el +// canvas tras reload. +bool entity_clear_group_id(const char* db_path, const char* entity_id); + // Duplica una entidad existente. Mismo type/metadata, sufijo "_copy" en id // y "(copia)" en name. Devuelve el nuevo id en out_id. bool entity_duplicate(const char* db_path, const char* id, diff --git a/gx-cli b/gx-cli index 14b077b..7fcb6a6 100755 --- a/gx-cli +++ b/gx-cli @@ -230,6 +230,12 @@ def cmd_node_update(args) -> None: _die(f"bad tags: {e}") sets.append("tags = ?") params.append(json.dumps(tags)) + # Issue 0036d: --clear-group-id saca la entidad de su grupo + # (UPDATE entities SET group_id = NULL). Booleano sin value param; + # combinable con otros sets. + if getattr(args, "clear_group_id", False): + sets.append("group_id = NULL") + _log("node_update", f"clear_group_id=true id={args.id}") if not sets: _die("no fields to update") @@ -829,7 +835,9 @@ MCP_TOOLS = [ "notes": {"type": "string", "description": "Texto del panel Note. Reemplaza el contenido salvo que append_notes=true."}, "append_notes": {"type": "boolean", "default": False, "description": "Si true, anyade `notes` al final con doble newline en vez de reemplazar."}, - "tags": {"type": "string", "description": "JSON array literal o CSV 'a,b,c'"}}, + "tags": {"type": "string", "description": "JSON array literal o CSV 'a,b,c'"}, + "clear_group_id": {"type": "boolean", "default": False, + "description": "saca la entidad del grupo (group_id = NULL)"}}, "required": ["id"]}}, {"name": "node_delete", "description": "Borra la entidad y todas sus relaciones. Irreversible. Confirmar con el usuario antes.", @@ -931,7 +939,8 @@ MCP_DISPATCH = { "node_update": (cmd_node_update, {"name": None, "type": None, "status": None, "description": None, "notes": None, "append_notes": False, - "tags": None}), + "tags": None, + "clear_group_id": False}), "node_delete": (cmd_node_delete, {}), "rel_create": (cmd_rel_create, {"name": None}), "rel_delete": (cmd_rel_delete, {}), @@ -1086,6 +1095,11 @@ def main() -> None: "en vez de reemplazarlas (separador: doble newline).") sp.add_argument("--tags", help='JSON array o "tag1,tag2" CSV') + sp.add_argument("--clear-group-id", dest="clear_group_id", + action="store_true", + help="saca la entidad del grupo (UPDATE group_id = NULL). " + "Idempotente; combinable con otros campos. " + "Issue 0036d.") sp.set_defaults(fn=cmd_node_update) sp = n.add_parser("list") sp.add_argument("--type") diff --git a/issues/0036d-promote-kind-aware.md b/issues/0036d-promote-kind-aware.md new file mode 100644 index 0000000..572f8c3 --- /dev/null +++ b/issues/0036d-promote-kind-aware.md @@ -0,0 +1,84 @@ +--- +id: 0036d +title: Promote en NodeGroups ramificado por kind (Table row vs Group child) +status: done +priority: high +created: 2026-05-04 +parent: 0036 +depends_on: [0036b] +--- + +## Objetivo + +Boton "Promote" por fila en la NodeGroups window. La accion se +ramifica por `kind`: + +- **kind = Table** (DuckDB row): comportamiento actual del issue 0011 + — INSERT entity nueva apuntando a la fila DuckDB. Idempotente: si + ya existe entidad para ese row_id, no duplica. +- **kind = Group** (entidad hija): UPDATE clear group_id. La entidad + sale del grupo y aparece como nodo libre en el canvas tras reload. + +## Cambios + +### Nueva op en `entity_ops` (Group promote) + +```cpp +// Saca la entidad de su grupo (group_id = NULL). No-op si ya estaba +// fuera (group_id ya era NULL). Idempotente. +bool entity_clear_group_id(const char* db_path, const char* entity_id); +``` + +Tambien anyadir su pareja MCP en `gx-cli`: + +``` +gx-cli node update --clear-group-id +``` + +(Para que el agente Echo pueda promover entidades del grupo via MCP. +Argumento booleano que dispara la op directa, sin pasar por el flujo +de --notes/--name.) + +### Render del boton Promote + +En la pintura de la window (en `views.cpp`), por cada fila visible +mostrar un `SmallButton(TI_ARROW_UP)` con tooltip: + +- kind = Table: `"Promote row to entity"` — comportamiento existente. +- kind = Group: `"Promote out of group (move to canvas)"` — llama a + la nueva op. + +Tras la accion, marcar `g_app.want_reload = true` para que el grafo +se refresque y la entidad reaparezca suelta. + +### Tooltip del header + +Cuando entras a la window, mostrar un texto suave indicando el +comportamiento del Promote segun kind. Una sola linea, color text +muted. + +## Acceptance criteria + +- Click promote en row de Group: la entidad pierde `group_id` + (verificable por SQL), reload del grafo la muestra suelta + colgando del source via la relacion existente. +- Click promote en row de Group ya promovida (group_id ya NULL): + no-op sin error. +- Click promote en row de Table: comportamiento como hoy. Sin + regresion en flow existente. +- Tooltip diferenciado entre kinds. +- Tests pytest: + - `test_entity_clear_group_id_removes_membership` + - `test_entity_clear_group_id_idempotent` + - `test_gx_cli_node_update_clear_group_id` (CLI) + - MCP regression: `clear_group_id` en defaults + +## TBD + +Branch `issue/0036d-promote-kind-aware`, merge `--no-ff` a master. + +## Out of scope + +- Promote masivo (multi-select) — fase 2. +- Re-agrupar por tipo (multi-select dentro de NodeGroups window) — + fase 2. diff --git a/main.cpp b/main.cpp index f4853fa..3d89d40 100644 --- a/main.cpp +++ b/main.cpp @@ -1819,6 +1819,20 @@ static void render() { g_app.want_demote_entity = false; g_app.demote_entity_id.clear(); } + // Issue 0036d: promote out-of-group (kind=Group de NodeGroups window). + if (g_app.want_clear_group_id_entity + && !g_app.clear_group_id_entity_id.empty() + && !g_input_path.empty()) { + if (ge::entity_clear_group_id(g_input_path.c_str(), + g_app.clear_group_id_entity_id.c_str())) { + std::fprintf(stdout, "[node_groups] promoted %s out of group\n", + g_app.clear_group_id_entity_id.c_str()); + for (auto& kv : g_app.node_groups_windows) kv.second.page_dirty = true; + reload_after_mutation(); + } + g_app.want_clear_group_id_entity = false; + g_app.clear_group_id_entity_id.clear(); + } if (g_app.want_focus_entity && !g_app.focus_entity_id.empty()) { for (int i = 0; i < g_graph.node_count; ++i) { const char* sid = ge::entity_index_lookup( diff --git a/tests/test_gx_cli.py b/tests/test_gx_cli.py index 91d25c6..82e1bf2 100644 --- a/tests/test_gx_cli.py +++ b/tests/test_gx_cli.py @@ -255,6 +255,57 @@ class TestCliNodeUpdate: "--name", "x", expect_ok=False) assert out.get("ok") is False + def test_node_update_clear_group_id(self, env_dirs): + """Issue 0036d: --clear-group-id saca la entidad del grupo.""" + nid = self._make(env_dirs, name="grouped") + # Asignar group_id directamente via SQL (simula entity en grupo). + cn = sqlite3.connect(env_dirs["ops"]) + cn.execute("UPDATE entities SET group_id = ? WHERE id = ?", + ("group_xyz", nid)) + cn.commit() + cn.close() + # Sanity: precondicion. + row = fetchone(env_dirs["ops"], + "SELECT group_id FROM entities WHERE id=?", nid) + assert row[0] == "group_xyz" + # Run clear. + run_gx(env_dirs, "node", "update", nid, "--clear-group-id") + row = fetchone(env_dirs["ops"], + "SELECT group_id FROM entities WHERE id=?", nid) + assert row[0] is None + + def test_node_update_clear_group_id_idempotent(self, env_dirs): + """Issue 0036d: clear sobre entidad ya sin grupo no falla.""" + nid = self._make(env_dirs, name="solo") + # Precondicion: group_id NULL desde el start. + row = fetchone(env_dirs["ops"], + "SELECT group_id FROM entities WHERE id=?", nid) + assert row[0] is None + # Clear es no-op. + run_gx(env_dirs, "node", "update", nid, "--clear-group-id") + row = fetchone(env_dirs["ops"], + "SELECT group_id FROM entities WHERE id=?", nid) + assert row[0] is None + # Y la entidad sigue existiendo. + row = fetchone(env_dirs["ops"], + "SELECT id FROM entities WHERE id=?", nid) + assert row is not None and row[0] == nid + + def test_node_update_clear_group_id_combinable(self, env_dirs): + """--clear-group-id se combina con otros sets en un mismo update.""" + nid = self._make(env_dirs, name="combo") + cn = sqlite3.connect(env_dirs["ops"]) + cn.execute("UPDATE entities SET group_id = ? WHERE id = ?", + ("group_abc", nid)) + cn.commit() + cn.close() + run_gx(env_dirs, "node", "update", nid, + "--clear-group-id", "--name", "renamed") + row = fetchone(env_dirs["ops"], + "SELECT group_id, name FROM entities WHERE id=?", nid) + assert row[0] is None + assert row[1] == "renamed" + class TestCliNodeListShow: def test_node_show_returns_notes(self, env_dirs): @@ -600,3 +651,49 @@ class TestMcpRegression0035d: props = tool["inputSchema"]["properties"] assert "notes" in props assert "append_notes" in props + + +class TestMcpRegression0036d: + """Issue 0036d: clear_group_id como flag MCP del node_update. + Bloquea cualquier futura regresion donde el dispatcher pierda el + default o el inputSchema deje de anunciar el campo.""" + + def test_node_update_dispatch_includes_clear_group_id_default(self, gx_module): + _, defaults = gx_module.MCP_DISPATCH["node_update"] + assert "clear_group_id" in defaults + assert defaults["clear_group_id"] is False + + def test_node_update_inputschema_advertises_clear_group_id(self, gx_module): + tool = next(t for t in gx_module.MCP_TOOLS + if t["name"] == "node_update") + props = tool["inputSchema"]["properties"] + assert "clear_group_id" in props + assert props["clear_group_id"]["type"] == "boolean" + assert props["clear_group_id"]["default"] is False + + def test_mcp_dispatch_clear_group_id_clears_membership(self, gx_module, + mcp_env): + """End-to-end via dispatcher: crear nodo, asignar grupo via SQL, + dispatch node_update con clear_group_id=True, verificar NULL.""" + out = gx_module._mcp_dispatch("node_create", + {"name": "grp_member", "type": "text"}) + nid = out["id"] + cn = sqlite3.connect(mcp_env["ops"]) + cn.execute("UPDATE entities SET group_id = ? WHERE id = ?", + ("group_mcp", nid)) + cn.commit() + cn.close() + out = gx_module._mcp_dispatch("node_update", + {"id": nid, "clear_group_id": True}) + assert out["ok"] is True + row = fetchone(mcp_env["ops"], + "SELECT group_id FROM entities WHERE id=?", nid) + assert row[0] is None + + def test_mcp_dispatch_clear_group_id_idempotent(self, gx_module, mcp_env): + out = gx_module._mcp_dispatch("node_create", + {"name": "grp_solo", "type": "text"}) + nid = out["id"] + out = gx_module._mcp_dispatch("node_update", + {"id": nid, "clear_group_id": True}) + assert out["ok"] is True diff --git a/views.cpp b/views.cpp index 97619b8..c05ecb7 100644 --- a/views.cpp +++ b/views.cpp @@ -2025,6 +2025,13 @@ void views_node_groups_window(AppState& app) { m.duckdb_path.c_str(), m.table_name.c_str(), (long long)w.total_rows); } + // Issue 0036d: tooltip suave que explica el promote segun kind. + if (is_group) { + ImGui::TextDisabled("Promote: saca el nodo del grupo"); + } else { + ImGui::TextDisabled( + "Promote: convierte fila DuckDB en entidad del grafo"); + } if (!w.last_error.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "ERROR: %s", w.last_error.c_str()); @@ -2033,10 +2040,11 @@ void views_node_groups_window(AppState& app) { // Tabla — layout depende del kind: // Table: [id_column] + columns[] + [promoted] (col_count = N+2) - // Group: columns[] (id, name, type_ref, status, updated_at) - // (col_count = N) + // Group: columns[] + [promote] (col_count = N+1) + // (issue 0036d: ultima columna lleva un boton TI_ARROW_UP + // para sacar la entidad del grupo.) const int col_count = is_group - ? (int)m.columns.size() + ? (int)m.columns.size() + 1 : (int)m.columns.size() + 2; ImGuiTableFlags tflags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | @@ -2053,6 +2061,9 @@ void views_node_groups_window(AppState& app) { : ImGuiTableColumnFlags_WidthStretch, is_id ? 160.0f : 0.0f); } + // 0036d: columna extra para el boton Promote-out-of-group. + ImGui::TableSetupColumn("promote", + ImGuiTableColumnFlags_WidthFixed, 60.0f); } else { ImGui::TableSetupColumn(m.id_column.empty() ? "id" : m.id_column.c_str(), ImGuiTableColumnFlags_WidthFixed, 100.0f); @@ -2101,6 +2112,15 @@ void views_node_groups_window(AppState& app) { if (c < row.values.size()) ImGui::TextUnformatted(row.values[c].c_str()); } + // 0036d: boton Promote-out-of-group en la ultima columna. + ImGui::TableSetColumnIndex(col_count - 1); + if (ImGui::SmallButton(TI_ARROW_UP "##promote_grp")) { + app.want_clear_group_id_entity = true; + app.clear_group_id_entity_id = row.id; + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Promote out of group (move to canvas)"); + } } else { // kind=Table — comportamiento original (DuckDB-backed). bool is_promoted = !row.promoted_entity_id.empty(); diff --git a/views.h b/views.h index 5c86eb4..da3ab19 100644 --- a/views.h +++ b/views.h @@ -200,6 +200,11 @@ struct AppState { bool want_focus_entity = false; // tras promote+open inspector std::string focus_entity_id; + // Issue 0036d: Promote en NodeGroups kind=Group → saca la entidad + // del grupo (group_id = NULL). main.cpp lo procesa y dispara reload. + bool want_clear_group_id_entity = false; + std::string clear_group_id_entity_id; + // Modal "Import dataset..." (issue 0011 Ingesta). bool show_import_modal = false; char import_path_buf[512] = {};