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
+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