feat(0036d): promote kind-aware (Group → clear group_id)

NodeGroups window kind=Group ahora expone un boton SmallButton(TI_ARROW_UP)
por fila que saca la entidad del grupo (group_id = NULL) y dispara
reload del grafo. kind=Table mantiene el comportamiento de issue 0011.

- entity_ops: nueva op `entity_clear_group_id(db, id)` idempotente. Si
  la columna group_id no existe (BD pre-0035a) retorna true como no-op.
  Falla solo si la entidad no existe o SQLite revienta.
- views.cpp: extra columna "promote" en kind=Group, tooltip header
  diferenciado por kind, boton conectado a app.want_clear_group_id_entity.
- main.cpp: handler que ejecuta entity_clear_group_id, marca windows
  como dirty, llama reload_after_mutation y loguea
  `[node_groups] promoted X out of group`.
- gx-cli: flag `node update --clear-group-id` (booleano) y exposicion
  MCP en inputSchema + MCP_DISPATCH defaults para que el agente Echo
  pueda promover via tool calls.
- tests: 3 nuevos CLI (clear, idempotente, combinable con --name) y
  4 MCP (defaults, schema, dispatch end-to-end, idempotente).

WSL: 102 passed (95 base + 7).
Windows: 91 passed, 11 skipped (84 base + 7).

Refs: issues/0036d-promote-kind-aware.md
This commit is contained in:
2026-05-04 01:03:11 +02:00
parent 98e744ea4e
commit f0d8a5ad04
8 changed files with 311 additions and 5 deletions
+60
View File
@@ -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)
{
+12
View File
@@ -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,
+16 -2
View File
@@ -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")
+84
View File
@@ -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 <id> --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.
+14
View File
@@ -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(
+97
View File
@@ -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
+23 -3
View File
@@ -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();
+5
View File
@@ -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] = {};