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)
{